2025-03-15 16:45:02 +01:00
|
|
|
<template>
|
2025-06-11 21:05:17 +02:00
|
|
|
<div v-if="schedule.deleted">
|
|
|
|
Error: Unexpected deleted schedule.
|
|
|
|
</div>
|
|
|
|
<div v-else>
|
2025-03-15 22:47:32 +01:00
|
|
|
<Timetable :schedule="schedulePreview" :eventSlotFilter :shiftSlotFilter />
|
2025-03-15 16:45:02 +01:00
|
|
|
<table>
|
|
|
|
<thead>
|
|
|
|
<tr>
|
|
|
|
<th>start</th>
|
|
|
|
<th>end</th>
|
|
|
|
<th>duration</th>
|
|
|
|
<th>shift</th>
|
|
|
|
<th>s</th>
|
|
|
|
<th>role</th>
|
2025-03-15 18:18:08 +01:00
|
|
|
<th>assigned</th>
|
2025-03-15 16:45:02 +01:00
|
|
|
<th v-if="edit"></th>
|
|
|
|
</tr>
|
|
|
|
</thead>
|
|
|
|
<tbody>
|
|
|
|
<template v-if="edit">
|
|
|
|
<tr
|
|
|
|
v-for="ss in shiftSlots"
|
|
|
|
:key='ss.slot?.id ?? ss.start.toMillis()'
|
|
|
|
:class='{
|
|
|
|
removed: ss.type === "slot" && removed.has(ss.id),
|
|
|
|
gap: ss.type === "gap",
|
|
|
|
}'
|
|
|
|
>
|
|
|
|
<template v-if="ss.type === 'gap'">
|
|
|
|
<td colspan="2">
|
|
|
|
{{ gapFormat(ss) }}
|
|
|
|
gap
|
|
|
|
</td>
|
|
|
|
<td>
|
|
|
|
<input
|
|
|
|
type="time"
|
|
|
|
v-model="newShiftDuration"
|
|
|
|
>
|
|
|
|
</td>
|
|
|
|
<td>
|
|
|
|
<input
|
|
|
|
type="text"
|
|
|
|
v-model="newShiftName"
|
|
|
|
>
|
|
|
|
</td>
|
|
|
|
<td></td>
|
|
|
|
<td>
|
|
|
|
<select
|
|
|
|
v-model="newShiftRole"
|
|
|
|
>
|
|
|
|
<option
|
2025-06-11 21:05:17 +02:00
|
|
|
v-for="role in schedule.roles?.filter(r => !r.deleted)"
|
2025-03-15 16:45:02 +01:00
|
|
|
:key="role.id"
|
|
|
|
:value="role.id"
|
|
|
|
:selected="role.id === newShiftRole"
|
|
|
|
>{{ role.name }}</option>
|
|
|
|
</select>
|
|
|
|
</td>
|
2025-03-15 18:18:08 +01:00
|
|
|
<td></td>
|
2025-03-15 16:45:02 +01:00
|
|
|
<td>
|
|
|
|
Add at
|
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
@click="newShiftSlot({ start: ss.start })"
|
|
|
|
>Start</button>
|
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
@click="newShiftSlot({ end: ss.end })"
|
|
|
|
>End</button>
|
|
|
|
</td>
|
|
|
|
</template>
|
|
|
|
<template v-else-if='edit'>
|
|
|
|
<td>
|
|
|
|
<input
|
|
|
|
type="datetime-local"
|
|
|
|
:value="ss.start.toFormat('yyyy-LL-dd\'T\'HH:mm')"
|
|
|
|
@blur="editShiftSlot(ss, { start: ($event as any).target.value })"
|
|
|
|
>
|
|
|
|
</td>
|
|
|
|
<td>
|
|
|
|
<input
|
|
|
|
type="time"
|
|
|
|
:value="ss.end.toFormat('HH:mm')"
|
|
|
|
@input="editShiftSlot(ss, { end: ($event as any).target.value })"
|
|
|
|
>
|
|
|
|
</td>
|
|
|
|
<td>
|
|
|
|
<input
|
|
|
|
type="time"
|
|
|
|
:value='dropDay(ss.end.diff(ss.start)).toFormat("hh:mm")'
|
|
|
|
@input="editShiftSlot(ss, { duration: ($event as any).target.value })"
|
|
|
|
>
|
|
|
|
</td>
|
|
|
|
<td>
|
|
|
|
<input
|
|
|
|
type="text"
|
|
|
|
:value="ss.name"
|
|
|
|
@input="editShiftSlot(ss, { name: ($event as any).target.value })"
|
|
|
|
>
|
|
|
|
</td>
|
|
|
|
<td>{{ status(ss) }}</td>
|
|
|
|
<td>
|
|
|
|
<select
|
2025-06-11 21:05:17 +02:00
|
|
|
:value="ss.roleId"
|
|
|
|
@change="editShiftSlot(ss, { roleId: parseInt(($event as any).target.value) })"
|
2025-03-15 16:45:02 +01:00
|
|
|
>
|
|
|
|
<option
|
2025-06-11 21:05:17 +02:00
|
|
|
v-for="role in schedule.roles?.filter(r => !r.deleted)"
|
2025-03-15 16:45:02 +01:00
|
|
|
:key="role.id"
|
|
|
|
:value="role.id"
|
2025-06-11 21:05:17 +02:00
|
|
|
:selected="role.id === ss.roleId"
|
2025-03-15 16:45:02 +01:00
|
|
|
>{{ role.name }}</option>
|
|
|
|
</select>
|
|
|
|
</td>
|
2025-03-15 18:18:08 +01:00
|
|
|
<td>
|
|
|
|
<AssignedCrew
|
|
|
|
:edit="true"
|
|
|
|
:modelValue="ss.assigned"
|
|
|
|
@update:modelValue="editShiftSlot(ss, { assigned: $event })"
|
|
|
|
/>
|
|
|
|
</td>
|
2025-03-15 16:45:02 +01:00
|
|
|
<td>
|
|
|
|
<button
|
|
|
|
:disabled="removed.has(ss.id)"
|
|
|
|
type="button"
|
|
|
|
@click="delShiftSlot(ss)"
|
|
|
|
>Remove</button>
|
|
|
|
<button
|
2025-06-11 21:05:17 +02:00
|
|
|
v-if="changes.some(c => c.id === ss.id)"
|
2025-03-15 16:45:02 +01:00
|
|
|
type="button"
|
|
|
|
@click="revertShiftSlot(ss.id)"
|
|
|
|
>Revert</button>
|
|
|
|
</td>
|
|
|
|
</template>
|
|
|
|
</tr>
|
|
|
|
<tr>
|
|
|
|
<td>
|
|
|
|
<input
|
|
|
|
type="datetime-local"
|
|
|
|
v-model="newShiftStart"
|
|
|
|
>
|
|
|
|
</td>
|
|
|
|
<td>
|
|
|
|
<input
|
|
|
|
type="time"
|
|
|
|
v-model="newShiftEnd"
|
|
|
|
>
|
|
|
|
</td>
|
|
|
|
<td>
|
|
|
|
<input
|
|
|
|
type="time"
|
|
|
|
v-model="newShiftDuration"
|
|
|
|
>
|
|
|
|
</td>
|
|
|
|
<td>
|
|
|
|
<input
|
|
|
|
type="text"
|
|
|
|
v-model="newShiftName"
|
|
|
|
>
|
|
|
|
</td>
|
2025-03-15 18:18:08 +01:00
|
|
|
<td colspan="3">
|
2025-03-15 16:45:02 +01:00
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
@click="newShiftSlot()"
|
|
|
|
>Add Shift</button>
|
|
|
|
</td>
|
|
|
|
</tr>
|
|
|
|
</template>
|
|
|
|
<template v-else>
|
|
|
|
<tr
|
|
|
|
v-for="ss in shiftSlots"
|
|
|
|
:key='ss.slot?.id ?? ss.start.toMillis()'
|
|
|
|
:class='{
|
|
|
|
gap: ss.type === "gap",
|
|
|
|
}'
|
|
|
|
>
|
|
|
|
<template v-if="ss.type === 'gap'">
|
|
|
|
<td colspan="2">
|
|
|
|
{{ gapFormat(ss) }}
|
|
|
|
gap
|
|
|
|
</td>
|
|
|
|
</template>
|
|
|
|
<template v-else>
|
|
|
|
<td>{{ ss.start.toFormat("yyyy-LL-dd HH:mm") }}</td>
|
|
|
|
<td>{{ ss.end.toFormat("HH:mm") }}</td>
|
|
|
|
<td>{{ ss.end.diff(ss.start).toFormat('hh:mm') }}</td>
|
|
|
|
<td>{{ ss.name }}</td>
|
|
|
|
<td>{{ status(ss) }}</td>
|
2025-06-11 21:05:17 +02:00
|
|
|
<td>{{ ss.roleId }}</td>
|
2025-03-15 18:18:08 +01:00
|
|
|
<td><AssignedCrew :modelValue="ss.assigned" :edit="false" /></td>
|
2025-03-15 16:45:02 +01:00
|
|
|
</template>
|
|
|
|
</tr>
|
|
|
|
</template>
|
|
|
|
</tbody>
|
|
|
|
</table>
|
|
|
|
<p v-if="changes.length">
|
|
|
|
Changes are not save yet.
|
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
@click="saveShiftSlots"
|
|
|
|
>Save Changes</button>
|
|
|
|
</p>
|
|
|
|
<details>
|
|
|
|
<summary>Debug</summary>
|
|
|
|
<b>ShiftSlot changes</b>
|
|
|
|
<ol>
|
|
|
|
<li v-for="change in changes">
|
2025-06-11 21:05:17 +02:00
|
|
|
<pre><code>{{ JSON.stringify((({ shift, slot, ...data }) => data)(change as any), undefined, " ") }}</code></pre>
|
2025-03-15 16:45:02 +01:00
|
|
|
</li>
|
|
|
|
</ol>
|
|
|
|
<b>Shift changes</b>
|
|
|
|
<ol>
|
|
|
|
<li v-for="change in shiftChanges">
|
2025-06-11 21:05:17 +02:00
|
|
|
<pre><code>{{ JSON.stringify((({ shift, slot, ...data }) => data)(change as any), undefined, " ") }}</code></pre>
|
2025-03-15 16:45:02 +01:00
|
|
|
</li>
|
|
|
|
</ol>
|
|
|
|
</details>
|
|
|
|
</div>
|
|
|
|
</template>
|
|
|
|
|
|
|
|
<script lang="ts" setup>
|
|
|
|
import { DateTime, Duration } from 'luxon';
|
2025-06-11 21:05:17 +02:00
|
|
|
import type { ApiSchedule, ApiScheduleEventSlot, ApiScheduleShift, ApiScheduleShiftSlot } from '~/shared/types/api';
|
|
|
|
import { applyUpdatesToArray } from '~/shared/utils/update';
|
|
|
|
import { enumerate, pairs } from '~/shared/utils/functions';
|
|
|
|
import type { Entity } from '~/shared/types/common';
|
2025-03-15 16:45:02 +01:00
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
edit?: boolean,
|
2025-06-11 21:05:17 +02:00
|
|
|
roleId?: number,
|
|
|
|
eventSlotFilter?: (slot: ApiScheduleEventSlot) => boolean,
|
|
|
|
shiftSlotFilter?: (slot: ApiScheduleShiftSlot) => boolean,
|
2025-03-15 16:45:02 +01:00
|
|
|
}>();
|
|
|
|
|
|
|
|
interface ShiftSlot {
|
|
|
|
type: "slot",
|
2025-06-11 21:05:17 +02:00
|
|
|
id: number,
|
|
|
|
updatedAt: string,
|
|
|
|
deleted?: boolean,
|
|
|
|
shift?: Extract<ApiScheduleShift, { deleted?: false }>,
|
|
|
|
slot?: ApiScheduleShiftSlot,
|
|
|
|
origRole: number,
|
2025-03-15 16:45:02 +01:00
|
|
|
name: string,
|
2025-06-11 21:05:17 +02:00
|
|
|
roleId: number,
|
2025-03-15 18:18:08 +01:00
|
|
|
assigned: number[],
|
2025-03-15 16:45:02 +01:00
|
|
|
start: DateTime,
|
|
|
|
end: DateTime,
|
|
|
|
}
|
|
|
|
|
|
|
|
interface Gap {
|
|
|
|
type: "gap",
|
|
|
|
id?: undefined,
|
|
|
|
shift?: undefined,
|
|
|
|
slot?: undefined,
|
|
|
|
name?: undefined,
|
2025-06-11 21:05:17 +02:00
|
|
|
roleId?: number,
|
2025-03-15 16:45:02 +01:00
|
|
|
start: DateTime,
|
|
|
|
end: DateTime,
|
|
|
|
}
|
|
|
|
|
|
|
|
function status(shiftSlot: ShiftSlot) {
|
2025-06-11 21:05:17 +02:00
|
|
|
if (schedule.value.deleted) {
|
|
|
|
throw new Error("Unexpected deleted schedule");
|
|
|
|
}
|
2025-03-15 16:45:02 +01:00
|
|
|
if (
|
|
|
|
!shiftSlot.shift
|
|
|
|
|| shiftSlot.shift.name !== shiftSlot.name
|
|
|
|
) {
|
2025-06-11 21:05:17 +02:00
|
|
|
const shift = schedule.value.shifts?.find(shift => !shift.deleted && shift.name === shiftSlot.name);
|
2025-03-15 16:45:02 +01:00
|
|
|
return shift ? "L" : "N";
|
|
|
|
}
|
|
|
|
return shiftSlot.shift.slots.length === 1 ? "" : shiftSlot.shift.slots.length;
|
|
|
|
}
|
|
|
|
|
|
|
|
// Filter out set records where a del record exists for the same id.
|
2025-06-11 21:05:17 +02:00
|
|
|
function filterSetOps<T extends Entity>(changes: T[]) {
|
|
|
|
const deleteIds = new Set(changes.filter(c => c.deleted).map(c => c.id));
|
|
|
|
return changes.filter(c => c.deleted || !deleteIds.has(c.id));
|
2025-03-15 16:45:02 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
function findShift(
|
|
|
|
shiftSlot: ShiftSlot,
|
2025-06-11 21:05:17 +02:00
|
|
|
changes: ApiScheduleShift[],
|
|
|
|
schedule: ApiSchedule,
|
2025-03-15 16:45:02 +01:00
|
|
|
) {
|
2025-06-11 21:05:17 +02:00
|
|
|
if (schedule.deleted) {
|
|
|
|
throw new Error("Unexpected deleted schedule");
|
|
|
|
}
|
|
|
|
let setShift = changes.filter(
|
|
|
|
c => !c.deleted,
|
|
|
|
).find(
|
2025-03-15 16:45:02 +01:00
|
|
|
c => (
|
2025-06-11 21:05:17 +02:00
|
|
|
c.name === shiftSlot.name
|
|
|
|
&& c.roleId === shiftSlot.roleId
|
2025-03-15 16:45:02 +01:00
|
|
|
)
|
2025-06-11 21:05:17 +02:00
|
|
|
);
|
2025-03-15 16:45:02 +01:00
|
|
|
if (
|
|
|
|
!setShift
|
|
|
|
&& shiftSlot.shift
|
|
|
|
&& shiftSlot.shift.name === shiftSlot.name
|
|
|
|
) {
|
|
|
|
setShift = shiftSlot.shift;
|
|
|
|
}
|
|
|
|
if (!setShift) {
|
2025-06-11 21:05:17 +02:00
|
|
|
setShift = schedule.shifts?.filter(s => !s.deleted).find(s => s.name === shiftSlot.name);
|
2025-03-15 16:45:02 +01:00
|
|
|
}
|
|
|
|
let delShift;
|
|
|
|
if (shiftSlot.shift) {
|
2025-06-11 21:05:17 +02:00
|
|
|
delShift = changes.filter(c => !c.deleted).find(
|
|
|
|
c => c.name === shiftSlot.shift!.name
|
|
|
|
);
|
2025-03-15 16:45:02 +01:00
|
|
|
if (!delShift) {
|
2025-06-11 21:05:17 +02:00
|
|
|
delShift = schedule.shifts?.filter(s => !s.deleted).find(s => s.name === shiftSlot.shift!.name);
|
2025-03-15 16:45:02 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
return { setShift, delShift };
|
|
|
|
}
|
|
|
|
|
2025-06-11 21:05:17 +02:00
|
|
|
function mergeSlot(
|
|
|
|
shift: Extract<ApiScheduleShift, { deleted?: false }>,
|
|
|
|
shiftSlot: ShiftSlot,
|
|
|
|
): Extract<ApiScheduleShift, { deleted?: false }> {
|
|
|
|
if (schedule.value.deleted) {
|
|
|
|
throw new Error("Unexpected deleted schedule");
|
|
|
|
}
|
2025-03-15 16:45:02 +01:00
|
|
|
const oldSlot = shift.slots.find(s => s.id === shiftSlot.id);
|
2025-06-11 21:05:17 +02:00
|
|
|
const nextId = Math.max(0, ...schedule.value.shifts?.filter(s => !s.deleted).flatMap(s => s.slots.map(slot => slot.id)) ?? []) + 1;
|
2025-03-15 16:45:02 +01:00
|
|
|
const start = shiftSlot.start.toUTC().toISO({ suppressSeconds: true })!;
|
|
|
|
const end = shiftSlot.end.toUTC().toISO({ suppressSeconds: true })!;
|
2025-03-15 18:18:08 +01:00
|
|
|
const assigned = shiftSlot.assigned.length ? shiftSlot.assigned : undefined;
|
2025-03-15 16:45:02 +01:00
|
|
|
|
2025-06-11 21:05:17 +02:00
|
|
|
if (shift.roleId !== shiftSlot.roleId) {
|
|
|
|
console.warn(`Attempt to add slot id=${shiftSlot.id} roleId=${shiftSlot.roleId} to shift id=${shift.id} roleId=${shift.roleId}`);
|
2025-03-15 16:45:02 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
// Edit slot in-place if possible
|
|
|
|
if (oldSlot && oldSlot.id === shiftSlot.id) {
|
|
|
|
return {
|
|
|
|
...shift,
|
2025-03-15 18:18:08 +01:00
|
|
|
slots: shift.slots.map(s => s.id !== oldSlot.id ? s : { ...s, assigned, start, end, }),
|
2025-03-15 16:45:02 +01:00
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
// Else remove old slot if it exist and insert a new one
|
|
|
|
return {
|
|
|
|
...shift,
|
|
|
|
slots: [...(oldSlot ? shift.slots.filter(s => s.id !== oldSlot.id) : shift.slots), {
|
2025-06-11 21:05:17 +02:00
|
|
|
id: oldSlot ? oldSlot.id : nextId,
|
2025-03-15 18:18:08 +01:00
|
|
|
assigned,
|
2025-03-15 16:45:02 +01:00
|
|
|
start,
|
|
|
|
end,
|
|
|
|
}],
|
|
|
|
};
|
|
|
|
}
|
|
|
|
|
|
|
|
const shiftChanges = computed(() => {
|
2025-06-11 21:05:17 +02:00
|
|
|
let eventChanges: Extract<ApiScheduleShift, { deleted?: false }>[] = [];
|
2025-03-15 16:45:02 +01:00
|
|
|
for (const change of filterSetOps(changes.value)) {
|
2025-06-11 21:05:17 +02:00
|
|
|
if (!change.deleted) {
|
|
|
|
let { setShift, delShift } = findShift(change, eventChanges, schedule.value);
|
2025-03-15 16:45:02 +01:00
|
|
|
if (delShift && delShift !== setShift) {
|
2025-06-11 21:05:17 +02:00
|
|
|
eventChanges = removeSlot(eventChanges, delShift, change);
|
2025-03-15 16:45:02 +01:00
|
|
|
}
|
|
|
|
if (!setShift) {
|
|
|
|
setShift = {
|
2025-06-11 21:05:17 +02:00
|
|
|
id: Math.floor(Math.random() * -1000), // XXX This wont work.
|
|
|
|
updatedAt: "",
|
|
|
|
name: change.name,
|
|
|
|
roleId: change.roleId,
|
2025-03-15 16:45:02 +01:00
|
|
|
slots: [],
|
|
|
|
};
|
|
|
|
}
|
|
|
|
setShift = {
|
|
|
|
...setShift,
|
2025-06-11 21:05:17 +02:00
|
|
|
roleId: change.roleId,
|
2025-03-15 16:45:02 +01:00
|
|
|
}
|
|
|
|
|
2025-06-11 21:05:17 +02:00
|
|
|
eventChanges = replaceChange(mergeSlot(setShift, change), eventChanges);
|
2025-03-15 16:45:02 +01:00
|
|
|
|
2025-06-11 21:05:17 +02:00
|
|
|
} else if (change.deleted) {
|
|
|
|
let { delShift } = findShift(change, eventChanges, schedule.value);
|
2025-03-15 16:45:02 +01:00
|
|
|
if (delShift) {
|
2025-06-11 21:05:17 +02:00
|
|
|
eventChanges = removeSlot(eventChanges, delShift, change);
|
2025-03-15 16:45:02 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
}
|
|
|
|
return eventChanges;
|
|
|
|
});
|
|
|
|
|
|
|
|
const schedulePreview = computed(() => {
|
2025-06-11 21:05:17 +02:00
|
|
|
if (schedule.value.deleted) {
|
|
|
|
throw new Error("Unexpected deleted schedule");
|
|
|
|
}
|
|
|
|
const shifts = [...schedule.value.shifts ?? []]
|
|
|
|
applyUpdatesToArray(shiftChanges.value, shifts);
|
2025-03-15 16:45:02 +01:00
|
|
|
return {
|
|
|
|
...schedule.value,
|
2025-06-11 21:05:17 +02:00
|
|
|
shifts,
|
2025-03-15 16:45:02 +01:00
|
|
|
};
|
|
|
|
});
|
|
|
|
|
2025-06-11 21:05:17 +02:00
|
|
|
function removeSlot(
|
|
|
|
eventChanges: Extract<ApiScheduleShift, { deleted?: false }>[],
|
|
|
|
shift: Extract<ApiScheduleShift, { deleted?: false }>,
|
|
|
|
shiftSlot: ShiftSlot,
|
|
|
|
) {
|
2025-03-15 16:45:02 +01:00
|
|
|
let oldSlot = shift.slots.find(s => s.id === shiftSlot.id);
|
|
|
|
if (oldSlot) {
|
|
|
|
eventChanges = replaceChange({
|
2025-06-11 21:05:17 +02:00
|
|
|
...shift,
|
|
|
|
slots: shift.slots.filter(s => s.id !== oldSlot.id)
|
2025-03-15 16:45:02 +01:00
|
|
|
}, eventChanges);
|
|
|
|
}
|
|
|
|
return eventChanges;
|
|
|
|
}
|
|
|
|
|
2025-05-24 20:01:23 +02:00
|
|
|
const accountStore = useAccountStore();
|
2025-03-15 16:45:02 +01:00
|
|
|
const schedule = await useSchedule();
|
|
|
|
|
2025-06-11 21:05:17 +02:00
|
|
|
const changes = ref<ShiftSlot[]>([]);
|
|
|
|
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
|
2025-03-15 16:45:02 +01:00
|
|
|
|
2025-06-11 21:05:17 +02:00
|
|
|
function replaceChange<T extends Entity>(
|
2025-03-15 16:45:02 +01:00
|
|
|
change: T,
|
|
|
|
changes: T[],
|
|
|
|
) {
|
|
|
|
const index = changes.findIndex(item => (
|
2025-06-11 21:05:17 +02:00
|
|
|
item.deleted === change.deleted && item.id === change.id
|
2025-03-15 16:45:02 +01:00
|
|
|
));
|
|
|
|
const copy = [...changes];
|
|
|
|
if (index !== -1)
|
|
|
|
copy.splice(index, 1, change);
|
|
|
|
else
|
|
|
|
copy.push(change);
|
|
|
|
return copy;
|
|
|
|
}
|
2025-06-11 21:05:17 +02:00
|
|
|
function revertChange<T extends Entity>(id: number, changes: T[]) {
|
|
|
|
return changes.filter(change => change.id !== id);
|
2025-03-15 16:45:02 +01:00
|
|
|
}
|
|
|
|
|
|
|
|
const oneDayMs = 24 * 60 * 60 * 1000;
|
|
|
|
function dropDay(diff: Duration) {
|
|
|
|
if (diff.toMillis() >= oneDayMs) {
|
|
|
|
return diff.minus({ days: 1 });
|
|
|
|
}
|
|
|
|
return diff;
|
|
|
|
}
|
|
|
|
|
|
|
|
const newShiftStart = ref("");
|
|
|
|
const newShiftDuration = ref("01:00");
|
|
|
|
const newShiftEnd = computed({
|
|
|
|
get: () => (
|
2025-05-25 23:32:50 +02:00
|
|
|
DateTime.fromISO(newShiftStart.value, { zone: accountStore.activeTimezone, locale: "en-US" })
|
|
|
|
.plus(Duration.fromISOTime(newShiftDuration.value, { locale: "en-US" }))
|
2025-03-15 16:45:02 +01:00
|
|
|
.toFormat("HH:mm")
|
|
|
|
),
|
|
|
|
set: (value: string) => {
|
2025-05-25 23:32:50 +02:00
|
|
|
const start = DateTime.fromISO(newShiftStart.value, { zone: accountStore.activeTimezone, locale: "en-US" });
|
2025-03-15 16:45:02 +01:00
|
|
|
const end = endFromTime(start, value);
|
|
|
|
newShiftDuration.value = dropDay(end.diff(start)).toFormat("hh:mm");
|
|
|
|
},
|
|
|
|
});
|
2025-06-11 21:05:17 +02:00
|
|
|
const newShiftRole = ref(props.roleId);
|
|
|
|
watch(() => props.roleId, () => {
|
|
|
|
newShiftRole.value = props.roleId;
|
2025-03-15 16:45:02 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
function endFromTime(start: DateTime, time: string) {
|
2025-05-25 23:32:50 +02:00
|
|
|
let end = start.startOf("day").plus(Duration.fromISOTime(time, { locale: "en-US" }));
|
2025-03-15 16:45:02 +01:00
|
|
|
if (end.toMillis() <= start.toMillis()) {
|
|
|
|
end = end.plus({ days: 1 });
|
|
|
|
}
|
|
|
|
return end;
|
|
|
|
}
|
|
|
|
function durationFromTime(time: string) {
|
2025-05-25 23:32:50 +02:00
|
|
|
let duration = Duration.fromISOTime(time, { locale: "en-US" });
|
2025-03-15 16:45:02 +01:00
|
|
|
if (duration.toMillis() === 0) {
|
2025-05-25 23:32:50 +02:00
|
|
|
duration = Duration.fromMillis(oneDayMs, { locale: "en-US" });
|
2025-03-15 16:45:02 +01:00
|
|
|
}
|
|
|
|
return duration;
|
|
|
|
}
|
|
|
|
const newShiftName = ref("");
|
|
|
|
function editShiftSlot(
|
|
|
|
shiftSlot: ShiftSlot,
|
|
|
|
edits: {
|
|
|
|
start?: string,
|
|
|
|
end?: string,
|
|
|
|
duration?: string,
|
|
|
|
name?: string,
|
2025-06-11 21:05:17 +02:00
|
|
|
roleId?: number,
|
2025-03-15 18:18:08 +01:00
|
|
|
assigned?: number[],
|
2025-03-15 16:45:02 +01:00
|
|
|
}
|
|
|
|
) {
|
|
|
|
if (edits.start) {
|
2025-05-25 23:32:50 +02:00
|
|
|
const start = DateTime.fromISO(edits.start, { zone: accountStore.activeTimezone, locale: "en-US" });
|
2025-03-15 16:45:02 +01:00
|
|
|
shiftSlot = {
|
|
|
|
...shiftSlot,
|
|
|
|
start,
|
|
|
|
end: start.plus(shiftSlot.end.diff(shiftSlot.start)),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
if (edits.end !== undefined) {
|
|
|
|
shiftSlot = {
|
|
|
|
...shiftSlot,
|
|
|
|
end: endFromTime(shiftSlot.start, edits.end),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
if (edits.duration !== undefined) {
|
|
|
|
shiftSlot = {
|
|
|
|
...shiftSlot,
|
|
|
|
end: shiftSlot.start.plus(durationFromTime(edits.duration)),
|
|
|
|
};
|
|
|
|
}
|
|
|
|
if (edits.name !== undefined) {
|
|
|
|
shiftSlot = {
|
|
|
|
...shiftSlot,
|
|
|
|
name: edits.name,
|
|
|
|
};
|
|
|
|
}
|
2025-06-11 21:05:17 +02:00
|
|
|
if (edits.roleId !== undefined) {
|
2025-03-15 16:45:02 +01:00
|
|
|
let changesCopy = changes.value;
|
|
|
|
for (const slot of shiftSlots.value) {
|
|
|
|
if (slot.type === "slot" && slot.shift?.name === shiftSlot.name) {
|
|
|
|
changesCopy = replaceChange({
|
2025-06-11 21:05:17 +02:00
|
|
|
...slot,
|
|
|
|
roleId: edits.roleId,
|
2025-03-15 16:45:02 +01:00
|
|
|
}, changesCopy);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
changesCopy = replaceChange({
|
2025-06-11 21:05:17 +02:00
|
|
|
...shiftSlot,
|
|
|
|
roleId: edits.roleId,
|
2025-03-15 16:45:02 +01:00
|
|
|
}, changesCopy);
|
|
|
|
changes.value = changesCopy;
|
|
|
|
return;
|
|
|
|
}
|
2025-03-15 18:18:08 +01:00
|
|
|
if (edits.assigned !== undefined) {
|
|
|
|
shiftSlot = {
|
|
|
|
...shiftSlot,
|
|
|
|
assigned: edits.assigned,
|
|
|
|
};
|
|
|
|
}
|
2025-06-11 21:05:17 +02:00
|
|
|
changes.value = replaceChange(shiftSlot, changes.value);
|
2025-03-15 16:45:02 +01:00
|
|
|
}
|
|
|
|
function delShiftSlot(shiftSlot: ShiftSlot) {
|
2025-06-11 21:05:17 +02:00
|
|
|
const change = {
|
|
|
|
...shiftSlot,
|
|
|
|
deleted: true,
|
|
|
|
};
|
2025-03-15 16:45:02 +01:00
|
|
|
changes.value = replaceChange(change, changes.value);
|
|
|
|
}
|
2025-06-11 21:05:17 +02:00
|
|
|
function revertShiftSlot(id: number) {
|
2025-03-15 16:45:02 +01:00
|
|
|
changes.value = revertChange(id, changes.value);
|
|
|
|
}
|
|
|
|
function newShiftSlot(options: { start?: DateTime, end?: DateTime } = {}) {
|
|
|
|
const name = newShiftName.value;
|
2025-06-11 21:05:17 +02:00
|
|
|
const roleId = newShiftRole.value;
|
|
|
|
if (!roleId) {
|
2025-03-15 16:45:02 +01:00
|
|
|
alert("Invalid role");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
let start;
|
|
|
|
let end;
|
|
|
|
const duration = durationFromTime(newShiftDuration.value);
|
|
|
|
if (!duration.isValid) {
|
|
|
|
alert("Invalid duration");
|
|
|
|
return;
|
|
|
|
}
|
|
|
|
if (options.start) {
|
|
|
|
start = options.start;
|
|
|
|
end = options.start.plus(duration);
|
|
|
|
} else if (options.end) {
|
|
|
|
end = options.end;
|
|
|
|
start = options.end.minus(duration);
|
|
|
|
} else {
|
2025-05-25 23:32:50 +02:00
|
|
|
start = DateTime.fromISO(newShiftStart.value, { zone: accountStore.activeTimezone, locale: "en-US" });
|
2025-03-15 16:45:02 +01:00
|
|
|
end = endFromTime(start, newShiftEnd.value);
|
|
|
|
}
|
|
|
|
if (!start.isValid || !end.isValid) {
|
|
|
|
alert("Invalid start and/or end time");
|
|
|
|
return;
|
|
|
|
}
|
2025-06-11 21:05:17 +02:00
|
|
|
const change: ShiftSlot = {
|
|
|
|
type: "slot",
|
|
|
|
updatedAt: "",
|
|
|
|
id: Math.floor(Math.random() * -1000), // XXX this wont work.
|
|
|
|
name,
|
|
|
|
origRole: roleId,
|
|
|
|
roleId,
|
|
|
|
assigned: [],
|
|
|
|
start,
|
|
|
|
end,
|
2025-03-15 16:45:02 +01:00
|
|
|
};
|
|
|
|
newShiftName.value = "";
|
|
|
|
changes.value = replaceChange(change, changes.value);
|
|
|
|
}
|
|
|
|
async function saveShiftSlots() {
|
|
|
|
try {
|
|
|
|
await $fetch("/api/schedule", {
|
|
|
|
method: "PATCH",
|
2025-06-11 21:05:17 +02:00
|
|
|
body: {
|
|
|
|
id: 111,
|
|
|
|
updatedAt: "",
|
|
|
|
shifts: shiftChanges.value
|
|
|
|
} satisfies ApiSchedule,
|
2025-03-15 16:45:02 +01:00
|
|
|
});
|
|
|
|
changes.value = [];
|
|
|
|
} catch (err: any) {
|
|
|
|
console.error(err);
|
|
|
|
alert(err?.data?.message ?? err.message);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
|
|
|
|
const oneHourMs = 60 * 60 * 1000;
|
|
|
|
function gapFormat(gap: Gap) {
|
|
|
|
let diff = gap.end.diff(gap.start);
|
|
|
|
if (diff.toMillis() % oneHourMs !== 0)
|
|
|
|
diff = diff.shiftTo("hours", "minutes");
|
|
|
|
else
|
|
|
|
diff = diff.shiftTo("hours");
|
|
|
|
return diff.toHuman({ listStyle: "short", unitDisplay: "short" });
|
|
|
|
}
|
|
|
|
|
|
|
|
const shiftSlots = computed(() => {
|
2025-06-11 21:05:17 +02:00
|
|
|
if (schedule.value.deleted) {
|
|
|
|
throw new Error("Unexpected deleted schedule");
|
|
|
|
}
|
2025-03-15 16:45:02 +01:00
|
|
|
const data: (ShiftSlot | Gap)[] = [];
|
2025-06-11 21:05:17 +02:00
|
|
|
for (const shift of schedule.value.shifts ?? []) {
|
|
|
|
if (shift.deleted || props.roleId !== undefined && shift.roleId !== props.roleId)
|
2025-03-15 16:45:02 +01:00
|
|
|
continue;
|
|
|
|
for (const slot of shift.slots) {
|
2025-03-15 22:47:32 +01:00
|
|
|
if (props.shiftSlotFilter && !props.shiftSlotFilter(slot))
|
|
|
|
continue;
|
2025-03-15 16:45:02 +01:00
|
|
|
data.push({
|
|
|
|
type: "slot",
|
|
|
|
id: slot.id,
|
2025-06-11 21:05:17 +02:00
|
|
|
updatedAt: "",
|
2025-03-15 16:45:02 +01:00
|
|
|
shift,
|
|
|
|
slot,
|
|
|
|
name: shift.name,
|
2025-06-11 21:05:17 +02:00
|
|
|
roleId: shift.roleId,
|
2025-03-15 18:18:08 +01:00
|
|
|
assigned: slot.assigned ?? [],
|
2025-06-11 21:05:17 +02:00
|
|
|
origRole: shift.roleId,
|
2025-05-25 23:32:50 +02:00
|
|
|
start: DateTime.fromISO(slot.start, { zone: accountStore.activeTimezone, locale: "en-US" }),
|
|
|
|
end: DateTime.fromISO(slot.end, { zone: accountStore.activeTimezone, locale: "en-US" }),
|
2025-03-15 16:45:02 +01:00
|
|
|
});
|
|
|
|
}
|
|
|
|
}
|
2025-06-11 21:05:17 +02:00
|
|
|
applyUpdatesToArray(changes.value.filter(c => !c.deleted), data as ShiftSlot[]);
|
2025-03-15 16:45:02 +01:00
|
|
|
data.sort((a, b) => a.start.toMillis() - b.start.toMillis() || a.end.toMillis() - b.end.toMillis());
|
|
|
|
|
|
|
|
// Insert gaps
|
|
|
|
let maxEnd = 0;
|
|
|
|
const gaps: [number, Gap][] = []
|
|
|
|
for (const [index, [first, second]] of enumerate(pairs(data))) {
|
|
|
|
maxEnd = Math.max(maxEnd, first.end.toMillis());
|
|
|
|
if (maxEnd < second.start.toMillis()) {
|
|
|
|
gaps.push([index, {
|
|
|
|
type: "gap",
|
2025-06-11 21:05:17 +02:00
|
|
|
roleId: props.roleId,
|
2025-05-25 23:32:50 +02:00
|
|
|
start: DateTime.fromMillis(maxEnd, { locale: "en-US" }),
|
2025-03-15 16:45:02 +01:00
|
|
|
end: second.start,
|
|
|
|
}]);
|
|
|
|
}
|
|
|
|
}
|
|
|
|
gaps.reverse();
|
|
|
|
for (const [index, gap] of gaps) {
|
|
|
|
data.splice(index + 1, 0, gap);
|
|
|
|
}
|
|
|
|
return data;
|
|
|
|
});
|
|
|
|
</script>
|
|
|
|
|
|
|
|
<style scoped>
|
|
|
|
label {
|
|
|
|
display: inline;
|
|
|
|
padding-inline-end: 0.75em;
|
|
|
|
}
|
|
|
|
table {
|
|
|
|
margin-block-start: 1rem;
|
|
|
|
border-spacing: 0;
|
|
|
|
}
|
|
|
|
table th {
|
|
|
|
text-align: left;
|
|
|
|
border-bottom: 1px solid var(--foreground);
|
|
|
|
}
|
|
|
|
table :is(th, td) + :is(th, td) {
|
|
|
|
padding-inline-start: 0.4em;
|
|
|
|
}
|
|
|
|
.gap {
|
|
|
|
height: 1.8em;
|
|
|
|
}
|
|
|
|
.removed {
|
|
|
|
background-color: color-mix(in oklab, var(--background), rgb(255, 0, 0) 40%);
|
|
|
|
}
|
|
|
|
.removed :is(td, input) {
|
|
|
|
text-decoration: line-through;
|
|
|
|
}
|
|
|
|
</style>
|