Compare commits

..

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

34 changed files with 295 additions and 2483 deletions

View File

@ -17,11 +17,11 @@ module.exports = {
project: resolve(__dirname, './tsconfig.json'), 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'
}, }
}; }

8
.gitignore vendored
View File

@ -3,11 +3,3 @@ 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.5",
"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.8",
"@flaschengeist/types": "^1.0.0", "@quasar/app": "^3.2.3",
"@quasar/app-webpack": "^3.7.2", "quasar": "^2.3.3",
"@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.5.2",
"pinia": "^2.0.4",
"@typescript-eslint/eslint-plugin": "^5.4.0",
"@typescript-eslint/parser": "^5.4.0",
"eslint": "^8.3.0",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.3.0",
"eslint-plugin-prettier": "^4.0.0", "eslint-plugin-vue": "^8.1.1"
"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.4",
"@flaschengeist/users": "^1.0.0" "@flaschengeist/users": "^1.0.0-alpha.1"
}, },
"prettier": { "prettier": {
"singleQuote": true, "singleQuote": true,

2
src/api.d.ts vendored
View File

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

View File

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

View File

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

View File

@ -6,28 +6,17 @@
<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-card>
<q-card-section>
<q-table :title="title" :rows="rows" row-key="id" :columns="columns"> <q-table :title="title" :rows="rows" row-key="id" :columns="columns">
<template #top-right> <template #top-right>
<q-input <q-input
@ -35,21 +24,26 @@
v-model="actualType.name" v-model="actualType.name"
:rules="rules" :rules="rules"
dense dense
filled
placeholder="Neuer Typ" placeholder="Neuer Typ"
> >
<template #after <slot name="after"
><q-btn color="primary" icon="mdi-plus" title="Hinzufügen" round @click="addType" ><q-btn color="primary" icon="mdi-plus" title="Hinzufügen" @click="addType"
/></template> /></slot>
</q-input> </q-input>
</template> </template>
<template #body-cell-actions="props"> <template #body-cell-actions="props">
<q-td :props="props" align="right" :auto-width="true"> <q-td :props="props" align="right" :auto-width="true">
<q-btn round icon="mdi-pencil" @click="editType(props.row.id)" /> <q-btn
round
icon="mdi-pencil"
@click="editType(props.row.id)"
/>
<q-btn round icon="mdi-delete" @click="deleteType(props.row.id)" /> <q-btn round icon="mdi-delete" @click="deleteType(props.row.id)" />
</q-td> </q-td>
</template> </template>
</q-table> </q-table>
</q-card-section>
</q-card>
</div> </div>
</template> </template>
@ -64,7 +58,7 @@ export default defineComponent({
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 = useEventStore();
@ -75,7 +69,7 @@ export default defineComponent({
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`]());
@ -84,7 +78,8 @@ export default defineComponent({
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,7 +100,8 @@ 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 = '';
}) })
@ -125,17 +121,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

@ -63,7 +63,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

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

View File

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

View File

@ -13,52 +13,26 @@
<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="!modelValue.locked && !isEnrolled && !isFull && canAssign" v-if="!modelValue.locked && !isEnrolled && !isFull"
flat flat
color="primary" color="primary"
label="Eintragen" label="Eintragen"
@click="assign()" @click="assignJob()"
/> />
<q-btn v-if="isEnrolled && !modelValue.locked" flat color="secondary" label="Optionen"> <q-btn v-if="isEnrolled && !modelValue.locked" flat color="secondary" label="Optionen">
<q-menu auto-close> <q-menu auto-close>
@ -69,18 +43,8 @@
<q-item clickable @click="transfer"> <q-item clickable @click="transfer">
<q-item-section>Tauschen</q-item-section> <q-item-section>Tauschen</q-item-section>
</q-item> </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-separator />
<q-item clickable @click="unassign()"> <q-item clickable @click="assignJob(false)">
<q-item-section class="text-negative">Austragen</q-item-section> <q-item-section class="text-negative">Austragen</q-item-section>
</q-item> </q-item>
</q-list> </q-list>
@ -92,20 +56,14 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, onBeforeMount, computed, PropType } from 'vue';
import { date, useQuasar } from 'quasar'; import { date, useQuasar } from 'quasar';
import { defineComponent, onBeforeMount, computed, ref, PropType } from 'vue'; import { asHour, useMainStore, useUserStore } from '@flaschengeist/api';
import { asHour, hasPermission, useMainStore, useUserStore } from '@flaschengeist/api';
import { useEventStore } from '../../../store'; import { useEventStore } from '../../../store';
import { PERMISSIONS } from '../../../permissions';
import TransferInviteDialog from './TransferInviteDialog.vue'; 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,
@ -123,65 +81,31 @@ export default defineComponent({
const userStore = useUserStore(); const userStore = useUserStore();
const quasar = useQuasar(); const quasar = useQuasar();
// 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 typeName = computed(() => const typeName = computed(() =>
typeof props.modelValue.type === 'object' typeof props.modelValue.type === 'object'
? props.modelValue.type.name ? props.modelValue.type.name
: store.jobTypes.find((j) => j.id === props.modelValue.type)?.name || : store.jobTypes.find((j) => j.id === props.modelValue.type)?.name || 'Unbekannter Diensttyp'
'Unbekannter Diensttyp'
); );
// The service of the current user if self assigned to the job const isEnrolled = computed(
const service = computed(() => () =>
props.modelValue.services.find((service) => service.userid == mainStore.currentUser.userid) props.modelValue.services.findIndex(
(service) => service.userid == mainStore.currentUser.userid
) !== -1
); );
// Weather the current user is assigned to the job
const isEnrolled = computed(() => service.value !== undefined);
// If the job has enough assigned services
const isFull = computed( const isFull = computed(
() => () =>
props.modelValue.services.map((s) => s.value).reduce((p, c) => p + c, 0) >= props.modelValue.services.map((s) => s.value).reduce((p, c) => p + c, 0) >=
props.modelValue.required_services props.modelValue.required_services
); );
// If current user is only backup service
const isBackup = computed(() => service.value?.is_backup || false);
// If it is still possible to invite other users (= job is today or in the future)
const canInvite = computed( const canInvite = computed(
() => () =>
(props.modelValue.end || props.modelValue.start) > (props.modelValue.end || props.modelValue.start) >
@ -191,15 +115,14 @@ export default defineComponent({
) )
); );
// Assign user to a job async function assignJob(assign = true) {
async function assign(service?: FG.Service) { const newService: FG.Service = {
service = service || {
userid: mainStore.currentUser.userid, userid: mainStore.currentUser.userid,
is_backup: false, is_backup: false,
value: 1, value: assign ? 1 : -1,
}; };
try { try {
const job = await store.assignToJob(props.modelValue.id, service); const job = await store.assignToJob(props.modelValue.id, newService);
emit('update:modelValue', job); emit('update:modelValue', job);
} catch (error) { } catch (error) {
console.warn(error); console.warn(error);
@ -214,7 +137,6 @@ export default defineComponent({
} }
} }
// open invite dialog (or transfer)
function invite(isInvite = true) { function invite(isInvite = true) {
quasar.dialog({ quasar.dialog({
component: TransferInviteDialog, component: TransferInviteDialog,
@ -225,75 +147,15 @@ export default defineComponent({
}); });
} }
/* Stuff needed if we can assign other user */
// Current user can assign other users
const canAssignOther = computed(() => hasPermission(PERMISSIONS.ASSIGN_OTHER));
const canAssign = computed(() => hasPermission(PERMISSIONS.ASSIGN));
// options shown in the select
const options = ref([] as FG.Service[]);
// users which are available (e.g. not already assigned)
const freeUsers = computed(() =>
userStore.users.filter((u) => props.modelValue.services.every((s) => s.userid !== u.userid))
);
// used to filter options based on user input
function filterUsers(
input: string,
doneFn: (
callbackFn: () => void,
afterFn?: (ref: { [index: string]: unknown }) => void
) => void,
abortFn: () => void
) {
if (freeUsers.value.length == 0) return abortFn();
// Filter the options
doneFn(() => {
// Skip filter options if input is too short
if (!input || input.length < 2) {
options.value = freeUsers.value.map<FG.Service>((u) => ({
userid: u.userid,
value: 1,
is_backup: false,
}));
return;
}
// Search matching string within all names
options.value = freeUsers.value
.filter((u) =>
input
.toLowerCase()
.split(' ')
.every(
(needle) =>
u.display_name.toLowerCase().indexOf(needle) > -1 ||
u.firstname.toLowerCase().indexOf(needle) > -1 ||
u.lastname.toLowerCase().indexOf(needle) > -1
)
)
.map<FG.Service>((u) => ({ userid: u.userid, value: 1, is_backup: false }));
});
}
return { return {
assign, assignJob,
unassign: (s?: FG.Service) => assign(Object.assign({}, s || service.value, { value: -1 })),
backup: (is_backup: boolean) => assign(Object.assign({}, service.value, { is_backup })),
canAssignOther,
canAssign,
canInvite, canInvite,
filterUsers,
isBackup,
isEnrolled, isEnrolled,
isFull, isFull,
invite: () => invite(true), invite: () => invite(true),
transfer: () => invite(false), transfer: () => invite(false),
typeName, 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

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

View File

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

View File

@ -44,8 +44,7 @@
</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';
@ -71,21 +70,6 @@ export default defineComponent({
: []), : []),
]); ]);
const route = useRoute();
const router = useRouter();
onBeforeMount(async () => {
if (
(route.query.q_tab && route.query.q_tab === 'create') ||
route.query.q_tab === 'jobtypes' ||
route.query.q_tab === 'eventtypes'
) {
tab.value = route.query.q_tab;
} else {
await router.replace({ query: { q_tab: tab.value } });
}
});
const drawer = ref<boolean>(false); const drawer = ref<boolean>(false);
const tab = ref<string>('create'); const tab = ref<string>('create');
@ -98,10 +82,6 @@ export default defineComponent({
}, },
}); });
watch(tab, async (val) => {
await router.replace({ query: { q_tab: val } });
});
return { return {
showDrawer, showDrawer,
tab, tab,

View File

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

View File

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

View File

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

View File

@ -1,7 +1,6 @@
import { api, isAxiosError } from '@flaschengeist/api'; import { api, isAxiosError } from '@flaschengeist/api';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { EditableEvent } from './models'; import { EditableEvent } from './models';
import { Notify } from 'quasar';
/** /**
* Convert JSON decoded Job to real job (fix Date object) * Convert JSON decoded Job to real job (fix Date object)
@ -29,7 +28,6 @@ export const useEventStore = defineStore({
jobTypes: [] as FG.JobType[], jobTypes: [] as FG.JobType[],
eventTypes: [] as FG.EventType[], eventTypes: [] as FG.EventType[],
templates: [] as FG.Event[], templates: [] as FG.Event[],
invitations: [] as FG.Invitation[],
}), }),
getters: {}, getters: {},
@ -147,16 +145,19 @@ export const useEventStore = defineStore({
}, },
async addEvent(event: EditableEvent) { async addEvent(event: EditableEvent) {
console.log('addEvent', event);
if (event?.id === undefined) { if (event?.id === undefined) {
const { data } = await api.post<FG.Event>('/events', event); const { data } = await api.post<FG.Event>('/events', event);
if (data.is_template) this.templates.push(data); if (data.is_template) this.templates.push(data);
fixEvent(data);
return data; return data;
} else { } else {
if (typeof event.type === 'object') event.type = event.type.id; if (typeof event.type === 'object') event.type = event.type.id;
const { data } = await api.put<FG.Event>(`/events/${event.id}`, event);
const { data } = await api.put<FG.Event>(
`/events/${event.id}`,
Object.assign(event, { jobs: undefined })
);
if (data.is_template) this.templates.push(data); if (data.is_template) this.templates.push(data);
fixEvent(data);
return data; return data;
} }
}, },
@ -167,10 +168,6 @@ export const useEventStore = defineStore({
.then(({ data }) => fixJob(data)); .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) { async getJobs(filter?: FG.PaginationFilter) {
return api return api
.get<FG.PaginationResponse<FG.Job>>('/events/jobs', { params: <unknown>filter }) .get<FG.PaginationResponse<FG.Job>>('/events/jobs', { params: <unknown>filter })
@ -190,60 +187,18 @@ export const useEventStore = defineStore({
return api.post<FG.Invitation[]>('/events/invitations', { return api.post<FG.Invitation[]>('/events/invitations', {
job: job.id, job: job.id,
invitees: invitees.map((v) => v.userid), invitees: invitees.map((v) => v.userid),
transferee: transferee?.userid, transferee: transferee,
}); });
}, },
async getInvitations(force = false) {
if (this.invitations.length == 0 || force) {
const { data } = await api.get<FG.Invitation[]>('/events/invitations');
this.invitations = data;
}
return this.invitations;
},
async rejectInvitation(invite: FG.Invitation | number) { async rejectInvitation(invite: FG.Invitation | number) {
try { return api.delete(`/events/invitations/${typeof invite === 'number' ? invite : invite.id}`);
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) { async acceptInvitation(invite: FG.Invitation | number) {
try { return api.put(`/events/invitations/${typeof invite === 'number' ? invite : invite.id}`, {
await api.put(`/events/invitations/${typeof invite === 'number' ? invite : invite.id}`, {
accept: true, 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

@ -4,76 +4,32 @@ import { date } from 'quasar';
export type EditableEvent = Omit<Omit<Omit<FG.Event, 'jobs'>, 'type'>, 'id'> & { export type EditableEvent = Omit<Omit<Omit<FG.Event, 'jobs'>, 'type'>, 'id'> & {
type?: FG.EventType | number; type?: FG.EventType | number;
id?: number; id?: number;
jobs: Job[]; jobs: EditableJob[];
}; };
export class Job implements FG.Job { /** A new job does not have an id or type assigned */
id = NaN; export type EditableJob = Omit<Omit<FG.Job, 'type'>, 'id'> & {
start: Date; type?: FG.EventType | number;
end?: Date = undefined; id?: number;
type: FG.JobType | number = NaN; };
comment?: string = undefined;
locked = false;
services = [] as FG.Service[];
required_services = 0;
/** export function emptyJob(startDate = new Date()): EditableJob {
* Build Job from API Job interface const start = date.adjustDate(startDate, {
* @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(), hours: new Date().getHours(),
minutes: 0,
seconds: 0,
milliseconds: 0,
}); });
else this.start = new Date(); // <-- make TS happy "no initalizer" return {
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, start: start,
end: duration === undefined ? undefined : date.addToDate(start, { hours: duration }), end: date.addToDate(start, { hours: 1 }),
}); services: [],
} locked: false,
required_services: 2,
/** };
* Check if this instance was loaded from API
*/
isPersistent() {
return !isNaN(this.id);
}
} }
export function emptyEvent(startDate: Date = new Date()): EditableEvent { export function emptyEvent(startDate: Date = new Date()): EditableEvent {
return { return {
start: date.adjustDate(startDate, { hours: 0, minutes: 0, seconds: 0, milliseconds: 0 }), start: date.adjustDate(startDate, { hours: 0, minutes: 0, seconds: 0, milliseconds: 0 }),
jobs: [Job.fromDate(startDate, true, 4)], jobs: [emptyJob(startDate)],
is_template: false, 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/",