Compare commits

...

2 Commits

11 changed files with 178 additions and 94 deletions

8
src/api.d.ts vendored
View File

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

View File

@ -114,14 +114,15 @@ import { useEventStore } from '../../store';
import { emptyEvent, emptyJob, EditableEvent } from '../../store/models'; import { emptyEvent, emptyJob, EditableEvent } from '../../store/models';
import { date, ModifyDateOptions } from 'quasar'; import { date, ModifyDateOptions } from 'quasar';
import { computed, defineComponent, PropType, ref, onBeforeMount } from 'vue'; import { computed, defineComponent, PropType, ref, onBeforeMount, watch } from 'vue';
import EditJobSlot from './EditJobSlot.vue'; import EditJobSlot from './EditJobSlot.vue';
import RecurrenceRule from './RecurrenceRule.vue'; import RecurrenceRuleVue from './RecurrenceRule.vue';
import { RecurrenceRule } from 'app/events';
export default defineComponent({ export default defineComponent({
name: 'EditEvent', name: 'EditEvent',
components: { IsoDateInput, EditJobSlot, RecurrenceRule }, components: { IsoDateInput, EditJobSlot, RecurrenceRule: RecurrenceRuleVue },
props: { props: {
modelValue: { modelValue: {
required: false, required: false,
@ -154,10 +155,10 @@ export default defineComponent({
const activeJob = ref<{ validate: () => Promise<boolean> }>(); const activeJob = ref<{ validate: () => Promise<boolean> }>();
const templates = computed(() => store.templates); const templates = computed(() => store.templates);
const template = ref<FG.Event>(); const template = ref<FG.Event>();
const event = ref<EditableEvent>(props.modelValue || emptyEvent(startDate.value)); const event = ref<EditableEvent>(props.modelValue || emptyEvent());
const eventtypes = computed(() => store.eventTypes); const eventtypes = computed(() => store.eventTypes);
const recurrent = ref(false); const recurrent = ref(false);
const recurrenceRule = ref<FG.RecurrenceRule>({ frequency: 'daily', interval: 1 }); const recurrenceRule = ref<RecurrenceRule>({ frequency: 'daily', interval: 1 });
onBeforeMount(() => { onBeforeMount(() => {
void store.getEventTypes(); void store.getEventTypes();
@ -165,6 +166,10 @@ export default defineComponent({
void store.getTemplates(); void store.getTemplates();
}); });
watch(props, (n, o) => {
if (event.value?.id !== n.modelValue?.id) reset();
});
function addJob() { function addJob() {
if (!activeJob.value) event.value.jobs.push(emptyJob()); if (!activeJob.value) event.value.jobs.push(emptyJob());
else else

View File

@ -38,6 +38,7 @@
import { defineComponent, PropType } from 'vue'; import { defineComponent, PropType } from 'vue';
import { IsoDateInput } from '@flaschengeist/api/components'; import { IsoDateInput } from '@flaschengeist/api/components';
import { notEmpty } from '@flaschengeist/api'; import { notEmpty } from '@flaschengeist/api';
import { RecurrenceRule } from '../../events';
export default defineComponent({ export default defineComponent({
name: 'RecurrenceRule', name: 'RecurrenceRule',
@ -45,11 +46,11 @@ export default defineComponent({
props: { props: {
modelValue: { modelValue: {
required: true, required: true,
type: Object as PropType<FG.RecurrenceRule>, type: Object as PropType<RecurrenceRule>,
}, },
}, },
emits: { emits: {
'update:modelValue': (rule: FG.RecurrenceRule) => !!rule, 'update:modelValue': (rule: RecurrenceRule) => !!rule,
}, },
setup(props, { emit }) { setup(props, { emit }) {
const freqTypes = [ const freqTypes = [

View File

@ -6,7 +6,7 @@
> >
<q-card-section class="text-primary q-pa-xs"> <q-card-section class="text-primary q-pa-xs">
<div class="text-weight-bolder text-center"> <div class="text-weight-bolder text-center">
{{ event.type.name }} {{ typeName }}
<template v-if="event.name" <template v-if="event.name"
>: <span>{{ event.name }}</span> >: <span>{{ event.name }}</span>
</template> </template>
@ -51,6 +51,7 @@
import { defineComponent, computed, PropType } from 'vue'; import { defineComponent, computed, PropType } from 'vue';
import { hasPermission } from '@flaschengeist/api'; import { hasPermission } from '@flaschengeist/api';
import { PERMISSIONS } from '../../../permissions'; import { PERMISSIONS } from '../../../permissions';
import { useEventStore } from '../../../store';
import { date } from 'quasar'; import { date } from 'quasar';
import JobSlot from './JobSlot.vue'; import JobSlot from './JobSlot.vue';
@ -69,6 +70,7 @@ export default defineComponent({
editEvent: (val: number) => !!val, editEvent: (val: number) => !!val,
}, },
setup(props, { emit }) { setup(props, { emit }) {
const store = useEventStore();
const canDelete = computed(() => hasPermission(PERMISSIONS.DELETE)); const canDelete = computed(() => hasPermission(PERMISSIONS.DELETE));
const canEdit = computed( const canEdit = computed(
() => () =>
@ -76,6 +78,11 @@ export default defineComponent({
(props.modelValue?.end || props.modelValue.start) >= (props.modelValue?.end || props.modelValue.start) >=
date.buildDate({ hours: 0, minutes: 0, seconds: 0, milliseconds: 0 }) date.buildDate({ hours: 0, minutes: 0, seconds: 0, milliseconds: 0 })
); );
const typeName = computed(() =>
typeof props.modelValue.type === 'object'
? props.modelValue.type.name
: store.eventTypes.find((e) => e.id === props.modelValue.type)?.name || 'Unbekannt'
);
const event = computed({ const event = computed({
get: () => props.modelValue, get: () => props.modelValue,
@ -95,6 +102,7 @@ export default defineComponent({
edit, edit,
event, event,
remove, remove,
typeName,
}; };
}, },
}); });

View File

@ -5,7 +5,7 @@
<template v-if="modelValue.end">- {{ asHour(modelValue.end) }}</template> <template v-if="modelValue.end">- {{ asHour(modelValue.end) }}</template>
</div> </div>
<div class="q-px-xs"> <div class="q-px-xs">
{{ modelValue.type.name }} {{ typeName }}
</div> </div>
<div class="col-auto q-px-xs" style="font-size: 10px"> <div class="col-auto q-px-xs" style="font-size: 10px">
{{ modelValue.comment }} {{ modelValue.comment }}
@ -32,7 +32,7 @@
flat flat
color="primary" color="primary"
label="Eintragen" label="Eintragen"
@click="enrollForJob" @click="assignJob()"
/> />
<q-btn v-if="isEnrolled && !modelValue.locked" flat color="secondary" label="Optionen"> <q-btn v-if="isEnrolled && !modelValue.locked" flat color="secondary" label="Optionen">
<q-menu auto-close> <q-menu auto-close>
@ -44,7 +44,7 @@
<q-item-section>Tauschen</q-item-section> <q-item-section>Tauschen</q-item-section>
</q-item> </q-item>
<q-separator /> <q-separator />
<q-item clickable @click="signOutFromJob"> <q-item clickable @click="assignJob(false)">
<q-item-section class="text-negative">Austragen</q-item-section> <q-item-section class="text-negative">Austragen</q-item-section>
</q-item> </q-item>
</q-list> </q-list>
@ -87,6 +87,12 @@ export default defineComponent({
return userStore.findUser(service.userid)?.display_name || service.userid; return userStore.findUser(service.userid)?.display_name || service.userid;
} }
const typeName = computed(() =>
typeof props.modelValue.type === 'object'
? props.modelValue.type.name
: store.jobTypes.find((j) => j.id === props.modelValue.type)?.name || 'Unbekannter Diensttyp'
);
const isEnrolled = computed( const isEnrolled = computed(
() => () =>
props.modelValue.services.findIndex( props.modelValue.services.findIndex(
@ -109,46 +115,21 @@ export default defineComponent({
) )
); );
async function enrollForJob() { async function assignJob(assign = true) {
const newService: FG.Service = { const newService: FG.Service = {
userid: mainStore.currentUser.userid, userid: mainStore.currentUser.userid,
is_backup: false, is_backup: false,
value: 1, value: assign ? 1 : -1,
}; };
try { try {
await store.assignToJob(props.modelValue.id, newService); const job = await store.assignToJob(props.modelValue.id, newService);
const job = Object.assign({}, props.modelValue)
job.services.push(newService)
emit('update:modelValue', job); emit('update:modelValue', job);
} catch (error) { } catch (error) {
console.warn(error); console.warn(error);
quasar.notify({ quasar.notify({
group: false, group: false,
type: 'negative', type: 'negative',
message: 'Fehler beim Eintragen als Dienst', message: 'Fehler beim Ein- oder Austragen 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 {
await store.assignToJob(props.modelValue.id, newService);
const job = Object.assign({}, props.modelValue)
job.services.push(newService)
emit('update:modelValue', job);
} catch (error) {
console.warn(error);
quasar.notify({
group: false,
type: 'negative',
message: 'Fehler beim Austragen als Dienst',
timeout: 10000, timeout: 10000,
progress: true, progress: true,
actions: [{ icon: 'mdi-close', color: 'white' }], actions: [{ icon: 'mdi-close', color: 'white' }],
@ -160,20 +141,20 @@ export default defineComponent({
quasar.dialog({ quasar.dialog({
component: TransferInviteDialog, component: TransferInviteDialog,
componentProps: { componentProps: {
invite: isInvite, isInvite: isInvite,
job: props.modelValue, job: props.modelValue,
}, },
}); });
} }
return { return {
assignJob,
canInvite, canInvite,
enrollForJob,
isEnrolled, isEnrolled,
isFull, isFull,
invite: () => invite(true), invite: () => invite(true),
signOutFromJob,
transfer: () => invite(false), transfer: () => invite(false),
typeName,
userDisplay, userDisplay,
asHour, asHour,
}; };

View File

@ -65,10 +65,9 @@ export default defineComponent({
); );
function invite() { function invite() {
store void store
.sendInvite(props.job, invitees.value, !props.isInvite) .invite(props.job, invitees.value, !props.isInvite ? mainStore.currentUser : undefined)
.then(() => onDialogOK()) .then(() => onDialogOK());
.catch(() => onDialogCancel());
} }
return { return {

32
src/events.d.ts vendored
View File

@ -1,9 +1,25 @@
declare namespace FG { import { FG_Plugin } from "@flaschengeist/types";
export interface RecurrenceRule {
frequency: string; export interface RecurrenceRule {
interval: number; frequency: string;
count?: number; interval: number;
until?: Date; count?: number;
weekdays?: Array<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,6 +1,39 @@
import { innerRoutes, privateRoutes } from './routes'; import { innerRoutes, privateRoutes } from './routes';
import { FG_Plugin } from '@flaschengeist/types'; import { FG_Plugin } from '@flaschengeist/types';
import { defineAsyncComponent } from 'vue'; import { defineAsyncComponent } from 'vue';
import { EventNotification, InvitationData, InvitationResponseData } from './events';
import { useEventStore } from './store';
const EventTypes = {
_mask_: 0xf0,
invitation: 0x00,
invite: 0x01,
transfer: 0x02,
invitation_response: 0x10,
invitation_accepted: 0x10,
invitation_rejected: 0x11,
};
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.link = {name: 'events-single-view', params: {id: (<InvitationResponseData>message.data).event}}
}
return message;
}
const plugin: FG_Plugin.Plugin = { const plugin: FG_Plugin.Plugin = {
id: 'dev.flaschengeist.events', id: 'dev.flaschengeist.events',
@ -9,6 +42,7 @@ const plugin: FG_Plugin.Plugin = {
internalRoutes: privateRoutes, internalRoutes: privateRoutes,
requiredModules: [['events']], requiredModules: [['events']],
version: '0.0.1', version: '0.0.1',
notification: transpile,
widgets: [ widgets: [
{ {
priority: 0, priority: 0,

View File

@ -1,25 +1,37 @@
<template> <template>
<q-page padding class="fit row justify-center content-start items-start q-gutter-sm"> <q-page padding class="fit row justify-center content-start items-start q-gutter-sm">
<EditEvent v-model="event" /> <EditEvent :model-value="event" />
</q-page> </q-page>
</template> </template>
<script lang="ts"> <script lang="ts">
import { onBeforeMount, defineComponent, ref } from 'vue'; import { defineComponent, onMounted, ref, watch, PropType } from 'vue';
import EditEvent from '../components/management/EditEvent.vue'; import EditEvent from '../components/management/EditEvent.vue';
import { useEventStore } from '../store'; import { useEventStore } from '../store';
import { useRoute } from 'vue-router';
export default defineComponent({ export default defineComponent({
components: { EditEvent }, components: { EditEvent },
setup() { props: {
const route = useRoute(); id: {
type: String as PropType<number | string>,
default: undefined,
},
},
setup(props) {
const store = useEventStore(); const store = useEventStore();
const event = ref<FG.Event | undefined>(undefined); const event = ref<FG.Event>();
onBeforeMount(async () => {
if ('id' in route.params && typeof route.params.id === 'string') function loadEvent(id?: string | number) {
event.value = await store.getEvent(parseInt(route.params.id)); 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 { return {
event, event,

View File

@ -7,8 +7,8 @@ export const innerRoutes: FG_Plugin.MenuRoute[] = [
icon: 'mdi-briefcase', icon: 'mdi-briefcase',
permissions: ['user'], permissions: ['user'],
route: { route: {
path: 'schedule', path: 'events',
name: 'schedule', name: 'events',
redirect: { name: 'schedule-overview' }, redirect: { name: 'schedule-overview' },
}, },
children: [ children: [
@ -39,8 +39,8 @@ export const innerRoutes: FG_Plugin.MenuRoute[] = [
icon: 'mdi-account-switch', icon: 'mdi-account-switch',
shortcut: false, shortcut: false,
route: { route: {
path: 'schedule-requests', path: 'events-requests',
name: 'schedule-requests', name: 'events-requests',
component: () => import('../pages/EventRequests.vue'), component: () => import('../pages/EventRequests.vue'),
}, },
}, },
@ -50,13 +50,14 @@ export const innerRoutes: FG_Plugin.MenuRoute[] = [
export const privateRoutes: FG_Plugin.NamedRouteRecordRaw[] = [ export const privateRoutes: FG_Plugin.NamedRouteRecordRaw[] = [
{ {
name: 'new-event', name: 'events-single-view',
path: 'new-event', path: 'events/:id',
redirect: { name: 'schedule-management' }, component: () => import('../pages/EventPage.vue'),
props: true
}, },
{ {
name: 'events-edit', name: 'events-edit',
path: 'schedule/:id/edit', path: 'events/:id/edit',
component: () => import('../pages/EventPage.vue'), component: () => import('../pages/EventPage.vue'),
}, },
]; ];

View File

@ -2,11 +2,18 @@ import { api, isAxiosError } from '@flaschengeist/api';
import { defineStore } from 'pinia'; import { defineStore } from 'pinia';
import { EditableEvent } from './models'; import { EditableEvent } from './models';
/**
* Convert JSON decoded Job to real job (fix Date object)
*/
function fixJob(job: FG.Job) { function fixJob(job: FG.Job) {
job.start = new Date(job.start); job.start = new Date(job.start);
if (job.end) job.end = new Date(job.end); 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) { function fixEvent(event: FG.Event) {
event.start = new Date(event.start); event.start = new Date(event.start);
if (event.end) event.end = new Date(event.end); if (event.end) event.end = new Date(event.end);
@ -77,18 +84,6 @@ export const useEventStore = defineStore({
.then(({ data }) => this.eventTypes.push(data)); .then(({ data }) => this.eventTypes.push(data));
}, },
async removeEvent(id: number) {
try {
await api.delete(`/events/${id}`);
const idx = this.templates.findIndex((v) => v.id === id);
if (idx !== -1) this.templates.splice(idx, 1);
} catch (e) {
if (isAxiosError(e, 404)) return false;
throw e;
}
return true;
},
removeEventType(id: number) { removeEventType(id: number) {
return api return api
.delete(`/events/event-types/${id}`) .delete(`/events/event-types/${id}`)
@ -135,8 +130,16 @@ export const useEventStore = defineStore({
} }
}, },
async assignToJob(jobId: number, service: FG.Service) { async removeEvent(id: number) {
return api.post<FG.Job>(`/events/jobs/${jobId}/assign`, service); 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) { async addEvent(event: EditableEvent) {
@ -157,11 +160,33 @@ export const useEventStore = defineStore({
} }
}, },
async sendInvite(job: FG.Job, invitees: FG.User[], isInvite = true) { async assignToJob(jobId: number, service: FG.Service) {
return api.post<FG.Event>('/events/transfer', { return api
.post<FG.Job>(`/events/jobs/${jobId}/assign`, service)
.then(({ data }) => fixJob(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, job: job.id,
receiver: invitees, invitees: invitees.map((v) => v.userid),
is_invite: isInvite, transferee: transferee,
});
},
async rejectInvitation(invite: FG.Invitation | number) {
return api.delete(`/events/invitations/${typeof invite === 'number' ? invite : invite.id}`);
},
async acceptInvitation(invite: FG.Invitation | number) {
return api.put(`/events/invitations/${typeof invite === 'number' ? invite : invite.id}`, {
accept: true,
}); });
}, },
}, },