Compare commits

..

No commits in common. "main" and "v1.0.0-alpha.5" have entirely different histories.

34 changed files with 295 additions and 2483 deletions

View File

@ -17,11 +17,11 @@ module.exports = {
project: resolve(__dirname, './tsconfig.json'),
tsconfigRootDir: __dirname,
ecmaVersion: 2019, // Allows for the parsing of modern ECMAScript features
sourceType: 'module', // Allows for the use of imports
sourceType: 'module' // Allows for the use of imports
},
env: {
browser: true,
browser: true
},
// Rules order is important, please avoid shuffling them
@ -44,7 +44,7 @@ module.exports = {
// https://github.com/prettier/eslint-config-prettier#installation
// usage with Prettier, provided by 'eslint-config-prettier'.
'plugin:prettier/recommended',
'prettier', //'plugin:prettier/recommended'
],
plugins: [
@ -54,6 +54,10 @@ module.exports = {
// https://eslint.vuejs.org/user-guide/#why-doesn-t-it-work-on-vue-file
// required to lint *.vue files
'vue',
// https://github.com/typescript-eslint/typescript-eslint/issues/389#issuecomment-509292674
// Prettier has not been included as plugin to avoid performance impact
// add it as an extension for your IDE
],
// add your custom rules here
@ -62,8 +66,10 @@ module.exports = {
// TypeScript
quotes: ['warn', 'single', { avoidEscape: true }],
'@typescript-eslint/explicit-function-return-type': 'off',
'@typescript-eslint/explicit-module-boundary-types': 'off',
// allow debugger during development only
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off',
},
};
'no-debugger': process.env.NODE_ENV === 'production' ? 'error' : 'off'
}
}

8
.gitignore vendored
View File

@ -3,11 +3,3 @@ node_modules/
yarn-error.log
# No need, this is done by user
yarn.lock
# Backend
*.egg-info
__pycache__
# IDE
.idea
*.swp

View File

@ -1,5 +0,0 @@
yarn-error.log
.woodpecker.yml
backend/

View File

@ -1,14 +0,0 @@
pipeline:
deploy:
when:
event: tag
tag: v*
image: node:lts-alpine
commands:
- echo "//registry.npmjs.org/:_authToken=$NODE_AUTH_TOKEN" > .npmrc
- yarn publish --non-interactive
secrets: [ node_auth_token ]
depends_on:
- lint

View File

@ -1,9 +0,0 @@
pipeline:
lint:
when:
branch: [main, develop]
image: node:lts-alpine
commands:
- yarn install
- yarn lint

View File

@ -1,5 +1,4 @@
# Flaschengeist `schedule` fontend-plugin
![status-badge](https://ci.os-sc.org/api/badges/Flaschengeist/flaschengeist-schedule/status.svg)
This package provides the [Flaschengeist](https://flaschengeist.dev/Flaschengeist/flaschengeist) frontend for the schedule plugin (event and schedule management).

View File

@ -1,37 +0,0 @@
"""Events plugin
Provides duty schedule / duty roster functions
"""
import pkg_resources
from flask import Blueprint, current_app
from werkzeug.local import LocalProxy
from flaschengeist.plugins import Plugin
from . import permissions, models
__version__ = pkg_resources.get_distribution("flaschengeist_events").version
class EventPlugin(Plugin):
# id = "dev.flaschengeist.events"
# provided resources
# permissions = permissions.permissions
models = models
# def __init__(self, cfg):
# super(EventPlugin, self).__init__(cfg)
# from . import routes
# from .event_controller import clear_services
def load(self):
from .routes import blueprint
self.blueprint = blueprint
def install(self):
self.install_permissions(permissions.permissions)
@staticmethod
def getPlugin() -> LocalProxy["EventPlugin"]:
return LocalProxy(lambda: current_app.config["FG_PLUGINS"]["events"])

View File

@ -1,450 +0,0 @@
from datetime import datetime, timedelta, timezone
from enum import IntEnum
from typing import Optional, Tuple, Union
from flaschengeist.controller import userController
from flaschengeist.models import Notification, UtcDateTime
from flaschengeist.models.user import User
from werkzeug.exceptions import BadRequest, Conflict, NotFound
from sqlalchemy.exc import IntegrityError
from sqlalchemy.orm.util import was_deleted
from flaschengeist import logger
from flaschengeist.database import db
from flaschengeist.plugins import before_delete_user
from flaschengeist.plugins.scheduler import scheduled
from . import EventPlugin
from .models import EventType, Event, Invitation, Job, JobType, Service
# STUB
def _(x):
return x
class NotifyType(IntEnum):
# Invitations 0x00..0x0F
INVITE = 0x01
TRANSFER = 0x02
# Invitation responsed 0x10..0x1F
INVITATION_ACCEPTED = 0x11
INVITATION_REJECTED = 0x12
# Information responses 0x20..0x2F
INFO_ACCEPTED = 0x21
INFO_REJECTED = 0x22
@before_delete_user
def clear_services(user):
"""
This is called when an user got deleted so it cleans future services.
It removes the deleted user from all future events.
"""
logger.debug(f"Clear deleted user {user.userid} from future events.")
_, jobs = get_jobs(user, UtcDateTime.current_utc())
for job in jobs:
job.services = list(filter(lambda s: s.user_ != user, job.services))
db.session.commit()
# def update():
# db.session.commit()
def get_event_types():
return EventType.query.all()
def get_event_type(identifier):
"""Get EventType by ID (int) or name (string)"""
if isinstance(identifier, int):
et = EventType.query.get(identifier)
elif isinstance(identifier, str):
et = EventType.query.filter(EventType.name == identifier).one_or_none()
else:
logger.debug("Invalid identifier type for EventType")
raise BadRequest
if not et:
raise NotFound
return et
def create_event_type(name):
try:
event = EventType(name=name)
db.session.add(event)
db.session.commit()
return event
except IntegrityError:
raise Conflict("Name already exists")
def rename_event_type(identifier, new_name):
event_type = get_event_type(identifier)
event_type.name = new_name
try:
db.session.commit()
except IntegrityError:
raise Conflict("Name already exists")
def delete_event_type(name):
event_type = get_event_type(name)
db.session.delete(event_type)
try:
db.session.commit()
except IntegrityError:
raise BadRequest("Type still in use")
def get_job_types():
return JobType.query.all()
def get_job_type(type_id):
job_type = JobType.query.get(type_id)
if not job_type:
raise NotFound
return job_type
def create_job_type(name):
try:
job_type = JobType(name=name)
db.session.add(job_type)
db.session.commit()
return job_type
except IntegrityError:
raise BadRequest("Name already exists")
def rename_job_type(name, new_name):
job_type = get_job_type(name)
job_type.name = new_name
try:
db.session.commit()
except IntegrityError:
raise BadRequest("Name already exists")
def delete_job_type(name):
job_type = get_job_type(name)
db.session.delete(job_type)
try:
db.session.commit()
except IntegrityError:
raise BadRequest("Type still in use")
def clear_backup(event: Event):
for job in event.jobs:
services = []
for service in job.services:
if not service.is_backup:
services.append(service)
job.services = services
def get_event(event_id, with_backup=False) -> Event:
event = Event.query.get(event_id)
if event is None:
raise NotFound
if not with_backup:
clear_backup(event)
return event
def get_templates():
return Event.query.filter(Event.is_template == True).all()
def get_events(
start: Optional[datetime] = None,
end: Optional[datetime] = None,
limit: Optional[int] = None,
offset: Optional[int] = None,
descending: Optional[bool] = False,
with_backup=False,
) -> Tuple[int, list[Event]]:
"""Query events which start from begin until end
Args:
start (datetime): Earliest start
end (datetime): Latest start
with_backup (bool): Export also backup services
Returns: collection of Event objects
"""
query = Event.query.filter(Event.is_template.__eq__(False))
if start is not None:
query = query.filter(start <= Event.start)
if end is not None:
query = query.filter(Event.start < end)
elif start is None:
# Neither start nor end was given
query = query.filter(datetime.now() <= Event.start)
if descending:
query = query.order_by(Event.start.desc())
else:
query = query.order_by(Event.start)
count = query.count()
if limit is not None:
query = query.limit(limit)
if offset is not None and offset > 0:
query = query.offset(offset)
events: list[Event] = query.all()
if not with_backup:
for event in events:
clear_backup(event)
# logger.debug(end)
# for event in events:
# logger.debug(f"{event.start} < {end} = {event.start < end}")
return count, events
def delete_event(event_id):
"""Delete event with given ID
Args:
event_id: id of Event to delete
Raises:
NotFound if not found
"""
event = get_event(event_id, True)
for job in event.jobs:
delete_job(job)
db.session.delete(event)
db.session.commit()
def create_event(event_type, start, end=None, jobs=[], is_template=None, name=None, description=None):
try:
logger.debug(event_type)
event = Event(
start=start,
end=end,
name=name,
description=description,
type=event_type,
is_template=is_template,
jobs=jobs,
)
db.session.add(event)
db.session.commit()
return event
except IntegrityError:
logger.debug("Database error when creating new event", exc_info=True)
raise BadRequest
def get_job(job_id, event_id=None) -> Job:
query = db.select(Job).where(Job.id == job_id)
if event_id is not None:
query = query.where(Job.event_id_ == event_id)
job = db.session.execute(query).scalar_one_or_none()
if job is None:
raise NotFound
return job
def get_jobs(user, start=None, end=None, limit=None, offset=None, descending=None) -> Tuple[int, list[Job]]:
query = Job.query.join(Service).filter(Service.user_ == user)
if start is not None:
query = query.filter(start <= Job.end)
if end is not None:
query = query.filter(end >= Job.start)
if descending is not None:
query = query.order_by(Job.start.desc(), Job.type)
else:
query = query.order_by(Job.start, Job.type)
count = query.count()
if limit is not None:
query = query.limit(limit)
if offset is not None:
query = query.offset(offset)
return count, query.all()
def add_job(event, job_type, required_services, start, end=None, comment=None):
job = Job(
required_services=required_services,
type_=job_type,
start=start,
end=end,
comment=comment,
)
event.jobs.append(job)
update()
return job
def update():
try:
db.session.commit()
except IntegrityError:
logger.debug(
"Error, looks like a Job with that type already exists on an event",
exc_info=True,
)
raise BadRequest()
def delete_job(job: Job):
for service in job.services:
unassign_job(service=service, notify=True)
for invitation in job.invitations_:
respond_invitation(invitation, False)
db.session.delete(job)
db.session.commit()
def assign_job(job: Job, user, value, is_backup=False, notify=False):
assert value > 0
service = Service.query.get((job.id, user.id_))
if service:
service.value = value
service.is_backup = is_backup
else:
job.services.append(Service(user_=user, value=value, is_backup=is_backup, job_=job))
if notify:
EventPlugin.getPlugin().notify(
user,
f"You were assigned to a job\n{job.start.strftime('%d.%m.%Y')}",
{"type": NotifyType.INFO_ACCEPTED, "event_id": job.event_id_},
)
db.session.commit()
def unassign_job(job: Job = None, user=None, service=None, notify=False):
_date = job.start.strftime("%d.%m.%Y")
if service is None:
assert job is not None and user is not None
service = Service.query.get((job.id, user.id_))
else:
user = service.user_
if not service:
raise BadRequest
event_id = service.job_.event_id_
db.session.delete(service)
db.session.commit()
if notify:
EventPlugin.getPlugin().notify(
user, f"Your assignmet was cancelled\n{_date}", {"type": NotifyType.INFO_REJECTED, "event_id": event_id}
)
def invite(job: Job, invitee, inviter, transferee=None):
inv = Invitation(job_=job, inviter_=inviter, invitee_=invitee, transferee_=transferee)
db.session.add(inv)
update()
_date = job.start.strftime("%d.%m.%Y")
if transferee is None:
EventPlugin.getPlugin().notify(
invitee, _(f"Job invitation\n{_date}"), {"type": NotifyType.INVITE, "invitation": inv.id}
)
else:
EventPlugin.getPlugin().notify(
invitee, _(f"Job transfer\n{_date}"), {"type": NotifyType.TRANSFER, "invitation": inv.id}
)
return inv
def get_invitation(id: int):
inv: Invitation = Invitation.query.get(id)
if inv is None:
raise NotFound
return inv
def get_invitations(user: User):
return Invitation.query.filter(
(Invitation.invitee_ == user) | (Invitation.inviter_ == user) | (Invitation.transferee_ == user)
).all()
def cleanup_notifications(inv: Invitation):
notifications = tuple(
filter(
lambda notification: notification.data.get("invitation") == inv.id, EventPlugin.getPlugin().notifications
)
)
for notification in notifications:
db.session.delete(notification)
db.session.commit()
def cancel_invitation(inv: Invitation):
db.session.delete(inv)
db.session.commit()
cleanup_notifications(inv)
def respond_invitation(invite: Invitation, accepted=True):
inviter = invite.inviter_
job = invite.job_
db.session.delete(invite)
db.session.commit()
if not was_deleted(invite):
raise Conflict
if not accepted:
EventPlugin.getPlugin().notify(
inviter,
_("Invitation rejected"),
{
"type": NotifyType.INVITATION_REJECTED,
"event": job.event_id_,
"job": invite.job_id,
"invitee": invite.invitee_id,
},
)
else:
if invite.transferee_id is None:
assign_job(job, invite.invitee_, 1)
else:
service = tuple(filter(lambda s: s.userid == invite.transferee_id, job.services))
if not service:
raise Conflict
unassign_job(job, invite.transferee_, service[0], True)
assign_job(job, invite.invitee_, service[0].value)
EventPlugin.getPlugin().notify(
inviter,
_("Invitation accepted"),
{
"type": NotifyType.INVITATION_ACCEPTED,
"event": job.event_id_,
"job": invite.job_id,
"invitee": invite.invitee_id,
},
)
@scheduled(id="dev.flaschengeist.events.assign_backups", minutes=30)
def assign_backups():
now = datetime.now(tz=timezone.utc)
# now + backup_time + next cron tick
start = now + timedelta(hours=16) + timedelta(minutes=30)
services = Service.query.filter(Service.is_backup == True).join(Job).filter(Job.start <= start).all()
for service in services:
if service.job_.start <= now or service.job_.is_full():
EventPlugin.getPlugin().notify(
service.user_,
"Your backup assignment was cancelled.",
{"event_id": service.job_.event_id_},
)
logger.debug(f"Service is outdated or full, removing. {service.serialize()}")
db.session.delete(service)
else:
service.is_backup = False
logger.debug(f"Service not full, assigning backup. {service.serialize()}")
EventPlugin.getPlugin().notify(
service.user_,
"Your backup assignment was accepted.",
{"event_id": service.job_.event_id_},
)
db.session.commit()

View File

@ -1,105 +0,0 @@
"""init events
Revision ID: e70508bd8cb4
Revises: 20482a003db8
Create Date: 2023-04-10 14:21:47.007251
"""
from alembic import op
import sqlalchemy as sa
import flaschengeist
# revision identifiers, used by Alembic.
revision = "e70508bd8cb4"
down_revision = None
branch_labels = ("events",)
depends_on = "flaschengeist"
def upgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.create_table(
"events_event_type",
sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("name", sa.String(length=30), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_events_event_type")),
sa.UniqueConstraint("name", name=op.f("uq_events_event_type_name")),
)
op.create_table(
"events_job_type",
sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("name", sa.String(length=30), nullable=False),
sa.PrimaryKeyConstraint("id", name=op.f("pk_events_job_type")),
sa.UniqueConstraint("name", name=op.f("uq_events_job_type_name")),
)
op.create_table(
"events_event",
sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("start", flaschengeist.database.types.UtcDateTime(), nullable=False),
sa.Column("end", flaschengeist.database.types.UtcDateTime(), nullable=True),
sa.Column("name", sa.String(length=255), nullable=True),
sa.Column("description", sa.String(length=512), nullable=True),
sa.Column("is_template", sa.Boolean(), nullable=True),
sa.Column("type_id", flaschengeist.database.types.Serial(), nullable=False),
sa.ForeignKeyConstraint(
["type_id"],
["events_event_type.id"],
name=op.f("fk_events_event_type_id_events_event_type"),
ondelete="CASCADE",
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_events_event")),
)
op.create_table(
"events_job",
sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("start", flaschengeist.database.types.UtcDateTime(), nullable=False),
sa.Column("end", flaschengeist.database.types.UtcDateTime(), nullable=True),
sa.Column("comment", sa.String(length=256), nullable=True),
sa.Column("type_id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("locked", sa.Boolean(), nullable=False),
sa.Column("required_services", sa.Numeric(precision=4, scale=2, asdecimal=False), nullable=False),
sa.Column("event_id", flaschengeist.database.types.Serial(), nullable=False),
sa.ForeignKeyConstraint(["event_id"], ["events_event.id"], name=op.f("fk_events_job_event_id_events_event")),
sa.ForeignKeyConstraint(
["type_id"], ["events_job_type.id"], name=op.f("fk_events_job_type_id_events_job_type")
),
sa.PrimaryKeyConstraint("id", name=op.f("pk_events_job")),
sa.UniqueConstraint("type_id", "start", "event_id", name="_type_start_uc"),
)
op.create_table(
"events_invitation",
sa.Column("id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("time", flaschengeist.database.types.UtcDateTime(), nullable=False),
sa.Column("job_id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("invitee_id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("inviter_id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("transferee_id", flaschengeist.database.types.Serial(), nullable=True),
sa.ForeignKeyConstraint(["invitee_id"], ["user.id"], name=op.f("fk_events_invitation_invitee_id_user")),
sa.ForeignKeyConstraint(["inviter_id"], ["user.id"], name=op.f("fk_events_invitation_inviter_id_user")),
sa.ForeignKeyConstraint(["job_id"], ["events_job.id"], name=op.f("fk_events_invitation_job_id_events_job")),
sa.ForeignKeyConstraint(["transferee_id"], ["user.id"], name=op.f("fk_events_invitation_transferee_id_user")),
sa.PrimaryKeyConstraint("id", name=op.f("pk_events_invitation")),
)
op.create_table(
"events_service",
sa.Column("is_backup", sa.Boolean(), nullable=True),
sa.Column("value", sa.Numeric(precision=3, scale=2, asdecimal=False), nullable=False),
sa.Column("job_id", flaschengeist.database.types.Serial(), nullable=False),
sa.Column("user_id", flaschengeist.database.types.Serial(), nullable=False),
sa.ForeignKeyConstraint(["job_id"], ["events_job.id"], name=op.f("fk_events_service_job_id_events_job")),
sa.ForeignKeyConstraint(["user_id"], ["user.id"], name=op.f("fk_events_service_user_id_user")),
sa.PrimaryKeyConstraint("job_id", "user_id", name=op.f("pk_events_service")),
)
# ### end Alembic commands ###
def downgrade():
# ### commands auto generated by Alembic - please adjust! ###
op.drop_table("events_service")
op.drop_table("events_invitation")
op.drop_table("events_job")
op.drop_table("events_event")
op.drop_table("events_job_type")
op.drop_table("events_event_type")
# ### end Alembic commands ###

View File

@ -1,146 +0,0 @@
from __future__ import annotations # TODO: Remove if python requirement is >= 3.10
from datetime import datetime
from typing import Optional, Union, List
from sqlalchemy import UniqueConstraint
from flaschengeist.models import ModelSerializeMixin, UtcDateTime, Serial
from flaschengeist.models.user import User
from flaschengeist.database import db
#########
# Types #
#########
_table_prefix_ = "events_"
class EventType(db.Model, ModelSerializeMixin):
__allow_unmapped__ = True
__tablename__ = _table_prefix_ + "event_type"
id: int = db.Column(Serial, primary_key=True)
name: str = db.Column(db.String(30), nullable=False, unique=True)
class JobType(db.Model, ModelSerializeMixin):
__tablename__ = _table_prefix_ + "job_type"
id: int = db.Column(Serial, primary_key=True)
name: str = db.Column(db.String(30), nullable=False, unique=True)
########
# Jobs #
########
class Service(db.Model, ModelSerializeMixin):
__allow_unmapped__ = True
__tablename__ = _table_prefix_ + "service"
userid: str = ""
is_backup: bool = db.Column(db.Boolean, default=False)
value: float = db.Column(db.Numeric(precision=3, scale=2, asdecimal=False), nullable=False)
_job_id = db.Column(
"job_id",
Serial,
db.ForeignKey(f"{_table_prefix_}job.id"),
nullable=False,
primary_key=True,
)
_user_id = db.Column("user_id", Serial, db.ForeignKey("user.id"), nullable=False, primary_key=True)
user_: User = db.relationship("User")
job_: Job = db.relationship("Job")
@property
def userid(self):
return self.user_.userid
class Job(db.Model, ModelSerializeMixin):
__allow_unmapped__ = True
__tablename__ = _table_prefix_ + "job"
id: int = db.Column(Serial, primary_key=True)
start: datetime = db.Column(UtcDateTime, nullable=False)
end: Optional[datetime] = db.Column(UtcDateTime)
comment: Optional[str] = db.Column(db.String(256))
type: int = db.Column("type_id", Serial, db.ForeignKey(f"{_table_prefix_}job_type.id"), nullable=False)
locked: bool = db.Column(db.Boolean(), default=False, nullable=False)
services: List[Service] = db.relationship(
"Service", back_populates="job_", cascade="save-update, merge, delete, delete-orphan"
)
required_services: float = db.Column(db.Numeric(precision=4, scale=2, asdecimal=False), nullable=False)
type_: JobType = db.relationship("JobType")
event_ = db.relationship("Event", back_populates="jobs")
event_id_ = db.Column("event_id", Serial, db.ForeignKey(f"{_table_prefix_}event.id"), nullable=False)
invitations_ = db.relationship("Invitation", cascade="all,delete,delete-orphan", back_populates="job_")
__table_args__ = (UniqueConstraint("type_id", "start", "event_id", name="_type_start_uc"),)
##########
# Events #
##########
class Event(db.Model, ModelSerializeMixin):
"""Model for an Event"""
__allow_unmapped__ = True
__tablename__ = _table_prefix_ + "event"
id: int = db.Column(Serial, primary_key=True)
start: datetime = db.Column(UtcDateTime, nullable=False)
end: Optional[datetime] = db.Column(UtcDateTime)
name: Optional[str] = db.Column(db.String(255))
description: Optional[str] = db.Column(db.String(512))
type: Union[EventType, int] = db.relationship("EventType")
is_template: bool = db.Column(db.Boolean, default=False)
jobs: List[Job] = db.relationship(
"Job",
back_populates="event_",
cascade="all,delete,delete-orphan",
order_by="[Job.start, Job.end]",
)
# Protected for internal use
_type_id = db.Column(
"type_id",
Serial,
db.ForeignKey(f"{_table_prefix_}event_type.id", ondelete="CASCADE"),
nullable=False,
)
class Invitation(db.Model, ModelSerializeMixin):
__allow_unmapped__ = True
__tablename__ = _table_prefix_ + "invitation"
id: int = db.Column(Serial, primary_key=True)
time: datetime = db.Column(UtcDateTime, nullable=False, default=UtcDateTime.current_utc)
job_id: int = db.Column(Serial, db.ForeignKey(_table_prefix_ + "job.id"), nullable=False)
# Dummy properties for API export
invitee_id: str = None # User who was invited to take over
inviter_id: str = None # User who invited the invitee
transferee_id: Optional[str] = None # In case of a transfer: The user who is transfered out of the job
# Not exported properties for backend use
job_: Job = db.relationship(Job, foreign_keys="Invitation.job_id")
invitee_: User = db.relationship("User", foreign_keys="Invitation._invitee_id")
inviter_: User = db.relationship("User", foreign_keys="Invitation._inviter_id")
transferee_: User = db.relationship("User", foreign_keys="Invitation._transferee_id")
# Protected properties needed for internal use
_invitee_id = db.Column("invitee_id", Serial, db.ForeignKey("user.id"), nullable=False)
_inviter_id = db.Column("inviter_id", Serial, db.ForeignKey("user.id"), nullable=False)
_transferee_id = db.Column("transferee_id", Serial, db.ForeignKey("user.id"))
@property
def invitee_id(self):
return self.invitee_.userid
@property
def inviter_id(self):
return self.inviter_.userid
@property
def transferee_id(self):
return self.transferee_.userid if self.transferee_ else None

View File

@ -1,28 +0,0 @@
CREATE = "events_create"
"""Can create events"""
EDIT = "events_edit"
"""Can edit events"""
DELETE = "events_delete"
"""Can delete events"""
EVENT_TYPE = "events_event_type"
"""Can create and edit EventTypes"""
JOB_TYPE = "events_job_type"
"""Can create and edit JobTypes"""
ASSIGN = "events_assign"
"""Can self assign to jobs"""
ASSIGN_OTHER = "events_assign_other"
"""Can assign other users to jobs"""
SEE_BACKUP = "events_see_backup"
"""Can see users assigned as backup"""
LOCK_JOBS = "events_lock_jobs"
"""Can lock jobs, no further services can be assigned or unassigned"""
permissions = [value for key, value in globals().items() if not key.startswith("_")]

View File

@ -1,567 +0,0 @@
from http.client import NO_CONTENT
from flask import request, jsonify, Blueprint
from werkzeug.exceptions import BadRequest, NotFound, Forbidden
from flaschengeist.models.session import Session
from flaschengeist.controller import userController
from flaschengeist.utils.decorators import login_required
from flaschengeist.utils.datetime import from_iso_format
from flaschengeist.utils.HTTP import get_filter_args, no_content
from flaschengeist import logger
from . import event_controller, permissions, EventPlugin
blueprint = Blueprint("events", __name__)
def dict_get(self, key, default=None, type=None):
"""Same as .get from MultiDict"""
try:
rv = self[key]
except KeyError:
return default
if type is not None:
try:
rv = type(rv)
except (ValueError, TypeError):
rv = default
return rv
@blueprint.route("/events/templates", methods=["GET"])
@login_required()
def get_templates(current_session):
return jsonify(event_controller.get_templates())
@blueprint.route("/events/event-types", methods=["GET"])
@blueprint.route("/events/event-types/<int:identifier>", methods=["GET"])
@login_required()
def get_event_types(current_session, identifier=None):
"""Get EventType(s)
Route: ``/events/event-types`` | Method: ``GET``
Route: ``/events/event-types/<identifier>`` | Method: ``GET``
Args:
current_session: Session sent with Authorization Header
identifier: If querying a specific EventType
Returns:
JSON encoded (list of) EventType(s) or HTTP-error
"""
if identifier:
result = event_controller.get_event_type(identifier)
else:
result = event_controller.get_event_types()
return jsonify(result)
@blueprint.route("/events/event-types", methods=["POST"])
@login_required(permission=permissions.EVENT_TYPE)
def new_event_type(current_session):
"""Create a new EventType
Route: ``/events/event-types`` | Method: ``POST``
POST-data: ``{name: string}``
Args:
current_session: Session sent with Authorization Header
Returns:
HTTP-Created or HTTP-error
"""
data = request.get_json()
if "name" not in data:
raise BadRequest
event_type = event_controller.create_event_type(data["name"])
return jsonify(event_type)
@blueprint.route("/events/event-types/<int:identifier>", methods=["PUT", "DELETE"])
@login_required(permission=permissions.EVENT_TYPE)
def modify_event_type(identifier, current_session):
"""Rename or delete an event type
Route: ``/events/event-types/<id>`` | Method: ``PUT`` or ``DELETE``
POST-data: (if renaming) ``{name: string}``
Args:
identifier: Identifier of the EventType
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
if request.method == "DELETE":
event_controller.delete_event_type(identifier)
else:
data = request.get_json()
if "name" not in data:
raise BadRequest("Parameter missing in data")
event_controller.rename_event_type(identifier, data["name"])
return "", NO_CONTENT
@blueprint.route("/events/job-types", methods=["GET"])
@login_required()
def get_job_types(current_session):
"""Get all JobTypes
Route: ``/events/job-types`` | Method: ``GET``
Args:
current_session: Session sent with Authorization Header
Returns:
JSON encoded list of JobType HTTP-error
"""
types = event_controller.get_job_types()
return jsonify(types)
@blueprint.route("/events/job-types", methods=["POST"])
@login_required(permission=permissions.JOB_TYPE)
def new_job_type(current_session):
"""Create a new JobType
Route: ``/events/job-types`` | Method: ``POST``
POST-data: ``{name: string}``
Args:
current_session: Session sent with Authorization Header
Returns:
JSON encoded JobType or HTTP-error
"""
data = request.get_json()
if "name" not in data:
raise BadRequest
jt = event_controller.create_job_type(data["name"])
return jsonify(jt)
@blueprint.route("/events/job-types/<int:type_id>", methods=["PUT", "DELETE"])
@login_required(permission=permissions.JOB_TYPE)
def modify_job_type(type_id, current_session):
"""Rename or delete a JobType
Route: ``/events/job-types/<name>`` | Method: ``PUT`` or ``DELETE``
POST-data: (if renaming) ``{name: string}``
Args:
type_id: Identifier of the JobType
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
if request.method == "DELETE":
event_controller.delete_job_type(type_id)
else:
data = request.get_json()
if "name" not in data:
raise BadRequest("Parameter missing in data")
event_controller.rename_job_type(type_id, data["name"])
return "", NO_CONTENT
@blueprint.route("/events/<int:event_id>", methods=["GET"])
@login_required()
def get_event(event_id, current_session):
"""Get event by id
Route: ``/events/<event_id>`` | Method: ``GET``
Args:
event_id: ID identifying the event
current_session: Session sent with Authorization Header
Returns:
JSON encoded event object
"""
event = event_controller.get_event(
event_id,
with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP),
)
return jsonify(event)
@blueprint.route("/events", methods=["GET"])
@login_required()
def get_events(current_session):
count, result = event_controller.get_events(
*get_filter_args(),
with_backup=current_session.user_.has_permission(permissions.SEE_BACKUP),
)
return jsonify({"count": count, "result": result})
def _add_job(event, data):
try:
start = from_iso_format(data["start"])
end = dict_get(data, "end", None, type=from_iso_format)
required_services = data["required_services"]
job_type = int(data["type"])
job_type = event_controller.get_job_type(job_type)
job_id = dict_get(data, "id", None, int)
if job_id:
job = next(job for job in event.jobs if job.id == job_id)
job.event = event
job.job_type = job_type
job.start = start
job.end = end
job.required_services = required_services
else:
event_controller.add_job(
event,
job_type,
required_services,
start,
end,
comment=dict_get(data, "comment", None, str),
)
except (KeyError, ValueError):
raise BadRequest("Missing or invalid POST parameter")
except StopIteration:
raise BadRequest("Job not in event")
def _delete_jobs_from_event(event, data):
job_ids = [x["id"] for x in data if "id" in x]
for job in event.jobs:
if job.id not in job_ids:
event.jobs.remove(job)
@blueprint.route("/events", methods=["POST"])
@login_required(permission=permissions.CREATE)
def create_event(current_session):
"""Create an new event
Route: ``/events`` | Method: ``POST``
POST-data: See interfaces for Event, can already contain jobs
Args:
current_session: Session sent with Authorization Header
Returns:
JSON encoded Event object or HTTP-error
"""
data = request.get_json()
try:
start = from_iso_format(data["start"])
end = dict_get(data, "end", None, type=from_iso_format)
event_type = event_controller.get_event_type(int(data["type"]))
event = event_controller.create_event(
start=start,
end=end,
name=dict_get(data, "name", None, type=str),
is_template=dict_get(data, "is_template", None, type=bool),
event_type=event_type,
description=dict_get(data, "description", None, type=str),
)
if "jobs" in data:
for job in data["jobs"]:
_add_job(event, job)
return jsonify(event)
except KeyError:
raise BadRequest("Missing POST parameter")
except (NotFound, ValueError):
raise BadRequest("Invalid parameter")
@blueprint.route("/events/<int:event_id>", methods=["PUT"])
@login_required(permission=permissions.EDIT)
def modify_event(event_id, current_session):
"""Modify an event
Route: ``/events/<event_id>`` | Method: ``PUT``
POST-data: See interfaces for Event, can already contain slots
Args:
event_id: Identifier of the event
current_session: Session sent with Authorization Header
Returns:
JSON encoded Event object or HTTP-error
"""
event = event_controller.get_event(event_id)
data = request.get_json()
logger.debug("PUT data: %s", data)
event.start = dict_get(data, "start", event.start, type=from_iso_format)
event.end = dict_get(data, "end", event.end, type=from_iso_format)
event.name = dict_get(data, "name", event.name, type=str)
event.description = dict_get(data, "description", event.description, type=str)
if "type" in data:
event_type = event_controller.get_event_type(data["type"])
event.type = event_type
if "jobs" in data:
_delete_jobs_from_event(event, data["jobs"])
for job in data["jobs"]:
_add_job(event, job)
event_controller.update()
return jsonify(event)
@blueprint.route("/events/<int:event_id>", methods=["DELETE"])
@login_required(permission=permissions.DELETE)
def delete_event(event_id, current_session):
"""Delete an event
Route: ``/events/<event_id>`` | Method: ``DELETE``
Args:
event_id: Identifier of the event
current_session: Session sent with Authorization Header
Returns:
HTTP-NoContent or HTTP-error
"""
event_controller.delete_event(event_id)
return "", NO_CONTENT
@blueprint.route("/events/<int:event_id>/jobs", methods=["POST"])
@login_required(permission=permissions.EDIT)
def add_job(event_id, current_session):
"""Add an new Job to an Event / EventSlot
Route: ``/events/<event_id>/jobs`` | Method: ``POST``
POST-data: See Job
Args:
event_id: Identifier of the event
current_session: Session sent with Authorization Header
Returns:
JSON encoded Event object or HTTP-error
"""
event = event_controller.get_event(event_id)
_add_job(event, request.get_json())
return jsonify(event)
@blueprint.route("/events/<int:event_id>/jobs/<int:job_id>", methods=["DELETE"])
@login_required(permission=permissions.DELETE)
def delete_job(event_id, job_id, current_session):
"""Delete a Job
Route: ``/events/<event_id>/jobs/<job_id>`` | Method: ``DELETE``
Args:
event_id: Identifier of the event
job_id: Identifier of the Job
current_session: Session sent with Authorization Header
Returns:
HTTP-no-content or HTTP error
"""
job = event_controller.get_job(job_id, event_id)
event_controller.delete_job(job)
return no_content()
@blueprint.route("/events/<int:event_id>/jobs/<int:job_id>", methods=["PUT"])
@login_required()
def update_job(event_id, job_id, current_session: Session):
"""Edit Job
Route: ``/events/<event_id>/jobs/<job_id>`` | Method: ``PUT``
POST-data: See TS interface for Job
Args:
event_id: Identifier of the event
job_id: Identifier of the Job
current_session: Session sent with Authorization Header
Returns:
JSON encoded Job object or HTTP-error
"""
if not current_session.user_.has_permission(permissions.EDIT):
raise Forbidden
data = request.get_json()
if not data:
raise BadRequest
job = event_controller.get_job(job_id, event_id)
try:
if "type" in data or "type_id" in data:
job.type_ = event_controller.get_job_type(data.get("type", None) or data["type_id"])
job.start = from_iso_format(data.get("start", job.start))
job.end = from_iso_format(data.get("end", job.end))
job.comment = str(data.get("comment", job.comment))
job.locked = bool(data.get("locked", job.locked))
job.required_services = float(data.get("required_services", job.required_services))
event_controller.update()
except NotFound:
raise BadRequest("Invalid JobType")
except ValueError:
raise BadRequest("Invalid POST data")
return jsonify(job)
@blueprint.route("/events/jobs", methods=["GET"])
@login_required()
def get_jobs(current_session: Session):
count, result = event_controller.get_jobs(current_session.user_, *get_filter_args())
return jsonify({"count": count, "result": result})
@blueprint.route("/events/jobs/<int:job_id>", methods=["GET"])
@login_required()
def get_job(job_id, current_session: Session):
return jsonify(event_controller.get_job(job_id))
@blueprint.route("/events/jobs/<int:job_id>/assign", methods=["POST"])
@login_required()
def assign_job(job_id, current_session: Session):
"""Assign / unassign user to the Job
Route: ``/events/jobs/<job_id>/assign`` | Method: ``POST``
POST-data: a Service object, see TS interface for Service
Args:
job_id: Identifier of the Job
current_session: Session sent with Authorization Header
Returns:
JSON encoded Job or HTTP-error
"""
data = request.get_json()
job = event_controller.get_job(job_id)
try:
value = data["value"]
user = userController.get_user(
data["userid"], deleted=value < 0
) # allow unassigning deleted users, but not assigning
if (user == current_session.user_ and not user.has_permission(permissions.ASSIGN)) or (
user != current_session.user_ and not current_session.user_.has_permission(permissions.ASSIGN_OTHER)
):
raise Forbidden
if value > 0:
event_controller.assign_job(
job, user, value, data.get("is_backup", False), notify=user != current_session.user_
)
else:
event_controller.unassign_job(job, user, notify=user != current_session.user_)
except (TypeError, KeyError, ValueError):
raise BadRequest
return jsonify(job)
@blueprint.route("/events/jobs/<int:job_id>/lock", methods=["POST"])
@login_required(permissions.LOCK_JOBS)
def lock_job(job_id, current_session: Session):
"""Lock / unlock the Job
Route: ``/events/jobs/<job_id>/lock`` | Method: ``POST``
POST-data: ``{locked: boolean}``
Args:
job_id: Identifier of the Job
current_session: Session sent with Authorization Header
Returns:
HTTP-No-Content or HTTP-error
"""
data = request.get_json()
job = event_controller.get_job(job_id)
try:
locked = bool(userController.get_user(data["locked"]))
job.locked = locked
event_controller.update()
except (TypeError, KeyError, ValueError):
raise BadRequest
return no_content()
@blueprint.route("/events/invitations", methods=["POST"])
@login_required()
def invite(current_session: Session):
"""Invite an user to a job or transfer job
Route: ``/events/invites`` | Method: ``POST``
POST-data: ``{job: number, invitees: string[], transferee?: string}``
Args:
current_session: Session sent with Authorization Header
Returns:
List of Invitation objects or HTTP-error
"""
data = request.get_json()
transferee = data.get("transferee", None)
if transferee is not None and (
transferee != current_session.userid or not current_session.user_.has_permission(permissions.ASSIGN_OTHER)
):
raise Forbidden
try:
job = event_controller.get_job(data["job"])
if not isinstance(data["invitees"], list):
raise BadRequest
return jsonify(
[
event_controller.invite(
job, invitee, current_session.user_, userController.get_user(transferee) if transferee else None
)
for invitee in [userController.get_user(uid) for uid in data["invitees"]]
]
)
except (TypeError, KeyError, ValueError, NotFound):
raise BadRequest
@blueprint.route("/events/invitations", methods=["GET"])
@login_required()
def get_invitations(current_session: Session):
return jsonify(event_controller.get_invitations(current_session.user_))
@blueprint.route("/events/invitations/<int:invitation_id>", methods=["GET"])
@login_required()
def get_invitation(invitation_id: int, current_session: Session):
inv = event_controller.get_invitation(invitation_id)
if current_session.userid not in [inv.invitee_id, inv.inviter_id, inv.transferee_id]:
raise NotFound
return jsonify(inv)
@blueprint.route("/events/invitations/<int:invitation_id>", methods=["DELETE", "PUT"])
@login_required()
def respond_invitation(invitation_id: int, current_session: Session):
inv = event_controller.get_invitation(invitation_id)
if request.method == "DELETE":
if current_session.userid == inv.invitee_id:
event_controller.respond_invitation(inv, False)
elif current_session.userid == inv.inviter_id:
event_controller.cancel_invitation(inv)
else:
raise Forbidden
else:
# maybe validate data is something like ({accepted: true})
if current_session.userid != inv.invitee_id:
raise Forbidden
event_controller.respond_invitation(inv)
return no_content()

View File

@ -1,3 +0,0 @@
[build-system]
requires = ["setuptools", "wheel"]
build-backend = "setuptools.build_meta"

View File

@ -1,34 +0,0 @@
[metadata]
license = MIT
version = 1.0.0
name = flaschengeist-events
description = Events plugin for Flaschengeist
url = https://flaschengeist.dev/Flaschengeist/flaschengeist-schedule
project_urls =
Source = https://flaschengeist.dev/Flaschengeist/flaschengeist-schedule
Tracker = https://flaschengeist.dev/Flaschengeist/flaschengeist-schedule/issues
classifiers =
Programming Language :: Python :: 3
Programming Language :: Python :: 3.8,
Programming Language :: Python :: 3.9,
Programming Language :: Python :: 3.10,
License :: OSI Approved :: MIT License
Operating System :: OS Independent
[bdist_wheel]
universal = True
[options]
python_requires = >=3.8
packages =
flaschengeist_events
install_requires =
flaschengeist >= 2.0.0
[options.package_data]
* = *.toml, script.py.mako, *.ini, migrations/*.py
[options.entry_points]
flaschengeist.plugins =
events = flaschengeist_events:EventPlugin

View File

@ -1,6 +1,6 @@
{
"license": "MIT",
"version": "1.1.0",
"version": "1.0.0-alpha.5",
"name": "@flaschengeist/schedule",
"author": "Ferdinand Thiessen <rpm@fthiessen.de>",
"homepage": "https://flaschengeist.dev/Flaschengeist",
@ -15,31 +15,29 @@
"main": "src/index.ts",
"types": "src/api.d.ts",
"scripts": {
"format": "prettier --config ./package.json --write '{,!(node_modules|backend)/**/}*.{js,ts,vue}'",
"pretty": "prettier --config ./package.json --write '{,!(node_modules)/**/}*.ts'",
"lint": "eslint --ext .js,.ts,.vue ./src"
},
"dependencies": {
"@quasar/quasar-ui-qcalendar": "^4.0.0-beta.11"
"@quasar/quasar-ui-qcalendar": "^4.0.0-beta.10"
},
"devDependencies": {
"@flaschengeist/api": "^1.0.0",
"@flaschengeist/types": "^1.0.0",
"@quasar/app-webpack": "^3.7.2",
"@typescript-eslint/eslint-plugin": "^5.8.0",
"@typescript-eslint/parser": "^5.8.0",
"@flaschengeist/types": "^1.0.0-alpha.8",
"@quasar/app": "^3.2.3",
"quasar": "^2.3.3",
"axios": "^0.24.0",
"eslint": "^8.5.0",
"prettier": "^2.4.1",
"typescript": "^4.5.2",
"pinia": "^2.0.4",
"@typescript-eslint/eslint-plugin": "^5.4.0",
"@typescript-eslint/parser": "^5.4.0",
"eslint": "^8.3.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-vue": "^8.2.0",
"pinia": "^2.0.8",
"prettier": "^2.5.1",
"quasar": "^2.11.10",
"typescript": "^4.5.4"
"eslint-plugin-vue": "^8.1.1"
},
"peerDependencies": {
"@flaschengeist/api": "^1.0.0",
"@flaschengeist/users": "^1.0.0"
"@flaschengeist/api": "^1.0.0-alpha.4",
"@flaschengeist/users": "^1.0.0-alpha.1"
},
"prettier": {
"singleQuote": true,

2
src/api.d.ts vendored
View File

@ -15,11 +15,11 @@ declare namespace FG {
}
interface Invitation {
id: number;
time: Date;
job_id: number;
invitee_id: string;
inviter_id: string;
transferee_id?: string;
transferee: User;
}
interface Job {
id: number;

View File

@ -2,9 +2,9 @@
<q-card style="text-align: center">
<q-card-section class="row justify-center items-center content-center">
<div class="col-5">
<q-icon :name="jobs == 0 ? 'mdi-calendar-blank' : 'mdi-calendar-alert'" :size="divHeight" />
<q-icon :name="jobs == 0 ? 'mdi-calendar-blank' : 'mdi-calendar-alert'" :size="divHeight" />
</div>
<div v-if="(jobs || 0) > 0" ref="div" class="col-7">
<div v-if="(jobs || 0) > 0" ref="div" class="col-7">
<div class="text-h6">Anstehende Dienste</div>
<div class="text-body1">{{ jobs }}</div>
<div class="text-h6">Nächster Dienst</div>

View File

@ -52,7 +52,6 @@
class="col-xs-12 col-sm-6 q-pa-sm"
label="Veranstaltungsende"
:rules="[afterStart]"
:key="update_time"
/>
<q-input
v-model="event.description"
@ -62,7 +61,7 @@
filled
/>
</q-card-section>
<q-card-section v-if="modelValue === undefined">
<q-card-section v-if="event.is_template !== true && modelValue === undefined">
<q-btn-toggle
v-model="recurrent"
spread
@ -82,14 +81,14 @@
<q-btn color="primary" label="Schicht hinzufügen" @click="addJob()" />
</div>
</div>
<template v-for="(job, index) in event.jobs" :key="index + update_time">
<template v-for="(job, index) in event.jobs" :key="index">
<edit-job-slot
ref="activeJob"
:ref="active === index ? 'activeJob' : undefined"
v-model="event.jobs[index]"
:active="index === active"
class="q-mb-md"
@remove-job="removeJob(index)"
@activate="activate(index)"
@remove-job="removeJob(index)"
/>
</template>
</q-card-section>
@ -112,9 +111,9 @@ import { notEmpty } from '@flaschengeist/api';
import { IsoDateInput } from '@flaschengeist/api/components';
import { useEventStore } from '../../store';
import { emptyEvent, Job, EditableEvent } from '../../store/models';
import { emptyEvent, emptyJob, EditableEvent } from '../../store/models';
import { date, DateOptions } from 'quasar';
import { date, ModifyDateOptions } from 'quasar';
import { computed, defineComponent, PropType, ref, onBeforeMount, watch } from 'vue';
import EditJobSlot from './EditJobSlot.vue';
@ -138,13 +137,22 @@ export default defineComponent({
},
emits: {
done: (val: boolean) => typeof val === 'boolean',
'update:modelValue': (val?: FG.Event) => typeof val === 'object',
},
setup(props, { emit }) {
const store = useEventStore();
const startDate = computed(() => {
const d = date.buildDate({ milliseconds: 0, seconds: 0, minutes: 0, hours: 0 });
if (!props.date || !date.isValid(props.date)) return d;
const split = props.date.split('-');
return date.adjustDate(d, {
year: parseInt(split[0]),
month: parseInt(split[1]),
date: parseInt(split[2]),
});
});
const active = ref(0);
const activeJob = ref<{ validate: () => Promise<boolean> }[]>([]);
const activeJob = ref<{ validate: () => Promise<boolean> }>();
const templates = computed(() => store.templates);
const template = ref<FG.Event>();
const event = ref<EditableEvent>(props.modelValue || emptyEvent());
@ -158,20 +166,16 @@ export default defineComponent({
void store.getTemplates();
});
watch(
() => props.modelValue,
(newModelValue) => {
if (event.value?.id !== newModelValue?.id) reset();
}
);
watch(props, (n, o) => {
if (event.value?.id !== n.modelValue?.id) reset();
});
function addJob() {
if (!activeJob.value[active.value]) {
event.value.jobs.push(new Job());
} else
void activeJob.value[active.value].validate().then((success) => {
if (!activeJob.value) event.value.jobs.push(emptyJob());
else
void activeJob.value.validate().then((success) => {
if (success) {
event.value.jobs.push(new Job());
event.value.jobs.push(emptyJob());
active.value = event.value.jobs.length - 1;
}
});
@ -183,7 +187,7 @@ export default defineComponent({
}
function fromTemplate(tpl: FG.Event) {
const today = props.modelValue?.start || new Date();
const today = new Date();
template.value = tpl;
event.value = Object.assign({}, tpl, { id: undefined });
@ -197,31 +201,23 @@ export default defineComponent({
const diff = event.value.start.getTime() - tpl.start.getTime();
// Adjust end of event and all jobs
if (event.value.end) event.value.end.setTime(event.value.end.getTime() + diff);
event.value.jobs = [];
tpl.jobs.forEach((job) => {
const copied_job: FG.Job = Object.assign({}, job, {
id: NaN,
start: new Date(),
end: undefined,
});
copied_job.start.setTime(job.start.getTime() + diff);
if (job.end) {
copied_job.end = new Date();
copied_job.end.setTime(job.end.getTime() + diff);
}
event.value.jobs.push(<Job>copied_job);
event.value.jobs.forEach((job) => {
job.start.setTime(job.start.getTime() + diff);
if (job.end) job.end.setTime(job.end.getTime() + diff);
});
}
async function save(is_template = false) {
event.value.is_template = is_template;
async function save(template = false) {
event.value.is_template = template;
try {
const _event = await store.addEvent(event.value);
emit('update:modelValue', _event);
if (event.value?.id !== undefined) {
//fix
}
await store.addEvent(event.value);
if (props.modelValue === undefined && recurrent.value && !event.value.is_template) {
let count = 0;
const options: DateOptions = {};
const options: ModifyDateOptions = {};
switch (recurrenceRule.value.frequency) {
case 'daily':
options['days'] = 1 * recurrenceRule.value.interval;
@ -274,60 +270,12 @@ export default defineComponent({
!d || event.value.start <= d || 'Das Veranstaltungsende muss vor dem Beginn liegen';
function activate(idx: number) {
void activeJob.value[active.value]?.validate().then((s) => {
void activeJob.value?.validate().then((s) => {
if (s) active.value = idx;
});
}
const computed_start = computed({
get: () => event.value?.start,
set: (value) => {
event.value.start = value;
},
});
const computed_end = computed({
get: () => event.value?.end,
set: (value) => {
event.value.end = value;
},
});
const update_time = ref(false);
watch(computed_start, (newValue, oldValue) => {
update_time.value = true;
const diff = newValue.getTime() - oldValue.getTime();
event.value?.jobs.forEach((job) => {
job.start.setTime(job.start.getTime() + diff);
job.end?.setTime(job.end.getTime() + diff);
});
computed_end.value?.setTime(computed_end.value?.getTime() + diff);
setTimeout(() => {
update_time.value = false;
}, 0);
});
watch(computed_end, (newValue, oldValue) => {
if (newValue && oldValue) {
update_time.value = true;
if (!newValue || !oldValue) return;
const diff = newValue.getTime() - oldValue.getTime();
event.value?.jobs.forEach((job) => {
if (job.end) job.end.setTime(job.end.getTime() + diff);
else job.end = new Date(newValue.getTime());
});
} else if (newValue && !oldValue) {
event.value?.jobs.forEach((job) => {
if (!job.end) job.end = new Date(newValue.getTime());
});
}
setTimeout(() => {
update_time.value = false;
}, 0);
});
return {
update_time,
activate,
active,
addJob,

View File

@ -33,19 +33,19 @@
input-debounce="0"
class="col-xs-12 col-sm-6 q-pa-sm"
:options="jobtypes"
:option-label="(jobtype) => (typeof jobtype === 'number' ? '' : jobtype.name)"
option-label="name"
option-value="id"
map-options
clearable
:rules="[notEmpty]"
/>
<q-input
v-model.number="job.required_services"
v-model="job.required_services"
filled
class="col-xs-12 col-sm-6 q-pa-sm"
label="Dienstanzahl"
type="number"
:rules="[minOneService, notEmpty]"
:rules="[notEmpty]"
/>
<q-input
v-model="job.comment"
@ -57,12 +57,7 @@
</q-form>
</q-card-section>
<q-card-actions>
<q-btn
label="Schicht löschen"
color="negative"
:disabled="canDelete"
@click="$emit('remove-job')"
/>
<q-btn label="Schicht löschen" color="negative" :disabled="canDelete" @click="$emit('remove-job')" />
</q-card-actions>
</q-card>
</template>
@ -131,13 +126,9 @@ export default defineComponent({
}
expose({
validate: () => form.value?.validate() || Promise.resolve(true),
validate: () => form.value?.validate() || Promise.resolve(true)
});
function minOneService(val: number) {
return parseInt(val) > 0 || 'Mindestens ein Dienst nötig';
}
return {
form,
formatStartEnd,
@ -145,11 +136,11 @@ export default defineComponent({
job,
jobtypes,
notEmpty,
minOneService,
typeName,
};
},
});
</script>
<style></style>

View File

@ -3,53 +3,47 @@
<q-dialog v-model="dialogOpen">
<q-card>
<q-card-section>
<div class="text-h6">Editere {{ title }} {{ actualType.name }}</div>
<div class="text-h6">Editere {{title}} {{ actualType.name }}</div>
</q-card-section>
<q-card-section>
<q-input
ref="dialogInput"
v-model="actualType.name"
:rules="rules"
dense
label="name"
filled
/>
<q-input ref="dialogInput" v-model="actualType.name" :rules="rules" dense label="name" filled />
</q-card-section>
<q-card-actions>
<q-btn flat color="danger" label="Abbrechen" @click="discardChanges()" />
<q-btn
flat
color="primary"
label="Speichern"
:disable="!!dialogInput && !dialogInput.validate()"
@click="saveChanges()"
/>
<q-btn flat color="primary" label="Speichern" :disable="!!dialogInput && !dialogInput.validate()" @click="saveChanges()" />
</q-card-actions>
</q-card>
</q-dialog>
<q-table :title="title" :rows="rows" row-key="id" :columns="columns">
<template #top-right>
<q-input
ref="input"
v-model="actualType.name"
:rules="rules"
dense
filled
placeholder="Neuer Typ"
>
<template #after
><q-btn color="primary" icon="mdi-plus" title="Hinzufügen" round @click="addType"
/></template>
</q-input>
</template>
<template #body-cell-actions="props">
<q-td :props="props" align="right" :auto-width="true">
<q-btn round icon="mdi-pencil" @click="editType(props.row.id)" />
<q-btn round icon="mdi-delete" @click="deleteType(props.row.id)" />
</q-td>
</template>
</q-table>
<q-card>
<q-card-section>
<q-table :title="title" :rows="rows" row-key="id" :columns="columns">
<template #top-right>
<q-input
ref="input"
v-model="actualType.name"
:rules="rules"
dense
placeholder="Neuer Typ"
>
<slot name="after"
><q-btn color="primary" icon="mdi-plus" title="Hinzufügen" @click="addType"
/></slot>
</q-input>
</template>
<template #body-cell-actions="props">
<q-td :props="props" align="right" :auto-width="true">
<q-btn
round
icon="mdi-pencil"
@click="editType(props.row.id)"
/>
<q-btn round icon="mdi-delete" @click="deleteType(props.row.id)" />
</q-td>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
</template>
@ -62,9 +56,9 @@ import { useQuasar, QInput } from 'quasar';
export default defineComponent({
name: 'ManageTypes',
components: {},
props: {
type: { type: String as PropType<'EventType' | 'JobType'>, required: true },
title: { type: String, required: true },
props:{
type: {type: String as PropType<'EventType' | 'JobType'>, required: true},
title: {type: String, required: true}
},
setup(props) {
const store = useEventStore();
@ -75,16 +69,17 @@ export default defineComponent({
const actualType = ref(emptyType);
const input = ref<QInput>();
const dialogInput = ref<QInput>();
const storeName = computed(() => (props.type == 'EventType' ? 'eventTypes' : 'jobTypes'));
const storeName = computed(() => props.type == 'EventType' ? 'eventTypes' : 'jobTypes')
onBeforeMount(async () => await store[`get${props.type}s`]());
const rows = computed(() => <(FG.EventType | FG.JobType)[]>store[storeName.value]);
const rows = computed(() => <(FG.EventType|FG.JobType)[]>store[storeName.value]);
const rules = [
(s: unknown) => !!s || 'Darf nicht leer sein!',
(s: string) =>
rows.value.find((e) => e.name === s) === undefined || 'Der Name wird bereits verwendet',
rows.value.find((e) => e.name === s) === undefined ||
'Der Name wird bereits verwendet',
];
const columns = [
@ -105,7 +100,8 @@ export default defineComponent({
function addType() {
if (input.value === undefined || input.value.validate())
store[`add${props.type}`](actualType.value.name)
store
[`add${props.type}`](actualType.value.name)
.then(() => {
actualType.value.name = '';
})
@ -125,17 +121,12 @@ export default defineComponent({
function editType(id: number) {
dialogOpen.value = true;
actualType.value = Object.assign(
{},
rows.value.find((v) => v.id === id)
);
actualType.value = Object.assign({}, rows.value.find((v) => v.id === id));
}
function saveChanges() {
if (dialogInput.value === undefined || dialogInput.value.validate())
void store[`rename${props.type}`](actualType.value.id, actualType.value.name).then(() =>
discardChanges()
);
void store[`rename${props.type}`](actualType.value.id, actualType.value.name).then(() => discardChanges());
}
function discardChanges() {

View File

@ -63,7 +63,7 @@ export default defineComponent({
const rule = new Proxy(props.modelValue, {
get(target, prop) {
if (typeof prop === 'string') {
return (props.modelValue as unknown as Record<string, unknown>)[prop];
return ((props.modelValue as unknown) as Record<string, unknown>)[prop];
}
},
set(target, prop, value) {

View File

@ -27,7 +27,7 @@
<q-popup-proxy ref="proxy" transition-show="scale" transition-hide="scale">
<q-date
ref="datepicker"
:model-value="date(selectedDate)"
:model-value="selectedDate"
mask="YYYY-MM-DD"
no-unset
@update:model-value="updateDate"
@ -53,7 +53,7 @@
</div>
</div>
<q-calendar-agenda
:model-value="date(realDate)"
v-model="selectedDate"
:view="calendarRealView"
:max-days="calendarDays"
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
@ -88,16 +88,14 @@
</template>
<script lang="ts">
import { ComponentPublicInstance, computed, defineComponent, onBeforeMount, ref, watch } from 'vue';
import { QCalendarAgenda } from '@quasar/quasar-ui-qcalendar';
import { date, QDate, QPopupProxy, useQuasar } from 'quasar';
import { useRoute, useRouter } from 'vue-router';
import { EditableEvent, emptyEvent } from '../../store/models';
import { startOfWeek } from '@flaschengeist/api';
import { ComponentPublicInstance, computed, defineComponent, onBeforeMount, ref } from 'vue';
import { useEventStore } from '../../store';
import EventSlot from './slots/EventSlot.vue';
import { date, QDate, QPopupProxy, useQuasar } from 'quasar';
import { startOfWeek } from '@flaschengeist/api';
import EditEvent from '../management/EditEvent.vue';
import { QCalendarAgenda } from '@quasar/quasar-ui-qcalendar';
import { EditableEvent, emptyEvent } from '../../store/models';
export default defineComponent({
name: 'AgendaView',
@ -106,36 +104,17 @@ export default defineComponent({
setup() {
const store = useEventStore();
const quasar = useQuasar();
const route = useRoute();
const router = useRouter();
const datepicker = ref<QDate>();
const proxy = ref<QPopupProxy>();
// User selectable (day vs week)
const selectedDate = ref(date.formatDate(new Date(), 'YYYY-MM-DD'));
const calendarView = ref('week');
// User selected date
const selectedDate = ref(date.buildDate({ hours: 0, minutes: 0, seconds: 0, milliseconds: 0 }));
// Real view depending on the screen size
const calendarRealView = computed(() =>
calendarView.value === 'day' || quasar.screen.xs || quasar.screen.sm ? 'day' : 'week'
const calendarRealView = computed(() => (calendarDays.value != 7 ? 'day' : 'week'));
const calendarDays = computed(() =>
calendarView.value == 'day' || quasar.screen.xs ? 1 : quasar.screen.sm ? 3 : 7
);
const calendarDays = computed(() => {
if (calendarView.value == 'day' || quasar.screen.xs) return 1;
if (calendarRealView.value == 'week') return 7;
return realDate.value.getDay() === 1 ? 3 : 4;
});
const realDate = computed(() => {
if (calendarView.value === 'day' || calendarRealView.value === 'week' || quasar.screen.xs)
return selectedDate.value;
const start = startOfWeek(selectedDate.value);
if (selectedDate.value.getDay() > 0 && selectedDate.value.getDay() <= 3) return start;
else return date.addToDate(start, { days: 3 });
});
const events = ref<Agendas>({});
const editor = ref<EditableEvent>();
@ -144,19 +123,6 @@ export default defineComponent({
}
onBeforeMount(async () => {
await router.replace({ query: { ...route.query, q_tab: 'agendaView' } });
if (!Object.keys(route.query).includes('q_date')) {
const q_date = date.formatDate(selectedDate.value, 'YYYY-MM-DD');
await router.replace({ query: { ...route.query, q_date } });
} else {
selectedDate.value = date.extractDate(route.query.q_date as string, 'YYYY-MM-DD');
}
if (!Object.keys(route.query).includes('q_view')) {
const q_view = calendarView.value;
await router.replace({ query: { ...route.query, q_view } });
} else {
calendarView.value = route.query.q_view as string;
}
await loadAgendas();
});
@ -187,98 +153,89 @@ export default defineComponent({
}
}
const loading = ref(false);
async function loadAgendas() {
if (loading.value) return;
loading.value = true;
const from =
calendarView.value === 'day' ? selectedDate.value : startOfWeek(selectedDate.value);
const to = date.addToDate(from, { days: calendarView.value === 'day' ? 1 : 7 });
const selected = date.adjustDate(selectedDate.value, {
milliseconds: 0,
seconds: 0,
minutes: 0,
hours: 0,
});
const start = calendarView.value === 'day' ? selected : startOfWeek(selected);
const end = date.addToDate(start, { days: calendarDays.value });
events.value = {};
const { result } = await store.getEvents({ from, to });
const { result } = await store.getEvents({ from: start, to: end });
result.forEach((event) => {
const day = event.start.getDay();
if (!events.value[day]) {
events.value[day] = [];
}
const idx = events.value[day].findIndex((e) => e.id === event.id);
if (idx === -1) events.value[day].push(event);
else events.value[day][idx] = event;
events.value[day].push(event);
});
loading.value = false;
}
function addDays(reverse: boolean) {
const oww = Math.floor((startOfWeek(selectedDate.value).getTime() / 1000) * 60 * 60 * 24);
selectedDate.value = date.addToDate(realDate.value, {
days: reverse ? -1 : calendarDays.value,
});
if (oww != Math.floor((startOfWeek(selectedDate.value).getTime() / 1000) * 60 * 60 * 24))
void loadAgendas();
function calendarNext() {
selectedDate.value = date.formatDate(
date.addToDate(selectedDate.value, { days: calendarDays.value }),
'YYYY-MM-DD'
);
void loadAgendas();
}
function asMonth(value?: Date) {
return [
'Januar',
'Februar',
'März',
'April',
'Mai',
'Juni',
'Juli',
'August',
'September',
'Oktober',
'November',
'Dezember',
'-',
][value?.getMonth() === 0 ? 0 : value?.getMonth() || 12];
}
function asYear(value?: Date) {
return value?.getFullYear() || '-';
function calendarPrev() {
selectedDate.value = date.formatDate(
date.subtractFromDate(selectedDate.value, { days: calendarDays.value }),
'YYYY-MM-DD'
);
void loadAgendas();
}
watch(selectedDate, async (newValue) => {
const q_date = date.formatDate(newValue, 'YYYY-MM-DD');
await router.replace({ query: { ...route.query, q_date } });
await loadAgendas();
});
watch(calendarView, async (newValue) => {
const q_view = newValue;
await router.replace({ query: { ...route.query, q_view } });
await loadAgendas();
});
function asMonth(value: string) {
if (value) {
return date.formatDate(new Date(value), 'MMMM', {
months: [
'Januar',
'Februar',
'März',
'April',
'Mai',
'Juni',
'Juli',
'August',
'September',
'Oktober',
'November',
'Dezember',
],
});
}
}
function asYear(value: string) {
if (value) {
return date.formatDate(new Date(value), 'YYYY');
}
}
return {
asYear,
asMonth,
calendarDays,
calendarNext: () => addDays(false),
calendarPrev: () => addDays(true),
calendarNext,
calendarPrev,
calendarRealView,
calendarView,
create,
date: (d: Date) => date.formatDate(d, 'YYYY-MM-DD'),
edit,
editor,
editDone,
events,
datepicker,
proxy,
realDate,
remove,
selectedDate,
updateDate: (ds: string) => {
selectedDate.value = date.adjustDate(date.extractDate(ds, 'YYYY-MM-DD'), {
hours: 0,
minutes: 0,
seconds: 0,
milliseconds: 0,
});
selectedDate.value = ds;
proxy.value?.hide();
},
};

View File

@ -16,31 +16,31 @@
</div>
</q-card>
</q-dialog>
<!-- <div class="q-pa-md"> -->
<!-- <q-card style="height: 70vh; max-width: 1800px" class="q-pa-md"> -->
<div ref="scrollDiv" class="scroll" style="height: 100%">
<q-infinite-scroll :offset="250" @load="load">
<q-list>
<q-item id="bbb">
<q-btn label="Ältere Veranstaltungen laden" @click="load(-1)" />
</q-item>
<template v-for="(events, index) in agendas" :key="index">
<q-separator />
<q-item-label header>{{ asDate(index) }}</q-item-label>
<q-item v-for="(event, idx) in events" :key="idx">
<event-slot v-model="events[idx]" @edit-event="edit" @remove-event="remove" />
</q-item>
</template>
</q-list>
<template #loading>
<div class="row justify-center q-my-md">
<q-spinner-dots color="primary" size="40px" />
</div>
</template>
</q-infinite-scroll>
<div class="q-pa-md">
<q-card style="height: 70vh; max-width: 1800px" class="q-pa-md">
<div class="scroll" ref="scrollDiv" style="height: 100%">
<q-infinite-scroll :offset="250" @load="load">
<q-list>
<q-item id="bbb">
<q-btn label="Ältere Veranstaltungen laden" @click="load(-1)" />
</q-item>
<template v-for="(events, index) in agendas" :key="index">
<q-separator />
<q-item-label overline>{{ index }}</q-item-label>
<q-item v-for="(event, idx) in events" :key="idx"
><event-slot :model-value="event" />{{ idx }}</q-item
>
</template>
</q-list>
<template #loading>
<div class="row justify-center q-my-md">
<q-spinner-dots color="primary" size="40px" />
</div>
</template>
</q-infinite-scroll>
</div>
</q-card>
</div>
<!-- </q-card> -->
<!-- </div> -->
</template>
<script lang="ts">
@ -88,10 +88,6 @@ export default defineComponent({
function editDone(changed: boolean) {
//if (changed) void loadAgendas();
const idx = events.value.findIndex((event) => event.id === editor.value?.id);
if (idx >= 0) {
events.value[idx] = editor.value as FG.Event;
}
editor.value = undefined;
}
@ -99,12 +95,7 @@ export default defineComponent({
const start = new Date();
if (index < 0) {
const { result } = await store.getEvents({ to: start, limit: 5, descending: true });
//events.value.unshift(...result);
for (const event of result) {
const idx = events.value.findIndex((e) => e.id === event.id);
if (idx === -1) events.value.unshift(event);
else events.value[idx] = event;
}
events.value.unshift(...result);
if (done) done(false);
} else {
const len = events.value.length;
@ -113,12 +104,7 @@ export default defineComponent({
offset: (index - 1) * 10,
limit: 10,
});
for (const event of result) {
const idx = events.value.findIndex((e) => e.id === event.id);
if (idx === -1) events.value.unshift(event);
else events.value[idx] = event;
}
if (len == events.value.length) {
if (len == events.value.push(...result)) {
if (done) return done(true);
} else if (done) done(false);
}
@ -131,10 +117,16 @@ export default defineComponent({
async function remove(id: number) {
if (await store.removeEvent(id)) {
const idx = events.value.findIndex((event) => event.id === id);
if (idx !== -1) {
events.value.splice(idx, 1);
// Successfull removed
for (const idx in agendas.value) {
const i = agendas.value[idx].findIndex((event) => event.id === id);
if (i !== -1) {
agendas.value[idx].splice(i, 1);
break;
}
}
} else {
// Not found, this means our eventa are outdated
}
}
@ -165,15 +157,6 @@ export default defineComponent({
}
}
function asDate(value: string) {
if (value) {
const year = parseInt(value.substring(0, 4));
const month = parseInt(value.substring(4, 6)) - 1;
const day = parseInt(value.substring(6, 8));
return date.formatDate(new Date(year, month, day), 'DD.MM.YYYY');
}
}
return {
agendas,
asYear,
@ -184,7 +167,6 @@ export default defineComponent({
editDone,
load,
remove,
asDate,
};
},
});

View File

@ -13,52 +13,26 @@
<div>
<q-select
:model-value="modelValue.services"
:disable="!canAssignOther || modelValue.locked"
:options="options"
option-value="userid"
filled
:option-label="(opt) => userDisplay(opt)"
multiple
use-input
disable
use-chips
stack-label
label="Dienste"
behavior="dialog"
class="col-auto q-px-xs"
@filter="filterUsers"
@add="({ value }) => assign(value)"
@remove="({ value }) => unassign(value)"
style="font-size: 6px"
counter
:max-values="modelValue.required_services"
>
<template #selected-item="{ opt, toggleOption }">
<service-user-chip :model-value="opt" removeable @remove="toggleOption" />
</template>
<template #option="{ opt, itemProps }">
<q-item v-bind="itemProps">
<q-item-section avatar>
<user-avatar :model-value="opt.userid" />
</q-item-section>
<q-item-section>{{ userDisplay(opt.userid) }}</q-item-section>
<q-item-section style="max-width: 10em" side>
<q-input
v-model.number="opt.value"
type="number"
min="0"
step="0.25"
dense
filled
@click.stop=""
/>
</q-item-section>
<q-item-section side>
<q-toggle v-model="opt.is_backup" label="Backup" left-label />
</q-item-section>
</q-item>
</template>
</q-select>
<div class="row col-12 justify-end">
<q-btn
v-if="!modelValue.locked && !isEnrolled && !isFull && canAssign"
v-if="!modelValue.locked && !isEnrolled && !isFull"
flat
color="primary"
label="Eintragen"
@click="assign()"
@click="assignJob()"
/>
<q-btn v-if="isEnrolled && !modelValue.locked" flat color="secondary" label="Optionen">
<q-menu auto-close>
@ -69,18 +43,8 @@
<q-item clickable @click="transfer">
<q-item-section>Tauschen</q-item-section>
</q-item>
<q-item v-if="isBackup" clickable @click="backup(false)">
<q-tooltip>Backup zu vollem Dienst machen</q-tooltip>
<q-item-section>Dienst</q-item-section>
<q-item-section side><q-icon name="mdi-eye" /></q-item-section>
</q-item>
<q-item v-else clickable @click="backup(true)">
<q-tooltip>Nur als Backup eintragen</q-tooltip>
<q-item-section>Backup</q-item-section>
<q-item-section side><q-icon name="mdi-eye-off" /></q-item-section>
</q-item>
<q-separator />
<q-item clickable @click="unassign()">
<q-item clickable @click="assignJob(false)">
<q-item-section class="text-negative">Austragen</q-item-section>
</q-item>
</q-list>
@ -92,20 +56,14 @@
</template>
<script lang="ts">
import { defineComponent, onBeforeMount, computed, PropType } from 'vue';
import { date, useQuasar } from 'quasar';
import { defineComponent, onBeforeMount, computed, ref, PropType } from 'vue';
import { asHour, hasPermission, useMainStore, useUserStore } from '@flaschengeist/api';
import { asHour, useMainStore, useUserStore } from '@flaschengeist/api';
import { useEventStore } from '../../../store';
import { PERMISSIONS } from '../../../permissions';
import TransferInviteDialog from './TransferInviteDialog.vue';
import ServiceUserChip from './ServiceUserChip.vue';
import { UserAvatar } from '@flaschengeist/api/components';
import { DisplayNameMode } from '@flaschengeist/users';
export default defineComponent({
name: 'JobSlot',
components: { ServiceUserChip, UserAvatar },
props: {
modelValue: {
required: true,
@ -123,65 +81,31 @@ export default defineComponent({
const userStore = useUserStore();
const quasar = useQuasar();
// Make sure users are loaded if we can assign them
onBeforeMount(() => {
void userStore.getUsers();
void userStore.getDisplayNameModeSetting(true);
});
onBeforeMount(async () => userStore.getUsers());
/* Stuff used for general display */
// Get displayname of user
function userDisplay(id: string) {
switch (userStore.userSettings.display_name) {
case DisplayNameMode.FIRSTNAME:
return userStore.findUser(id)?.firstname || id;
case DisplayNameMode.LASTNAME:
return userStore.findUser(id)?.lastname || id;
case DisplayNameMode.DISPLAYNAME:
return userStore.findUser(id)?.display_name || id;
case DisplayNameMode.FIRSTNAME_LASTNAME:
return (
`${<string>userStore.findUser(id)?.firstname} ${<string>(
userStore.findUser(id)?.lastname
)}` || id
);
case DisplayNameMode.LASTNAME_FIRSTNAME:
return (
`${<string>userStore.findUser(id)?.lastname}, ${<string>(
userStore.findUser(id)?.firstname
)}` || id
);
}
return userStore.findUser(id)?.display_name || id;
function userDisplay(service: FG.Service) {
return userStore.findUser(service.userid)?.display_name || service.userid;
}
// The name of the current job
const typeName = computed(() =>
typeof props.modelValue.type === 'object'
? props.modelValue.type.name
: store.jobTypes.find((j) => j.id === props.modelValue.type)?.name ||
'Unbekannter Diensttyp'
: store.jobTypes.find((j) => j.id === props.modelValue.type)?.name || 'Unbekannter Diensttyp'
);
// The service of the current user if self assigned to the job
const service = computed(() =>
props.modelValue.services.find((service) => service.userid == mainStore.currentUser.userid)
const isEnrolled = computed(
() =>
props.modelValue.services.findIndex(
(service) => service.userid == mainStore.currentUser.userid
) !== -1
);
// Weather the current user is assigned to the job
const isEnrolled = computed(() => service.value !== undefined);
// If the job has enough assigned services
const isFull = computed(
() =>
props.modelValue.services.map((s) => s.value).reduce((p, c) => p + c, 0) >=
props.modelValue.required_services
);
// If current user is only backup service
const isBackup = computed(() => service.value?.is_backup || false);
// If it is still possible to invite other users (= job is today or in the future)
const canInvite = computed(
() =>
(props.modelValue.end || props.modelValue.start) >
@ -191,15 +115,14 @@ export default defineComponent({
)
);
// Assign user to a job
async function assign(service?: FG.Service) {
service = service || {
async function assignJob(assign = true) {
const newService: FG.Service = {
userid: mainStore.currentUser.userid,
is_backup: false,
value: 1,
value: assign ? 1 : -1,
};
try {
const job = await store.assignToJob(props.modelValue.id, service);
const job = await store.assignToJob(props.modelValue.id, newService);
emit('update:modelValue', job);
} catch (error) {
console.warn(error);
@ -214,7 +137,6 @@ export default defineComponent({
}
}
// open invite dialog (or transfer)
function invite(isInvite = true) {
quasar.dialog({
component: TransferInviteDialog,
@ -225,75 +147,15 @@ export default defineComponent({
});
}
/* Stuff needed if we can assign other user */
// Current user can assign other users
const canAssignOther = computed(() => hasPermission(PERMISSIONS.ASSIGN_OTHER));
const canAssign = computed(() => hasPermission(PERMISSIONS.ASSIGN));
// options shown in the select
const options = ref([] as FG.Service[]);
// users which are available (e.g. not already assigned)
const freeUsers = computed(() =>
userStore.users.filter((u) => props.modelValue.services.every((s) => s.userid !== u.userid))
);
// used to filter options based on user input
function filterUsers(
input: string,
doneFn: (
callbackFn: () => void,
afterFn?: (ref: { [index: string]: unknown }) => void
) => void,
abortFn: () => void
) {
if (freeUsers.value.length == 0) return abortFn();
// Filter the options
doneFn(() => {
// Skip filter options if input is too short
if (!input || input.length < 2) {
options.value = freeUsers.value.map<FG.Service>((u) => ({
userid: u.userid,
value: 1,
is_backup: false,
}));
return;
}
// Search matching string within all names
options.value = freeUsers.value
.filter((u) =>
input
.toLowerCase()
.split(' ')
.every(
(needle) =>
u.display_name.toLowerCase().indexOf(needle) > -1 ||
u.firstname.toLowerCase().indexOf(needle) > -1 ||
u.lastname.toLowerCase().indexOf(needle) > -1
)
)
.map<FG.Service>((u) => ({ userid: u.userid, value: 1, is_backup: false }));
});
}
return {
assign,
unassign: (s?: FG.Service) => assign(Object.assign({}, s || service.value, { value: -1 })),
backup: (is_backup: boolean) => assign(Object.assign({}, service.value, { is_backup })),
canAssignOther,
canAssign,
assignJob,
canInvite,
filterUsers,
isBackup,
isEnrolled,
isFull,
invite: () => invite(true),
transfer: () => invite(false),
typeName,
userDisplay,
options,
asHour,
};
},

View File

@ -1,67 +0,0 @@
<template>
<q-chip
:removable="removeable"
:color="modelValue.is_backup ? 'grey' : undefined"
@remove="remove"
>
<q-tooltip>{{ displayName }} ({{ serviceValue }}x)</q-tooltip>
<user-avatar :model-value="modelValue.userid">
<slot v-if="modelValue.is_backup">
<q-icon name="mdi-eye-off" />
</slot>
</user-avatar>
<div class="ellipsis">{{ displayName }}</div>
<q-badge v-if="modelValue.value !== 1" :label="serviceValue" style="margin-left: 0.25em" />
<slot />
</q-chip>
</template>
<script lang="ts">
import { useUserStore } from '@flaschengeist/api';
import { PropType, computed, defineComponent, onBeforeMount, ref, watch } from 'vue';
import { UserAvatar } from '@flaschengeist/api/components';
export default defineComponent({
name: 'ServiceUserChip',
components: { UserAvatar },
props: {
modelValue: {
type: Object as PropType<FG.Service>,
required: true,
},
removeable: {
type: Boolean,
default: false,
},
},
emits: ['remove'],
setup(props, { emit }) {
const userStore = useUserStore();
const user = ref<FG.User>();
onBeforeMount(async () => {
user.value = await userStore.getUser(props.modelValue.userid);
});
watch(
() => props.modelValue,
async () => (user.value = await userStore.getUser(props.modelValue.userid))
);
const displayName = computed(() => user.value?.display_name || '...');
const serviceValue = computed(() =>
props.modelValue.value.toFixed(Number.isInteger(props.modelValue.value) ? 0 : 1)
);
return {
displayName,
remove: () => emit('remove', props.modelValue),
serviceValue,
};
},
});
</script>
<style></style>

View File

@ -13,8 +13,7 @@
filled
:options="otherUsers"
:option-label="(opt) => opt.display_name"
:option-disable="(opt) => !isInvite && invitees.length > 0 && opt != invitees[0]"
multiple
:multiple="isInvite"
use-chips
stack-label
label="Dienste"
@ -23,7 +22,7 @@
</q-card-section>
<!-- buttons example -->
<q-card-actions align="right">
<q-btn color="primary" label="Ok" :disable="invitees.length === 0" @click="invite" />
<q-btn color="primary" label="Ok" @click="invite" />
<q-btn color="primary" label="Abbrechen" @click="onDialogCancel" />
</q-card-actions>
</q-card>
@ -81,4 +80,4 @@ export default defineComponent({
};
},
});
</script>
</script>

View File

@ -10,11 +10,8 @@ const EventTypes = {
invite: 0x01,
transfer: 0x02,
invitation_response: 0x10,
invitation_accepted: 0x11,
invitation_rejected: 0x12,
info: 0x20,
info_accepted: 0x21,
info_rejected: 0x22,
invitation_accepted: 0x10,
invitation_rejected: 0x11,
};
function transpile(msg: FG_Plugin.Notification) {
@ -32,10 +29,7 @@ function transpile(msg: FG_Plugin.Notification) {
};
message.link = { name: 'events-requests' };
} else if (
(message.data.type & EventTypes._mask_) === EventTypes.invitation_response ||
(message.data.type & EventTypes._mask_) === EventTypes.info
) {
} else if ((message.data.type & EventTypes._mask_) === EventTypes.invitation_response) {
message.link = {
name: 'events-single-view',
params: { id: (<InvitationResponseData>message.data).event },
@ -45,7 +39,7 @@ function transpile(msg: FG_Plugin.Notification) {
}
const plugin: FG_Plugin.Plugin = {
id: 'events',
id: 'dev.flaschengeist.events',
name: 'Event schedule',
innerRoutes,
internalRoutes: privateRoutes,

View File

@ -44,8 +44,7 @@
</template>
<script lang="ts">
import { computed, defineComponent, ref, onBeforeMount, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { computed, defineComponent, ref } from 'vue';
import ManageTypes from '../components/management/ManageTypes.vue';
import EditEvent from '../components/management/EditEvent.vue';
import { hasPermission } from '@flaschengeist/api';
@ -71,21 +70,6 @@ export default defineComponent({
: []),
]);
const route = useRoute();
const router = useRouter();
onBeforeMount(async () => {
if (
(route.query.q_tab && route.query.q_tab === 'create') ||
route.query.q_tab === 'jobtypes' ||
route.query.q_tab === 'eventtypes'
) {
tab.value = route.query.q_tab;
} else {
await router.replace({ query: { q_tab: tab.value } });
}
});
const drawer = ref<boolean>(false);
const tab = ref<string>('create');
@ -98,10 +82,6 @@ export default defineComponent({
},
});
watch(tab, async (val) => {
await router.replace({ query: { q_tab: val } });
});
return {
showDrawer,
tab,

View File

@ -43,39 +43,21 @@
</template>
<script lang="ts">
import { computed, defineComponent, ref, onBeforeMount, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { computed, defineComponent, ref } from 'vue';
import { useQuasar } from 'quasar';
import AgendaView from '../components/overview/AgendaView.vue';
import ListView from '../components/overview/ListView.vue';
import { useEventStore } from '../store';
export default defineComponent({
name: 'EventOverview',
components: { AgendaView, ListView },
setup() {
const store = useEventStore();
const route = useRoute();
const router = useRouter();
const tab = ref<string>('agendaView');
onBeforeMount(async () => {
void store.getJobTypes();
if (
Object.keys(route.query).includes('q_tab') &&
(route.query.q_tab === 'listView' || route.query.q_tab === 'agendaView')
) {
tab.value = route.query.q_tab as string;
} else {
await router.replace({ query: { ...route.query, q_tab: tab.value } });
}
});
const quasar = useQuasar();
const tabs = computed(() => [
const tabs = computed(() => ([
{ name: 'listView', label: 'Liste' },
{ name: 'agendaView', label: 'Kalendar' },
]);
{ name: 'agendaView', label: 'Kalendar' }
]));
const drawer = ref<boolean>(false);
@ -86,10 +68,7 @@ export default defineComponent({
},
});
watch(tab, async (val) => {
console.log(val);
await router.replace({ query: { ...route.query, q_tab: val } });
});
const tab = ref<string>('agendaView');
return {
showDrawer,

View File

@ -1,320 +1,8 @@
<template>
<q-page padding>
<q-table
v-model:pagination="pagination"
title="Dienstanfragen"
:rows="rows"
:columns="columns"
row-key="id"
:loading="loading"
binary-state-sort
@request="onRequest"
>
<template #top-right>
<q-toggle v-model="showSent" dense label="Gesendete anzeigen" />
</template>
<template #body-cell-inviter="props">
<q-td :props="props">
<div>
{{ props.value.with
}}<q-icon v-if="props.value.sender" name="mdi-account-alert">
<q-tooltip>Gesendet von {{ props.value.sender }}</q-tooltip>
</q-icon>
</div>
</q-td>
</template>
<template #body-cell-type="props">
<q-td :props="props">
<q-icon size="sm" :name="types[props.value].icon">
<q-tooltip>{{ types[props.value].tooltip }}</q-tooltip>
</q-icon>
</q-td>
</template>
<template #body-cell-actions="props">
<q-td :props="props">
<!-- <q-btn v-for="action in props.value" :key="action.icon" :icon="action.icon" dense /> -->
<div class="row justify-end">
<div v-for="action in props.value" :key="action.icon">
<q-btn
class="q-mx-xs"
:icon="action.icon"
dense
@click="action.onClick"
round
:color="action.color"
>
<q-tooltip>{{ action.tooltip }}</q-tooltip>
</q-btn>
</div>
</div>
</q-td>
</template>
</q-table>
<q-card>
<q-card-section class="row"> </q-card-section>
<q-card-section> </q-card-section>
</q-card>
</q-page>
</template>
<script lang="ts">
import { formatStartEnd, useMainStore, useUserStore } from '@flaschengeist/api';
import { computed, defineComponent, ref, onBeforeMount, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { QTableProps } from 'quasar';
import { Job } from '../store/models';
import { useEventStore } from '../store';
import { EventNotification, InvitationData, InvitationResponseData } from '../events';
export default defineComponent({
name: 'PageEventRequests',
setup() {
const store = useEventStore();
const userStore = useUserStore();
const mainStore = useMainStore();
const router = useRouter();
const route = useRoute();
interface RowData extends FG.Invitation {
inviter: FG.User;
invitee: FG.User;
transferee?: FG.User;
job: Job;
}
// Generated data used for the table
const rows = ref([] as RowData[]);
// Loading state for data generation (getting Job information)
const loading = ref(false);
// Which "page" of invitations to show (we load all, but do not fetch all jobs)
const pagination = ref({
sortBy: 'desc',
descending: false,
page: 1,
rowsPerPage: 3,
rowsNumber: 4,
});
// Real invitations
const invitations = computed(() =>
showSent.value
? store.invitations
: store.invitations.filter((i) => i.inviter_id !== mainStore.currentUser.userid)
);
const all_notifications = computed<EventNotification[]>(() => {
return mainStore.notifications.filter((n) => n.plugin === 'events') as EventNotification[];
});
const showSent = ref(false);
async function fillRows(data: FG.Invitation[]) {
const res = [] as RowData[];
for (let i = 0; i < data.length; ++i) {
res.push(
Object.assign({}, data[i], {
inviter: <FG.User>await userStore.getUser(data[i].inviter_id),
invitee: <FG.User>await userStore.getUser(data[i].invitee_id),
transferee: data[i].transferee_id
? await userStore.getUser(<string>data[i].transferee_id)
: undefined,
job: new Job(await store.getJob(data[i].job_id)),
})
);
}
rows.value = res;
}
type onRequestType = QTableProps['onRequest'];
const onRequest: onRequestType = (requestProp) => {
const { page, rowsPerPage, sortBy, descending } = requestProp.pagination;
loading.value = true;
// Number of total invitations
pagination.value.rowsNumber = invitations.value.length;
// calculate starting row of data
const startRow = (page - 1) * rowsPerPage;
// get all rows if "All" (0) is selected
const fetchCount =
rowsPerPage === 0
? pagination.value.rowsNumber
: Math.min(pagination.value.rowsNumber - startRow, rowsPerPage);
// copy array, as sort is in-place
function sorting<T = any>(key: string | keyof T, descending = true) {
return (a: T, b: T) => {
const v1 = a[key as keyof T];
if (v1 === undefined) return descending ? -1 : 1;
const v2 = b[key as keyof T];
if (v2 === undefined) return descending ? 1 : -1;
return (v1 < v2 ? -1 : 1) * (descending ? -1 : 1);
};
}
// Set table data
fillRows(
[...invitations.value]
.sort(sorting(sortBy, descending))
.slice(startRow, startRow + fetchCount)
)
.then(() => {
pagination.value.page = page;
pagination.value.rowsPerPage = rowsPerPage;
pagination.value.sortBy = sortBy;
pagination.value.descending = descending;
})
.finally(() => (loading.value = false));
};
onBeforeMount(async () => {
if (route.query.sent === 'true') {
showSent.value = true;
}
void Promise.allSettled([
userStore.getUsers(),
store.getInvitations(),
store.getJobTypes(),
]).then(() =>
onRequest({ pagination: pagination.value, filter: () => [], getCellValue: () => [] })
);
});
watch(showSent, async () => {
onRequest({ pagination: pagination.value, filter: () => [], getCellValue: () => [] });
await router.replace({ query: { sent: showSent.value ? 'true' : 'false' } });
});
function getType(row: RowData) {
var idx = row.transferee === undefined ? 0 : 1;
if (row.inviter.userid === mainStore.currentUser.userid) idx += 2;
return idx;
}
const dimmed = (row: RowData) => (getType(row) >= types.length / 2 ? 'dimmed' : undefined);
const columns = [
{
label: 'Type',
name: 'type',
align: 'left',
field: getType,
sortable: true,
classes: dimmed,
},
{
label: 'Dienstart',
align: 'left',
name: 'job_type',
sortable: true,
classes: dimmed,
field: (row: RowData) =>
store.jobTypes.find((t) => t.id == row.job.type)?.name || 'Unbekannt',
},
{
label: 'Wann',
align: 'center',
sortable: true,
name: 'job_start',
classes: dimmed,
field: (row: RowData) => formatStartEnd(row.job.start, row.job.end) + ' Uhr',
},
{
label: 'Von / Mit',
name: 'inviter',
align: 'center',
classes: dimmed,
field: (row: RowData) => {
const sender =
row.transferee_id && row.transferee_id !== row.inviter_id
? row.inviter.display_name
: undefined;
if (row.invitee_id === mainStore.currentUser.userid) {
return {
with: row.transferee ? row.transferee.display_name : row.inviter.display_name,
sender,
};
}
if (row.transferee_id === mainStore.currentUser.userid) {
return {
with: row.invitee.display_name,
sender,
};
}
return {
with: !row.transferee
? row.invitee.display_name
: `${row.transferee.display_name} <-> ${row.invitee.display_name}`,
};
},
},
{
label: 'Aktionen',
align: 'right',
name: 'actions',
classes: dimmed,
field: (row: RowData) => {
const sender = row.inviter_id === mainStore.currentUser.userid;
let actions = [];
const reject = {
icon: 'mdi-delete',
tooltip: 'Einladung löschen',
color: 'negative',
onClick: () => {
void store.rejectInvitation(row.id).then(() => {
onRequest({
pagination: pagination.value,
filter: () => [],
getCellValue: () => [],
});
const notification = all_notifications.value.find(
(n) => (<InvitationData>n.data).invitation === row.id
);
if (notification !== undefined) {
void mainStore.removeNotification(notification.id);
}
});
},
};
const accept = {
icon: 'mdi-check',
tooltip: 'Einladung annehmen',
color: 'primary',
onClick: () => {
void store.acceptInvitation(row.id).then(() => {
onRequest({
pagination: pagination.value,
filter: () => [],
getCellValue: () => [],
});
const notification = all_notifications.value.find(
(n) => (<InvitationData>n.data).invitation === row.id
);
if (notification !== undefined) {
void mainStore.removeNotification(notification.id);
}
});
},
};
if (sender) {
actions.push(reject);
} else if (row.invitee_id === mainStore.currentUser.userid) {
actions.push(accept);
actions.push({ ...reject, icon: 'mdi-close' });
}
return actions;
},
},
];
const types = [
{ icon: 'mdi-calendar', tooltip: 'Einladung' },
{ icon: 'mdi-calendar-sync', tooltip: 'Tauschanfrage' },
{ icon: 'mdi-calendar-outline', tooltip: 'Einladung (von dir)' },
{ icon: 'mdi-calendar-sync-outline', tooltip: 'Tauschanfrage (von dir)' },
];
return {
columns,
loading,
onRequest,
pagination,
rows,
showSent,
types,
};
},
});
</script>

View File

@ -22,16 +22,6 @@ export const innerRoutes: FG_Plugin.MenuRoute[] = [
component: () => import('../pages/EventOverview.vue'),
},
},
{
title: 'Dienstanfragen',
icon: 'mdi-account-switch',
shortcut: false,
route: {
path: 'events-requests',
name: 'events-requests',
component: () => import('../pages/EventRequests.vue'),
},
},
{
title: 'Dienstverwaltung',
icon: 'mdi-account-details',
@ -44,6 +34,16 @@ export const innerRoutes: FG_Plugin.MenuRoute[] = [
props: (route) => ({ date: route.query.date }),
},
},
{
title: 'Dienstanfragen',
icon: 'mdi-account-switch',
shortcut: false,
route: {
path: 'events-requests',
name: 'events-requests',
component: () => import('../pages/EventRequests.vue'),
},
},
],
},
];

View File

@ -1,7 +1,6 @@
import { api, isAxiosError } from '@flaschengeist/api';
import { defineStore } from 'pinia';
import { EditableEvent } from './models';
import { Notify } from 'quasar';
/**
* Convert JSON decoded Job to real job (fix Date object)
@ -29,7 +28,6 @@ export const useEventStore = defineStore({
jobTypes: [] as FG.JobType[],
eventTypes: [] as FG.EventType[],
templates: [] as FG.Event[],
invitations: [] as FG.Invitation[],
}),
getters: {},
@ -147,16 +145,19 @@ export const useEventStore = defineStore({
},
async addEvent(event: EditableEvent) {
console.log('addEvent', event);
if (event?.id === undefined) {
const { data } = await api.post<FG.Event>('/events', event);
if (data.is_template) this.templates.push(data);
fixEvent(data);
return data;
} else {
if (typeof event.type === 'object') event.type = event.type.id;
const { data } = await api.put<FG.Event>(`/events/${event.id}`, event);
const { data } = await api.put<FG.Event>(
`/events/${event.id}`,
Object.assign(event, { jobs: undefined })
);
if (data.is_template) this.templates.push(data);
fixEvent(data);
return data;
}
},
@ -167,10 +168,6 @@ export const useEventStore = defineStore({
.then(({ data }) => fixJob(data));
},
async getJob(id: number) {
return api.get<FG.Job>(`/events/jobs/${id}`).then(({ data }) => fixJob(data));
},
async getJobs(filter?: FG.PaginationFilter) {
return api
.get<FG.PaginationResponse<FG.Job>>('/events/jobs', { params: <unknown>filter })
@ -190,60 +187,18 @@ export const useEventStore = defineStore({
return api.post<FG.Invitation[]>('/events/invitations', {
job: job.id,
invitees: invitees.map((v) => v.userid),
transferee: transferee?.userid,
transferee: transferee,
});
},
async getInvitations(force = false) {
if (this.invitations.length == 0 || force) {
const { data } = await api.get<FG.Invitation[]>('/events/invitations');
this.invitations = data;
}
return this.invitations;
},
async rejectInvitation(invite: FG.Invitation | number) {
try {
await api.delete(`/events/invitations/${typeof invite === 'number' ? invite : invite.id}`);
const idx = this.invitations.findIndex((v) => v.id === (invite.id || invite));
if (idx >= 0) this.invitations.splice(idx, 1);
notify_success('Einladung für erfolgreich abgelehnt');
} catch (e) {
notify_failure();
}
return api.delete(`/events/invitations/${typeof invite === 'number' ? invite : invite.id}`);
},
async acceptInvitation(invite: FG.Invitation | number) {
try {
await api.put(`/events/invitations/${typeof invite === 'number' ? invite : invite.id}`, {
accept: true,
});
const idx = this.invitations.findIndex((v) => v.id === (invite.id || invite));
if (idx >= 0) this.invitations.splice(idx, 1);
notify_success('Einladung für erfolgreich angenommen');
} catch (e) {
notify_failure();
}
return api.put(`/events/invitations/${typeof invite === 'number' ? invite : invite.id}`, {
accept: true,
});
},
},
});
function notify_failure() {
Notify.create({
message: 'Es ist ein Fehler aufgetreten.',
color: 'negative',
group: false,
timeout: 10000,
actions: [{ icon: 'mdi-close', color: 'white' }],
});
}
function notify_success(msg: string) {
Notify.create({
message: msg,
color: 'positive',
group: false,
timeout: 5000,
actions: [{ icon: 'mdi-close', color: 'white' }],
});
}

View File

@ -4,76 +4,32 @@ import { date } from 'quasar';
export type EditableEvent = Omit<Omit<Omit<FG.Event, 'jobs'>, 'type'>, 'id'> & {
type?: FG.EventType | number;
id?: number;
jobs: Job[];
jobs: EditableJob[];
};
export class Job implements FG.Job {
id = NaN;
start: Date;
end?: Date = undefined;
type: FG.JobType | number = NaN;
comment?: string = undefined;
locked = false;
services = [] as FG.Service[];
required_services = 0;
/** A new job does not have an id or type assigned */
export type EditableJob = Omit<Omit<FG.Job, 'type'>, 'id'> & {
type?: FG.EventType | number;
id?: number;
};
/**
* Build Job from API Job interface
* @param iJob Object following the API Job interface
*/
constructor(iJob?: Partial<FG.Job>) {
if (!iJob || iJob.start === undefined)
this.start = date.buildDate({
hours: new Date().getHours(),
minutes: 0,
seconds: 0,
milliseconds: 0,
});
else this.start = new Date(); // <-- make TS happy "no initalizer"
if (!iJob || iJob.end === undefined) {
this.end = date.buildDate({
hours: new Date().getHours() + 4,
minutes: 0,
seconds: 0,
milliseconds: 0,
});
}
if (iJob !== undefined) Object.assign(this, iJob);
}
/**
* Create Job from start Date
* @param start when does the event start?
* @param adjustTime Set hours to current value, zero minutes and seconds
* @param duration How long should the job go? Value in hours or undefined
* @returns new Job event
*/
static fromDate(start: Date, adjustTime = true, duration?: number) {
if (adjustTime)
start = date.adjustDate(start, {
hours: new Date().getHours(),
minutes: 0,
seconds: 0,
milliseconds: 0,
});
return new Job({
start: start,
end: duration === undefined ? undefined : date.addToDate(start, { hours: duration }),
});
}
/**
* Check if this instance was loaded from API
*/
isPersistent() {
return !isNaN(this.id);
}
export function emptyJob(startDate = new Date()): EditableJob {
const start = date.adjustDate(startDate, {
hours: new Date().getHours(),
});
return {
start: start,
end: date.addToDate(start, { hours: 1 }),
services: [],
locked: false,
required_services: 2,
};
}
export function emptyEvent(startDate: Date = new Date()): EditableEvent {
return {
start: date.adjustDate(startDate, { hours: 0, minutes: 0, seconds: 0, milliseconds: 0 }),
jobs: [Job.fromDate(startDate, true, 4)],
jobs: [emptyJob(startDate)],
is_template: false,
};
}

View File

@ -1,5 +1,5 @@
{
"extends": "@quasar/app-webpack/tsconfig-preset",
"extends": "@quasar/app/tsconfig-preset",
"target": "esnext",
"compilerOptions": {
"baseUrl": "./src/",