owltide/components/ShiftScheduleTable.vue

460 lines
11 KiB
Vue
Raw Normal View History

2025-03-15 16:45:02 +01:00
<template>
<div>
<Timetable :schedule :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>
<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>
<input
type="text"
v-model="newShiftName"
>
2025-03-15 16:45:02 +01:00
</td>
<td></td>
<td>
<select
v-model="newShiftRoleId"
2025-03-15 16:45:02 +01:00
>
<option
v-for="role in schedule.roles.values()"
2025-03-15 16:45:02 +01:00
:key="role.id"
:value="role.id"
:disabled="role.deleted"
:selected="role.id === newShiftRoleId"
2025-03-15 16:45:02 +01:00
>{{ role.name }}</option>
</select>
</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>
<input
type="text"
:value="ss.name"
@input="editShift(ss, { name: ($event as any).target.value })"
2025-03-15 16:45:02 +01:00
>
</td>
<td>{{ status(ss) }}</td>
<td>
<select
:value="ss.role.id"
@change="editShift(ss, { role: schedule.roles.get(parseInt(($event as any).target.value)) })"
2025-03-15 16:45:02 +01:00
>
<option
v-for="role in schedule.roles.values()"
2025-03-15 16:45:02 +01:00
:key="role.id"
:value="role.id"
:disabled="role.deleted"
:selected="role.id === ss.role.id"
2025-03-15 16:45:02 +01:00
>{{ role.name }}</option>
</select>
</td>
<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="ss.deleted"
2025-03-15 16:45:02 +01:00
type="button"
@click="editShiftSlot(ss, { deleted: true })"
2025-03-15 16:45:02 +01:00
>Remove</button>
<button
v-if="schedule.isModifiedShiftSlot(ss.slot.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>
<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>
<td>{{ ss.role.id }}</td>
<td><AssignedCrew :modelValue="ss.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,
eventSlotFilter?: (slot: ClientScheduleEventSlot) => boolean,
shiftSlotFilter?: (slot: ClientScheduleShiftSlot) => boolean,
2025-03-15 16:45:02 +01:00
}>();
interface ShiftSlot {
type: "slot",
id: Id,
deleted: boolean,
shift: ClientScheduleShift,
slot: ClientScheduleShiftSlot,
2025-03-15 16:45:02 +01:00
name: string,
role: ClientScheduleRole,
assigned: Set<Id>,
2025-03-15 16:45:02 +01:00
start: DateTime,
end: DateTime,
}
interface Gap {
type: "gap",
id?: undefined,
shift?: undefined,
slot?: undefined,
name?: undefined,
role?: undefined,
2025-03-15 16:45:02 +01:00
start: DateTime,
end: DateTime,
}
function status(shiftSlot: ShiftSlot) {
if (
!shiftSlot.shift
|| shiftSlot.shift.name !== shiftSlot.name
) {
const shift = [...schedule.value.shifts.values()].find(shift => !shift.deleted && shift.name === shiftSlot.name);
2025-03-15 16:45:02 +01:00
return shift ? "L" : "N";
}
return shiftSlot.shift.slots.size === 1 ? "" : shiftSlot.shift.slots.size;
2025-03-15 16:45:02 +01:00
}
const accountStore = useAccountStore();
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");
},
});
const newShiftRoleId = ref(props.roleId);
watch(() => props.roleId, () => {
newShiftRoleId.value = props.roleId;
2025-03-15 16:45:02 +01:00
});
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 newShiftName = ref("");
function editShift(
shiftSlot: ShiftSlot,
edits: Parameters<ClientSchedule["editShift"]>[1],
) {
schedule.value.editShift(shiftSlot.shift, edits);
}
2025-03-15 16:45:02 +01:00
function editShiftSlot(
shiftSlot: ShiftSlot,
edits: {
deleted?: boolean,
2025-03-15 16:45:02 +01:00
start?: string,
end?: string,
duration?: string,
assigned?: Set<Id>,
2025-03-15 16:45:02 +01:00
}
) {
const computedEdits: Parameters<ClientSchedule["editShiftSlot"]>[1] = {
deleted: edits.deleted,
assigned: edits.assigned,
};
2025-03-15 16:45:02 +01:00
if (edits.start) {
const start = DateTime.fromISO(edits.start, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
computedEdits.start = start;
computedEdits.end = start.plus(shiftSlot.slot.end.diff(shiftSlot.slot.start));
2025-03-15 16:45:02 +01:00
}
if (edits.end !== undefined) {
computedEdits.end = endFromTime(shiftSlot.start, edits.end);
2025-03-15 16:45:02 +01:00
}
if (edits.duration !== undefined) {
computedEdits.end = shiftSlot.start.plus(durationFromTime(edits.duration));
}
schedule.value.editShiftSlot(shiftSlot.slot, computedEdits);
2025-03-15 16:45:02 +01:00
}
function revertShiftSlot(id: Id) {
schedule.value.restoreShiftSlot(id);
2025-03-15 16:45:02 +01:00
}
function newShiftSlot(options: { start?: DateTime, end?: DateTime } = {}) {
const name = newShiftName.value;
const nameId = toId(name);
const shift = [...schedule.value.shifts.values()].find(shift => toId(shift.name) === nameId);
if (!shift) {
alert("Invalid shift");
return;
}
const role = schedule.value.roles.get(newShiftRoleId.value!);
if (!role) {
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 {
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 = new ClientScheduleShiftSlot(
schedule.value.nextClientId--,
false,
shift.id,
start,
end,
new Set(),
);
schedule.value.setShiftSlot(slot);
2025-03-15 16:45:02 +01:00
newShiftName.value = "";
}
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 shift of schedule.value.shifts.values()) {
if (props.roleId !== undefined && shift.role.id !== props.roleId)
2025-03-15 16:45:02 +01:00
continue;
for (const slot of shift.slots.values()) {
if (props.shiftSlotFilter && !props.shiftSlotFilter(slot))
continue;
2025-03-15 16:45:02 +01:00
data.push({
type: "slot",
id: slot.id,
deleted: slot.deleted || shift.deleted,
2025-03-15 16:45:02 +01:00
shift,
slot,
name: shift.name,
role: shift.role,
assigned: slot.assigned,
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>