Compare commits

...

3 Commits

12 changed files with 275 additions and 93 deletions

View File

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

View File

@ -15,29 +15,30 @@
"main": "src/index.ts", "main": "src/index.ts",
"types": "src/api.d.ts", "types": "src/api.d.ts",
"scripts": { "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" "lint": "eslint --ext .js,.ts,.vue ./src"
}, },
"dependencies": { "dependencies": {
"@quasar/quasar-ui-qcalendar": "^4.0.0-beta.10" "@quasar/quasar-ui-qcalendar": "^4.0.0-beta.10"
}, },
"devDependencies": { "devDependencies": {
"@flaschengeist/api": "^1.0.0-alpha.6", "@flaschengeist/api": "^1.0.0-alpha.7",
"@flaschengeist/types": "^1.0.0-alpha.9", "@flaschengeist/types": "^1.0.0-alpha.10",
"@quasar/app": "^3.2.3", "@quasar/app": "^3.2.4",
"quasar": "^2.3.3", "@typescript-eslint/eslint-plugin": "^5.5.0",
"@typescript-eslint/parser": "^5.5.0",
"axios": "^0.24.0", "axios": "^0.24.0",
"prettier": "^2.5.0", "eslint": "^8.4.0",
"typescript": "^4.5.2",
"pinia": "^2.0.4",
"@typescript-eslint/eslint-plugin": "^5.4.0",
"@typescript-eslint/parser": "^5.4.0",
"eslint": "^8.3.0",
"eslint-config-prettier": "^8.3.0", "eslint-config-prettier": "^8.3.0",
"eslint-plugin-vue": "^8.1.1" "eslint-plugin-prettier": "^4.0.0",
"eslint-plugin-vue": "^8.1.1",
"pinia": "^2.0.6",
"prettier": "^2.5.1",
"quasar": "^2.3.3",
"typescript": "^4.5.2"
}, },
"peerDependencies": { "peerDependencies": {
"@flaschengeist/api": "^1.0.0-alpha.6", "@flaschengeist/api": "^1.0.0-alpha.7",
"@flaschengeist/users": "^1.0.0-alpha.2" "@flaschengeist/users": "^1.0.0-alpha.2"
}, },
"prettier": { "prettier": {

View File

@ -2,9 +2,9 @@
<q-card style="text-align: center"> <q-card style="text-align: center">
<q-card-section class="row justify-center items-center content-center"> <q-card-section class="row justify-center items-center content-center">
<div class="col-5"> <div class="col-5">
<q-icon :name="jobs == 0 ? 'mdi-calendar-blank' : 'mdi-calendar-alert'" :size="divHeight" /> <q-icon :name="jobs == 0 ? 'mdi-calendar-blank' : 'mdi-calendar-alert'" :size="divHeight" />
</div> </div>
<div v-if="(jobs || 0) > 0" ref="div" class="col-7"> <div v-if="(jobs || 0) > 0" ref="div" class="col-7">
<div class="text-h6">Anstehende Dienste</div> <div class="text-h6">Anstehende Dienste</div>
<div class="text-body1">{{ jobs }}</div> <div class="text-body1">{{ jobs }}</div>
<div class="text-h6">Nächster Dienst</div> <div class="text-h6">Nächster Dienst</div>

View File

@ -140,16 +140,6 @@ export default defineComponent({
}, },
setup(props, { emit }) { setup(props, { emit }) {
const store = useEventStore(); const store = useEventStore();
const startDate = computed(() => {
const d = date.buildDate({ milliseconds: 0, seconds: 0, minutes: 0, hours: 0 });
if (!props.date || !date.isValid(props.date)) return d;
const split = props.date.split('-');
return date.adjustDate(d, {
year: parseInt(split[0]),
month: parseInt(split[1]),
date: parseInt(split[2]),
});
});
const active = ref(0); const active = ref(0);
const activeJob = ref<{ validate: () => Promise<boolean> }>(); const activeJob = ref<{ validate: () => Promise<boolean> }>();
@ -166,9 +156,12 @@ export default defineComponent({
void store.getTemplates(); void store.getTemplates();
}); });
watch(props, (n, o) => { watch(
if (event.value?.id !== n.modelValue?.id) reset(); () => props.modelValue,
}); (newModelValue) => {
if (event.value?.id !== newModelValue?.id) reset();
}
);
function addJob() { function addJob() {
if (!activeJob.value) event.value.jobs.push(emptyJob()); if (!activeJob.value) event.value.jobs.push(emptyJob());

View File

@ -57,7 +57,12 @@
</q-form> </q-form>
</q-card-section> </q-card-section>
<q-card-actions> <q-card-actions>
<q-btn label="Schicht löschen" color="negative" :disabled="canDelete" @click="$emit('remove-job')" /> <q-btn
label="Schicht löschen"
color="negative"
:disabled="canDelete"
@click="$emit('remove-job')"
/>
</q-card-actions> </q-card-actions>
</q-card> </q-card>
</template> </template>
@ -126,7 +131,7 @@ export default defineComponent({
} }
expose({ expose({
validate: () => form.value?.validate() || Promise.resolve(true) validate: () => form.value?.validate() || Promise.resolve(true),
}); });
return { return {
@ -140,7 +145,6 @@ export default defineComponent({
}; };
}, },
}); });
</script> </script>
<style></style> <style></style>

View File

@ -3,14 +3,27 @@
<q-dialog v-model="dialogOpen"> <q-dialog v-model="dialogOpen">
<q-card> <q-card>
<q-card-section> <q-card-section>
<div class="text-h6">Editere {{title}} {{ actualType.name }}</div> <div class="text-h6">Editere {{ title }} {{ actualType.name }}</div>
</q-card-section> </q-card-section>
<q-card-section> <q-card-section>
<q-input ref="dialogInput" v-model="actualType.name" :rules="rules" dense label="name" filled /> <q-input
ref="dialogInput"
v-model="actualType.name"
:rules="rules"
dense
label="name"
filled
/>
</q-card-section> </q-card-section>
<q-card-actions> <q-card-actions>
<q-btn flat color="danger" label="Abbrechen" @click="discardChanges()" /> <q-btn flat color="danger" label="Abbrechen" @click="discardChanges()" />
<q-btn flat color="primary" label="Speichern" :disable="!!dialogInput && !dialogInput.validate()" @click="saveChanges()" /> <q-btn
flat
color="primary"
label="Speichern"
:disable="!!dialogInput && !dialogInput.validate()"
@click="saveChanges()"
/>
</q-card-actions> </q-card-actions>
</q-card> </q-card>
</q-dialog> </q-dialog>
@ -33,11 +46,7 @@
</template> </template>
<template #body-cell-actions="props"> <template #body-cell-actions="props">
<q-td :props="props" align="right" :auto-width="true"> <q-td :props="props" align="right" :auto-width="true">
<q-btn <q-btn round icon="mdi-pencil" @click="editType(props.row.id)" />
round
icon="mdi-pencil"
@click="editType(props.row.id)"
/>
<q-btn round icon="mdi-delete" @click="deleteType(props.row.id)" /> <q-btn round icon="mdi-delete" @click="deleteType(props.row.id)" />
</q-td> </q-td>
</template> </template>
@ -56,9 +65,9 @@ import { useQuasar, QInput } from 'quasar';
export default defineComponent({ export default defineComponent({
name: 'ManageTypes', name: 'ManageTypes',
components: {}, components: {},
props:{ props: {
type: {type: String as PropType<'EventType' | 'JobType'>, required: true}, type: { type: String as PropType<'EventType' | 'JobType'>, required: true },
title: {type: String, required: true} title: { type: String, required: true },
}, },
setup(props) { setup(props) {
const store = useEventStore(); const store = useEventStore();
@ -69,17 +78,16 @@ export default defineComponent({
const actualType = ref(emptyType); const actualType = ref(emptyType);
const input = ref<QInput>(); const input = ref<QInput>();
const dialogInput = ref<QInput>(); const dialogInput = ref<QInput>();
const storeName = computed(() => props.type == 'EventType' ? 'eventTypes' : 'jobTypes') const storeName = computed(() => (props.type == 'EventType' ? 'eventTypes' : 'jobTypes'));
onBeforeMount(async () => await store[`get${props.type}s`]()); onBeforeMount(async () => await store[`get${props.type}s`]());
const rows = computed(() => <(FG.EventType|FG.JobType)[]>store[storeName.value]); const rows = computed(() => <(FG.EventType | FG.JobType)[]>store[storeName.value]);
const rules = [ const rules = [
(s: unknown) => !!s || 'Darf nicht leer sein!', (s: unknown) => !!s || 'Darf nicht leer sein!',
(s: string) => (s: string) =>
rows.value.find((e) => e.name === s) === undefined || rows.value.find((e) => e.name === s) === undefined || 'Der Name wird bereits verwendet',
'Der Name wird bereits verwendet',
]; ];
const columns = [ const columns = [
@ -100,8 +108,7 @@ export default defineComponent({
function addType() { function addType() {
if (input.value === undefined || input.value.validate()) if (input.value === undefined || input.value.validate())
store store[`add${props.type}`](actualType.value.name)
[`add${props.type}`](actualType.value.name)
.then(() => { .then(() => {
actualType.value.name = ''; actualType.value.name = '';
}) })
@ -121,12 +128,17 @@ export default defineComponent({
function editType(id: number) { function editType(id: number) {
dialogOpen.value = true; dialogOpen.value = true;
actualType.value = Object.assign({}, rows.value.find((v) => v.id === id)); actualType.value = Object.assign(
{},
rows.value.find((v) => v.id === id)
);
} }
function saveChanges() { function saveChanges() {
if (dialogInput.value === undefined || dialogInput.value.validate()) if (dialogInput.value === undefined || dialogInput.value.validate())
void store[`rename${props.type}`](actualType.value.id, actualType.value.name).then(() => discardChanges()); void store[`rename${props.type}`](actualType.value.id, actualType.value.name).then(() =>
discardChanges()
);
} }
function discardChanges() { function discardChanges() {

View File

@ -63,7 +63,7 @@ export default defineComponent({
const rule = new Proxy(props.modelValue, { const rule = new Proxy(props.modelValue, {
get(target, prop) { get(target, prop) {
if (typeof prop === 'string') { if (typeof prop === 'string') {
return ((props.modelValue as unknown) as Record<string, unknown>)[prop]; return (props.modelValue as unknown as Record<string, unknown>)[prop];
} }
}, },
set(target, prop, value) { set(target, prop, value) {

View File

@ -18,7 +18,7 @@
</q-dialog> </q-dialog>
<div class="q-pa-md"> <div class="q-pa-md">
<q-card style="height: 70vh; max-width: 1800px" class="q-pa-md"> <q-card style="height: 70vh; max-width: 1800px" class="q-pa-md">
<div class="scroll" ref="scrollDiv" style="height: 100%"> <div ref="scrollDiv" class="scroll" style="height: 100%">
<q-infinite-scroll :offset="250" @load="load"> <q-infinite-scroll :offset="250" @load="load">
<q-list> <q-list>
<q-item id="bbb"> <q-item id="bbb">

View File

@ -13,18 +13,44 @@
<div> <div>
<q-select <q-select
:model-value="modelValue.services" :model-value="modelValue.services"
:disable="!canAssignOther || modelValue.locked"
:options="options"
option-value="userid"
filled filled
:option-label="(opt) => userDisplay(opt)"
multiple multiple
disable use-input
use-chips
stack-label
label="Dienste" label="Dienste"
behavior="dialog"
class="col-auto q-px-xs" class="col-auto q-px-xs"
style="font-size: 6px" @filter="filterUsers"
counter @add="({ value }) => assign(value)"
:max-values="modelValue.required_services" @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> </q-select>
<div class="row col-12 justify-end"> <div class="row col-12 justify-end">
<q-btn <q-btn
@ -32,7 +58,7 @@
flat flat
color="primary" color="primary"
label="Eintragen" label="Eintragen"
@click="assignJob()" @click="assign()"
/> />
<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>
@ -43,8 +69,18 @@
<q-item clickable @click="transfer"> <q-item clickable @click="transfer">
<q-item-section>Tauschen</q-item-section> <q-item-section>Tauschen</q-item-section>
</q-item> </q-item>
<q-item v-if="isBackup" clickable @click="backup(false)">
<q-tooltip>Backup zu vollem Dienst machen</q-tooltip>
<q-item-section>Dienst</q-item-section>
<q-item-section side><q-icon name="mdi-eye" /></q-item-section>
</q-item>
<q-item v-else clickable @click="backup(true)">
<q-tooltip>Nur als Backup eintragen</q-tooltip>
<q-item-section>Backup</q-item-section>
<q-item-section side><q-icon name="mdi-eye-off" /></q-item-section>
</q-item>
<q-separator /> <q-separator />
<q-item clickable @click="assignJob(false)"> <q-item clickable @click="unassign()">
<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>
@ -56,14 +92,19 @@
</template> </template>
<script lang="ts"> <script lang="ts">
import { defineComponent, onBeforeMount, computed, PropType } from 'vue';
import { date, useQuasar } from 'quasar'; import { date, useQuasar } from 'quasar';
import { asHour, useMainStore, useUserStore } from '@flaschengeist/api'; import { defineComponent, onBeforeMount, computed, ref, PropType } from 'vue';
import { asHour, hasPermission, useMainStore, useUserStore } from '@flaschengeist/api';
import { useEventStore } from '../../../store'; import { useEventStore } from '../../../store';
import { PERMISSIONS } from '../../../permissions';
import TransferInviteDialog from './TransferInviteDialog.vue'; import TransferInviteDialog from './TransferInviteDialog.vue';
import ServiceUserChip from './ServiceUserChip.vue';
import { UserAvatar } from '@flaschengeist/api/components';
export default defineComponent({ export default defineComponent({
name: 'JobSlot', name: 'JobSlot',
components: { ServiceUserChip, UserAvatar },
props: { props: {
modelValue: { modelValue: {
required: true, required: true,
@ -81,31 +122,42 @@ export default defineComponent({
const userStore = useUserStore(); const userStore = useUserStore();
const quasar = useQuasar(); const quasar = useQuasar();
onBeforeMount(async () => userStore.getUsers()); // Make sure users are loaded if we can assign them
onBeforeMount(async () => await userStore.getUsers());
function userDisplay(service: FG.Service) { /* Stuff used for general display */
return userStore.findUser(service.userid)?.display_name || service.userid; // Get displayname of user
function userDisplay(id: string) {
return userStore.findUser(id)?.display_name || id;
} }
// The name of the current job
const typeName = computed(() => const typeName = computed(() =>
typeof props.modelValue.type === 'object' typeof props.modelValue.type === 'object'
? props.modelValue.type.name ? props.modelValue.type.name
: store.jobTypes.find((j) => j.id === props.modelValue.type)?.name || 'Unbekannter Diensttyp' : store.jobTypes.find((j) => j.id === props.modelValue.type)?.name ||
'Unbekannter Diensttyp'
); );
const isEnrolled = computed( // The service of the current user if self assigned to the job
() => const service = computed(() =>
props.modelValue.services.findIndex( props.modelValue.services.find((service) => service.userid == mainStore.currentUser.userid)
(service) => service.userid == mainStore.currentUser.userid
) !== -1
); );
// Weather the current user is assigned to the job
const isEnrolled = computed(() => service.value !== undefined);
// If the job has enough assigned services
const isFull = computed( const isFull = computed(
() => () =>
props.modelValue.services.map((s) => s.value).reduce((p, c) => p + c, 0) >= props.modelValue.services.map((s) => s.value).reduce((p, c) => p + c, 0) >=
props.modelValue.required_services props.modelValue.required_services
); );
// If current user is only backup service
const isBackup = computed(() => service.value?.is_backup || false);
// If it is still possible to invite other users (= job is today or in the future)
const canInvite = computed( const canInvite = computed(
() => () =>
(props.modelValue.end || props.modelValue.start) > (props.modelValue.end || props.modelValue.start) >
@ -115,14 +167,15 @@ export default defineComponent({
) )
); );
async function assignJob(assign = true) { // Assign user to a job
const newService: FG.Service = { async function assign(service?: FG.Service) {
service = service || {
userid: mainStore.currentUser.userid, userid: mainStore.currentUser.userid,
is_backup: false, is_backup: false,
value: assign ? 1 : -1, value: 1,
}; };
try { try {
const job = await store.assignToJob(props.modelValue.id, newService); const job = await store.assignToJob(props.modelValue.id, service);
emit('update:modelValue', job); emit('update:modelValue', job);
} catch (error) { } catch (error) {
console.warn(error); console.warn(error);
@ -137,6 +190,7 @@ export default defineComponent({
} }
} }
// open invite dialog (or transfer)
function invite(isInvite = true) { function invite(isInvite = true) {
quasar.dialog({ quasar.dialog({
component: TransferInviteDialog, component: TransferInviteDialog,
@ -147,15 +201,72 @@ export default defineComponent({
}); });
} }
/* Stuff needed if we can assign other user */
// Current user can assign other users
const canAssignOther = computed(() => hasPermission(PERMISSIONS.ASSIGN_OTHER));
// options shown in the select
const options = ref([] as FG.Service[]);
// users which are available (e.g. not already assigned)
const freeUsers = computed(() =>
userStore.users.filter((u) => props.modelValue.services.every((s) => s.userid !== u.userid))
);
// used to filter options based on user input
function filterUsers(
input: string,
doneFn: (
callbackFn: () => void,
afterFn?: (ref: { [index: string]: unknown }) => void
) => void,
abortFn: () => void
) {
if (freeUsers.value.length == 0) return abortFn();
// Filter the options
doneFn(() => {
// Skip filter options if input is too short
if (!input || input.length < 2) {
options.value = freeUsers.value.map<FG.Service>((u) => ({
userid: u.userid,
value: 1,
is_backup: false,
}));
return;
}
// Search matching string within all names
options.value = freeUsers.value
.filter((u) =>
input
.toLowerCase()
.split(' ')
.every(
(needle) =>
u.display_name.toLowerCase().indexOf(needle) > -1 ||
u.firstname.toLowerCase().indexOf(needle) > -1 ||
u.lastname.toLowerCase().indexOf(needle) > -1
)
)
.map<FG.Service>((u) => ({ userid: u.userid, value: 1, is_backup: false }));
});
}
return { return {
assignJob, 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,
canInvite, canInvite,
filterUsers,
isBackup,
isEnrolled, isEnrolled,
isFull, isFull,
invite: () => invite(true), invite: () => invite(true),
transfer: () => invite(false), transfer: () => invite(false),
typeName, typeName,
userDisplay, userDisplay,
options,
asHour, asHour,
}; };
}, },

View File

@ -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 v-if="modelValue.is_backup" 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

@ -81,4 +81,4 @@ export default defineComponent({
}; };
}, },
}); });
</script> </script>

View File

@ -54,10 +54,10 @@ export default defineComponent({
setup() { setup() {
const quasar = useQuasar(); const quasar = useQuasar();
const tabs = computed(() => ([ const tabs = computed(() => [
{ name: 'listView', label: 'Liste' }, { name: 'listView', label: 'Liste' },
{ name: 'agendaView', label: 'Kalendar' } { name: 'agendaView', label: 'Kalendar' },
])); ]);
const drawer = ref<boolean>(false); const drawer = ref<boolean>(false);