282 lines
9.4 KiB
Vue
282 lines
9.4 KiB
Vue
<script setup lang="ts">
|
||
|
||
import { computed, ref, watch } from 'vue';
|
||
import { useI18n } from 'vue-i18n';
|
||
import { SHIFT_KEY, type ShiftKey, type ShiftPayload, type ShiftSelectOption } from '../../types/shift.types';
|
||
import type { UpsertShiftsBody } from '../../types/shift.interfaces';
|
||
import { upsertShiftsByDate } from '../../composables/api/use-shift-api';
|
||
/* eslint-disable */
|
||
|
||
const { t } = useI18n();
|
||
|
||
const props = defineProps<{
|
||
mode: 'create' | 'edit' | 'delete';
|
||
dateIso: string;
|
||
initialShift?: ShiftPayload | null;
|
||
shiftOptions: ShiftSelectOption[];
|
||
email: string;
|
||
}>();
|
||
|
||
const emit = defineEmits<{
|
||
'close': []
|
||
'saved': []
|
||
}>();
|
||
|
||
const isSubmitting = ref(false);
|
||
const errorBanner = ref<string | null>(null);
|
||
const conflicts = ref<Array<{ start_time: string; end_time: string; type: string }>>([]);
|
||
|
||
const opened = defineModel<boolean>({ default: false });
|
||
const startTime = defineModel<string>('startTime', { default: '' });
|
||
const endTime = defineModel<string>('endTime', { default: '' });
|
||
const type = defineModel<ShiftKey | ''>('type', { default: '' });
|
||
const isRemote = defineModel<boolean>('isRemote', { default: false });
|
||
const comment = defineModel<string>('comment', { default: '' });
|
||
|
||
const isShiftKey = (val: unknown): val is ShiftKey => SHIFT_KEY.includes(val as ShiftKey);
|
||
|
||
const buildNewShiftPayload = (): ShiftPayload => {
|
||
if (!isShiftKey(type.value)) throw new Error('Invalid shift type');
|
||
const trimmed = (comment.value ?? '').trim();
|
||
return {
|
||
start_time: startTime.value,
|
||
end_time: endTime.value,
|
||
type: type.value,
|
||
is_remote: isRemote.value,
|
||
...(trimmed ? { comment: trimmed } : {}),
|
||
};
|
||
};
|
||
|
||
const onSubmit = async () => {
|
||
errorBanner.value = null;
|
||
conflicts.value = [];
|
||
isSubmitting.value = true;
|
||
|
||
try {
|
||
let body: UpsertShiftsBody;
|
||
if (props.mode === 'create') {
|
||
body = { new_shift: buildNewShiftPayload() };
|
||
} else if (props.mode === 'edit') {
|
||
if (!props.initialShift) throw new Error('Missing initial Shift for edit');
|
||
body = { old_shift: props.initialShift, new_shift: buildNewShiftPayload() };
|
||
} else {
|
||
if (!props.initialShift) throw new Error('Missing initial Shift for delete.');
|
||
body = { old_shift: props.initialShift };
|
||
}
|
||
await upsertShiftsByDate(props.email, props.dateIso, body);
|
||
opened.value = false;
|
||
emit('saved');
|
||
} catch (error: any) {
|
||
const status = error?.status_code ?? error.response?.status ?? 500;
|
||
|
||
const apiConflicts = error?.response?.data?.conflicts ?? error?.data?.conflicts;
|
||
if (Array.isArray(apiConflicts)) {
|
||
conflicts.value = apiConflicts.map((c: any) => ({
|
||
start_time: String(c.start_time ?? ''),
|
||
end_time: String(c.end_time ?? ''),
|
||
type: String(c.type ?? ''),
|
||
}));
|
||
} else {
|
||
conflicts.value = [];
|
||
}
|
||
|
||
if (status === 404) errorBanner.value = t('timesheet.shift.errors.not_found')
|
||
else if (status === 409) errorBanner.value = t('timesheet.shift.errors.overlap')
|
||
else if (status === 422) errorBanner.value = t('timesheet.shift.errors.invalid')
|
||
else errorBanner.value = t('timesheet.shift.errors.unknown')
|
||
//add conflicts.value error management
|
||
} finally {
|
||
isSubmitting.value = false;
|
||
}
|
||
}
|
||
|
||
const hydrateFromProps = () => {
|
||
if (props.mode === 'edit' || props.mode === 'delete') {
|
||
const shift = props.initialShift;
|
||
startTime.value = shift?.start_time ?? '';
|
||
endTime.value = shift?.end_time ?? '';
|
||
type.value = shift?.type ?? '';
|
||
isRemote.value = !!shift?.is_remote;
|
||
comment.value = (shift as any)?.comment ?? '';
|
||
} else {
|
||
startTime.value = '';
|
||
endTime.value = '';
|
||
type.value = '';
|
||
isRemote.value = false;
|
||
comment.value = '';
|
||
}
|
||
};
|
||
|
||
const canSubmit = computed(() =>
|
||
props.mode === 'delete' ||
|
||
(startTime.value.trim().length === 5 &&
|
||
endTime.value.trim().length === 5 &&
|
||
isShiftKey(type.value))
|
||
);
|
||
|
||
watch(
|
||
() => [opened.value, props.mode, props.initialShift, props.dateIso],
|
||
() => { if (opened.value) hydrateFromProps(); },
|
||
{ immediate: true }
|
||
);
|
||
|
||
|
||
</script>
|
||
|
||
<!-- create/edit/delete shifts dialog -->
|
||
<template>
|
||
<q-dialog
|
||
v-model="opened"
|
||
persistent
|
||
transition-show="fade"
|
||
transition-hide="fade"
|
||
>
|
||
|
||
<q-card class="q-pa-md">
|
||
<div class="row items-center q-mb-sm">
|
||
<q-icon
|
||
name="schedule"
|
||
size="24px"
|
||
class="q-mr-sm"
|
||
/>
|
||
<div class="text-h6">
|
||
{{
|
||
props.mode === 'create'
|
||
? $t('timesheet.shift.actions.add')
|
||
: props.mode === 'edit'
|
||
? $t('timesheet.shift.actions.edit')
|
||
: $t('timesheet.shift.actions.delete')
|
||
}}
|
||
</div>
|
||
<q-space />
|
||
<q-badge
|
||
outline
|
||
color="primary"
|
||
>
|
||
{{ props.dateIso }}
|
||
</q-badge>
|
||
</div>
|
||
|
||
<q-separator spaced />
|
||
|
||
<div
|
||
v-if="props.mode !== 'delete'"
|
||
class="column q-gutter-md"
|
||
>
|
||
<div class="row ">
|
||
<div class="col">
|
||
<q-input
|
||
v-model="startTime"
|
||
:label="$t('timesheet.shift.fields.start')"
|
||
filled
|
||
dense
|
||
inputmode="numeric"
|
||
mask="##:##"
|
||
/>
|
||
</div>
|
||
<div class="col">
|
||
<q-input
|
||
v-model="endTime"
|
||
:label="$t('timesheet.shift.fields.end')"
|
||
filled
|
||
dense
|
||
inputmode="numeric"
|
||
mask="##:##"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<div class="row items-center">
|
||
<q-select
|
||
v-model="type"
|
||
options-dense
|
||
:options="props.shiftOptions"
|
||
:label="$t('timesheet.shift.types.label')"
|
||
class="col"
|
||
color="primary"
|
||
filled
|
||
dense
|
||
hide-dropdown-icon
|
||
emit-value
|
||
map-options
|
||
/>
|
||
<q-toggle
|
||
v-model="isRemote"
|
||
:label="$t('timesheet.shift.types.REMOTE')"
|
||
class="col-auto"
|
||
/>
|
||
</div>
|
||
<q-input
|
||
v-model="comment"
|
||
type="textarea"
|
||
autogrow
|
||
filled
|
||
dense
|
||
:label="$t('timesheet.shift.fields.header_comment')"
|
||
:counter="true"
|
||
:maxlength="512"
|
||
/>
|
||
</div>
|
||
|
||
<div
|
||
v-else
|
||
class="q-pa-md"
|
||
>
|
||
{{ $t('timesheet.shift.actions.delete_confirmation_msg') }}
|
||
</div>
|
||
|
||
<div
|
||
v-if="errorBanner"
|
||
class="q-mt-md"
|
||
>
|
||
<q-banner
|
||
dense
|
||
class="bg-red-2 text-negative"
|
||
>{{ errorBanner }}</q-banner>
|
||
<div
|
||
v-if="conflicts.length"
|
||
class="q-mt-xs"
|
||
>
|
||
<div class="text-caption">Conflits :</div>
|
||
<ul class="q-pl-md q-mt-xs">
|
||
<li
|
||
v-for="(c, i) in conflicts"
|
||
:key="i"
|
||
>
|
||
{{ c.start_time }}–{{ c.end_time }} ({{ c.type }})
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
</div>
|
||
|
||
<q-separator spaced />
|
||
|
||
<div class="row justify-end q-gutter-sm">
|
||
<q-btn
|
||
flat
|
||
color="grey-8"
|
||
:label="$t('timesheet.cancel_button')"
|
||
@click="() => { opened = false; emit('close'); }"
|
||
/>
|
||
<q-btn
|
||
v-if="props.mode === 'delete'"
|
||
outline
|
||
color="negative"
|
||
icon="cancel"
|
||
:label="$t('timesheet.delete_button')"
|
||
:loading="isSubmitting"
|
||
:disable="!canSubmit"
|
||
@click="onSubmit"
|
||
/>
|
||
<q-btn
|
||
v-else
|
||
color="primary"
|
||
icon="save_alt"
|
||
:label="$t('timesheet.save_button')"
|
||
:loading="isSubmitting"
|
||
:disable="!canSubmit"
|
||
@click="onSubmit"
|
||
/>
|
||
</div>
|
||
</q-card>
|
||
</q-dialog>
|
||
</template> |