Compare commits

..

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

42 changed files with 752 additions and 3506 deletions

View File

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

9
.gitignore vendored
View File

@ -1,13 +1,4 @@
node_modules
node_modules/ node_modules/
yarn-error.log yarn-error.log
# No need, this is done by user # No need, this is done by user
yarn.lock 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 # 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). 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", "license": "MIT",
"version": "1.1.0", "version": "1.0.0-alpha.4",
"name": "@flaschengeist/schedule", "name": "@flaschengeist/schedule",
"author": "Ferdinand Thiessen <rpm@fthiessen.de>", "author": "Ferdinand Thiessen <rpm@fthiessen.de>",
"homepage": "https://flaschengeist.dev/Flaschengeist", "homepage": "https://flaschengeist.dev/Flaschengeist",
@ -15,31 +15,29 @@
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/api.d.ts", "types": "src/api.d.ts",
"scripts": { "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" "lint": "eslint --ext .js,.ts,.vue ./src"
}, },
"dependencies": { "dependencies": {
"@quasar/quasar-ui-qcalendar": "^4.0.0-beta.11" "@quasar/quasar-ui-qcalendar": "^4.0.0-beta.10"
}, },
"devDependencies": { "devDependencies": {
"@flaschengeist/api": "^1.0.0", "@flaschengeist/types": "^1.0.0-alpha.5",
"@flaschengeist/types": "^1.0.0", "@quasar/app": "^3.2.2",
"@quasar/app-webpack": "^3.7.2", "quasar": "^2.3.2",
"@typescript-eslint/eslint-plugin": "^5.8.0",
"@typescript-eslint/parser": "^5.8.0",
"axios": "^0.24.0", "axios": "^0.24.0",
"eslint": "^8.5.0", "prettier": "^2.4.1",
"typescript": "^4.4.4",
"pinia": "^2.0.3",
"@typescript-eslint/eslint-plugin": "^5.4.0",
"@typescript-eslint/parser": "^5.4.0",
"eslint": "^8.2.0",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0", "eslint-plugin-vue": "^8.0.3"
"eslint-plugin-vue": "^8.2.0",
"pinia": "^2.0.8",
"prettier": "^2.5.1",
"quasar": "^2.11.10",
"typescript": "^4.5.4"
}, },
"peerDependencies": { "peerDependencies": {
"@flaschengeist/api": "^1.0.0", "@flaschengeist/api": "^1.0.0-alpha.2",
"@flaschengeist/users": "^1.0.0" "@flaschengeist/users": "^1.0.0-alpha.1"
}, },
"prettier": { "prettier": {
"singleQuote": true, "singleQuote": true,

7
src/api.d.ts vendored
View File

@ -13,13 +13,11 @@ declare namespace FG {
id: number; id: number;
name: string; name: string;
} }
interface Invitation { interface Invite {
id: number; id: number;
time: Date;
job_id: number; job_id: number;
invitee_id: string; invitee_id: string;
inviter_id: string; sender_id: string;
transferee_id?: string;
} }
interface Job { interface Job {
id: number; id: number;
@ -27,7 +25,6 @@ declare namespace FG {
end?: Date; end?: Date;
type: JobType | number; type: JobType | number;
comment?: string; comment?: string;
locked: boolean;
services: Array<Service>; services: Array<Service>;
required_services: number; required_services: number;
} }

View File

@ -1,53 +1,25 @@
<template> <template>
<q-card style="text-align: center"> <q-card class="row justify-center content-center" style="text-align: center">
<q-card-section class="row justify-center items-center content-center"> <q-card-section>
<div class="col-5"> <div class="text-h6 col-12">Dienste diesen Monat: {{ jobs }}</div>
<q-icon :name="jobs == 0 ? 'mdi-calendar-blank' : 'mdi-calendar-alert'" :size="divHeight" /> <!--TODO: Filters are deprecated! -->
</div> <!--<div class="text-h6 col-12">Nächster Dienst: {{ nextJob | date }}</div>-->
<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>
<div class="text-body1">{{ formatDate(nextJob) }}</div>
</div>
<div v-else ref="div" class="col-7">
<div class="text-subtitle1">Keine anstehenden Dienste</div>
</div>
</q-card-section> </q-card-section>
</q-card> </q-card>
</template> </template>
<script lang="ts"> <script lang="ts">
import { date } from 'quasar'; import { defineComponent } from 'vue';
import { computed, defineComponent, onBeforeMount, ref } from 'vue';
import { asHour, formatDateTime } from '@flaschengeist/api';
import { useEventStore } from '../store';
export default defineComponent({ export default defineComponent({
name: 'EventsWidget', name: 'EventsWidget',
setup() { setup() {
const store = useEventStore(); function randomNumber(start: number, end: number) {
return start + Math.floor(Math.random() * Math.floor(end));
const jobs = ref<number>();
const nextJob = ref<Date>();
const div = ref<HTMLElement>();
const divHeight = computed(() => `${div.value?.scrollHeight || '100'}px`);
onBeforeMount(() => {
void store.getJobs({ limit: 1, from: new Date() }).then(({ count, result }) => {
jobs.value = count;
nextJob.value = count > 0 ? result[0].start : undefined;
});
});
function formatDate(d?: Date) {
if (d === undefined) return '-';
if (date.isSameDate(d, new Date(), 'day')) return `Heute ${asHour(d)} Uhr`;
return formatDateTime(d, true, true, false, true) + ' Uhr';
} }
const jobs = randomNumber(0, 5);
return { div, divHeight, formatDate, jobs, nextJob }; const nextJob = new Date(2021, randomNumber(1, 12), randomNumber(1, 31));
return { jobs, nextJob };
}, },
}); });
</script> </script>

View File

@ -51,8 +51,6 @@
v-model="event.end" v-model="event.end"
class="col-xs-12 col-sm-6 q-pa-sm" class="col-xs-12 col-sm-6 q-pa-sm"
label="Veranstaltungsende" label="Veranstaltungsende"
:rules="[afterStart]"
:key="update_time"
/> />
<q-input <q-input
v-model="event.description" v-model="event.description"
@ -62,7 +60,7 @@
filled filled
/> />
</q-card-section> </q-card-section>
<q-card-section v-if="modelValue === undefined"> <q-card-section v-if="event.is_template !== true && modelValue === undefined">
<q-btn-toggle <q-btn-toggle
v-model="recurrent" v-model="recurrent"
spread spread
@ -76,22 +74,16 @@
</q-card-section> </q-card-section>
<q-separator /> <q-separator />
<q-card-section> <q-card-section>
<div class="row justify-around q-mb-sm" align="around"> <q-btn color="primary" label="Schicht hinzufügen" @click="addJob()" />
<div class="text-h6 text-center col-6">Schichten</div> </q-card-section>
<div class="col-6 text-center"> <q-card-section v-for="(job, index) in event.jobs" :key="index">
<q-btn color="primary" label="Schicht hinzufügen" @click="addJob()" /> <q-card class="q-my-auto">
</div> <job
</div>
<template v-for="(job, index) in event.jobs" :key="index + update_time">
<edit-job-slot
ref="activeJob"
v-model="event.jobs[index]" v-model="event.jobs[index]"
:active="index === active" :job-can-delete="jobDeleteDisabled"
class="q-mb-md"
@remove-job="removeJob(index)" @remove-job="removeJob(index)"
@activate="activate(index)"
/> />
</template> </q-card>
</q-card-section> </q-card-section>
<q-card-actions align="around"> <q-card-actions align="around">
<q-card-actions align="left"> <q-card-actions align="left">
@ -108,22 +100,17 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, PropType, ref, onBeforeMount } from 'vue';
import { date, ModifyDateOptions } from 'quasar';
import { useScheduleStore } from '../../store';
import { notEmpty } from '@flaschengeist/api'; import { notEmpty } from '@flaschengeist/api';
import { IsoDateInput } from '@flaschengeist/api/components'; import { IsoDateInput } from '@flaschengeist/api/components';
import Job from './Job.vue';
import { useEventStore } from '../../store'; import RecurrenceRule from './RecurrenceRule.vue';
import { emptyEvent, Job, EditableEvent } from '../../store/models';
import { date, DateOptions } from 'quasar';
import { computed, defineComponent, PropType, ref, onBeforeMount, watch } from 'vue';
import EditJobSlot from './EditJobSlot.vue';
import RecurrenceRuleVue from './RecurrenceRule.vue';
import { RecurrenceRule } from 'app/events';
export default defineComponent({ export default defineComponent({
name: 'EditEvent', name: 'EditEvent',
components: { IsoDateInput, EditJobSlot, RecurrenceRule: RecurrenceRuleVue }, components: { IsoDateInput, Job, RecurrenceRule },
props: { props: {
modelValue: { modelValue: {
required: false, required: false,
@ -132,25 +119,40 @@ export default defineComponent({
}, },
date: { date: {
required: false, required: false,
default: undefined, default: '',
type: String as PropType<string | undefined>, type: String
}, }
}, },
emits: { emits: {
done: (val: boolean) => typeof val === 'boolean', done: (val: boolean) => typeof val === 'boolean',
'update:modelValue': (val?: FG.Event) => typeof val === 'object',
}, },
setup(props, { emit }) { setup(props, { emit }) {
const store = useEventStore(); const store = useScheduleStore();
const emptyJob = {
id: NaN,
start: date.adjustDate(new Date(props.date), {hours: (new Date()).getHours()}),
end: date.addToDate(date.adjustDate(new Date(props.date), {hours: (new Date()).getHours()}), {hours: 1}),
services: [],
required_services: 2,
type: store.jobTypes[0],
};
const emptyEvent = {
id: NaN,
start: new Date(props.date),
jobs: [Object.assign({}, emptyJob)],
type: store.eventTypes[0],
is_template: false,
};
const active = ref(0);
const activeJob = ref<{ validate: () => Promise<boolean> }[]>([]);
const templates = computed(() => store.templates); const templates = computed(() => store.templates);
const template = ref<FG.Event>(); const template = ref<FG.Event | undefined>(undefined);
const event = ref<EditableEvent>(props.modelValue || emptyEvent()); const event = ref<FG.Event>(props.modelValue || Object.assign({}, emptyEvent));
const eventtypes = computed(() => store.eventTypes); const eventtypes = computed(() => store.eventTypes);
const jobDeleteDisabled = computed(() => event.value.jobs.length < 2);
const recurrent = ref(false); const recurrent = ref(false);
const recurrenceRule = ref<RecurrenceRule>({ frequency: 'daily', interval: 1 }); const recurrenceRule = ref<FG.RecurrenceRule>({ frequency: 'daily', interval: 1 });
onBeforeMount(() => { onBeforeMount(() => {
void store.getEventTypes(); void store.getEventTypes();
@ -158,70 +160,26 @@ export default defineComponent({
void store.getTemplates(); void store.getTemplates();
}); });
watch(
() => props.modelValue,
(newModelValue) => {
if (event.value?.id !== newModelValue?.id) reset();
}
);
function addJob() { function addJob() {
if (!activeJob.value[active.value]) { event.value.jobs.push(Object.assign({}, emptyJob));
event.value.jobs.push(new Job());
} else
void activeJob.value[active.value].validate().then((success) => {
if (success) {
event.value.jobs.push(new Job());
active.value = event.value.jobs.length - 1;
}
});
} }
function removeJob(index: number) { function removeJob(index: number) {
event.value.jobs.splice(index, 1); event.value.jobs.splice(index, 1);
if (active.value >= index) active.value--;
} }
function fromTemplate(tpl: FG.Event) { function fromTemplate(tpl: FG.Event) {
const today = props.modelValue?.start || new Date();
template.value = tpl; template.value = tpl;
event.value = Object.assign({}, tpl);
event.value = Object.assign({}, tpl, { id: undefined });
// Adjust the start to match today
event.value.start = date.adjustDate(event.value.start, {
date: today.getDate(),
month: today.getMonth() + 1, // js inconsitency between getDate (1-31) and getMonth (0-11)
year: today.getFullYear(),
});
// Use timestamp difference for faster adjustment
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);
});
} }
async function save(is_template = false) { async function save(template = false) {
event.value.is_template = is_template; event.value.is_template = template;
try { try {
const _event = await store.addEvent(event.value); await store.addEvent(event.value);
emit('update:modelValue', _event);
if (props.modelValue === undefined && recurrent.value && !event.value.is_template) { if (props.modelValue === undefined && recurrent.value && !event.value.is_template) {
let count = 0; let count = 0;
const options: DateOptions = {}; const options: ModifyDateOptions = {};
switch (recurrenceRule.value.frequency) { switch (recurrenceRule.value.frequency) {
case 'daily': case 'daily':
options['days'] = 1 * recurrenceRule.value.interval; options['days'] = 1 * recurrenceRule.value.interval;
@ -265,86 +223,25 @@ export default defineComponent({
} }
function reset() { function reset() {
event.value = Object.assign({}, props.modelValue || emptyEvent()); event.value = Object.assign({}, props.modelValue || emptyEvent);
active.value = 0;
template.value = undefined; template.value = undefined;
} }
const afterStart = (d: Date) =>
!d || event.value.start <= d || 'Das Veranstaltungsende muss vor dem Beginn liegen';
function activate(idx: number) {
void activeJob.value[active.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 { return {
update_time, jobDeleteDisabled,
activate,
active,
addJob, addJob,
activeJob,
afterStart,
event,
eventtypes, eventtypes,
fromTemplate,
notEmpty,
recurrenceRule,
recurrent,
removeJob,
removeTemplate,
reset,
save,
template,
templates, templates,
removeJob,
notEmpty,
save,
reset,
recurrent,
fromTemplate,
removeTemplate,
template,
recurrenceRule,
event,
}; };
}, },
}); });

View File

@ -1,155 +0,0 @@
<template>
<q-card class="fit">
<q-card-section
v-if="!active"
class="fit row justify-start content-center items-center text-center"
@click="$emit('activate')"
>
<div class="text-h6 col-12">{{ formatStartEnd(modelValue.start, modelValue.end) }}</div>
<div class="text-subtitle1 col-12">{{ typeName }} ({{ modelValue.required_services }})</div>
<div class="text-body2 text-italic text-left col-12">{{ modelValue.comment }}</div>
</q-card-section>
<q-card-section v-else>
<q-form ref="form" class="fit row justify-start content-center items-center">
<IsoDateInput
v-model="job.start"
class="col-xs-12 col-sm-6 q-pa-sm"
label="Beginn"
type="datetime"
:rules="[notEmpty]"
/>
<IsoDateInput
v-model="job.end"
class="col-xs-12 col-sm-6 q-pa-sm"
label="Ende"
type="datetime"
:rules="[notEmpty, isAfterDate]"
/>
<q-select
v-model="job.type"
filled
use-input
label="Dienstart"
input-debounce="0"
class="col-xs-12 col-sm-6 q-pa-sm"
:options="jobtypes"
:option-label="(jobtype) => (typeof jobtype === 'number' ? '' : jobtype.name)"
option-value="id"
map-options
clearable
:rules="[notEmpty]"
/>
<q-input
v-model.number="job.required_services"
filled
class="col-xs-12 col-sm-6 q-pa-sm"
label="Dienstanzahl"
type="number"
:rules="[minOneService, notEmpty]"
/>
<q-input
v-model="job.comment"
class="col-12 q-pa-sm"
label="Kommentar"
type="textarea"
filled
/>
</q-form>
</q-card-section>
<q-card-actions>
<q-btn
label="Schicht löschen"
color="negative"
:disabled="canDelete"
@click="$emit('remove-job')"
/>
</q-card-actions>
</q-card>
</template>
<script lang="ts">
import { defineComponent, computed, onBeforeMount, ref, PropType } from 'vue';
import { IsoDateInput } from '@flaschengeist/api/components';
import { formatStartEnd, notEmpty } from '@flaschengeist/api';
import { useEventStore } from '../../store';
import { QForm } from 'quasar';
export default defineComponent({
name: 'JobSlot',
components: { IsoDateInput },
props: {
active: {
type: Boolean,
required: true,
},
modelValue: {
required: true,
type: Object as PropType<FG.Job>,
},
canDelete: {
type: Boolean,
default: false,
},
},
emits: {
activate: () => true,
'remove-job': () => true,
'update:modelValue': (job: FG.Job) => !!job,
},
setup(props, { emit, expose }) {
const store = useEventStore();
onBeforeMount(() => store.getJobTypes());
const form = ref<QForm>();
const jobtypes = computed(() => store.jobTypes);
const typeName = computed(() =>
typeof props.modelValue.type === 'object'
? props.modelValue.type.name
: jobtypes.value.find((j) => j.id === props.modelValue.type)?.name || 'Kein Typ gesetzt!'
);
const job = new Proxy(props.modelValue, {
get(target, prop) {
if (typeof prop === 'string') {
return (props.modelValue as unknown as Record<string, unknown>)[prop];
}
},
set(obj, prop, value) {
if (typeof prop === 'string') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
emit('update:modelValue', Object.assign({}, props.modelValue, { [prop]: value }));
}
return true;
},
});
function isAfterDate(val: Date) {
return props.modelValue.start < val || 'Ende muss hinter dem Start liegen';
}
expose({
validate: () => form.value?.validate() || Promise.resolve(true),
});
function minOneService(val: number) {
return parseInt(val) > 0 || 'Mindestens ein Dienst nötig';
}
return {
form,
formatStartEnd,
isAfterDate,
job,
jobtypes,
notEmpty,
minOneService,
typeName,
};
},
});
</script>
<style></style>

View File

@ -0,0 +1,113 @@
<template>
<q-card-section class="fit row justify-start content-center items-center">
<q-card-section class="fit row justify-start content-center items-center">
<IsoDateInput
v-model="job.start"
class="col-xs-12 col-sm-6 q-pa-sm"
label="Beginn"
type="datetime"
:rules="[notEmpty]"
/>
<IsoDateInput
v-model="job.end"
class="col-xs-12 col-sm-6 q-pa-sm"
label="Ende"
type="datetime"
:rules="[notEmpty, isAfterDate]"
/>
<q-select
v-model="job.type"
filled
use-input
label="Dienstart"
input-debounce="0"
class="col-xs-12 col-sm-6 q-pa-sm"
:options="jobtypes"
option-label="name"
option-value="id"
map-options
clearable
:rules="[notEmpty]"
/>
<q-input
v-model="job.required_services"
filled
class="col-xs-12 col-sm-6 q-pa-sm"
label="Dienstanzahl"
type="number"
:rules="[notEmpty]"
/>
<q-input
v-model="job.comment"
class="col-12 q-pa-sm"
label="Beschreibung"
type="textarea"
filled
/>
</q-card-section>
<q-btn label="Schicht löschen" color="negative" :disabled="jobCanDelete" @click="removeJob" />
</q-card-section>
</template>
<script lang="ts">
import { defineComponent, computed, onBeforeMount, PropType } from 'vue';
import { IsoDateInput } from '@flaschengeist/api/components';
import { notEmpty } from '@flaschengeist/api';
import { useScheduleStore } from '../../store';
export default defineComponent({
name: 'Job',
components: { IsoDateInput },
props: {
modelValue: {
required: true,
type: Object as PropType<FG.Job>,
},
jobCanDelete: Boolean,
},
emits: {
'remove-job': () => true,
'update:modelValue': (job: FG.Job) => !!job,
},
setup(props, { emit }) {
const store = useScheduleStore();
onBeforeMount(() => store.getJobTypes());
const jobtypes = computed(() => store.jobTypes);
const job = new Proxy(props.modelValue, {
get(target, prop) {
if (typeof prop === 'string') {
return ((props.modelValue as unknown) as Record<string, unknown>)[prop];
}
},
set(obj, prop, value) {
if (typeof prop === 'string') {
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
emit('update:modelValue', Object.assign({}, props.modelValue, { [prop]: value }));
}
return true;
},
});
function removeJob() {
emit('remove-job');
}
function isAfterDate(val: string) {
return props.modelValue.start < new Date(val) || 'Ende muss hinter dem Start liegen';
}
return {
job,
jobtypes,
removeJob,
notEmpty,
isAfterDate,
};
},
});
</script>
<style></style>

View File

@ -3,88 +3,81 @@
<q-dialog v-model="dialogOpen"> <q-dialog v-model="dialogOpen">
<q-card> <q-card>
<q-card-section> <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-card-section> <q-card-section>
<q-input <q-input ref="dialogInput" v-model="actualType.name" :rules="rules" dense label="name" filled />
ref="dialogInput"
v-model="actualType.name"
:rules="rules"
dense
label="name"
filled
/>
</q-card-section> </q-card-section>
<q-card-actions> <q-card-actions>
<q-btn flat color="danger" label="Abbrechen" @click="discardChanges()" /> <q-btn flat color="danger" label="Abbrechen" @click="discardChanges()" />
<q-btn <q-btn flat color="primary" label="Speichern" :disable="!!dialogInput && !dialogInput.validate()" @click="saveChanges()" />
flat
color="primary"
label="Speichern"
:disable="!!dialogInput && !dialogInput.validate()"
@click="saveChanges()"
/>
</q-card-actions> </q-card-actions>
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-table :title="title" :rows="rows" row-key="id" :columns="columns"> <q-card>
<template #top-right> <q-card-section>
<q-input <q-table :title="title" :rows="rows" row-key="id" :columns="columns">
ref="input" <template #top-right>
v-model="actualType.name" <q-input
:rules="rules" ref="input"
dense v-model="actualType.name"
filled :rules="rules"
placeholder="Neuer Typ" dense
> placeholder="Neuer Typ"
<template #after >
><q-btn color="primary" icon="mdi-plus" title="Hinzufügen" round @click="addType" <slot name="after"
/></template> ><q-btn color="primary" icon="mdi-plus" title="Hinzufügen" @click="addType"
</q-input> /></slot>
</template> </q-input>
<template #body-cell-actions="props"> </template>
<q-td :props="props" align="right" :auto-width="true"> <template #body-cell-actions="props">
<q-btn round icon="mdi-pencil" @click="editType(props.row.id)" /> <q-td :props="props" align="right" :auto-width="true">
<q-btn round icon="mdi-delete" @click="deleteType(props.row.id)" /> <q-btn
</q-td> round
</template> icon="mdi-pencil"
</q-table> @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> </div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { isAxiosError } from '@flaschengeist/api'; import { isAxiosError } from '@flaschengeist/api';
import { defineComponent, ref, computed, PropType, onBeforeMount } from 'vue'; import { defineComponent, ref, computed, PropType, onBeforeMount } from 'vue';
import { useEventStore } from '../../store'; import { useScheduleStore } from '../../store';
import { useQuasar, QInput } from 'quasar'; import { Notify, QInput } from 'quasar';
export default defineComponent({ export default defineComponent({
name: 'ManageTypes', name: 'ManageTypes',
components: {}, components: {},
props: { props:{
type: { type: String as PropType<'EventType' | 'JobType'>, required: true }, type: {type: String as PropType<'EventType' | 'JobType'>, required: true},
title: { type: String, required: true }, title: {type: String, required: true}
}, },
setup(props) { setup(props) {
const store = useEventStore(); const store = useScheduleStore();
const quasar = useQuasar();
const dialogOpen = ref(false); const dialogOpen = ref(false);
const emptyType = { id: -1, name: '' }; const emptyType = { id: -1, name: '' };
const actualType = ref(emptyType); const actualType = ref(emptyType);
const input = ref<QInput>(); const input = ref<QInput>();
const dialogInput = 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`]()); 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 = [ const rules = [
(s: unknown) => !!s || 'Darf nicht leer sein!', (s: unknown) => !!s || 'Darf nicht leer sein!',
(s: string) => (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 = [ const columns = [
@ -105,18 +98,19 @@ export default defineComponent({
function addType() { function addType() {
if (input.value === undefined || input.value.validate()) if (input.value === undefined || input.value.validate())
store[`add${props.type}`](actualType.value.name) store
[`add${props.type}`](actualType.value.name)
.then(() => { .then(() => {
actualType.value.name = ''; actualType.value.name = '';
}) })
.catch((e) => { .catch((e) => {
if (isAxiosError(e, 409)) if (isAxiosError(e, 409))
quasar.notify({ Notify.create({
type: 'negative', type: 'negative',
message: 'Der Name wird bereits verwendet', message: 'Der Name wird bereits verwendet',
}); });
else else
quasar.notify({ Notify.create({
type: 'negative', type: 'negative',
message: 'Unbekannter Fehler beim speichern.', message: 'Unbekannter Fehler beim speichern.',
}); });
@ -125,17 +119,12 @@ export default defineComponent({
function editType(id: number) { function editType(id: number) {
dialogOpen.value = true; dialogOpen.value = true;
actualType.value = Object.assign( actualType.value = Object.assign({}, rows.value.find((v) => v.id === id));
{},
rows.value.find((v) => v.id === id)
);
} }
function saveChanges() { function saveChanges() {
if (dialogInput.value === undefined || dialogInput.value.validate()) if (dialogInput.value === undefined || dialogInput.value.validate())
void store[`rename${props.type}`](actualType.value.id, actualType.value.name).then(() => void store[`rename${props.type}`](actualType.value.id, actualType.value.name).then(() => discardChanges());
discardChanges()
);
} }
function discardChanges() { function discardChanges() {

View File

@ -38,7 +38,6 @@
import { defineComponent, PropType } from 'vue'; import { defineComponent, PropType } from 'vue';
import { IsoDateInput } from '@flaschengeist/api/components'; import { IsoDateInput } from '@flaschengeist/api/components';
import { notEmpty } from '@flaschengeist/api'; import { notEmpty } from '@flaschengeist/api';
import { RecurrenceRule } from '../../events';
export default defineComponent({ export default defineComponent({
name: 'RecurrenceRule', name: 'RecurrenceRule',
@ -46,11 +45,11 @@ export default defineComponent({
props: { props: {
modelValue: { modelValue: {
required: true, required: true,
type: Object as PropType<RecurrenceRule>, type: Object as PropType<FG.RecurrenceRule>,
}, },
}, },
emits: { emits: {
'update:modelValue': (rule: RecurrenceRule) => !!rule, 'update:modelValue': (rule: FG.RecurrenceRule) => !!rule,
}, },
setup(props, { emit }) { setup(props, { emit }) {
const freqTypes = [ const freqTypes = [
@ -63,7 +62,7 @@ export default defineComponent({
const rule = new Proxy(props.modelValue, { const rule = new Proxy(props.modelValue, {
get(target, prop) { get(target, prop) {
if (typeof prop === 'string') { 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) { set(target, prop, value) {

View File

@ -8,7 +8,7 @@
<q-card> <q-card>
<div class="column"> <div class="column">
<div class="col" align="right" style="position: sticky; top: 0; z-index: 999"> <div class="col" align="right" style="position: sticky; top: 0; z-index: 999">
<q-btn round color="negative" icon="mdi-close" dense rounded @click="editDone(false)" /> <q-btn round color="negative" icon="close" dense rounded @click="editDone(false)" />
</div> </div>
<div class="col" style="margin: 0; padding: 0; margin-top: -2.4em"> <div class="col" style="margin: 0; padding: 0; margin-top: -2.4em">
<edit-event v-model="editor" @done="editDone" /> <edit-event v-model="editor" @done="editDone" />
@ -16,153 +16,125 @@
</div> </div>
</q-card> </q-card>
</q-dialog> </q-dialog>
<q-card> <q-page padding>
<div style="max-width: 1800px; width: 100%"> <q-card>
<div class="bg-primary text-white q-my-sm shadow-2 row justify-center"> <div style="max-width: 1800px; width: 100%">
<div class="col-xs-12 col-sm-9 row justify-center items-center"> <div class="bg-primary text-white q-my-sm shadow-2 row justify-center">
<q-btn flat dense icon="mdi-chevron-left" title="previous" @click="calendarPrev" /> <div class="col-xs-12 col-sm-9 row justify-center items-center">
<q-separator vertical /> <q-btn flat dense icon="mdi-chevron-left" title="previous" @click="calendarPrev" />
<q-btn flat dense <q-separator vertical />
>{{ asMonth(selectedDate) }} {{ asYear(selectedDate) }} <q-btn flat dense
<q-popup-proxy ref="proxy" transition-show="scale" transition-hide="scale"> >{{ asMonth(selectedDate) }} {{ asYear(selectedDate) }}
<q-date <q-popup-proxy
ref="datepicker" transition-show="scale"
:model-value="date(selectedDate)" transition-hide="scale"
mask="YYYY-MM-DD" @before-show="updateProxy"
no-unset >
@update:model-value="updateDate" <q-date v-model="proxyDate">
><q-btn v-close-popup label="jetzt" dense flat @click="datepicker?.setToday()" <div class="row items-center justify-end q-gutter-sm">
/></q-date> <q-btn v-close-popup label="Cancel" color="primary" flat />
</q-popup-proxy> <q-btn
</q-btn> v-close-popup
<q-separator vertical /> label="OK"
<q-btn flat dense icon="mdi-chevron-right" title="next" @click="calendarNext" /> color="primary"
</div> flat
<div class="col-xs-12 col-sm-3 text-center"> @click="saveNewSelectedDate"
<q-btn-toggle />
v-if="$q.screen.gt.xs" </div>
v-model="calendarView" </q-date>
flat </q-popup-proxy>
stretch </q-btn>
toggle-color="black" <q-separator vertical />
:options="[ <q-btn flat dense icon="mdi-chevron-right" title="next" @click="calendarNext" />
{ label: 'Tag', value: 'day' }, </div>
{ label: 'Woche', value: 'week' }, <div class="col-xs-12 col-sm-3 text-center">
]" <q-btn-toggle
/> v-model="calendarView"
</div> flat
</div> stretch
<q-calendar-agenda toggle-color="black"
:model-value="date(realDate)" :options="[
:view="calendarRealView" { label: 'Tag', value: 'day' },
:max-days="calendarDays" { label: 'Woche', value: 'week' },
:weekdays="[1, 2, 3, 4, 5, 6, 0]" ]"
locale="de-de"
style="height: 100%; min-height: 400px"
>
<template #head-day-label="{ scope: { timestamp } }">
{{ timestamp.day }}
<q-menu>
<q-list style="min-width: 100px">
<q-item exact clickable @click="create(timestamp.date)">
<q-item-section>Neue Veranstaltung</q-item-section>
</q-item>
</q-list>
</q-menu>
</template>
<template #day="{ scope: { timestamp } }">
<div itemref="" class="q-pa-sm" style="min-height: 200px">
<event-slot
v-for="(agenda, index) in events[timestamp.weekday]"
:key="index"
v-model="events[timestamp.weekday][index]"
class="q-mb-sm"
@remove-event="remove"
@edit-event="edit"
/> />
</div> </div>
</template> </div>
</q-calendar-agenda> <q-calendar-agenda
</div> v-model="selectedDate"
</q-card> :view="calendarRealView"
:max-days="calendarDays"
:weekdays="[1, 2, 3, 4, 5, 6, 0]"
locale="de-de"
style="height: 100%; min-height: 400px"
>
<template #head-day-label="{scope: {timestamp}}">
{{timestamp.day}}
<q-menu>
<q-list style="min-width: 100px">
<q-item exact :to="{name: 'new-event', query: {date: timestamp.date}}">
<q-item-section>Neue Veranstaltung</q-item-section>
</q-item>
</q-list>
</q-menu>
</template>
<template #day="{ scope: { timestamp } }">
<div itemref="" class="q-pb-sm" style="min-height: 200px">
<eventslot
v-for="(agenda, index) in events[timestamp.weekday]"
:key="index"
v-model="events[timestamp.weekday][index]"
@remove-event="remove"
@edit-event="edit"
/>
</div>
</template>
</q-calendar-agenda>
</div>
</q-card>
</q-page>
</template> </template>
<script lang="ts"> <script lang="ts">
import { ComponentPublicInstance, computed, defineComponent, onBeforeMount, ref, watch } from 'vue'; import { ComponentPublicInstance, computed, defineComponent, onBeforeMount, ref } from 'vue';
import { QCalendarAgenda } from '@quasar/quasar-ui-qcalendar'; import { useScheduleStore } from '../../store';
import { date, QDate, QPopupProxy, useQuasar } from 'quasar'; import Eventslot from './slots/EventSlot.vue';
import { useRoute, useRouter } from 'vue-router'; import { date } from 'quasar';
import { EditableEvent, emptyEvent } from '../../store/models';
import { startOfWeek } from '@flaschengeist/api'; import { startOfWeek } from '@flaschengeist/api';
import { useEventStore } from '../../store';
import EventSlot from './slots/EventSlot.vue';
import EditEvent from '../management/EditEvent.vue'; import EditEvent from '../management/EditEvent.vue';
import { QCalendarAgenda } from '@quasar/quasar-ui-qcalendar';
export default defineComponent({ export default defineComponent({
name: 'AgendaView', name: 'AgendaView',
components: { EventSlot, EditEvent, QCalendarAgenda: <ComponentPublicInstance>QCalendarAgenda }, components: { Eventslot, EditEvent, QCalendarAgenda: <ComponentPublicInstance>QCalendarAgenda },
setup() { setup() {
const store = useEventStore(); const store = useScheduleStore();
const quasar = useQuasar(); const windowWidth = ref(window.innerWidth);
const route = useRoute(); const selectedDate = ref(date.formatDate(new Date(), 'YYYY-MM-DD'));
const router = useRouter(); const proxyDate = ref('');
const datepicker = ref<QDate>();
const proxy = ref<QPopupProxy>();
// User selectable (day vs week)
const calendarView = ref('week'); 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(() => (calendarDays.value != 7 ? 'day' : 'week'));
const calendarRealView = computed(() => const calendarDays = computed(() =>
calendarView.value === 'day' || quasar.screen.xs || quasar.screen.sm ? 'day' : 'week' // <= 1023 is the breakpoint for sm to md
calendarView.value == 'day' ? 1 : windowWidth.value <= 1023 ? 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 events = ref<Agendas>({});
const editor = ref<EditableEvent>(); const editor = ref<FG.Event | undefined>(undefined);
interface Agendas { interface Agendas {
[index: number]: FG.Event[]; [index: number]: FG.Event[];
} }
onBeforeMount(async () => { onBeforeMount(async () => {
await router.replace({ query: { ...route.query, q_tab: 'agendaView' } }); window.addEventListener('resize', () => {
if (!Object.keys(route.query).includes('q_date')) { windowWidth.value = window.innerWidth;
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(); await loadAgendas();
}); });
function create(ds: string) {
editor.value = emptyEvent(date.extractDate(ds, 'YYYY-MM-DD'));
}
async function edit(id: number) { async function edit(id: number) {
editor.value = await store.getEvent(id); editor.value = await store.getEvent(id);
} }
@ -187,100 +159,90 @@ export default defineComponent({
} }
} }
const loading = ref(false);
async function loadAgendas() { async function loadAgendas() {
if (loading.value) return; const selected = new Date(selectedDate.value);
loading.value = true; console.log(selected);
const from = const start = calendarRealView.value === 'day' ? selected : startOfWeek(selected);
calendarView.value === 'day' ? selectedDate.value : startOfWeek(selectedDate.value); const end = date.addToDate(start, { days: calendarDays.value });
const to = date.addToDate(from, { days: calendarView.value === 'day' ? 1 : 7 });
events.value = {}; events.value = {};
const { result } = await store.getEvents({ from, to }); const list = await store.getEvents({ from: start, to: end });
result.forEach((event) => { list.forEach((event) => {
const day = event.start.getDay(); const day = event.start.getDay();
if (!events.value[day]) { if (!events.value[day]) {
events.value[day] = []; events.value[day] = [];
} }
const idx = events.value[day].findIndex((e) => e.id === event.id); events.value[day].push(event);
if (idx === -1) events.value[day].push(event);
else events.value[day][idx] = event;
}); });
loading.value = false;
} }
function addDays(reverse: boolean) { function calendarNext() {
const oww = Math.floor((startOfWeek(selectedDate.value).getTime() / 1000) * 60 * 60 * 24); selectedDate.value = date.formatDate(
selectedDate.value = date.addToDate(realDate.value, { date.addToDate(selectedDate.value, { days: calendarDays.value }),
days: reverse ? -1 : calendarDays.value, 'YYYY-MM-DD'
}); );
if (oww != Math.floor((startOfWeek(selectedDate.value).getTime() / 1000) * 60 * 60 * 24)) void loadAgendas();
void loadAgendas();
} }
function asMonth(value?: Date) { function calendarPrev() {
return [ selectedDate.value = date.formatDate(
'Januar', date.subtractFromDate(selectedDate.value, { days: calendarDays.value }),
'Februar', 'YYYY-MM-DD'
'März', );
'April', void loadAgendas();
'Mai',
'Juni',
'Juli',
'August',
'September',
'Oktober',
'November',
'Dezember',
'-',
][value?.getMonth() === 0 ? 0 : value?.getMonth() || 12];
}
function asYear(value?: Date) {
return value?.getFullYear() || '-';
} }
watch(selectedDate, async (newValue) => { function updateProxy() {
const q_date = date.formatDate(newValue, 'YYYY-MM-DD'); proxyDate.value = selectedDate.value;
await router.replace({ query: { ...route.query, q_date } }); }
await loadAgendas(); function saveNewSelectedDate() {
}); proxyDate.value = date.formatDate(proxyDate.value, 'YYYY-MM-DD');
selectedDate.value = proxyDate.value;
watch(calendarView, async (newValue) => { }
const q_view = newValue; function asMonth(value: string) {
await router.replace({ query: { ...route.query, q_view } }); if (value) {
await loadAgendas(); 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 { return {
asYear, asYear,
asMonth, asMonth,
calendarDays, selectedDate,
calendarNext: () => addDays(false),
calendarPrev: () => addDays(true),
calendarRealView,
calendarView,
create,
date: (d: Date) => date.formatDate(d, 'YYYY-MM-DD'),
edit, edit,
editor, editor,
editDone, editDone,
events, events,
datepicker, calendarNext,
proxy, calendarPrev,
realDate, updateProxy,
saveNewSelectedDate,
proxyDate,
remove, remove,
selectedDate, calendarDays,
updateDate: (ds: string) => { calendarView,
selectedDate.value = date.adjustDate(date.extractDate(ds, 'YYYY-MM-DD'), { calendarRealView,
hours: 0,
minutes: 0,
seconds: 0,
milliseconds: 0,
});
proxy.value?.hide();
},
}; };
}, },
}); });

View File

@ -1,205 +0,0 @@
<template>
<q-dialog
:model-value="editor !== undefined"
persistent
transition-show="scale"
transition-hide="scale"
>
<q-card>
<div class="column">
<div class="col" align="right" style="position: sticky; top: 0; z-index: 999">
<q-btn round color="negative" icon="close" dense rounded @click="editDone(false)" />
</div>
<div class="col" style="margin: 0; padding: 0; margin-top: -2.4em">
<edit-event v-model="editor" @done="editDone" />
</div>
</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>
<!-- </q-card> -->
<!-- </div> -->
</template>
<script lang="ts">
import { computed, defineComponent, onBeforeMount, ref } from 'vue';
import { useEventStore } from '../../store';
import { date } from 'quasar';
import EditEvent from '../management/EditEvent.vue';
import EventSlot from '../overview/slots/EventSlot.vue';
export default defineComponent({
name: 'ListView',
components: { EditEvent, EventSlot },
setup() {
interface Agendas {
[index: string]: FG.Event[];
}
const store = useEventStore();
const editor = ref<FG.Event | undefined>(undefined);
const events = ref<FG.Event[]>([]);
const scrollDiv = ref<Element>();
const agendas = computed<Agendas>(() => {
const ag = {} as Agendas;
events.value?.forEach((event) => {
const day = date.formatDate(event.start, 'YYYYMMDD');
if (!ag[day]) {
ag[day] = [];
}
ag[day].push(event);
});
return ag;
});
onBeforeMount(async () => {
//await loadAgendas();
});
async function edit(id: number) {
editor.value = await store.getEvent(id);
}
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;
}
async function load(index: number, done?: (stop: boolean) => void) {
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;
}
if (done) done(false);
} else {
const len = events.value.length;
const { result } = await store.getEvents({
from: start,
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 (done) return done(true);
} else if (done) done(false);
}
if (index <= 1) {
window.setTimeout(() => {
(<Element>scrollDiv.value).scrollTop = document.getElementById('bbb')?.scrollHeight || 0;
}, 150);
}
}
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);
}
}
}
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');
}
}
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,
asMonth,
edit,
editor,
scrollDiv,
editDone,
load,
remove,
asDate,
};
},
});
</script>
<style>
@import '@quasar/quasar-ui-qcalendar/dist/index.css';
/* Fill full height of card */
.q-calendar-agenda__pane {
height: 100%;
}
/* Same as Qcard */
.q-calendar {
--calendar-background-dark: --q-dark;
}
</style>

View File

@ -1,17 +1,16 @@
<template> <template>
<q-card <q-card
style="width: 100%" class="q-mx-xs q-mt-sm justify-start content-center items-center rounded-borders shadow-5"
class="justify-start content-center items-center rounded-borders shadow-5"
bordered bordered
> >
<q-card-section class="text-primary q-pa-xs"> <q-card-section class="text-primary q-pa-xs">
<div class="text-weight-bolder text-center"> <div class="text-weight-bolder text-center" style="font-size: 1.5vw">
{{ typeName }} {{ event.type.name }}
<template v-if="event.name" <template v-if="event.name"
>: <span>{{ event.name }}</span> >: <span style="font-size: 1.2vw">{{ event.name }}</span>
</template> </template>
</div> </div>
<div v-if="event.description" class="text-weight-medium"> <div v-if="event.description" class="text-weight-medium" style="font-size: 1vw">
{{ event.description }} {{ event.description }}
</div> </div>
</q-card-section> </q-card-section>
@ -51,12 +50,10 @@
import { defineComponent, computed, PropType } from 'vue'; import { defineComponent, computed, PropType } from 'vue';
import { hasPermission } from '@flaschengeist/api'; import { hasPermission } from '@flaschengeist/api';
import { PERMISSIONS } from '../../../permissions'; import { PERMISSIONS } from '../../../permissions';
import { useEventStore } from '../../../store';
import { date } from 'quasar';
import JobSlot from './JobSlot.vue'; import JobSlot from './JobSlot.vue';
export default defineComponent({ export default defineComponent({
name: 'EventSlot', name: 'Eventslot',
components: { JobSlot }, components: { JobSlot },
props: { props: {
modelValue: { modelValue: {
@ -70,20 +67,12 @@ export default defineComponent({
editEvent: (val: number) => !!val, editEvent: (val: number) => !!val,
}, },
setup(props, { emit }) { setup(props, { emit }) {
const store = useEventStore();
const canDelete = computed(() => hasPermission(PERMISSIONS.DELETE)); const canDelete = computed(() => hasPermission(PERMISSIONS.DELETE));
const canEdit = computed( const canEdit = computed(
() => () =>
hasPermission(PERMISSIONS.EDIT) && hasPermission(PERMISSIONS.EDIT) &&
(props.modelValue?.end || props.modelValue.start) >= (props.modelValue?.end || props.modelValue.start) > new Date()
date.buildDate({ hours: 0, minutes: 0, seconds: 0, milliseconds: 0 })
); );
const typeName = computed(() =>
typeof props.modelValue.type === 'object'
? props.modelValue.type.name
: store.eventTypes.find((e) => e.id === props.modelValue.type)?.name || 'Unbekannt'
);
const event = computed({ const event = computed({
get: () => props.modelValue, get: () => props.modelValue,
set: (v) => emit('update:modelValue', v), set: (v) => emit('update:modelValue', v),
@ -102,8 +91,9 @@ export default defineComponent({
edit, edit,
event, event,
remove, remove,
typeName,
}; };
}, },
}); });
</script> </script>
<style scoped></style>

View File

@ -5,7 +5,7 @@
<template v-if="modelValue.end">- {{ asHour(modelValue.end) }}</template> <template v-if="modelValue.end">- {{ asHour(modelValue.end) }}</template>
</div> </div>
<div class="q-px-xs"> <div class="q-px-xs">
{{ typeName }} {{ modelValue.type.name }}
</div> </div>
<div class="col-auto q-px-xs" style="font-size: 10px"> <div class="col-auto q-px-xs" style="font-size: 10px">
{{ modelValue.comment }} {{ modelValue.comment }}
@ -13,99 +13,35 @@
<div> <div>
<q-select <q-select
:model-value="modelValue.services" :model-value="modelValue.services"
:disable="!canAssignOther || modelValue.locked"
:options="options"
option-value="userid"
filled filled
:option-label="(opt) => userDisplay(opt)"
multiple multiple
use-input disable
use-chips
stack-label
label="Dienste" label="Dienste"
behavior="dialog"
class="col-auto q-px-xs" class="col-auto q-px-xs"
@filter="filterUsers" style="font-size: 6px"
@add="({ value }) => assign(value)" counter
@remove="({ value }) => unassign(value)" :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> </q-select>
<div class="row col-12 justify-end"> <div class="row col-12 justify-end">
<q-btn <q-btn v-if="canEnroll" flat color="primary" label="Eintragen" @click="enrollForJob" />
v-if="!modelValue.locked && !isEnrolled && !isFull && canAssign" <q-btn v-if="isEnrolled" flat color="negative" label="Austragen" @click="signOutFromJob" />
flat
color="primary"
label="Eintragen"
@click="assign()"
/>
<q-btn v-if="isEnrolled && !modelValue.locked" flat color="secondary" label="Optionen">
<q-menu auto-close>
<q-list style="min-width: 100px">
<q-item v-if="!isFull && canInvite" clickable @click="invite">
<q-item-section>Einladen</q-item-section>
</q-item>
<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-section class="text-negative">Austragen</q-item-section>
</q-item>
</q-list>
</q-menu>
</q-btn>
</div> </div>
</div> </div>
</q-card> </q-card>
</template> </template>
<script lang="ts"> <script lang="ts">
import { date, useQuasar } from 'quasar'; import { defineComponent, onBeforeMount, computed, PropType } from 'vue';
import { defineComponent, onBeforeMount, computed, ref, PropType } from 'vue'; import { Notify } from 'quasar';
import { asHour, hasPermission, useMainStore, useUserStore } from '@flaschengeist/api'; import { asHour, useMainStore, useUserStore } from '@flaschengeist/api';
import { useEventStore } from '../../../store'; import { useScheduleStore } 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({ export default defineComponent({
name: 'JobSlot', name: 'JobSlot',
components: { ServiceUserChip, UserAvatar },
props: { props: {
modelValue: { modelValue: {
required: true, required: true,
@ -118,95 +54,69 @@ export default defineComponent({
}, },
emits: { 'update:modelValue': (v: FG.Job) => !!v }, emits: { 'update:modelValue': (v: FG.Job) => !!v },
setup(props, { emit }) { setup(props, { emit }) {
const store = useEventStore(); const store = useScheduleStore();
const mainStore = useMainStore(); const mainStore = useMainStore();
const userStore = useUserStore(); const userStore = useUserStore();
const quasar = useQuasar(); const availableUsers = null;
// Make sure users are loaded if we can assign them onBeforeMount(async () => userStore.getUsers());
onBeforeMount(() => {
void userStore.getUsers();
void userStore.getDisplayNameModeSetting(true);
});
/* Stuff used for general display */ function userDisplay(service: FG.Service) {
// Get displayname of user return userStore.findUser(service.userid)?.display_name || service.userid;
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;
} }
// The name of the current job const isEnrolled = computed(
const typeName = computed(() =>
typeof props.modelValue.type === 'object'
? props.modelValue.type.name
: 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)
);
// 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.services.findIndex(
props.modelValue.required_services (service) => service.userid == mainStore.currentUser.userid
) !== -1
); );
// If current user is only backup service const canEnroll = computed(() => {
const isBackup = computed(() => service.value?.is_backup || false); const is = isEnrolled.value;
let sum = 0;
props.modelValue.services.forEach((s) => (sum += s.value));
return sum < props.modelValue.required_services && !is;
});
// If it is still possible to invite other users (= job is today or in the future) async function enrollForJob() {
const canInvite = computed( const newService: FG.Service = {
() =>
(props.modelValue.end || props.modelValue.start) >
date.subtractFromDate(
date.buildDate({ hours: 0, minutes: 0, seconds: 0, milliseconds: 0 }),
{ days: 1 }
)
);
// Assign user to a job
async function assign(service?: FG.Service) {
service = service || {
userid: mainStore.currentUser.userid, userid: mainStore.currentUser.userid,
is_backup: false, is_backup: false,
value: 1, value: 1,
}; };
try { try {
const job = await store.assignToJob(props.modelValue.id, service); const job = await store.updateJob(props.eventId, props.modelValue.id, { user: newService });
emit('update:modelValue', job); emit('update:modelValue', job);
} catch (error) { } catch (error) {
console.warn(error); console.warn(error);
quasar.notify({ Notify.create({
group: false, group: false,
type: 'negative', type: 'negative',
message: 'Fehler beim Ein- oder Austragen als Dienst', message: 'Fehler beim Eintragen als Dienst',
timeout: 10000,
progress: true,
actions: [{ icon: 'mdi-close', color: 'white' }],
});
}
}
async function signOutFromJob() {
const newService: FG.Service = {
userid: mainStore.currentUser.userid,
is_backup: false,
value: -1,
};
try {
const job = await store.updateJob(props.eventId, props.modelValue.id, {
user: newService,
});
emit('update:modelValue', job);
} catch (error) {
console.warn(error);
Notify.create({
group: false,
type: 'negative',
message: 'Fehler beim Austragen als Dienst',
timeout: 10000, timeout: 10000,
progress: true, progress: true,
actions: [{ icon: 'mdi-close', color: 'white' }], actions: [{ icon: 'mdi-close', color: 'white' }],
@ -214,86 +124,13 @@ export default defineComponent({
} }
} }
// open invite dialog (or transfer)
function invite(isInvite = true) {
quasar.dialog({
component: TransferInviteDialog,
componentProps: {
isInvite: isInvite,
job: props.modelValue,
},
});
}
/* 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 { return {
assign, availableUsers,
unassign: (s?: FG.Service) => assign(Object.assign({}, s || service.value, { value: -1 })), enrollForJob,
backup: (is_backup: boolean) => assign(Object.assign({}, service.value, { is_backup })),
canAssignOther,
canAssign,
canInvite,
filterUsers,
isBackup,
isEnrolled, isEnrolled,
isFull, signOutFromJob,
invite: () => invite(true), canEnroll,
transfer: () => invite(false),
typeName,
userDisplay, userDisplay,
options,
asHour, 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

@ -1,84 +0,0 @@
<template>
<!-- notice dialogRef here -->
<q-dialog ref="dialogRef" @hide="onDialogHide">
<q-card class="q-dialog-plugin">
<q-card-section>
<div v-if="isInvite" class="text-h6">Zum Dienst einladen</div>
<div v-else class="text-h6">Dienst tauschen</div>
</q-card-section>
<q-separator />
<q-card-section>
<q-select
v-model="invitees"
filled
:options="otherUsers"
:option-label="(opt) => opt.display_name"
:option-disable="(opt) => !isInvite && invitees.length > 0 && opt != invitees[0]"
multiple
use-chips
stack-label
label="Dienste"
>
</q-select>
</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="Abbrechen" @click="onDialogCancel" />
</q-card-actions>
</q-card>
</q-dialog>
</template>
<script lang="ts">
import { useMainStore, useUserStore } from '@flaschengeist/api';
import { useEventStore } from '../../../store';
import { useDialogPluginComponent } from 'quasar';
import { PropType, computed, defineComponent, ref } from 'vue';
export default defineComponent({
props: {
isInvite: {
type: Boolean,
default: true,
},
job: {
required: true,
type: Object as PropType<FG.Job>,
},
},
emits: [...useDialogPluginComponent.emits],
setup(props) {
const { dialogRef, onDialogHide, onDialogOK, onDialogCancel } = useDialogPluginComponent();
const userStore = useUserStore();
const mainStore = useMainStore();
const store = useEventStore();
const invitees = ref([] as FG.User[]);
const otherUsers = computed(() =>
userStore.users.filter(
(u) =>
u.userid !== mainStore.currentUser.userid &&
props.job.services.findIndex((s) => s.userid === u.userid) === -1
)
);
function invite() {
void store
.invite(props.job, invitees.value, !props.isInvite ? mainStore.currentUser : undefined)
.then(() => onDialogOK());
}
return {
invite,
invitees,
otherUsers,
dialogRef,
onDialogHide,
onDialogCancel,
};
},
});
</script>

32
src/events.d.ts vendored
View File

@ -1,25 +1,9 @@
import { FG_Plugin } from '@flaschengeist/types'; declare namespace FG {
export interface RecurrenceRule {
export interface RecurrenceRule { frequency: string;
frequency: string; interval: number;
interval: number; count?: number;
count?: number; until?: Date;
until?: Date; weekdays?: Array<number>;
weekdays?: Array<number>; }
}
interface InvitationData {
invitation: number;
}
interface InvitationResponseData {
event: number;
job: number;
invitee: string;
}
export interface EventNotification extends FG_Plugin.Notification {
data: {
type: number;
} & (InvitationData | InvitationResponseData);
} }

View File

@ -1,57 +1,14 @@
import { innerRoutes, privateRoutes } from './routes'; import { innerRoutes, privateRoutes } from './routes';
import { FG_Plugin } from '@flaschengeist/types'; import { FG_Plugin } from '@flaschengeist/types';
import { defineAsyncComponent } from 'vue'; import { defineAsyncComponent } from 'vue';
import { EventNotification, InvitationData, InvitationResponseData } from './events';
import { useEventStore } from './store';
const EventTypes = {
_mask_: 0xf0,
invitation: 0x00,
invite: 0x01,
transfer: 0x02,
invitation_response: 0x10,
invitation_accepted: 0x11,
invitation_rejected: 0x12,
info: 0x20,
info_accepted: 0x21,
info_rejected: 0x22,
};
function transpile(msg: FG_Plugin.Notification) {
const message = msg as EventNotification;
message.icon = 'mdi-calendar';
if ((message.data.type & EventTypes._mask_) === EventTypes.invitation) {
message.accept = () => {
const store = useEventStore();
return store.acceptInvitation((<InvitationData>message.data).invitation);
};
message.reject = () => {
const store = useEventStore();
return store.rejectInvitation((<InvitationData>message.data).invitation);
};
message.link = { name: 'events-requests' };
} else if (
(message.data.type & EventTypes._mask_) === EventTypes.invitation_response ||
(message.data.type & EventTypes._mask_) === EventTypes.info
) {
message.link = {
name: 'events-single-view',
params: { id: (<InvitationResponseData>message.data).event },
};
}
return message;
}
const plugin: FG_Plugin.Plugin = { const plugin: FG_Plugin.Plugin = {
id: 'events', id: 'dev.flaschengeist.events',
name: 'Event schedule', name: 'Event schedule',
innerRoutes, innerRoutes,
internalRoutes: privateRoutes, internalRoutes: privateRoutes,
requiredModules: [['events']], requiredModules: [['events']],
version: '0.0.1', version: '0.0.1',
notification: transpile,
widgets: [ widgets: [
{ {
priority: 0, priority: 0,

29
src/pages/Event.vue Normal file
View File

@ -0,0 +1,29 @@
<template>
<q-page padding class="fit row justify-center content-start items-start q-gutter-sm">
<EditEvent v-model="event" />
</q-page>
</template>
<script lang="ts">
import { onBeforeMount, defineComponent, ref } from 'vue';
import EditEvent from '../components/management/EditEvent.vue';
import { useScheduleStore } from '../store';
import { useRoute } from 'vue-router';
export default defineComponent({
components: { EditEvent },
setup() {
const route = useRoute();
const store = useScheduleStore();
const event = ref<FG.Event | undefined>(undefined);
onBeforeMount(async () => {
if ('id' in route.params && typeof route.params.id === 'string')
event.value = await store.getEvent(parseInt(route.params.id));
});
return {
event,
};
},
});
</script>

View File

@ -1,41 +0,0 @@
<template>
<q-page padding class="fit row justify-center content-start items-start q-gutter-sm">
<EditEvent :model-value="event" />
</q-page>
</template>
<script lang="ts">
import { defineComponent, onMounted, ref, watch, PropType } from 'vue';
import EditEvent from '../components/management/EditEvent.vue';
import { useEventStore } from '../store';
export default defineComponent({
components: { EditEvent },
props: {
id: {
type: String as PropType<number | string>,
default: undefined,
},
},
setup(props) {
const store = useEventStore();
const event = ref<FG.Event>();
function loadEvent(id?: string | number) {
if (id != event.value?.id)
if (id === undefined) event.value = undefined;
else
void store
.getEvent(typeof id === 'number' ? id : parseInt(id))
.then((e) => (event.value = e));
}
watch(props, (v) => loadEvent(v.id));
onMounted(() => loadEvent(props.id));
return {
event,
};
},
});
</script>

View File

@ -1,320 +0,0 @@
<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-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

@ -1,5 +1,5 @@
<template> <template>
<q-page padding class="fit row justify-center content-start items-start q-gutter-sm"> <div>
<q-tabs v-if="$q.screen.gt.sm" v-model="tab"> <q-tabs v-if="$q.screen.gt.sm" v-model="tab">
<q-tab <q-tab
v-for="(tabindex, index) in tabs" v-for="(tabindex, index) in tabs"
@ -24,28 +24,29 @@
</q-item> </q-item>
</q-list> </q-list>
</q-drawer> </q-drawer>
<q-tab-panels <q-page padding class="fit row justify-center content-start items-start q-gutter-sm">
v-model="tab" <q-tab-panels
style="background-color: transparent" v-model="tab"
class="q-ma-none q-pa-none fit row justify-center content-start items-start" style="background-color: transparent"
animated class="q-ma-none q-pa-none fit row justify-center content-start items-start"
> animated
<q-tab-panel name="create"> >
<EditEvent :date="date" /> <q-tab-panel name="create">
</q-tab-panel> <EditEvent :date="date" />
<q-tab-panel name="eventtypes"> </q-tab-panel>
<ManageTypes title="Veranstaltungstyp" type="EventType" /> <q-tab-panel name="eventtypes">
</q-tab-panel> <ManageTypes title="Veranstaltungstyp" type="EventType" />
<q-tab-panel name="jobtypes"> </q-tab-panel>
<ManageTypes title="Dienstart" type="JobType" /> <q-tab-panel name="jobtypes">
</q-tab-panel> <ManageTypes title="Dienstart" type="JobType" />
</q-tab-panels> </q-tab-panel>
</q-page> </q-tab-panels>
</q-page>
</div>
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, ref, onBeforeMount, watch } from 'vue'; import { computed, defineComponent, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import ManageTypes from '../components/management/ManageTypes.vue'; import ManageTypes from '../components/management/ManageTypes.vue';
import EditEvent from '../components/management/EditEvent.vue'; import EditEvent from '../components/management/EditEvent.vue';
import { hasPermission } from '@flaschengeist/api'; import { hasPermission } from '@flaschengeist/api';
@ -59,33 +60,16 @@ export default defineComponent({
date: { date: {
type: String, type: String,
required: false, required: false,
default: undefined, default: undefined
}, }
}, },
setup() { setup() {
const tabs = computed(() => [ const tabs = computed(() => [
{ name: 'create', label: 'Veranstaltungen' }, { name: 'create', label: 'Veranstaltungen' },
...(hasPermission(PERMISSIONS.JOB_TYPE) ? [{ name: 'jobtypes', label: 'Dienstarten' }] : []), ...(hasPermission(PERMISSIONS.JOB_TYPE) ? [{ name: 'jobtypes', label: 'Dienstarten' }] : []),
...(hasPermission(PERMISSIONS.EVENT_TYPE) ...(hasPermission(PERMISSIONS.EVENT_TYPE) ? [{ name: 'eventtypes', label: 'Veranstaltungsarten' }] : [])
? [{ name: 'eventtypes', label: 'Veranstaltungsarten' }]
: []),
]); ]);
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 drawer = ref<boolean>(false);
const tab = ref<string>('create'); const tab = ref<string>('create');
@ -98,9 +82,6 @@ export default defineComponent({
}, },
}); });
watch(tab, async (val) => {
await router.replace({ query: { q_tab: val } });
});
return { return {
showDrawer, showDrawer,

View File

@ -32,10 +32,9 @@
animated animated
> >
<q-tab-panel name="agendaView"> <q-tab-panel name="agendaView">
<agenda-view /> <AgendaView />
</q-tab-panel> </q-tab-panel>
<q-tab-panel name="listView"> <q-tab-panel name="eventtypes">
<list-view />
</q-tab-panel> </q-tab-panel>
</q-tab-panels> </q-tab-panels>
</q-page> </q-page>
@ -43,53 +42,35 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { computed, defineComponent, ref, onBeforeMount, watch } from 'vue'; import { computed, defineComponent, ref } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import { useQuasar } from 'quasar';
import AgendaView from '../components/overview/AgendaView.vue'; import AgendaView from '../components/overview/AgendaView.vue';
import ListView from '../components/overview/ListView.vue'; import { Screen } from 'quasar';
import { useEventStore } from '../store';
export default defineComponent({ export default defineComponent({
name: 'EventOverview', name: 'EventOverview',
components: { AgendaView, ListView }, components: { AgendaView },
setup() { setup() {
const store = useEventStore(); interface Tab {
const route = useRoute(); name: string;
const router = useRouter(); label: string;
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: Tab[] = [
{ name: 'listView', label: 'Liste' }, { name: 'agendaView', label: 'Kalendar' }
{ name: 'agendaView', label: 'Kalendar' }, ];
]);
const drawer = ref<boolean>(false); const drawer = ref<boolean>(false);
const showDrawer = computed({ const showDrawer = computed({
get: () => !quasar.screen.gt.sm && drawer.value, get: () => {
return !Screen.gt.sm && drawer.value;
},
set: (val: boolean) => { set: (val: boolean) => {
drawer.value = val; drawer.value = val;
}, },
}); });
watch(tab, async (val) => { const tab = ref<string>('agendaView');
console.log(val);
await router.replace({ query: { ...route.query, q_tab: val } });
});
return { return {
showDrawer, showDrawer,

8
src/pages/Requests.vue Normal file
View File

@ -0,0 +1,8 @@
<template>
<q-page padding>
<q-card>
<q-card-section class="row"> </q-card-section>
<q-card-section> </q-card-section>
</q-card>
</q-page>
</template>

View File

@ -13,8 +13,4 @@ export const PERMISSIONS = {
ASSIGN: 'events_assign', ASSIGN: 'events_assign',
// Can assign other users to jobs // Can assign other users to jobs
ASSIGN_OTHER: 'events_assign_other', ASSIGN_OTHER: 'events_assign_other',
// Can see users assigned as backup
SEE_BACKUP: 'events_see_backup',
// Can lock jobs, no further services can be assigned or unassigned
LOCK_JOBS: 'events_lock_jobs',
}; };

View File

@ -7,8 +7,8 @@ export const innerRoutes: FG_Plugin.MenuRoute[] = [
icon: 'mdi-briefcase', icon: 'mdi-briefcase',
permissions: ['user'], permissions: ['user'],
route: { route: {
path: 'events', path: 'schedule',
name: 'events', name: 'schedule',
redirect: { name: 'schedule-overview' }, redirect: { name: 'schedule-overview' },
}, },
children: [ children: [
@ -19,17 +19,7 @@ export const innerRoutes: FG_Plugin.MenuRoute[] = [
route: { route: {
path: 'schedule-overview', path: 'schedule-overview',
name: 'schedule-overview', name: 'schedule-overview',
component: () => import('../pages/EventOverview.vue'), component: () => import('../pages/Overview.vue'),
},
},
{
title: 'Dienstanfragen',
icon: 'mdi-account-switch',
shortcut: false,
route: {
path: 'events-requests',
name: 'events-requests',
component: () => import('../pages/EventRequests.vue'),
}, },
}, },
{ {
@ -40,8 +30,18 @@ export const innerRoutes: FG_Plugin.MenuRoute[] = [
route: { route: {
path: 'schedule-management', path: 'schedule-management',
name: 'schedule-management', name: 'schedule-management',
component: () => import('../pages/EventManagement.vue'), component: () => import('../pages/Management.vue'),
props: (route) => ({ date: route.query.date }), props: (route) => ({date: route.query.date}),
},
},
{
title: 'Dienstanfragen',
icon: 'mdi-account-switch',
shortcut: false,
route: {
path: 'schedule-requests',
name: 'schedule-requests',
component: () => import('../pages/Requests.vue'),
}, },
}, },
], ],
@ -50,14 +50,13 @@ export const innerRoutes: FG_Plugin.MenuRoute[] = [
export const privateRoutes: FG_Plugin.NamedRouteRecordRaw[] = [ export const privateRoutes: FG_Plugin.NamedRouteRecordRaw[] = [
{ {
name: 'events-single-view', name: 'new-event',
path: 'events/:id', path: 'new-event',
component: () => import('../pages/EventPage.vue'), redirect: {name: 'schedule-management'}
props: true,
}, },
{ {
name: 'events-edit', name: 'events-edit',
path: 'events/:id/edit', path: 'schedule/:id/edit',
component: () => import('../pages/EventPage.vue'), component: () => import('../pages/Event.vue'),
}, },
]; ];

159
src/store.ts Normal file
View File

@ -0,0 +1,159 @@
import { api, isAxiosError } from '@flaschengeist/api';
import { AxiosError } from 'axios';
import { defineStore } from 'pinia';
interface UserService {
user: FG.Service;
}
function fixJob(job: FG.Job) {
job.start = new Date(job.start);
if (job.end) job.end = new Date(job.end);
}
function fixEvent(event: FG.Event) {
event.start = new Date(event.start);
if (event.end) event.end = new Date(event.end);
event.jobs.forEach((job) => fixJob(job));
}
export const useScheduleStore = defineStore({
id: 'events',
state: () => ({
jobTypes: [] as FG.JobType[],
eventTypes: [] as FG.EventType[],
templates: [] as FG.Event[],
}),
getters: {},
actions: {
async getJobTypes(force = false) {
if (force || this.jobTypes.length == 0)
try {
const { data } = await api.get<FG.JobType[]>('/events/job-types');
this.jobTypes = data;
} catch (error) {
throw error;
}
return this.jobTypes;
},
addJobType(name: string) {
return api
.post<FG.JobType>('/events/job-types', { name: name })
.then(({ data }) => this.jobTypes.push(data));
},
removeJobType(id: number) {
return api
.delete(`/events/job-types/${id}`)
.then(() => this.jobTypes = this.jobTypes.filter(v => v.id !== id));
},
renameJobType(id: number, newName: string) {
return api
.put(`/events/job-types/${id}`, { name: newName })
.then(() => {
const idx = this.jobTypes.findIndex(v=>v.id===id);
if (idx >= 0) this.jobTypes[idx].name = newName;
})
},
async getEventTypes(force = false) {
if (force || this.eventTypes.length == 0)
try {
const { data } = await api.get<FG.EventType[]>('/events/event-types');
this.eventTypes = data;
} catch (error) {
throw error;
}
return this.eventTypes;
},
/** Add new EventType
*
* @param name Name of new EventType
*/
addEventType(name: string) {
return api
.post<FG.EventType>('/events/event-types', { name: name })
.then(({data}) => this.eventTypes.push(data))
},
async removeEvent(id: number) {
try {
await api.delete(`/events/${id}`);
const idx = this.templates.findIndex((v) => v.id === id);
if (idx !== -1) this.templates.splice(idx, 1);
} catch (e) {
const error = <AxiosError>e;
if (error.response && error.response.status === 404) return false;
throw e;
}
return true;
},
removeEventType(id: number) {
return api
.delete(`/events/event-types/${id}`)
.then(() => this.eventTypes = this.eventTypes.filter(v => v.id !== id));
},
renameEventType(id: number, newName: string) {
return api
.put(`/events/event-types/${id}`, { name: newName })
.then(() => {
const idx = this.eventTypes.findIndex(v=>v.id===id);
if (idx >= 0) this.eventTypes[idx].name = newName;
})
},
async getTemplates(force = false) {
if (force || this.templates.length == 0) {
const { data } = await api.get<FG.Event[]>('/events/templates');
data.forEach((element) => fixEvent(element));
this.templates = data;
}
return this.templates;
},
async getEvents(filter: { from?: Date; to?: Date } | undefined = undefined) {
try {
const { data } = await api.get<FG.Event[]>('/events', { params: filter });
data.forEach((element) => fixEvent(element));
return data;
} catch (error) {
throw error;
}
},
async getEvent(id: number) {
try {
const { data } = await api.get<FG.Event>(`/events/${id}`);
fixEvent(data);
return data;
} catch (error) {
throw error;
}
},
async updateJob(eventId: number, jobId: number, service: FG.Service | UserService) {
try {
const { data } = await api.put<FG.Job>(`/events/${eventId}/jobs/${jobId}`, service);
fixJob(data);
return data;
} catch (error) {
throw error;
}
},
async addEvent(event: FG.Event) {
const { data } = await api.post<FG.Event>('/events', event);
if (data.is_template) this.templates.push(data);
return data;
},
},
});

View File

@ -1,249 +0,0 @@
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)
*/
function fixJob(job: FG.Job) {
job.start = new Date(job.start);
if (job.end) job.end = new Date(job.end);
return job;
}
/**
* Convert JSON decoded Event to real Event object (fix Date object)
*/
function fixEvent(event: FG.Event) {
event.start = new Date(event.start);
if (event.end) event.end = new Date(event.end);
event.jobs.forEach((job) => fixJob(job));
}
export const useEventStore = defineStore({
id: 'events',
state: () => ({
jobTypes: [] as FG.JobType[],
eventTypes: [] as FG.EventType[],
templates: [] as FG.Event[],
invitations: [] as FG.Invitation[],
}),
getters: {},
actions: {
async getJobTypes(force = false) {
if (force || this.jobTypes.length == 0)
try {
const { data } = await api.get<FG.JobType[]>('/events/job-types');
this.jobTypes = data;
} catch (error) {
throw error;
}
return this.jobTypes;
},
addJobType(name: string) {
return api
.post<FG.JobType>('/events/job-types', { name: name })
.then(({ data }) => this.jobTypes.push(data));
},
removeJobType(id: number) {
return api
.delete(`/events/job-types/${id}`)
.then(() => (this.jobTypes = this.jobTypes.filter((v) => v.id !== id)));
},
renameJobType(id: number, newName: string) {
return api.put(`/events/job-types/${id}`, { name: newName }).then(() => {
const idx = this.jobTypes.findIndex((v) => v.id === id);
if (idx >= 0) this.jobTypes[idx].name = newName;
});
},
async getEventTypes(force = false) {
if (force || this.eventTypes.length == 0)
try {
const { data } = await api.get<FG.EventType[]>('/events/event-types');
this.eventTypes = data;
} catch (error) {
throw error;
}
return this.eventTypes;
},
/** Add new EventType
*
* @param name Name of new EventType
*/
addEventType(name: string) {
return api
.post<FG.EventType>('/events/event-types', { name: name })
.then(({ data }) => this.eventTypes.push(data));
},
removeEventType(id: number) {
return api
.delete(`/events/event-types/${id}`)
.then(() => (this.eventTypes = this.eventTypes.filter((v) => v.id !== id)));
},
renameEventType(id: number, newName: string) {
return api.put(`/events/event-types/${id}`, { name: newName }).then(() => {
const idx = this.eventTypes.findIndex((v) => v.id === id);
if (idx >= 0) this.eventTypes[idx].name = newName;
});
},
async getTemplates(force = false) {
if (force || this.templates.length == 0) {
const { data } = await api.get<FG.Event[]>('/events/templates');
data.forEach((element) => fixEvent(element));
this.templates = data;
}
return this.templates;
},
async getEvents(
filter?: FG.PaginationFilter & {
user?: string;
}
) {
try {
const { data } = await api.get<FG.PaginationResponse<FG.Event>>('/events', {
params: <unknown>filter,
});
data.result.forEach((element) => fixEvent(element));
return data;
} catch (error) {
throw error;
}
},
async getEvent(id: number) {
try {
const { data } = await api.get<FG.Event>(`/events/${id}`);
fixEvent(data);
return data;
} catch (error) {
throw error;
}
},
async removeEvent(id: number) {
try {
await api.delete(`/events/${id}`);
const idx = this.templates.findIndex((v) => v.id === id);
if (idx !== -1) this.templates.splice(idx, 1);
} catch (e) {
if (isAxiosError(e, 404)) return false;
throw e;
}
return true;
},
async addEvent(event: EditableEvent) {
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);
if (data.is_template) this.templates.push(data);
fixEvent(data);
return data;
}
},
async assignToJob(jobId: number, service: FG.Service) {
return api
.post<FG.Job>(`/events/jobs/${jobId}/assign`, service)
.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 })
.then(({ data }) => {
data.result.forEach((j) => fixJob(j));
return data;
});
},
/**
* Send invite to job or transfer to other user
* @param job Job to invite to
* @param invitees Users to invite
* @param isTransfer set to True to transfer service instead of invite
*/
async invite(job: FG.Job, invitees: FG.User[], transferee: FG.User | undefined = undefined) {
return api.post<FG.Invitation[]>('/events/invitations', {
job: job.id,
invitees: invitees.map((v) => v.userid),
transferee: transferee?.userid,
});
},
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();
}
},
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();
}
},
},
});
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

@ -1,79 +0,0 @@
import { date } from 'quasar';
/** An new event does not contain an id and the type might be unset */
export type EditableEvent = Omit<Omit<Omit<FG.Event, 'jobs'>, 'type'>, 'id'> & {
type?: FG.EventType | number;
id?: number;
jobs: Job[];
};
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;
/**
* 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 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)],
is_template: false,
};
}

View File

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