[core] Moved source from main flaschengeist tree

This commit is contained in:
Ferdinand Thiessen 2021-05-25 17:11:44 +02:00
parent 3a0b9a770e
commit 1a63f350a2
21 changed files with 1828 additions and 0 deletions

75
.eslintrc.js Normal file
View File

@ -0,0 +1,75 @@
const { resolve } = require('path');
module.exports = {
// https://eslint.org/docs/user-guide/configuring#configuration-cascading-and-hierarchy
// This option interrupts the configuration hierarchy at this file
// Remove this if you have an higher level ESLint config file (it usually happens into a monorepos)
root: true,
// https://eslint.vuejs.org/user-guide/#how-to-use-custom-parser
// Must use parserOptions instead of "parser" to allow vue-eslint-parser to keep working
// `parser: 'vue-eslint-parser'` is already included with any 'plugin:vue/**' config and should be omitted
parserOptions: {
// https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/parser#configuration
// https://github.com/TypeStrong/fork-ts-checker-webpack-plugin#eslint
// Needed to make the parser take into account 'vue' files
extraFileExtensions: ['.vue'],
parser: '@typescript-eslint/parser',
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
},
env: {
browser: true
},
// Rules order is important, please avoid shuffling them
extends: [
// Base ESLint recommended rules
// 'eslint:recommended',
// https://github.com/typescript-eslint/typescript-eslint/tree/master/packages/eslint-plugin#usage
// ESLint typescript rules
'plugin:@typescript-eslint/recommended',
// consider disabling this class of rules if linting takes too long
'plugin:@typescript-eslint/recommended-requiring-type-checking',
// Uncomment any of the lines below to choose desired strictness,
// but leave only one uncommented!
// See https://eslint.vuejs.org/rules/#available-rules
// 'plugin:vue/vue3-essential', // Priority A: Essential (Error Prevention)
// 'plugin:vue/vue3-strongly-recommended', // Priority B: Strongly Recommended (Improving Readability)
'plugin:vue/vue3-recommended', // Priority C: Recommended (Minimizing Arbitrary Choices and Cognitive Overhead)
// https://github.com/prettier/eslint-config-prettier#installation
// usage with Prettier, provided by 'eslint-config-prettier'.
'prettier', //'plugin:prettier/recommended'
],
plugins: [
// required to apply rules which need type information
'@typescript-eslint',
// 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
rules: {
'prefer-promise-reject-errors': 'off',
// 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'
}
}

50
package.json Normal file
View File

@ -0,0 +1,50 @@
{
"private": true,
"license": "MIT",
"version": "1.0.0-alpha.1",
"name": "@flaschengeist/schedule",
"author": "Ferdinand <rpm@fthiessen.de>",
"homepage": "https://flaschengeist.dev/Flaschengeist",
"description": "Flaschengeist schedule plugin",
"bugs": {
"url": "https://flaschengeist.dev/Flaschengeist/flaschengeist/issues"
},
"repository": {
"type": "git",
"url": "https://flaschengeist.dev/Flaschengeist/flaschengeist-schedule"
},
"main": "src/index.ts",
"scripts": {
"valid": "tsc --noEmit",
"pretty": "prettier --config ./package.json --write '{,!(node_modules)/**/}*.ts'",
"lint": "eslint --ext .js,.ts,.vue ./src"
},
"dependencies": {
"@quasar/quasar-ui-qcalendar": "^4.0.0-alpha.8"
},
"devDependencies": {
"@flaschengeist/api": "file:../flaschengeist-frontend/api",
"@flaschengeist/types": "git+https://flaschengeist.dev/ferfissimo/flaschengeist-types.git#develop",
"@quasar/app": "^3.0.0-beta.26",
"quasar": "^2.0.0-beta.18",
"axios": "^0.21.1",
"prettier": "^2.3.0",
"typescript": "^4.2.4",
"pinia": "^2.0.0-alpha.18",
"@typescript-eslint/eslint-plugin": "^4.24.0",
"@typescript-eslint/parser": "^4.24.0",
"eslint": "^7.26.0",
"eslint-config-prettier": "^8.3.0",
"eslint-plugin-vue": "^7.9.0"
},
"peerDependencies": {
"@flaschengeist/api": "^1.0.0-alpha.1",
"@flaschengeist/users": "^1.0.0-alpha.1"
},
"prettier": {
"singleQuote": true,
"semi": true,
"printWidth": 100,
"arrowParens": "always"
}
}

25
src/components/Widget.vue Normal file
View File

@ -0,0 +1,25 @@
<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-section>
</q-card>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
export default defineComponent({
name: 'DummyWidget',
setup() {
function randomNumber(start: number, end: number) {
return start + Math.floor(Math.random() * Math.floor(end));
}
const jobs = randomNumber(0, 5);
const nextJob = new Date(2021, randomNumber(1, 12), randomNumber(1, 31));
return { jobs, nextJob };
},
});
</script>

View File

@ -0,0 +1,245 @@
<template>
<q-card>
<q-form @submit="save()" @reset="reset">
<q-card-section class="fit row justify-start content-center items-center">
<div class="text-h6 col-xs-12 col-sm-6 q-pa-sm">
Veranstaltung <template v-if="modelValue">bearbeiten</template
><template v-else>erstellen</template>
</div>
<q-select
:model-value="template"
filled
label="Vorlage"
class="col-xs-12 col-sm-6 q-pa-sm"
:options="templates"
option-label="name"
map-options
clearable
:disable="templates.length == 0"
@update:modelValue="fromTemplate"
@clear="reset()"
/>
<q-input
v-model="event.name"
class="col-xs-12 col-sm-6 q-pa-sm"
label="Name"
type="text"
filled
/>
<q-select
v-model="event.type"
filled
use-input
label="Veranstaltungstyp"
input-debounce="0"
class="col-xs-12 col-sm-6 q-pa-sm"
:options="eventtypes"
option-label="name"
option-value="id"
emit-value
map-options
clearable
:rules="[notEmpty]"
/>
<IsoDateInput
v-model="event.start"
class="col-xs-12 col-sm-6 q-pa-sm"
label="Veranstaltungsbeginn"
:rules="[notEmpty]"
/>
<IsoDateInput
v-model="event.end"
class="col-xs-12 col-sm-6 q-pa-sm"
label="Veranstaltungsende"
/>
<q-input
v-model="event.description"
class="col-12 q-pa-sm"
label="Beschreibung"
type="textarea"
filled
/>
</q-card-section>
<q-card-section v-if="event.template_id === undefined && modelValue === undefined">
<q-btn-toggle
v-model="recurrent"
spread
no-caps
:options="[
{ label: 'Einmalig', value: false },
{ label: 'Wiederkehrend', value: true },
]"
/>
<RecurrenceRule v-if="!!recurrent" v-model="recurrenceRule" />
</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
v-model="event.jobs[index]"
:job-can-delete="jobDeleteDisabled"
@remove-job="removeJob(index)"
/>
</q-card>
</q-card-section>
<q-card-actions align="around">
<q-card-actions align="left">
<q-btn v-if="!template" color="secondary" label="Neue Vorlage" @click="save(true)" />
<q-btn v-else color="negative" label="Vorlage löschen" @click="removeTemplate" />
</q-card-actions>
<q-card-actions align="right">
<q-btn label="Zurücksetzen" type="reset" />
<q-btn color="primary" type="submit" label="Speichern" />
</q-card-actions>
</q-card-actions>
</q-form>
</q-card>
</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 'src/components/utils/IsoDateInput.vue';
import Job from './Job.vue';
import RecurrenceRule from './RecurrenceRule.vue';
export default defineComponent({
name: 'EditEvent',
components: { IsoDateInput, Job, RecurrenceRule },
props: {
modelValue: {
required: false,
default: () => undefined,
type: Object as PropType<FG.Event | undefined>,
},
},
emits: {
done: (val: boolean) => typeof val === 'boolean',
},
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 templates = computed(() => store.templates);
const template = ref<FG.Event | undefined>(undefined);
const event = ref<FG.Event>(props.modelValue || Object.assign({}, 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 });
onBeforeMount(() => {
void store.getEventTypes();
void store.getJobTypes();
void store.getTemplates();
});
function addJob() {
event.value.jobs.push(Object.assign({}, emptyJob));
}
function removeJob(index: number) {
event.value.jobs.splice(index, 1);
}
function fromTemplate(tpl: FG.Event) {
template.value = tpl;
event.value = Object.assign({}, tpl);
}
async function save(template = false) {
event.value.is_template = template;
try {
await store.addEvent(event.value);
if (props.modelValue === undefined && recurrent.value && !event.value.is_template) {
let count = 0;
const options: ModifyDateOptions = {};
switch (recurrenceRule.value.frequency) {
case 'daily':
options['days'] = 1 * recurrenceRule.value.interval;
break;
case 'weekly':
options['days'] = 7 * recurrenceRule.value.interval;
break;
case 'monthly':
options['months'] = 1 * recurrenceRule.value.interval;
break;
}
while (true) {
event.value.start = date.addToDate(event.value.start, options);
if (event.value.end) event.value.end = date.addToDate(event.value.end, options);
event.value.jobs.forEach((job) => {
job.start = date.addToDate(job.start, options);
if (job.end) job.end = date.addToDate(job.end, options);
});
count++;
if (
count <= 120 &&
(!recurrenceRule.value.count || count <= recurrenceRule.value.count) &&
(!recurrenceRule.value.until || event.value.start < recurrenceRule.value.until)
)
await store.addEvent(event.value);
else break;
}
}
reset();
emit('done', true);
} catch (error) {
console.error(error);
}
}
async function removeTemplate() {
if (template.value !== undefined) {
await store.removeEvent(template.value.id);
template.value = undefined;
}
}
function reset() {
event.value = Object.assign({}, props.modelValue || emptyEvent);
template.value = undefined;
}
return {
jobDeleteDisabled,
addJob,
eventtypes,
templates,
removeJob,
notEmpty,
save,
reset,
recurrent,
fromTemplate,
removeTemplate,
template,
recurrenceRule,
event,
};
},
});
</script>
<style></style>

View File

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

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

View File

@ -0,0 +1,125 @@
<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,96 @@
<template>
<q-card class="fit row justify-start content-center items-center">
<q-input
v-model="rule.interval"
filled
class="col-xs-12 col-sm-6 q-pa-sm"
label="Interval"
type="number"
:rules="[notEmpty]"
/>
<q-select
v-model="rule.frequency"
filled
label="Wiederholung"
input-debounce="200"
class="col-xs-12 col-sm-6 q-pa-sm"
:options="freqTypes"
emit-value
map-options
/>
<q-input
v-model="rule.count"
filled
class="col-xs-12 col-sm-6 q-pa-sm"
label="Anzahl Wiederholungen"
type="number"
/>
<IsoDateInput
v-model="rule.until"
class="col-xs-12 col-sm-6 q-pa-sm"
label="Wiederholen bis"
type="date"
/>
</q-card>
</template>
<script lang="ts">
import IsoDateInput from 'src/components/utils/IsoDateInput.vue';
import { defineComponent, PropType } from 'vue';
import { notEmpty } from '@flaschengeist/api';
export default defineComponent({
name: 'RecurrenceRule',
components: { IsoDateInput },
props: {
modelValue: {
required: true,
type: Object as PropType<FG.RecurrenceRule>,
},
},
emits: {
'update:modelValue': (rule: FG.RecurrenceRule) => !!rule,
},
setup(props, { emit }) {
const freqTypes = [
{ label: 'Täglich', value: 'daily' },
{ label: 'Wöchentlich', value: 'weekly' },
{ label: 'Monatlich', value: 'monthly' },
{ label: 'Jährlich', value: 'yearly' },
];
const rule = new Proxy(props.modelValue, {
get(target, prop) {
if (typeof prop === 'string') {
return ((props.modelValue as unknown) as Record<string, unknown>)[prop];
}
},
set(target, prop, value) {
if (typeof prop === 'string') {
const obj = Object.assign({}, props.modelValue);
if (prop == 'frequency' && typeof value === 'string') obj.frequency = value;
else if (prop == 'interval') {
obj.interval = typeof value === 'string' ? parseInt(value) : <number>value;
} else if (prop == 'count') {
obj.until = undefined;
obj.count = typeof value === 'string' ? parseInt(value) : <number>value;
} else if (prop == 'until' && (value instanceof Date || value === undefined)) {
obj.count = undefined;
obj.until = <Date | undefined>value;
} else return false;
emit('update:modelValue', obj);
}
return true;
},
});
return {
rule,
notEmpty,
freqTypes,
};
},
});
</script>
<style></style>

View File

@ -0,0 +1,241 @@
<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>
<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-btn-toggle
v-model="calendarView"
class="row absolute-right"
flat
stretch
toggle-color=""
: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>
</q-card>
</q-page>
</template>
<script lang="ts">
import { computed, defineComponent, onBeforeMount, ref } from 'vue';
import { useScheduleStore } from '../../store';
import Eventslot from './slots/EventSlot.vue';
import { date } from 'quasar';
import { startOfWeek } from '@flaschengeist/api';
import EditEvent from '../management/EditEvent.vue';
export default defineComponent({
name: 'AgendaView',
components: { Eventslot, EditEvent },
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 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 events = ref<Agendas>({});
const editor = ref<FG.Event | undefined>(undefined);
interface Agendas {
[index: number]: FG.Event[];
}
onBeforeMount(async () => {
window.addEventListener('resize', () => {
windowWidth.value = window.innerWidth;
});
await loadAgendas();
});
async function edit(id: number) {
editor.value = await store.getEvent(id);
}
function editDone(changed: boolean) {
if (changed) void loadAgendas();
editor.value = undefined;
}
async function remove(id: number) {
if (await store.removeEvent(id)) {
// Successfull removed
for (const idx in events.value) {
const i = events.value[idx].findIndex((event) => event.id === id);
if (i !== -1) {
events.value[idx].splice(i, 1);
break;
}
}
} else {
// Not found, this means our eventa are outdated
await loadAgendas();
}
}
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 });
events.value = {};
const list = await store.getEvents({ from: start, to: end });
list.forEach((event) => {
const day = event.start.getDay();
if (!events.value[day]) {
events.value[day] = [];
}
events.value[day].push(event);
});
}
function calendarNext() {
selectedDate.value = date.formatDate(
date.addToDate(selectedDate.value, { days: calendarDays.value }),
'YYYY-MM-DD'
);
void loadAgendas();
}
function calendarPrev() {
selectedDate.value = date.formatDate(
date.subtractFromDate(selectedDate.value, { days: calendarDays.value }),
'YYYY-MM-DD'
);
void loadAgendas();
}
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');
}
}
return {
asYear,
asMonth,
selectedDate,
edit,
editor,
editDone,
events,
calendarNext,
calendarPrev,
updateProxy,
saveNewSelectedDate,
proxyDate,
remove,
calendarDays,
calendarView,
calendarRealView,
};
},
});
</script>
<style></style>

View File

@ -0,0 +1,99 @@
<template>
<q-card
class="q-mx-xs q-mt-sm 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 }}
<template v-if="event.name"
>: <span style="font-size: 1.2vw">{{ event.name }}</span>
</template>
</div>
<div v-if="event.description" class="text-weight-medium" style="font-size: 1vw">
{{ event.description }}
</div>
</q-card-section>
<q-separator />
<q-card-section class="q-pa-xs">
<!-- Jobs -->
<JobSlot
v-for="(job, index) in event.jobs"
:key="index"
v-model="event.jobs[index]"
class="col q-my-xs"
:event-id="event.id"
/>
</q-card-section>
<q-card-actions v-if="canEdit || canDelete" vertical align="center">
<q-btn
v-if="canEdit"
color="secondary"
flat
label="Bearbeiten"
style="min-width: 95%"
@click="edit"
/>
<q-btn
v-if="canDelete"
color="negative"
flat
label="Löschen"
style="min-width: 95%"
@click="remove"
/>
</q-card-actions>
</q-card>
</template>
<script lang="ts">
import { defineComponent, computed, PropType } from 'vue';
import { hasPermission } from '@flaschengeist/api';
import { PERMISSIONS } from '../../../permissions';
import JobSlot from './JobSlot.vue';
export default defineComponent({
name: 'Eventslot',
components: { JobSlot },
props: {
modelValue: {
required: true,
type: Object as PropType<FG.Event>,
},
},
emits: {
'update:modelValue': (val: FG.Event) => !!val,
removeEvent: (val: number) => typeof val === 'number',
editEvent: (val: number) => !!val,
},
setup(props, { emit }) {
const canDelete = computed(() => hasPermission(PERMISSIONS.DELETE));
const canEdit = computed(
() =>
hasPermission(PERMISSIONS.EDIT) &&
(props.modelValue?.end || props.modelValue.start) > new Date()
);
const event = computed({
get: () => props.modelValue,
set: (v) => emit('update:modelValue', v),
});
function remove() {
emit('removeEvent', props.modelValue.id);
}
function edit() {
emit('editEvent', props.modelValue.id);
}
return {
canDelete,
canEdit,
edit,
event,
remove,
};
},
});
</script>
<style scoped></style>

View File

@ -0,0 +1,140 @@
<template>
<q-card bordered>
<div class="text-weight-medium q-px-xs">
{{ asHour(modelValue.start) }}
<template v-if="modelValue.end">- {{ asHour(modelValue.end) }}</template>
</div>
<div class="q-px-xs">
{{ modelValue.type.name }}
</div>
<div class="col-auto q-px-xs" style="font-size: 10px">
{{ modelValue.comment }}
</div>
<div>
<q-select
:model-value="modelValue.services"
filled
:option-label="(opt) => userDisplay(opt)"
multiple
disable
use-chips
stack-label
label="Dienste"
class="col-auto q-px-xs"
style="font-size: 6px"
counter
:max-values="modelValue.required_services"
>
</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" />
</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';
export default defineComponent({
name: 'JobSlot',
props: {
modelValue: {
required: true,
type: Object as PropType<FG.Job>,
},
eventId: {
required: true,
type: Number,
},
},
emits: { 'update:modelValue': (v: FG.Job) => !!v },
setup(props, { emit }) {
const store = useScheduleStore();
const mainStore = useMainStore();
const userStore = useUserStore();
const availableUsers = null;
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;
});
async function enrollForJob() {
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 Eintragen als Dienst',
timeout: 10000,
progress: true,
actions: [{ icon: 'mdi-close', color: 'white' }],
});
}
}
async function signOutFromJob() {
const newService: FG.Service = {
userid: mainStore.currentUser.userid,
is_backup: false,
value: -1,
};
try {
const job = await store.updateJob(props.eventId, props.modelValue.id, {
user: newService,
});
emit('update:modelValue', job);
} catch (error) {
console.warn(error);
Notify.create({
group: false,
type: 'negative',
message: 'Fehler beim Austragen als Dienst',
timeout: 10000,
progress: true,
actions: [{ icon: 'mdi-close', color: 'white' }],
});
}
}
return {
availableUsers,
enrollForJob,
isEnrolled,
signOutFromJob,
canEnroll,
userDisplay,
asHour,
};
},
});
</script>
<style scoped></style>

9
src/events.d.ts vendored Normal file
View File

@ -0,0 +1,9 @@
declare namespace FG {
export interface RecurrenceRule {
frequency: string;
interval: number;
count?: number;
until?: Date;
weekdays?: Array<number>;
}
}

22
src/index.ts Normal file
View File

@ -0,0 +1,22 @@
import { innerRoutes, privateRoutes } from './routes';
import { FG_Plugin } from '@flaschengeist/types';
import { defineAsyncComponent } from 'vue';
const plugin: FG_Plugin.Plugin = {
id: 'schedule',
name: 'Schedule',
innerRoutes,
internalRoutes: privateRoutes,
requiredModules: [['events']],
version: '0.0.1',
widgets: [
{
priority: 0,
name: 'stats',
permissions: [],
widget: defineAsyncComponent(() => import('./components/Widget.vue')),
},
],
};
export default plugin;

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

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

95
src/pages/Management.vue Normal file
View File

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

87
src/pages/Overview.vue Normal file
View File

@ -0,0 +1,87 @@
<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="agendaView">
<AgendaView />
</q-tab-panel>
<q-tab-panel name="eventtypes">
<EventTypes />
</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 CreateEvent from '../components/management/CreateEvent.vue';
import AgendaView from '../components/overview/AgendaView.vue';
import { Screen } from 'quasar';
export default defineComponent({
name: 'EventOverview',
components: { AgendaView, EventTypes },
setup() {
interface Tab {
name: string;
label: string;
}
const tabs: Tab[] = [
{ 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;
},
set: (val: boolean) => {
drawer.value = val;
},
});
const tab = ref<string>('agendaView');
return {
showDrawer,
tab,
tabs,
};
},
});
</script>

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

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

16
src/permissions.ts Normal file
View File

@ -0,0 +1,16 @@
export const PERMISSIONS = {
// Can create events
CREATE: 'events_create',
// Can edit events
EDIT: 'events_edit',
// Can delete events
DELETE: 'events_delete',
// Can create and edit EventTypes
EVENT_TYPE: 'events_event_type',
// Can create and edit JobTypes
JOB_TYPE: 'events_job_type',
// Can self assign to jobs
ASSIGN: 'events_assign',
// Can assign other users to jobs
ASSIGN_OTHER: 'events_assign_other',
};

56
src/routes/index.ts Normal file
View File

@ -0,0 +1,56 @@
import { FG_Plugin } from '@flaschengeist/types';
import { PERMISSIONS } from '../permissions';
export const innerRoutes: FG_Plugin.MenuRoute[] = [
{
title: 'Dienste',
icon: 'mdi-briefcase',
permissions: ['user'],
route: {
path: 'schedule',
name: 'schedule',
redirect: { name: 'schedule-overview' },
},
children: [
{
title: 'Dienstübersicht',
icon: 'mdi-account-group',
shortcut: true,
route: {
path: 'schedule-overview',
name: 'schedule-overview',
component: () => import('../pages/Overview.vue'),
},
},
{
title: 'Dienstverwaltung',
icon: 'mdi-account-details',
shortcut: false,
permissions: [PERMISSIONS.CREATE],
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'),
},
},
],
},
];
export const privateRoutes: FG_Plugin.NamedRouteRecordRaw[] = [
{
name: 'events-edit',
path: 'schedule/:id/edit',
component: () => import('../pages/Event.vue'),
},
];

6
src/shims-vue.d.ts vendored Normal file
View File

@ -0,0 +1,6 @@
//https://github.com/vuejs/vue-next/issues/3130
declare module '*.vue' {
import { ComponentOptions } from 'vue';
const component: ComponentOptions;
export default component;
}

165
src/store.ts Normal file
View File

@ -0,0 +1,165 @@
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) {
await api.post<FG.JobType>('/events/job-types', { name: name });
//TODO: HAndle new JT
},
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;
},
},
});