owltide/components/TableScheduleEventSlots.vue

470 lines
12 KiB
Vue
Raw Normal View History

<template>
<div>
<table>
<thead>
<tr>
<th>start</th>
<th>end</th>
<th>duration</th>
<th>event</th>
<th>s</th>
<th>location</th>
<th>assigned</th>
<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='{
removed: es.type === "slot" && es.deleted,
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>
<input
type="text"
v-model="newEventName"
>
</td>
<td></td>
<td>
<select
v-model="newEventLocation"
>
<option
v-for="location in schedule.locations.values()"
:key="location.id"
:value="location.id"
:selected="location.id === newEventLocation"
>{{ location.name }}</option>
</select>
</td>
<td></td>
<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>
<input
type="text"
:value="es.name"
@input="editEvent(es, { name: ($event as any).target.value })"
>
</td>
<td>{{ status(es) }}</td>
<td>
<select
:value="es.location.id"
@change="editEventSlot(es, { locationId: parseInt(($event as any).target.value) })"
>
<option
v-for="location in schedule.locations.values()"
:key="location.id"
:value="location.id"
:selected="location.id === es.location.id"
>{{ location.name }}</option>
</select>
</td>
<td>
<AssignedCrew
:edit="true"
:modelValue="es.assigned"
@update:modelValue="editEventSlot(es, { assigned: $event })"
/>
</td>
<td>
<button
:disabled="es.deleted"
type="button"
@click="editEventSlot(es, { deleted: true })"
>Remove</button>
<button
v-if="schedule.isModifiedEventSlot(es.id)"
type="button"
@click="revertEventSlot(es.id)"
>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>
<input
type="text"
v-model="newEventName"
>
</td>
<td></td>
<td></td>
<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>
<td>{{ es.name }}</td>
<td>{{ status(es) }}</td>
<td>{{ es.location.id }}</td>
<td><AssignedCrew :modelValue="es.assigned" :edit="false" /></td>
</template>
</tr>
</template>
</tbody>
</table>
</div>
</template>
<script lang="ts" setup>
import { DateTime, Duration } from '~/shared/utils/luxon';
import type { Id } from '~/shared/types/common';
import { enumerate, pairs, toId } from '~/shared/utils/functions';
const props = defineProps<{
edit?: boolean,
locationId?: number,
eventSlotFilter?: (slot: ClientScheduleEventSlot) => boolean,
}>();
interface EventSlot {
type: "slot",
id: Id,
deleted?: boolean,
event: ClientScheduleEvent,
slot: ClientScheduleEventSlot,
name: string,
location: ClientScheduleLocation,
assigned: Set<Id>,
start: DateTime,
end: DateTime,
}
interface Gap {
type: "gap",
id?: undefined,
event?: undefined,
slot?: undefined,
name?: undefined,
locationId?: number,
start: DateTime,
end: DateTime,
}
function status(eventSlot: EventSlot) {
if (
!eventSlot.event
|| eventSlot.event.name !== eventSlot.name
) {
const event = [...schedule.value.events.values()].find(event => event.name === eventSlot.name);
return event ? "L" : "N";
}
return eventSlot.event.slots.size === 1 ? "" : eventSlot.event.slots.size;
}
const accountStore = useAccountStore();
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({
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 "";
}
},
set: (value: string) => {
const start = DateTime.fromISO(newEventStart.value, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
const end = endFromTime(start, value);
newEventDuration.value = dropDay(end.diff(start)).toFormat("hh:mm");
},
});
const newEventLocation = ref(props.locationId);
watch(() => props.locationId, () => {
newEventLocation.value = props.locationId;
});
function endFromTime(start: DateTime, time: string) {
let end = start.startOf("day").plus(Duration.fromISOTime(time, { locale: accountStore.activeLocale }));
if (end.toMillis() <= start.toMillis()) {
end = end.plus({ days: 1 });
}
return end;
}
function durationFromTime(time: string) {
let duration = Duration.fromISOTime(time, { locale: accountStore.activeLocale });
if (duration.toMillis() === 0) {
duration = Duration.fromMillis(oneDayMs, { locale: accountStore.activeLocale });
}
return duration;
}
const newEventName = ref("");
function editEvent(
eventSlot: EventSlot,
edits: Parameters<ClientSchedule["editEvent"]>[1],
) {
schedule.value.editEvent(eventSlot.event, edits);
}
function editEventSlot(
eventSlot: EventSlot,
edits: {
deleted?: boolean,
start?: string,
end?: string,
duration?: string,
locationId?: Id,
assigned?: Set<Id>,
}
) {
const computedEdits: Parameters<ClientSchedule["editEventSlot"]>[1] = {
deleted: edits.deleted,
assigned: edits.assigned,
};
if (edits.start) {
const start = DateTime.fromISO(edits.start, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
computedEdits.start = start;
computedEdits.end = start.plus(eventSlot.end.diff(eventSlot.start));
}
if (edits.end !== undefined) {
computedEdits.end = endFromTime(eventSlot.start, edits.end);
}
if (edits.duration !== undefined) {
computedEdits.end = eventSlot.start.plus(durationFromTime(edits.duration));
}
if (edits.locationId !== undefined) {
const location = schedule.value.locations.get(edits.locationId);
if (location)
computedEdits.locations = [location];
}
schedule.value.editEventSlot(eventSlot.slot, computedEdits);
}
function revertEventSlot(id: Id) {
schedule.value.restoreEventSlot(id);
}
function newEventSlot(options: { start?: DateTime, end?: DateTime } = {}) {
const name = newEventName.value;
const nameId = toId(name);
const event = [...schedule.value.events.values()].find(event => toId(event.name) === nameId);
if (!event) {
alert("Invalid event");
return;
}
const location = schedule.value.locations.get(newEventLocation.value!);
if (!location) {
alert("Invalid location");
return;
}
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 {
start = DateTime.fromISO(newEventStart.value, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
end = endFromTime(start, newEventEnd.value);
}
if (!start.isValid || !end.isValid) {
alert("Invalid start and/or end time");
return;
}
const slot = new ClientScheduleEventSlot(
schedule.value.nextClientId--,
false,
event.id,
start,
end,
[location],
new Set(),
0,
);
newEventName.value = "";
schedule.value.setEventSlot(slot);
}
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)[] = [];
for (const event of schedule.value.events.values()) {
if (event.deleted)
continue;
for (const slot of event.slots.values()) {
if (props.eventSlotFilter && !props.eventSlotFilter(slot))
continue;
for (const location of slot.locations) {
if (props.locationId !== undefined && location.id !== props.locationId)
continue;
data.push({
type: "slot",
id: slot.id,
deleted: slot.deleted || event.deleted,
event,
slot,
name: event.name,
location,
assigned: slot.assigned ?? [],
start: slot.start,
end: slot.end,
});
}
}
}
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",
locationId: props.locationId,
start: DateTime.fromMillis(maxEnd, { locale: accountStore.activeLocale }),
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>