2025-06-30 18:58:24 +02:00
|
|
|
<!--
|
|
|
|
SPDX-FileCopyrightText: © 2025 Hornwitser <code@hornwitser.no>
|
|
|
|
SPDX-License-Identifier: AGPL-3.0-or-later
|
|
|
|
-->
|
2025-03-14 18:19:58 +01:00
|
|
|
<template>
|
2025-06-14 19:22:53 +02:00
|
|
|
<div>
|
2025-03-14 18:19:58 +01:00
|
|
|
<table>
|
|
|
|
<thead>
|
|
|
|
<tr>
|
|
|
|
<th>start</th>
|
|
|
|
<th>end</th>
|
|
|
|
<th>duration</th>
|
|
|
|
<th>event</th>
|
|
|
|
<th>location</th>
|
2025-03-15 18:18:08 +01:00
|
|
|
<th>assigned</th>
|
2025-03-14 18:19:58 +01:00
|
|
|
<th v-if="edit"></th>
|
|
|
|
</tr>
|
|
|
|
</thead>
|
|
|
|
<tbody>
|
|
|
|
<template v-if="edit">
|
|
|
|
<tr
|
|
|
|
v-for="es in eventSlots"
|
|
|
|
:key='es.slot?.id ?? es.start.toMillis()'
|
|
|
|
:class='{
|
2025-06-27 18:59:23 +02:00
|
|
|
removed: es.type === "slot" && es.slot.deleted,
|
2025-03-14 18:19:58 +01:00
|
|
|
gap: es.type === "gap",
|
|
|
|
}'
|
|
|
|
>
|
|
|
|
<template v-if="es.type === 'gap'">
|
|
|
|
<td colspan="2">
|
|
|
|
{{ gapFormat(es) }}
|
|
|
|
gap
|
|
|
|
</td>
|
|
|
|
<td>
|
|
|
|
<input
|
|
|
|
type="time"
|
|
|
|
v-model="newEventDuration"
|
|
|
|
>
|
|
|
|
</td>
|
|
|
|
<td>
|
2025-06-27 18:59:23 +02:00
|
|
|
<SelectSingleEntity
|
|
|
|
:entities="schedule.events"
|
|
|
|
v-model="newEventId"
|
|
|
|
/>
|
2025-03-14 18:19:58 +01:00
|
|
|
</td>
|
|
|
|
<td>
|
2025-06-27 18:59:23 +02:00
|
|
|
<SelectMultiEntity
|
|
|
|
:entities="schedule.locations"
|
|
|
|
v-model="newEventLocationIds"
|
|
|
|
/>
|
2025-03-14 18:19:58 +01:00
|
|
|
</td>
|
2025-03-15 18:18:08 +01:00
|
|
|
<td></td>
|
2025-03-14 18:19:58 +01:00
|
|
|
<td>
|
|
|
|
Add at
|
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
@click="newEventSlot({ start: es.start })"
|
|
|
|
>Start</button>
|
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
@click="newEventSlot({ end: es.end })"
|
|
|
|
>End</button>
|
|
|
|
</td>
|
|
|
|
</template>
|
|
|
|
<template v-else-if='edit'>
|
|
|
|
<td>
|
|
|
|
<input
|
|
|
|
type="datetime-local"
|
|
|
|
:value="es.start.toFormat('yyyy-LL-dd\'T\'HH:mm')"
|
|
|
|
@blur="editEventSlot(es, { start: ($event as any).target.value })"
|
|
|
|
>
|
|
|
|
</td>
|
|
|
|
<td>
|
|
|
|
<input
|
|
|
|
type="time"
|
|
|
|
:value="es.end.toFormat('HH:mm')"
|
|
|
|
@input="editEventSlot(es, { end: ($event as any).target.value })"
|
|
|
|
>
|
|
|
|
</td>
|
|
|
|
<td>
|
|
|
|
<input
|
|
|
|
type="time"
|
|
|
|
:value='dropDay(es.end.diff(es.start)).toFormat("hh:mm")'
|
|
|
|
@input="editEventSlot(es, { duration: ($event as any).target.value })"
|
|
|
|
>
|
|
|
|
</td>
|
|
|
|
<td>
|
2025-06-27 18:59:23 +02:00
|
|
|
<SelectSingleEntity
|
|
|
|
:entities="schedule.events"
|
|
|
|
:modelValue="es.slot.eventId"
|
|
|
|
@update:modelValue="es.slot.setEventId($event)"
|
|
|
|
/>
|
2025-03-14 18:19:58 +01:00
|
|
|
</td>
|
|
|
|
<td>
|
2025-06-27 18:59:23 +02:00
|
|
|
<SelectMultiEntity
|
|
|
|
:entities="schedule.locations"
|
|
|
|
v-model="es.slot.locationIds"
|
|
|
|
/>
|
2025-03-14 18:19:58 +01:00
|
|
|
</td>
|
2025-03-15 18:18:08 +01:00
|
|
|
<td>
|
2025-06-27 18:59:23 +02:00
|
|
|
<SelectMultiEntity
|
|
|
|
:entities="usersStore.users"
|
2025-06-23 22:46:39 +02:00
|
|
|
v-model="es.slot.assigned"
|
2025-03-15 18:18:08 +01:00
|
|
|
/>
|
|
|
|
</td>
|
2025-03-14 18:19:58 +01:00
|
|
|
<td>
|
|
|
|
<button
|
2025-06-27 18:59:23 +02:00
|
|
|
:disabled="es.slot.deleted"
|
2025-03-14 18:19:58 +01:00
|
|
|
type="button"
|
2025-06-23 22:46:39 +02:00
|
|
|
@click="es.slot.deleted = true"
|
2025-03-14 18:19:58 +01:00
|
|
|
>Remove</button>
|
|
|
|
<button
|
2025-06-23 22:46:39 +02:00
|
|
|
v-if="es.slot.isModified()"
|
2025-03-14 18:19:58 +01:00
|
|
|
type="button"
|
2025-06-27 18:34:37 +02:00
|
|
|
@click="schedule.discardEventSlot(es.slot.id)"
|
2025-03-14 18:19:58 +01:00
|
|
|
>Revert</button>
|
|
|
|
</td>
|
|
|
|
</template>
|
|
|
|
</tr>
|
|
|
|
<tr>
|
|
|
|
<td>
|
|
|
|
<input
|
|
|
|
type="datetime-local"
|
|
|
|
v-model="newEventStart"
|
|
|
|
>
|
|
|
|
</td>
|
|
|
|
<td>
|
|
|
|
<input
|
|
|
|
type="time"
|
|
|
|
v-model="newEventEnd"
|
|
|
|
>
|
|
|
|
</td>
|
|
|
|
<td>
|
|
|
|
<input
|
|
|
|
type="time"
|
|
|
|
v-model="newEventDuration"
|
|
|
|
>
|
|
|
|
</td>
|
|
|
|
<td>
|
2025-06-27 18:59:23 +02:00
|
|
|
<SelectSingleEntity
|
|
|
|
:entities="schedule.events"
|
|
|
|
v-model="newEventId"
|
|
|
|
/>
|
|
|
|
</td>
|
|
|
|
<td>
|
|
|
|
<SelectMultiEntity
|
|
|
|
:entities="schedule.locations"
|
|
|
|
v-model="newEventLocationIds"
|
|
|
|
/>
|
2025-03-14 18:19:58 +01:00
|
|
|
</td>
|
2025-03-15 18:18:08 +01:00
|
|
|
<td></td>
|
2025-03-14 18:19:58 +01:00
|
|
|
<td colspan="2">
|
|
|
|
<button
|
|
|
|
type="button"
|
|
|
|
@click="newEventSlot()"
|
|
|
|
>Add Event</button>
|
|
|
|
</td>
|
|
|
|
</tr>
|
|
|
|
</template>
|
|
|
|
<template v-else>
|
|
|
|
<tr
|
|
|
|
v-for="es in eventSlots"
|
|
|
|
:key='es.slot?.id ?? es.start.toMillis()'
|
|
|
|
:class='{
|
|
|
|
gap: es.type === "gap",
|
|
|
|
}'
|
|
|
|
>
|
|
|
|
<template v-if="es.type === 'gap'">
|
|
|
|
<td colspan="2">
|
|
|
|
{{ gapFormat(es) }}
|
|
|
|
gap
|
|
|
|
</td>
|
|
|
|
</template>
|
|
|
|
<template v-else>
|
|
|
|
<td>{{ es.start.toFormat("yyyy-LL-dd HH:mm") }}</td>
|
|
|
|
<td>{{ es.end.toFormat("HH:mm") }}</td>
|
|
|
|
<td>{{ es.end.diff(es.start).toFormat('hh:mm') }}</td>
|
2025-06-27 18:59:23 +02:00
|
|
|
<td>{{ es.event?.name }}</td>
|
|
|
|
<td></td>
|
|
|
|
<td><AssignedCrew :modelValue="es.slot.assigned" :edit="false" /></td>
|
2025-03-14 18:19:58 +01:00
|
|
|
</template>
|
|
|
|
</tr>
|
|
|
|
</template>
|
|
|
|
</tbody>
|
|
|
|
</table>
|
|
|
|
</div>
|
|
|
|
</template>
|
|
|
|
|
|
|
|
<script lang="ts" setup>
|
2025-06-23 12:48:09 +02:00
|
|
|
import { DateTime, Duration } from '~/shared/utils/luxon';
|
2025-06-14 19:22:53 +02:00
|
|
|
import type { Id } from '~/shared/types/common';
|
|
|
|
import { enumerate, pairs, toId } from '~/shared/utils/functions';
|
2025-03-14 18:19:58 +01:00
|
|
|
|
|
|
|
const props = defineProps<{
|
|
|
|
edit?: boolean,
|
2025-06-11 21:05:17 +02:00
|
|
|
locationId?: number,
|
2025-06-14 19:22:53 +02:00
|
|
|
eventSlotFilter?: (slot: ClientScheduleEventSlot) => boolean,
|
2025-03-14 18:19:58 +01:00
|
|
|
}>();
|
|
|
|
|
|
|
|
interface EventSlot {
|
|
|
|
type: "slot",
|
2025-06-27 18:59:23 +02:00
|
|
|
event?: ClientScheduleEvent,
|
2025-06-14 19:22:53 +02:00
|
|
|
slot: ClientScheduleEventSlot,
|
2025-03-14 18:19:58 +01:00
|
|
|
start: DateTime,
|
|
|
|
end: DateTime,
|
|
|
|
}
|
|
|
|
|
|
|
|
interface Gap {
|
|
|
|
type: "gap",
|
|
|
|
event?: undefined,
|
|
|
|
slot?: undefined,
|
|
|
|
start: DateTime,
|
|
|
|
end: DateTime,
|
|
|
|
}
|
|
|
|
|
2025-05-24 20:01:23 +02:00
|
|
|
const accountStore = useAccountStore();
|
2025-06-27 18:59:23 +02:00
|
|
|
const usersStore = useUsersStore();
|
2025-03-14 18:19:58 +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 newEventStart = ref("");
|
|
|
|
const newEventDuration = ref("01:00");
|
|
|
|
const newEventEnd = computed({
|
2025-06-14 19:22:53 +02:00
|
|
|
get: () => {
|
|
|
|
try {
|
|
|
|
return DateTime.fromISO(newEventStart.value, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale })
|
|
|
|
.plus(Duration.fromISOTime(newEventDuration.value, { locale: accountStore.activeLocale }))
|
|
|
|
.toFormat("HH:mm")
|
|
|
|
} catch (err) {
|
|
|
|
return "";
|
|
|
|
}
|
|
|
|
},
|
2025-03-14 18:19:58 +01:00
|
|
|
set: (value: string) => {
|
2025-06-14 19:22:53 +02:00
|
|
|
const start = DateTime.fromISO(newEventStart.value, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
|
2025-03-14 18:19:58 +01:00
|
|
|
const end = endFromTime(start, value);
|
|
|
|
newEventDuration.value = dropDay(end.diff(start)).toFormat("hh:mm");
|
|
|
|
},
|
|
|
|
});
|
2025-06-27 18:59:23 +02:00
|
|
|
const newEventLocationIds = ref(new Set(props.locationId === undefined ? undefined : [props.locationId]));
|
2025-06-11 21:05:17 +02:00
|
|
|
watch(() => props.locationId, () => {
|
2025-06-27 18:59:23 +02:00
|
|
|
newEventLocationIds.value = new Set(props.locationId === undefined ? undefined : [props.locationId]);
|
2025-03-14 18:19:58 +01:00
|
|
|
});
|
|
|
|
|
|
|
|
function endFromTime(start: DateTime, time: string) {
|
2025-06-14 19:22:53 +02:00
|
|
|
let end = start.startOf("day").plus(Duration.fromISOTime(time, { locale: accountStore.activeLocale }));
|
2025-03-14 18:19:58 +01:00
|
|
|
if (end.toMillis() <= start.toMillis()) {
|
|
|
|
end = end.plus({ days: 1 });
|
|
|
|
}
|
|
|
|
return end;
|
|
|
|
}
|
|
|
|
function durationFromTime(time: string) {
|
2025-06-14 19:22:53 +02:00
|
|
|
let duration = Duration.fromISOTime(time, { locale: accountStore.activeLocale });
|
2025-03-14 18:19:58 +01:00
|
|
|
if (duration.toMillis() === 0) {
|
2025-06-14 19:22:53 +02:00
|
|
|
duration = Duration.fromMillis(oneDayMs, { locale: accountStore.activeLocale });
|
2025-03-14 18:19:58 +01:00
|
|
|
}
|
|
|
|
return duration;
|
|
|
|
}
|
2025-06-27 18:59:23 +02:00
|
|
|
const newEventId = ref<Id>();
|
2025-06-14 19:22:53 +02:00
|
|
|
|
2025-03-14 18:19:58 +01:00
|
|
|
function editEventSlot(
|
|
|
|
eventSlot: EventSlot,
|
|
|
|
edits: {
|
|
|
|
start?: string,
|
|
|
|
end?: string,
|
|
|
|
duration?: string,
|
|
|
|
}
|
|
|
|
) {
|
|
|
|
if (edits.start) {
|
2025-06-14 19:22:53 +02:00
|
|
|
const start = DateTime.fromISO(edits.start, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
|
2025-06-23 22:46:39 +02:00
|
|
|
eventSlot.slot.start = start;
|
|
|
|
eventSlot.slot.end = start.plus(eventSlot.end.diff(eventSlot.start));
|
2025-03-14 18:19:58 +01:00
|
|
|
}
|
|
|
|
if (edits.end !== undefined) {
|
2025-06-23 22:46:39 +02:00
|
|
|
eventSlot.slot.end = endFromTime(eventSlot.start, edits.end);
|
2025-03-14 18:19:58 +01:00
|
|
|
}
|
|
|
|
if (edits.duration !== undefined) {
|
2025-06-23 22:46:39 +02:00
|
|
|
eventSlot.slot.end = eventSlot.start.plus(durationFromTime(edits.duration));
|
2025-03-14 18:19:58 +01:00
|
|
|
}
|
|
|
|
}
|
|
|
|
function newEventSlot(options: { start?: DateTime, end?: DateTime } = {}) {
|
2025-06-27 18:59:23 +02:00
|
|
|
const event = schedule.value.events.get(newEventId.value!);
|
2025-06-14 19:22:53 +02:00
|
|
|
if (!event) {
|
|
|
|
alert("Invalid event");
|
|
|
|
return;
|
|
|
|
}
|
2025-03-14 18:19:58 +01:00
|
|
|
let start;
|
|
|
|
let end;
|
|
|
|
const duration = durationFromTime(newEventDuration.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-06-14 19:22:53 +02:00
|
|
|
start = DateTime.fromISO(newEventStart.value, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
|
2025-03-14 18:19:58 +01:00
|
|
|
end = endFromTime(start, newEventEnd.value);
|
|
|
|
}
|
|
|
|
if (!start.isValid || !end.isValid) {
|
|
|
|
alert("Invalid start and/or end time");
|
|
|
|
return;
|
|
|
|
}
|
2025-06-23 22:46:39 +02:00
|
|
|
const slot = ClientScheduleEventSlot.create(
|
|
|
|
schedule.value,
|
2025-06-14 19:22:53 +02:00
|
|
|
schedule.value.nextClientId--,
|
|
|
|
event.id,
|
2025-06-11 21:05:17 +02:00
|
|
|
start,
|
|
|
|
end,
|
2025-06-27 18:59:23 +02:00
|
|
|
new Set(newEventLocationIds.value),
|
2025-06-14 19:22:53 +02:00
|
|
|
new Set(),
|
|
|
|
0,
|
|
|
|
);
|
2025-06-23 22:46:39 +02:00
|
|
|
schedule.value.eventSlots.set(slot.id, slot);
|
|
|
|
event.slotIds.add(slot.id);
|
2025-06-27 18:59:23 +02:00
|
|
|
newEventId.value = undefined;
|
2025-03-14 18:19:58 +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 eventSlots = computed(() => {
|
|
|
|
const data: (EventSlot | Gap)[] = [];
|
2025-06-27 18:59:23 +02:00
|
|
|
for (const slot of schedule.value.eventSlots.values()) {
|
|
|
|
const event = schedule.value.events.get(slot.eventId!);
|
|
|
|
if (event?.deleted)
|
2025-06-11 21:05:17 +02:00
|
|
|
continue;
|
2025-06-27 18:59:23 +02:00
|
|
|
if (props.eventSlotFilter && !props.eventSlotFilter(slot))
|
|
|
|
continue;
|
|
|
|
if (props.locationId !== undefined && !slot.locationIds.has(props.locationId))
|
|
|
|
continue;
|
|
|
|
data.push({
|
|
|
|
type: "slot",
|
|
|
|
event,
|
|
|
|
slot,
|
|
|
|
start: slot.start,
|
|
|
|
end: slot.end,
|
|
|
|
});
|
2025-03-14 18:19:58 +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-14 19:22:53 +02:00
|
|
|
start: DateTime.fromMillis(maxEnd, { locale: accountStore.activeLocale }),
|
2025-03-14 18:19:58 +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>
|