owltide/components/TableScheduleShiftSlots.vue

395 lines
9.6 KiB
Vue
Raw Normal View History

<!--
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
SPDX-License-Identifier: AGPL-3.0-or-later
-->
2025-03-15 16:45:02 +01:00
<template>
<div>
2025-03-15 16:45:02 +01:00
<table>
<thead>
<tr>
<th>start</th>
<th>end</th>
<th>duration</th>
<th>shift</th>
<th>role</th>
<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.slot?.deleted || ss.shift?.deleted,
2025-03-15 16:45:02 +01:00
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>
<SelectSingleEntity
:entities="schedule.shifts"
v-model="newShiftId"
/>
2025-03-15 16:45:02 +01:00
</td>
<td></td>
<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>
<SelectSingleEntity
:entities="schedule.shifts"
:modelValue="ss.slot.shiftId"
@update:modelValue="ss.slot.setShiftId($event)"
/>
2025-03-15 16:45:02 +01:00
</td>
<td>
<SelectSingleEntity
v-if="ss.shift"
:entities="schedule.roles"
v-model="ss.shift.roleId"
/>
2025-03-15 16:45:02 +01:00
</td>
<td>
<SelectMultiEntity
:entities="usersStore.users"
v-model="ss.slot.assigned"
/>
</td>
2025-03-15 16:45:02 +01:00
<td>
<button
:disabled="ss.slot.deleted"
2025-03-15 16:45:02 +01:00
type="button"
@click="ss.slot.deleted = true"
2025-03-15 16:45:02 +01:00
>Remove</button>
<button
v-if="ss.slot.isModified()"
2025-03-15 16:45:02 +01:00
type="button"
@click="schedule.discardShiftSlot(ss.slot.id)"
2025-03-15 16:45:02 +01:00
>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>
<SelectSingleEntity
:entities="schedule.shifts"
v-model="newShiftId"
/>
</td>
<td></td>
<td colspan="2">
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.shift?.name }}</td>
<td>{{ ss.shift?.roleId }}</td>
<td>
<AssignedCrew :modelValue="ss.slot.assigned" :edit="false" />
</td>
2025-03-15 16:45:02 +01:00
</template>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<script lang="ts" setup>
import { DateTime, Duration } from '~/shared/utils/luxon';
import { enumerate, pairs, toId } from '~/shared/utils/functions';
import type { Id } from '~/shared/types/common';
2025-03-15 16:45:02 +01:00
const props = defineProps<{
edit?: boolean,
roleId?: Id,
shiftSlotFilter?: (slot: ClientScheduleShiftSlot) => boolean,
2025-03-15 16:45:02 +01:00
}>();
interface ShiftSlot {
type: "slot",
id: Id,
shift?: ClientScheduleShift,
slot: ClientScheduleShiftSlot,
2025-03-15 16:45:02 +01:00
start: DateTime,
end: DateTime,
}
interface Gap {
type: "gap",
id?: undefined,
shift?: undefined,
slot?: undefined,
start: DateTime,
end: DateTime,
}
const accountStore = useAccountStore();
const usersStore = useUsersStore();
await usersStore.fetch();
2025-03-15 16:45:02 +01:00
const schedule = await useSchedule();
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: () => {
try {
return DateTime.fromISO(newShiftStart.value, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale })
.plus(Duration.fromISOTime(newShiftDuration.value, { locale: accountStore.activeLocale }))
.toFormat("HH:mm")
} catch (err) {
return "";
}
},
2025-03-15 16:45:02 +01:00
set: (value: string) => {
const start = DateTime.fromISO(newShiftStart.value, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
2025-03-15 16:45:02 +01:00
const end = endFromTime(start, value);
newShiftDuration.value = dropDay(end.diff(start)).toFormat("hh:mm");
},
});
function endFromTime(start: DateTime, time: string) {
let end = start.startOf("day").plus(Duration.fromISOTime(time, { locale: accountStore.activeLocale }));
2025-03-15 16:45:02 +01:00
if (end.toMillis() <= start.toMillis()) {
end = end.plus({ days: 1 });
}
return end;
}
function durationFromTime(time: string) {
let duration = Duration.fromISOTime(time, { locale: accountStore.activeLocale });
2025-03-15 16:45:02 +01:00
if (duration.toMillis() === 0) {
duration = Duration.fromMillis(oneDayMs, { locale: accountStore.activeLocale });
2025-03-15 16:45:02 +01:00
}
return duration;
}
const newShiftId = ref<Id>();
2025-03-15 16:45:02 +01:00
function editShiftSlot(
shiftSlot: ShiftSlot,
edits: {
start?: string,
end?: string,
duration?: string,
}
) {
if (edits.start) {
const start = DateTime.fromISO(edits.start, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
shiftSlot.slot.start = start;
shiftSlot.slot.end = start.plus(shiftSlot.slot.end.diff(shiftSlot.slot.start));
2025-03-15 16:45:02 +01:00
}
if (edits.end !== undefined) {
shiftSlot.slot.end = endFromTime(shiftSlot.slot.start, edits.end);
2025-03-15 16:45:02 +01:00
}
if (edits.duration !== undefined) {
shiftSlot.slot.end = shiftSlot.slot.start.plus(durationFromTime(edits.duration));
}
2025-03-15 16:45:02 +01:00
}
function newShiftSlot(options: { start?: DateTime, end?: DateTime } = {}) {
const shift = schedule.value.shifts.get(newShiftId.value!);
if (!shift) {
alert("Invalid shift");
return;
}
2025-03-15 16:45:02 +01:00
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 {
start = DateTime.fromISO(newShiftStart.value, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
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;
}
const slot = ClientScheduleShiftSlot.create(
schedule.value,
schedule.value.nextClientId--,
shift.id,
start,
end,
new Set(),
);
schedule.value.shiftSlots.set(slot.id, slot);
shift.slotIds.add(slot.id);
newShiftId.value = undefined;
2025-03-15 16:45:02 +01:00
}
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(() => {
const data: (ShiftSlot | Gap)[] = [];
for (const slot of schedule.value.shiftSlots.values()) {
const shift = schedule.value.shifts.get(slot.shiftId!);
if (shift && props.roleId !== undefined && shift.roleId !== props.roleId)
2025-03-15 16:45:02 +01:00
continue;
if (props.shiftSlotFilter && !props.shiftSlotFilter(slot))
continue;
data.push({
type: "slot",
id: slot.id,
shift,
slot,
start: slot.start,
end: slot.end,
});
2025-03-15 16:45:02 +01:00
}
const byTime = (a: DateTime, b: DateTime) => a.toMillis() - b.toMillis();
data.sort((a, b) => byTime(a.start, b.start) || byTime(a.end, b.end));
2025-03-15 16:45:02 +01:00
// 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",
start: DateTime.fromMillis(maxEnd, { locale: accountStore.activeLocale }),
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>