Compare commits

...

96 Commits

Author SHA1 Message Date
Tim Gröger 6737bd5b45 update to version 1.1.0 2024-10-08 14:53:22 +02:00
Tim Gröger aa3c172160 [feat] show names by display mode setting 2024-10-08 14:46:44 +02:00
Tim Gröger 923f5ec27c update version to 1.0.0 2024-01-16 19:54:07 +01:00
Tim Gröger 084ad8f945 fix correct datetimes in jobslots when change event start 2024-01-16 15:55:14 +01:00
Tim Gröger 7b1e37b3a7 fix correct datetimes in jobslots when come from template 2024-01-16 15:52:23 +01:00
Tim Gröger 9a648e8443 fix show january 2024-01-16 14:58:34 +01:00
Tim Gröger 38480a1eec update to v1.0.0-alpha.9 2023-05-16 00:04:42 +02:00
Tim Gröger f0e07138b1 fixed intput theming 2023-05-15 10:32:31 +02:00
Tim Gröger ba7013ac67 add show sended invitation to query 2023-05-13 00:06:27 +02:00
Tim Gröger 252072df57 (fix) #1 in frontend
required services have to be >= 1
2023-05-12 23:37:20 +02:00
Tim Gröger c5012c8431 update dependencies, new version 2023-05-08 21:40:52 +02:00
Tim Gröger 40148ba10f update version 2023-05-05 14:48:52 +02:00
Tim Gröger 3fbbacabca check has permission assign 2023-05-03 06:11:52 +02:00
Tim Gröger 6f6fca84ed fix load older events in listView 2023-05-03 00:19:09 +02:00
Tim Gröger b5d43bb1de fix editing ande remove events in listview 2023-05-03 00:09:21 +02:00
Tim Gröger 7ea5a8fac3 duty fix for listView in Overview 2023-05-02 23:17:45 +02:00
Tim Gröger 67d844d6e6 add on every page query for location 2023-05-02 23:15:32 +02:00
Tim Gröger 93f88792d0 update job start and end time when event time change 2023-05-02 22:40:54 +02:00
Tim Gröger 6ad340fe7c add query on agendaView 2023-05-02 15:00:39 +02:00
Tim Gröger c767d92442 add notification 2023-05-02 06:40:48 +02:00
Tim Gröger 8cd9182a8b remove invitation when reject or accept 2023-05-02 06:23:36 +02:00
Tim Gröger 46939d4b64 fix typing 2023-05-01 22:47:37 +02:00
Tim Gröger ae275aeabb remove unecessary notification in frontend 2023-05-01 22:46:16 +02:00
Tim Gröger c721f25104 delete unnecessary notifications 2023-05-01 22:06:24 +02:00
Tim Gröger 26235fef49 add actions on events-request-page 2023-05-01 11:00:24 +02:00
Tim Gröger b33d30fe40 black 2023-04-30 13:56:14 +02:00
Tim Gröger 28223d12b6 fix transferjob when accepted 2023-04-30 13:55:57 +02:00
Tim Gröger 0f0e6702e2 prettier 2023-04-30 13:55:17 +02:00
Tim Gröger b4c3cfa365 fix and add more notification 2023-04-30 13:55:00 +02:00
Tim Gröger 941841b1bb change id to get notifications 2023-04-30 09:49:51 +02:00
Tim Gröger 31c6410eba change view of listview 2023-04-28 20:45:47 +02:00
Tim Gröger 144bc1d58e load jobTypes before used 2023-04-28 20:45:30 +02:00
Tim Gröger a88e3a0160 remove card from EventRequests 2023-04-28 20:05:15 +02:00
Tim Gröger 2ddb89f89c add IDE options in gitignore 2023-04-28 19:56:58 +02:00
Tim Gröger 3ba7e5d366 add end time for job, delete empty jobtype 2023-04-28 19:55:12 +02:00
Ferdinand Thiessen e1ad8f0f11 feat(ui): Implemented job invitations / transfer page 2023-04-28 13:52:32 +02:00
Tim Gröger eb0e54714b fix add Jobs backend 2023-04-28 13:51:41 +02:00
Tim Gröger 994f65c38b fix add jobs 2023-04-28 13:51:11 +02:00
Tim Gröger b7741cfa37 fix adding, modify, remove jobs from event 2023-04-26 11:28:16 +02:00
Tim Gröger 9359db3881 fix add jobs 2023-04-24 21:14:19 +02:00
Tim Gröger f54911ed48 add alembic migration; delete unneccessary card layout 2023-04-10 14:55:17 +02:00
Tim Gröger 7923f4889c fix depracation warnings (sqlalchemy 2.0), fix installable plugin 2023-04-09 23:41:10 +02:00
Ferdinand Thiessen 75fa825962 fix(backend): Provide version, fix metadata
continuous-integration/woodpecker the build was successful Details
2021-12-23 03:24:12 +01:00
Ferdinand Thiessen 166073fb55 fix(ui): Fix routes, requests should have more priority than settings
continuous-integration/woodpecker the build was successful Details
2021-12-22 01:09:39 +01:00
Ferdinand Thiessen 0f65ae53af feat(ui): Add functions for getting a job and invitations to the events store 2021-12-22 01:08:48 +01:00
Ferdinand Thiessen 2ef9fd023a fix(ui): Align api with backend 2021-12-22 01:07:45 +01:00
Ferdinand Thiessen a09ce26474 feat(backend): Add routed for get_job and get_invitations 2021-12-22 01:06:23 +01:00
Ferdinand Thiessen 5a52c364e4 chore(deps): Set minimum supported python version to 3.8. 2021-12-22 01:05:42 +01:00
Ferdinand Thiessen 72cb163a00 fix(backend): Fix Job model and using controller functions
type should be the type id not the object
2021-12-22 01:05:10 +01:00
Ferdinand Thiessen 62599898d0 fix(backend): Invite must search user in database
continuous-integration/woodpecker the build was successful Details
2021-12-17 14:50:42 +01:00
Ferdinand Thiessen 90219c5ddd chore(ci): Rename pipelines to match other projects 2021-12-17 14:48:53 +01:00
Ferdinand Thiessen c2e0e55a19 fix(ui): Invite should send only transferee userid 2021-12-17 14:48:20 +01:00
Ferdinand Thiessen eed25f8290 chore(deps): Update dependencies + tag new version
continuous-integration/woodpecker the build was successful Details
2021-12-13 20:18:19 +01:00
Ferdinand Thiessen e733ee0f40 feat(ci): Added woodpecker CI 2021-12-13 20:18:16 +01:00
Ferdinand Thiessen d200370975 fix(backend): Use new scheduler plugin for assign_backups 2021-12-06 23:49:27 +01:00
Ferdinand Thiessen 960a19dae8 chore(ui): Minor cleanup 2021-12-06 13:15:34 +01:00
Ferdinand Thiessen e4d3ef2097 chore(ui): Update dependencies 2021-12-06 00:24:08 +01:00
Ferdinand Thiessen dd49b0eb9e feat(ui): Allow assigning other users (if you have the permission). 2021-12-06 00:23:15 +01:00
Ferdinand Thiessen 58621d3da4 chore(ui): Fix code formatting 2021-12-06 00:23:10 +01:00
Ferdinand Thiessen 11a4f87005 fix(backend): Allow unassigning deleted users, but not assigning 2021-12-05 22:59:13 +01:00
Ferdinand Thiessen 32bcf7ecbf fix(backend): Fix changing service type between backup and normal 2021-12-02 21:31:20 +01:00
Ferdinand Thiessen 7baa274d02 feat(backend): Remove deleted user from all futur services 2021-12-02 21:30:42 +01:00
Ferdinand Thiessen 81c33ece8c fix(ui) Minor usability fixes for transfer / invite dialog 2021-11-29 11:32:20 +01:00
Ferdinand Thiessen de6e959937 chore(backend) Split backend from flaschengeist, now developed here 2021-11-28 22:30:15 +01:00
Ferdinand Thiessen c8ae458775 fix(ui) AgendaView now shows correct events on small devices 2021-11-28 22:21:07 +01:00
Ferdinand Thiessen 38cc0d43d6 Tag a new alpha version 2021-11-25 15:38:57 +01:00
Ferdinand Thiessen f539d2c386 Set start of empty event model to midnight 2021-11-25 15:38:02 +01:00
Ferdinand Thiessen f87c691f61 [api] Match store functions with new pagination API for events 2021-11-25 15:37:34 +01:00
Ferdinand Thiessen 7b622f96ea [cleanup] Minor styling fixes 2021-11-25 15:36:32 +01:00
Ferdinand Thiessen 7eb0074111 [widget] Implemented functional widget, show next services 2021-11-25 15:35:14 +01:00
Ferdinand Thiessen f79823531d [agendaview] Fix week not loaded if window resized from 3 to 7 days 2021-11-25 15:33:50 +01:00
Ferdinand Thiessen 4c92fb0ff7 [agendaview] Edit events as dialog 2021-11-25 15:32:58 +01:00
Ferdinand Thiessen 512e68f1ed Implemented transfer and invite to jobs and notifications 2021-11-24 21:47:14 +01:00
Ferdinand Thiessen 6c32aae7b4 Fix typesafe names 2021-11-24 21:45:00 +01:00
Ferdinand Thiessen ff15ceb7d0 [ported] Validate jobs before adding new and minimize inactive jobs
Ported from flaschengeist-frontend @7d1993e3faecdca3af47bc19f444857c49c3f3c4
2021-11-23 17:32:48 +01:00
Ferdinand Thiessen c31b804102 [ported] EditEvent set date of templates to today
Ported from flaschengeist-frontend @5e19a437bdfff83762ca899d506d4cd32da744ad
2021-11-22 13:12:32 +01:00
Ferdinand Thiessen 82a24a5a53 AgendaView fix datepicker autoclose 2021-11-22 13:01:22 +01:00
Ferdinand Thiessen 6985904a3b [ported] AgendaView button for current week / day
Ported from flaschengeist-frontend @a59f7788511f9ce4fd4522621c82b389809ac7ab
2021-11-22 12:24:25 +01:00
Ferdinand Thiessen 5b657f4bf0 [ported] Structural and minor optical cleanup
Took from flaschengeist-frontend @f712bfd4f9e36210766cec117ed8504669556a75
2021-11-22 12:00:59 +01:00
Ferdinand Thiessen 13539a675b Renamed page components (multi word) 2021-11-22 12:00:17 +01:00
Ferdinand Thiessen ea64568e2b First work on transfer services 2021-11-21 17:51:10 +01:00
Ferdinand Thiessen 8b6fd67f1d [store] Rename to EventStore 2021-11-21 12:40:53 +01:00
Ferdinand Thiessen 661685c57a [fix] set start date to midnight 2021-11-21 12:36:17 +01:00
Tim Gröger 8f8eef1784 [fix] load events from time with hour 0 2021-11-19 22:49:03 +01:00
Ferdinand Thiessen ba697e1d1a Add first version of ListView 2021-11-19 14:14:42 +01:00
Ferdinand Thiessen 70545c3dda Improve event editing 2021-11-17 01:55:55 +01:00
Ferdinand Thiessen 511014d043 [style] Fix style on extra small devices
* Show only one day in the agenda view if devices has a very low width.
* Fix very tiny font on small devices
2021-11-16 23:30:21 +01:00
Ferdinand Thiessen a0192418e2 [deps] Fix compatibility of EditJob with current API.
This should, by the way, fix issues with Safari browser.
2021-11-16 23:27:20 +01:00
Ferdinand Thiessen a8953bbae6 [deps] Update dependencies and increase version 2021-11-16 21:22:13 +01:00
Ferdinand Thiessen c003b58183 [cleanup] Fixed some warnings 2021-11-15 10:25:52 +01:00
Ferdinand Thiessen 8b425e723e [calendar] Allow creating events by clicking on the date. 2021-11-15 10:25:21 +01:00
Ferdinand Thiessen 3dc108656a [manage] Merge types management into one component 2021-11-15 00:33:28 +01:00
Ferdinand Thiessen 9331006db3 [permissions] Fixed permission parsing 2021-11-13 14:52:50 +01:00
Ferdinand Thiessen 3db6e0d2d1 [management] Improved eventtypes management 2021-11-13 14:51:56 +01:00
Ferdinand Thiessen e0176c57e1 [style] Fixed darkmode and layout on very small devices 2021-11-11 15:25:11 +01:00
Ferdinand Thiessen 75b9e39f11 Fix ID of plugin 2021-11-11 13:39:39 +01:00
45 changed files with 3695 additions and 1027 deletions

View File

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

9
.gitignore vendored
View File

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

5
.npmignore Normal file
View File

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

14
.woodpecker/deploy.yml Normal file
View File

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

9
.woodpecker/lint.yml Normal file
View File

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

View File

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

View File

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

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

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

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

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

@ -0,0 +1,567 @@
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()

3
backend/pyproject.toml Normal file
View File

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

34
backend/setup.cfg Normal file
View File

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

View File

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

7
src/api.d.ts vendored
View File

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

View File

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

View File

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

View File

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

View File

@ -1,126 +0,0 @@
<template>
<div>
<q-dialog v-model="edittype">
<q-card>
<q-card-section>
<div class="text-h6">Editere Diensttyp {{ actualEvent.name }}</div>
</q-card-section>
<q-card-section>
<q-input v-model="newEventName" dense label="name" filled />
</q-card-section>
<q-card-actions>
<q-btn flat color="danger" label="Abbrechen" @click="discardChanges()" />
<q-btn flat color="primary" label="Speichern" @click="saveChanges()" />
</q-card-actions>
</q-card>
</q-dialog>
<q-card>
<q-card-section>
<q-table title="Veranstaltungstypen" :rows="rows" row-key="jobid" :columns="columns">
<template #top-right>
<q-input v-model="newEventType" dense placeholder="Neuer Typ" />
<div></div>
<q-btn color="primary" icon="mdi-plus" label="Hinzufügen" @click="addType" />
</template>
<template #body-cell-actions="props">
<!-- <q-btn :label="item"> -->
<!-- {{ item.row.name }} -->
<q-td :props="props" align="right" :auto-width="true">
<q-btn
round
icon="mdi-pencil"
@click="editType({ id: props.row.id, name: props.row.name })"
/>
<q-btn round icon="mdi-delete" @click="deleteType(props.row.id)" />
</q-td>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, computed, onBeforeMount } from 'vue';
import { useScheduleStore } from '../../store';
export default defineComponent({
name: 'EventTypes',
components: {},
setup() {
const store = useScheduleStore();
const newEventType = ref('');
const edittype = ref(false);
const emptyEvent: FG.EventType = { id: -1, name: '' };
const actualEvent = ref(emptyEvent);
const newEventName = ref('');
onBeforeMount(async () => await store.getEventTypes());
const rows = computed(() => store.eventTypes);
const columns = [
{
name: 'name',
label: 'Veranstaltungstyp',
field: 'name',
align: 'left',
sortable: true,
},
{
name: 'actions',
label: 'Aktionen',
field: 'actions',
align: 'right',
},
];
async function addType() {
await store.addEventType(newEventType.value);
// if null then conflict with name
newEventType.value = '';
}
function editType(event: FG.EventType) {
edittype.value = true;
actualEvent.value = event;
}
async function saveChanges() {
try {
await store.renameEventType(actualEvent.value.id, newEventName.value);
} finally {
discardChanges();
}
}
function discardChanges() {
actualEvent.value = emptyEvent;
newEventName.value = '';
edittype.value = false;
}
async function deleteType(id: number) {
await store.removeEventType(id);
}
return {
columns,
rows,
addType,
newEventType,
deleteType,
edittype,
editType,
actualEvent,
newEventName,
discardChanges,
saveChanges,
};
},
});
</script>
<style scoped></style>

View File

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

View File

@ -1,125 +0,0 @@
<template>
<div>
<q-dialog v-model="edittype">
<q-card>
<q-card-section>
<div class="text-h6">Editere Diensttyp {{ actualJob.name }}</div>
</q-card-section>
<q-card-section>
<q-input v-model="newJobName" dense label="name" filled />
</q-card-section>
<q-card-actions>
<q-btn flat color="danger" label="Abbrechen" @click="discardChanges()" />
<q-btn flat color="primary" label="Speichern" @click="saveChanges()" />
</q-card-actions>
</q-card>
</q-dialog>
<q-card>
<q-card-section>
<q-table title="Diensttypen" :rows="rows" row-key="jobid" :columns="columns">
<template #top-right>
<q-input v-model="newJob" dense placeholder="Neuer Typ" />
<div></div>
<q-btn color="primary" icon="mdi-plus" label="Hinzufügen" @click="addType" />
</template>
<template #body-cell-actions="props">
<!-- <q-btn :label="item"> -->
<!-- {{ item.row.name }} -->
<q-td :props="props" align="right" :auto-width="true">
<q-btn
round
icon="mdi-pencil"
@click="editType({ id: props.row.id, name: props.row.name })"
/>
<q-btn round icon="mdi-delete" @click="deleteType(props.row.id)" />
</q-td>
</template>
</q-table>
</q-card-section>
</q-card>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, computed, onBeforeMount } from 'vue';
import { useScheduleStore } from '../../store';
export default defineComponent({
name: 'JobTypes',
components: {},
setup() {
const store = useScheduleStore();
const newJob = ref('');
const edittype = ref(false);
const emptyJob: FG.JobType = { id: -1, name: '' };
const actualJob = ref(emptyJob);
const newJobName = ref('');
onBeforeMount(() => store.getJobTypes());
const rows = computed(() => store.jobTypes);
const columns = [
{
name: 'jobname',
label: 'Name',
field: 'name',
align: 'left',
sortable: true,
},
{
name: 'actions',
label: 'Aktionen',
field: 'actions',
align: 'right',
},
];
async function addType() {
await store.addJobType(newJob.value);
newJob.value = '';
}
function editType(job: FG.JobType) {
edittype.value = true;
actualJob.value = job;
}
async function saveChanges() {
try {
await store.renameJobType(actualJob.value.id, newJobName.value);
} finally {
discardChanges();
}
}
function discardChanges() {
actualJob.value = emptyJob;
newJobName.value = '';
edittype.value = false;
}
function deleteType(id: number) {
void store.removeJobType(id);
}
return {
columns,
rows,
addType,
newJob,
deleteType,
edittype,
editType,
actualJob,
newJobName,
discardChanges,
saveChanges,
};
},
});
</script>
<style scoped></style>

View File

@ -0,0 +1,168 @@
<template>
<div>
<q-dialog v-model="dialogOpen">
<q-card>
<q-card-section>
<div class="text-h6">Editere {{ title }} {{ actualType.name }}</div>
</q-card-section>
<q-card-section>
<q-input
ref="dialogInput"
v-model="actualType.name"
:rules="rules"
dense
label="name"
filled
/>
</q-card-section>
<q-card-actions>
<q-btn flat color="danger" label="Abbrechen" @click="discardChanges()" />
<q-btn
flat
color="primary"
label="Speichern"
:disable="!!dialogInput && !dialogInput.validate()"
@click="saveChanges()"
/>
</q-card-actions>
</q-card>
</q-dialog>
<q-table :title="title" :rows="rows" row-key="id" :columns="columns">
<template #top-right>
<q-input
ref="input"
v-model="actualType.name"
:rules="rules"
dense
filled
placeholder="Neuer Typ"
>
<template #after
><q-btn color="primary" icon="mdi-plus" title="Hinzufügen" round @click="addType"
/></template>
</q-input>
</template>
<template #body-cell-actions="props">
<q-td :props="props" align="right" :auto-width="true">
<q-btn round icon="mdi-pencil" @click="editType(props.row.id)" />
<q-btn round icon="mdi-delete" @click="deleteType(props.row.id)" />
</q-td>
</template>
</q-table>
</div>
</template>
<script lang="ts">
import { isAxiosError } from '@flaschengeist/api';
import { defineComponent, ref, computed, PropType, onBeforeMount } from 'vue';
import { useEventStore } from '../../store';
import { useQuasar, QInput } from 'quasar';
export default defineComponent({
name: 'ManageTypes',
components: {},
props: {
type: { type: String as PropType<'EventType' | 'JobType'>, required: true },
title: { type: String, required: true },
},
setup(props) {
const store = useEventStore();
const quasar = useQuasar();
const dialogOpen = ref(false);
const emptyType = { id: -1, name: '' };
const actualType = ref(emptyType);
const input = ref<QInput>();
const dialogInput = ref<QInput>();
const storeName = computed(() => (props.type == 'EventType' ? 'eventTypes' : 'jobTypes'));
onBeforeMount(async () => await store[`get${props.type}s`]());
const rows = computed(() => <(FG.EventType | FG.JobType)[]>store[storeName.value]);
const rules = [
(s: unknown) => !!s || 'Darf nicht leer sein!',
(s: string) =>
rows.value.find((e) => e.name === s) === undefined || 'Der Name wird bereits verwendet',
];
const columns = [
{
name: 'name',
label: props.title,
field: 'name',
align: 'left',
sortable: true,
},
{
name: 'actions',
label: 'Aktionen',
field: 'actions',
align: 'right',
},
];
function addType() {
if (input.value === undefined || input.value.validate())
store[`add${props.type}`](actualType.value.name)
.then(() => {
actualType.value.name = '';
})
.catch((e) => {
if (isAxiosError(e, 409))
quasar.notify({
type: 'negative',
message: 'Der Name wird bereits verwendet',
});
else
quasar.notify({
type: 'negative',
message: 'Unbekannter Fehler beim speichern.',
});
});
}
function editType(id: number) {
dialogOpen.value = true;
actualType.value = Object.assign(
{},
rows.value.find((v) => v.id === id)
);
}
function saveChanges() {
if (dialogInput.value === undefined || dialogInput.value.validate())
void store[`rename${props.type}`](actualType.value.id, actualType.value.name).then(() =>
discardChanges()
);
}
function discardChanges() {
actualType.value = emptyType;
dialogOpen.value = false;
}
async function deleteType(id: number) {
await store[`remove${props.type}`](id);
}
return {
actualType,
addType,
columns,
deleteType,
dialogInput,
dialogOpen,
discardChanges,
editType,
input,
rows,
rules,
saveChanges,
};
},
});
</script>
<style scoped></style>

View File

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

View File

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

View File

@ -0,0 +1,205 @@
<template>
<q-dialog
:model-value="editor !== undefined"
persistent
transition-show="scale"
transition-hide="scale"
>
<q-card>
<div class="column">
<div class="col" align="right" style="position: sticky; top: 0; z-index: 999">
<q-btn round color="negative" icon="close" dense rounded @click="editDone(false)" />
</div>
<div class="col" style="margin: 0; padding: 0; margin-top: -2.4em">
<edit-event v-model="editor" @done="editDone" />
</div>
</div>
</q-card>
</q-dialog>
<!-- <div class="q-pa-md"> -->
<!-- <q-card style="height: 70vh; max-width: 1800px" class="q-pa-md"> -->
<div ref="scrollDiv" class="scroll" style="height: 100%">
<q-infinite-scroll :offset="250" @load="load">
<q-list>
<q-item id="bbb">
<q-btn label="Ältere Veranstaltungen laden" @click="load(-1)" />
</q-item>
<template v-for="(events, index) in agendas" :key="index">
<q-separator />
<q-item-label header>{{ asDate(index) }}</q-item-label>
<q-item v-for="(event, idx) in events" :key="idx">
<event-slot v-model="events[idx]" @edit-event="edit" @remove-event="remove" />
</q-item>
</template>
</q-list>
<template #loading>
<div class="row justify-center q-my-md">
<q-spinner-dots color="primary" size="40px" />
</div>
</template>
</q-infinite-scroll>
</div>
<!-- </q-card> -->
<!-- </div> -->
</template>
<script lang="ts">
import { computed, defineComponent, onBeforeMount, ref } from 'vue';
import { useEventStore } from '../../store';
import { date } from 'quasar';
import EditEvent from '../management/EditEvent.vue';
import EventSlot from '../overview/slots/EventSlot.vue';
export default defineComponent({
name: 'ListView',
components: { EditEvent, EventSlot },
setup() {
interface Agendas {
[index: string]: FG.Event[];
}
const store = useEventStore();
const editor = ref<FG.Event | undefined>(undefined);
const events = ref<FG.Event[]>([]);
const scrollDiv = ref<Element>();
const agendas = computed<Agendas>(() => {
const ag = {} as Agendas;
events.value?.forEach((event) => {
const day = date.formatDate(event.start, 'YYYYMMDD');
if (!ag[day]) {
ag[day] = [];
}
ag[day].push(event);
});
return ag;
});
onBeforeMount(async () => {
//await loadAgendas();
});
async function edit(id: number) {
editor.value = await store.getEvent(id);
}
function editDone(changed: boolean) {
//if (changed) void loadAgendas();
const idx = events.value.findIndex((event) => event.id === editor.value?.id);
if (idx >= 0) {
events.value[idx] = editor.value as FG.Event;
}
editor.value = undefined;
}
async function load(index: number, done?: (stop: boolean) => void) {
const start = new Date();
if (index < 0) {
const { result } = await store.getEvents({ to: start, limit: 5, descending: true });
//events.value.unshift(...result);
for (const event of result) {
const idx = events.value.findIndex((e) => e.id === event.id);
if (idx === -1) events.value.unshift(event);
else events.value[idx] = event;
}
if (done) done(false);
} else {
const len = events.value.length;
const { result } = await store.getEvents({
from: start,
offset: (index - 1) * 10,
limit: 10,
});
for (const event of result) {
const idx = events.value.findIndex((e) => e.id === event.id);
if (idx === -1) events.value.unshift(event);
else events.value[idx] = event;
}
if (len == events.value.length) {
if (done) return done(true);
} else if (done) done(false);
}
if (index <= 1) {
window.setTimeout(() => {
(<Element>scrollDiv.value).scrollTop = document.getElementById('bbb')?.scrollHeight || 0;
}, 150);
}
}
async function remove(id: number) {
if (await store.removeEvent(id)) {
const idx = events.value.findIndex((event) => event.id === id);
if (idx !== -1) {
events.value.splice(idx, 1);
}
}
}
function asMonth(value: string) {
if (value) {
return date.formatDate(new Date(value), 'MMMM', {
months: [
'Januar',
'Februar',
'März',
'April',
'Mai',
'Juni',
'Juli',
'August',
'September',
'Oktober',
'November',
'Dezember',
],
});
}
}
function asYear(value: string) {
if (value) {
return date.formatDate(new Date(value), 'YYYY');
}
}
function asDate(value: string) {
if (value) {
const year = parseInt(value.substring(0, 4));
const month = parseInt(value.substring(4, 6)) - 1;
const day = parseInt(value.substring(6, 8));
return date.formatDate(new Date(year, month, day), 'DD.MM.YYYY');
}
}
return {
agendas,
asYear,
asMonth,
edit,
editor,
scrollDiv,
editDone,
load,
remove,
asDate,
};
},
});
</script>
<style>
@import '@quasar/quasar-ui-qcalendar/dist/index.css';
/* Fill full height of card */
.q-calendar-agenda__pane {
height: 100%;
}
/* Same as Qcard */
.q-calendar {
--calendar-background-dark: --q-dark;
}
</style>

View File

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

View File

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

View File

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

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

32
src/events.d.ts vendored
View File

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

View File

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

View File

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

View File

@ -0,0 +1,112 @@
<template>
<q-page padding class="fit row justify-center content-start items-start q-gutter-sm">
<q-tabs v-if="$q.screen.gt.sm" v-model="tab">
<q-tab
v-for="(tabindex, index) in tabs"
:key="'tab' + index"
:name="tabindex.name"
:label="tabindex.label"
/>
</q-tabs>
<div v-else class="fit row justify-end">
<q-btn flat round icon="mdi-menu" @click="showDrawer = !showDrawer" />
</div>
<q-drawer v-model="showDrawer" side="right" behavior="mobile" @click="showDrawer = !showDrawer">
<q-list v-model="tab">
<q-item
v-for="(tabindex, index) in tabs"
:key="'tab' + index"
:active="tab == tabindex.name"
clickable
@click="tab = tabindex.name"
>
<q-item-label>{{ tabindex.label }}</q-item-label>
</q-item>
</q-list>
</q-drawer>
<q-tab-panels
v-model="tab"
style="background-color: transparent"
class="q-ma-none q-pa-none fit row justify-center content-start items-start"
animated
>
<q-tab-panel name="create">
<EditEvent :date="date" />
</q-tab-panel>
<q-tab-panel name="eventtypes">
<ManageTypes title="Veranstaltungstyp" type="EventType" />
</q-tab-panel>
<q-tab-panel name="jobtypes">
<ManageTypes title="Dienstart" type="JobType" />
</q-tab-panel>
</q-tab-panels>
</q-page>
</template>
<script lang="ts">
import { computed, defineComponent, ref, onBeforeMount, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import ManageTypes from '../components/management/ManageTypes.vue';
import EditEvent from '../components/management/EditEvent.vue';
import { hasPermission } from '@flaschengeist/api';
import { PERMISSIONS } from '../permissions';
import { Screen } from 'quasar';
export default defineComponent({
name: 'EventManagement',
components: { EditEvent, ManageTypes },
props: {
date: {
type: String,
required: false,
default: undefined,
},
},
setup() {
const tabs = computed(() => [
{ name: 'create', label: 'Veranstaltungen' },
...(hasPermission(PERMISSIONS.JOB_TYPE) ? [{ name: 'jobtypes', label: 'Dienstarten' }] : []),
...(hasPermission(PERMISSIONS.EVENT_TYPE)
? [{ name: 'eventtypes', label: 'Veranstaltungsarten' }]
: []),
]);
const route = useRoute();
const router = useRouter();
onBeforeMount(async () => {
if (
(route.query.q_tab && route.query.q_tab === 'create') ||
route.query.q_tab === 'jobtypes' ||
route.query.q_tab === 'eventtypes'
) {
tab.value = route.query.q_tab;
} else {
await router.replace({ query: { q_tab: tab.value } });
}
});
const drawer = ref<boolean>(false);
const tab = ref<string>('create');
const showDrawer = computed({
get: () => {
return !Screen.gt.sm && drawer.value;
},
set: (val: boolean) => {
drawer.value = val;
},
});
watch(tab, async (val) => {
await router.replace({ query: { q_tab: val } });
});
return {
showDrawer,
tab,
tabs,
};
},
});
</script>

View File

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

41
src/pages/EventPage.vue Normal file
View File

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

320
src/pages/EventRequests.vue Normal file
View File

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

View File

@ -1,95 +0,0 @@
<template>
<div>
<q-tabs v-if="$q.screen.gt.sm" v-model="tab">
<q-tab
v-for="(tabindex, index) in tabs"
:key="'tab' + index"
:name="tabindex.name"
:label="tabindex.label"
/>
</q-tabs>
<div v-else class="fit row justify-end">
<q-btn flat round icon="mdi-menu" @click="showDrawer = !showDrawer" />
</div>
<q-drawer v-model="showDrawer" side="right" behavior="mobile" @click="showDrawer = !showDrawer">
<q-list v-model="tab">
<q-item
v-for="(tabindex, index) in tabs"
:key="'tab' + index"
:active="tab == tabindex.name"
clickable
@click="tab = tabindex.name"
>
<q-item-label>{{ tabindex.label }}</q-item-label>
</q-item>
</q-list>
</q-drawer>
<q-page padding class="fit row justify-center content-start items-start q-gutter-sm">
<q-tab-panels
v-model="tab"
style="background-color: transparent"
class="q-ma-none q-pa-none fit row justify-center content-start items-start"
animated
>
<q-tab-panel name="create">
<EditEvent />
</q-tab-panel>
<q-tab-panel name="eventtypes">
<EventTypes />
</q-tab-panel>
<q-tab-panel name="jobtypes">
<JobTypes v-if="canEditJobTypes" />
</q-tab-panel>
</q-tab-panels>
</q-page>
</div>
</template>
<script lang="ts">
import { computed, defineComponent, ref } from 'vue';
import EventTypes from '../components/management/EventTypes.vue';
import JobTypes from '../components/management/JobTypes.vue';
import EditEvent from '../components/management/EditEvent.vue';
import { hasPermission } from '@flaschengeist/api';
import { PERMISSIONS } from '../permissions';
import { Screen } from 'quasar';
export default defineComponent({
name: 'EventManagement',
components: { EditEvent, EventTypes, JobTypes },
setup() {
const canEditJobTypes = computed(() => hasPermission(PERMISSIONS.JOB_TYPE));
interface Tab {
name: string;
label: string;
}
const tabs: Tab[] = [
{ name: 'create', label: 'Veranstaltungen' },
{ name: 'eventtypes', label: 'Veranstaltungsarten' },
{ name: 'jobtypes', label: 'Dienstarten' },
];
const drawer = ref<boolean>(false);
const showDrawer = computed({
get: () => {
return !Screen.gt.sm && drawer.value;
},
set: (val: boolean) => {
drawer.value = val;
},
});
const tab = ref<string>('create');
return {
canEditJobTypes,
showDrawer,
tab,
tabs,
};
},
});
</script>

View File

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

View File

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

View File

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

View File

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

249
src/store/index.ts Normal file
View File

@ -0,0 +1,249 @@
import { api, isAxiosError } from '@flaschengeist/api';
import { defineStore } from 'pinia';
import { EditableEvent } from './models';
import { Notify } from 'quasar';
/**
* Convert JSON decoded Job to real job (fix Date object)
*/
function fixJob(job: FG.Job) {
job.start = new Date(job.start);
if (job.end) job.end = new Date(job.end);
return job;
}
/**
* Convert JSON decoded Event to real Event object (fix Date object)
*/
function fixEvent(event: FG.Event) {
event.start = new Date(event.start);
if (event.end) event.end = new Date(event.end);
event.jobs.forEach((job) => fixJob(job));
}
export const useEventStore = defineStore({
id: 'events',
state: () => ({
jobTypes: [] as FG.JobType[],
eventTypes: [] as FG.EventType[],
templates: [] as FG.Event[],
invitations: [] as FG.Invitation[],
}),
getters: {},
actions: {
async getJobTypes(force = false) {
if (force || this.jobTypes.length == 0)
try {
const { data } = await api.get<FG.JobType[]>('/events/job-types');
this.jobTypes = data;
} catch (error) {
throw error;
}
return this.jobTypes;
},
addJobType(name: string) {
return api
.post<FG.JobType>('/events/job-types', { name: name })
.then(({ data }) => this.jobTypes.push(data));
},
removeJobType(id: number) {
return api
.delete(`/events/job-types/${id}`)
.then(() => (this.jobTypes = this.jobTypes.filter((v) => v.id !== id)));
},
renameJobType(id: number, newName: string) {
return api.put(`/events/job-types/${id}`, { name: newName }).then(() => {
const idx = this.jobTypes.findIndex((v) => v.id === id);
if (idx >= 0) this.jobTypes[idx].name = newName;
});
},
async getEventTypes(force = false) {
if (force || this.eventTypes.length == 0)
try {
const { data } = await api.get<FG.EventType[]>('/events/event-types');
this.eventTypes = data;
} catch (error) {
throw error;
}
return this.eventTypes;
},
/** Add new EventType
*
* @param name Name of new EventType
*/
addEventType(name: string) {
return api
.post<FG.EventType>('/events/event-types', { name: name })
.then(({ data }) => this.eventTypes.push(data));
},
removeEventType(id: number) {
return api
.delete(`/events/event-types/${id}`)
.then(() => (this.eventTypes = this.eventTypes.filter((v) => v.id !== id)));
},
renameEventType(id: number, newName: string) {
return api.put(`/events/event-types/${id}`, { name: newName }).then(() => {
const idx = this.eventTypes.findIndex((v) => v.id === id);
if (idx >= 0) this.eventTypes[idx].name = newName;
});
},
async getTemplates(force = false) {
if (force || this.templates.length == 0) {
const { data } = await api.get<FG.Event[]>('/events/templates');
data.forEach((element) => fixEvent(element));
this.templates = data;
}
return this.templates;
},
async getEvents(
filter?: FG.PaginationFilter & {
user?: string;
}
) {
try {
const { data } = await api.get<FG.PaginationResponse<FG.Event>>('/events', {
params: <unknown>filter,
});
data.result.forEach((element) => fixEvent(element));
return data;
} catch (error) {
throw error;
}
},
async getEvent(id: number) {
try {
const { data } = await api.get<FG.Event>(`/events/${id}`);
fixEvent(data);
return data;
} catch (error) {
throw error;
}
},
async removeEvent(id: number) {
try {
await api.delete(`/events/${id}`);
const idx = this.templates.findIndex((v) => v.id === id);
if (idx !== -1) this.templates.splice(idx, 1);
} catch (e) {
if (isAxiosError(e, 404)) return false;
throw e;
}
return true;
},
async addEvent(event: EditableEvent) {
if (event?.id === undefined) {
const { data } = await api.post<FG.Event>('/events', event);
if (data.is_template) this.templates.push(data);
fixEvent(data);
return data;
} else {
if (typeof event.type === 'object') event.type = event.type.id;
const { data } = await api.put<FG.Event>(`/events/${event.id}`, event);
if (data.is_template) this.templates.push(data);
fixEvent(data);
return data;
}
},
async assignToJob(jobId: number, service: FG.Service) {
return api
.post<FG.Job>(`/events/jobs/${jobId}/assign`, service)
.then(({ data }) => fixJob(data));
},
async getJob(id: number) {
return api.get<FG.Job>(`/events/jobs/${id}`).then(({ data }) => fixJob(data));
},
async getJobs(filter?: FG.PaginationFilter) {
return api
.get<FG.PaginationResponse<FG.Job>>('/events/jobs', { params: <unknown>filter })
.then(({ data }) => {
data.result.forEach((j) => fixJob(j));
return data;
});
},
/**
* Send invite to job or transfer to other user
* @param job Job to invite to
* @param invitees Users to invite
* @param isTransfer set to True to transfer service instead of invite
*/
async invite(job: FG.Job, invitees: FG.User[], transferee: FG.User | undefined = undefined) {
return api.post<FG.Invitation[]>('/events/invitations', {
job: job.id,
invitees: invitees.map((v) => v.userid),
transferee: transferee?.userid,
});
},
async getInvitations(force = false) {
if (this.invitations.length == 0 || force) {
const { data } = await api.get<FG.Invitation[]>('/events/invitations');
this.invitations = data;
}
return this.invitations;
},
async rejectInvitation(invite: FG.Invitation | number) {
try {
await api.delete(`/events/invitations/${typeof invite === 'number' ? invite : invite.id}`);
const idx = this.invitations.findIndex((v) => v.id === (invite.id || invite));
if (idx >= 0) this.invitations.splice(idx, 1);
notify_success('Einladung für erfolgreich abgelehnt');
} catch (e) {
notify_failure();
}
},
async acceptInvitation(invite: FG.Invitation | number) {
try {
await api.put(`/events/invitations/${typeof invite === 'number' ? invite : invite.id}`, {
accept: true,
});
const idx = this.invitations.findIndex((v) => v.id === (invite.id || invite));
if (idx >= 0) this.invitations.splice(idx, 1);
notify_success('Einladung für erfolgreich angenommen');
} catch (e) {
notify_failure();
}
},
},
});
function notify_failure() {
Notify.create({
message: 'Es ist ein Fehler aufgetreten.',
color: 'negative',
group: false,
timeout: 10000,
actions: [{ icon: 'mdi-close', color: 'white' }],
});
}
function notify_success(msg: string) {
Notify.create({
message: msg,
color: 'positive',
group: false,
timeout: 5000,
actions: [{ icon: 'mdi-close', color: 'white' }],
});
}

79
src/store/models.ts Normal file
View File

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

View File

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