Refactor to use ClientSchedule on client
Use the ClientSchedule data structure for deserialising and tracking edit state on the client instead of trying to directly deal with the ApiSchedule type which is not build for ease of edits or rendering.
This commit is contained in:
parent
ce9f758f84
commit
bb450fd583
15 changed files with 488 additions and 1297 deletions
|
@ -5,7 +5,7 @@
|
||||||
<button
|
<button
|
||||||
v-if="edit"
|
v-if="edit"
|
||||||
type="button"
|
type="button"
|
||||||
@click="assignedIds = assignedIds.filter(id => id !== account.id)"
|
@click="assignedIds = new Set([...assignedIds].filter(id => id !== account.id))"
|
||||||
>
|
>
|
||||||
x
|
x
|
||||||
</button>
|
</button>
|
||||||
|
@ -26,9 +26,9 @@ defineProps<{
|
||||||
}>();
|
}>();
|
||||||
const { data: accounts } = useAccounts();
|
const { data: accounts } = useAccounts();
|
||||||
const accountsById = computed(() => new Map(accounts.value?.map?.(a => [a.id, a])));
|
const accountsById = computed(() => new Map(accounts.value?.map?.(a => [a.id, a])));
|
||||||
const assignedIds = defineModel<number[]>({ required: true });
|
const assignedIds = defineModel<Set<number>>({ required: true });
|
||||||
const assigned = computed(
|
const assigned = computed(
|
||||||
() => assignedIds.value.map(
|
() => [...assignedIds.value].map(
|
||||||
id => accountsById.value.get(id) ?? { id, name: String(id) }
|
id => accountsById.value.get(id) ?? { id, name: String(id) }
|
||||||
)
|
)
|
||||||
);
|
);
|
||||||
|
@ -43,8 +43,8 @@ function addCrew() {
|
||||||
return;
|
return;
|
||||||
const account = crewByName.value.get(addName.value);
|
const account = crewByName.value.get(addName.value);
|
||||||
if (account) {
|
if (account) {
|
||||||
if (!assignedIds.value.some(id => id === account.id)) {
|
if (!assignedIds.value.has(account.id)) {
|
||||||
assignedIds.value = [...assignedIds.value, account.id];
|
assignedIds.value = new Set([...assignedIds.value, account.id]);
|
||||||
} else {
|
} else {
|
||||||
alert(`${addName.value} has already been added`);
|
alert(`${addName.value} has already been added`);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,10 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<section class="event" v-if="event.deleted">
|
<section class="event">
|
||||||
<p>
|
|
||||||
Error: Unexpected deleted event.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
<section class="event" v-else>
|
|
||||||
<h3>{{ event.name }}</h3>
|
<h3>{{ event.name }}</h3>
|
||||||
<p>{{ event.description ?? "No description provided" }}</p>
|
<p>{{ event.description ?? "No description provided" }}</p>
|
||||||
<p v-if="event.interested">
|
<p v-if="event.interested">
|
||||||
|
@ -14,7 +9,7 @@
|
||||||
<button
|
<button
|
||||||
class="interested"
|
class="interested"
|
||||||
:class="{ active: accountStore.interestedEventIds.has(event.id) }"
|
:class="{ active: accountStore.interestedEventIds.has(event.id) }"
|
||||||
@click="toggle('event', event.id, event.slots.map(slot => slot.id))"
|
@click="toggle('event', event.id, [...event.slots.keys()])"
|
||||||
>
|
>
|
||||||
{{ accountStore.interestedEventIds.has(event.id) ? "✔ interested" : "🔔 interested?" }}
|
{{ accountStore.interestedEventIds.has(event.id) ? "✔ interested" : "🔔 interested?" }}
|
||||||
</button>
|
</button>
|
||||||
|
@ -22,10 +17,10 @@
|
||||||
|
|
||||||
<h4>Timeslots</h4>
|
<h4>Timeslots</h4>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="slot in event.slots" :key="slot.id">
|
<li v-for="slot in event.slots.values()" :key="slot.id">
|
||||||
{{ formatTime(slot.start) }} - {{ formatTime(slot.end) }}
|
{{ formatTime(slot.start) }} - {{ formatTime(slot.end) }}
|
||||||
<button
|
<button
|
||||||
v-if="accountStore.valid && event.slots.length > 1"
|
v-if="accountStore.valid && event.slots.size > 1"
|
||||||
class="interested"
|
class="interested"
|
||||||
:disabled="accountStore.interestedEventIds.has(event.id)"
|
:disabled="accountStore.interestedEventIds.has(event.id)"
|
||||||
:class="{ active: accountStore.interestedEventIds.has(event.id) || accountStore.interestedEventSlotIds.has(slot.id) }"
|
:class="{ active: accountStore.interestedEventIds.has(event.id) || accountStore.interestedEventSlotIds.has(slot.id) }"
|
||||||
|
@ -38,7 +33,7 @@
|
||||||
</template>
|
</template>
|
||||||
<p v-if="slot.assigned">
|
<p v-if="slot.assigned">
|
||||||
Crew:
|
Crew:
|
||||||
{{ slot.assigned.map(id => idToAccount.get(id)?.name).join(", ") }}
|
{{ [...slot.assigned].map(id => idToAccount.get(id)?.name).join(", ") }}
|
||||||
</p>
|
</p>
|
||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
|
@ -47,18 +42,17 @@
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { DateTime } from 'luxon';
|
import { DateTime } from 'luxon';
|
||||||
import type { ApiScheduleEvent } from '~/shared/types/api';
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
event: ApiScheduleEvent
|
event: ClientScheduleEvent
|
||||||
}>()
|
}>()
|
||||||
|
|
||||||
const accountStore = useAccountStore();
|
const accountStore = useAccountStore();
|
||||||
const { data: accounts } = await useAccounts();
|
const { data: accounts } = await useAccounts();
|
||||||
const idToAccount = computed(() => new Map(accounts.value?.map(a => [a.id, a])));
|
const idToAccount = computed(() => new Map(accounts.value?.map(a => [a.id, a])));
|
||||||
|
|
||||||
function formatTime(time: string) {
|
function formatTime(time: DateTime) {
|
||||||
return DateTime.fromISO(time, { zone: accountStore.activeTimezone, locale: "en-US" }).toFormat("yyyy-LL-dd HH:mm");
|
return time.toFormat("yyyy-LL-dd HH:mm");
|
||||||
}
|
}
|
||||||
|
|
||||||
async function toggle(type: "event" | "slot", id: number, slotIds?: number[]) {
|
async function toggle(type: "event" | "slot", id: number, slotIds?: number[]) {
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="schedule.deleted">
|
<div>
|
||||||
Error: Unexpected deleted schedule.
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -17,9 +14,9 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-if="edit">
|
<template v-if="edit">
|
||||||
<tr
|
<tr
|
||||||
v-for="event in events.filter(e => !e.deleted)"
|
v-for="event in schedule.events.values()"
|
||||||
:key="event.id"
|
:key="event.id"
|
||||||
:class="{ removed: removed.has(event.id) }"
|
:class="{ removed: event.deleted }"
|
||||||
>
|
>
|
||||||
<td>{{ event.id }}</td>
|
<td>{{ event.id }}</td>
|
||||||
<td>
|
<td>
|
||||||
|
@ -47,22 +44,22 @@
|
||||||
@change="editEvent(event, { crew: !($event as any).target.value })"
|
@change="editEvent(event, { crew: !($event as any).target.value })"
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ event.slots.length ? event.slots.length : "" }}</td>
|
<td>{{ event.slots.size ? event.slots.size : "" }}</td>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="!canEdit(event) || removed.has(event.id)"
|
:disabled="!canEdit(event) || event.deleted"
|
||||||
@click="delEvent(event.id)"
|
@click="editEvent(event, { deleted: true })"
|
||||||
>Delete</button>
|
>Delete</button>
|
||||||
<button
|
<button
|
||||||
v-if="changes.some(c => c.id === event.id)"
|
v-if="schedule.isModifiedEvent(event.id)"
|
||||||
type="button"
|
type="button"
|
||||||
@click="revertEvent(event.id)"
|
@click="revertEvent(event.id)"
|
||||||
>Revert</button>
|
>Revert</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ toId(newEventName) }}</td>
|
<td>{{ schedule.nextClientId }}</td>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
@ -98,40 +95,25 @@
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<tr
|
<tr
|
||||||
v-for="event in events.filter(e => !e.deleted)"
|
v-for="event in schedule.events.values()"
|
||||||
:key="event.id"
|
:key="event.id"
|
||||||
>
|
>
|
||||||
<td>{{ event.id }}</td>
|
<td>{{ event.id }}</td>
|
||||||
<td>{{ event.name }}</td>
|
<td>{{ event.name }}</td>
|
||||||
<td>{{ event.description }}</td>
|
<td>{{ event.description }}</td>
|
||||||
<td>{{ event.crew ? "" : "Yes"}}</td>
|
<td>{{ event.crew ? "" : "Yes"}}</td>
|
||||||
<td>{{ event.slots.length ? event.slots.length : "" }}</td>
|
<td>{{ event.slots.size ? event.slots.size : "" }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p v-if="changes.length">
|
|
||||||
Changes are not saved yet.
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="saveEvents"
|
|
||||||
>Save Changes</button>
|
|
||||||
</p>
|
|
||||||
<details>
|
|
||||||
<summary>Debug</summary>
|
|
||||||
<ol>
|
|
||||||
<li v-for="change in changes">
|
|
||||||
{{ JSON.stringify(change) }}
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { ApiSchedule, ApiScheduleEvent } from '~/shared/types/api';
|
import type { Id } from '~/shared/types/common';
|
||||||
|
import { DateTime } from '~/shared/utils/luxon';
|
||||||
import { toId } from '~/shared/utils/functions';
|
import { toId } from '~/shared/utils/functions';
|
||||||
import { applyUpdatesToArray } from '~/shared/utils/update';
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
edit?: boolean,
|
edit?: boolean,
|
||||||
|
@ -140,64 +122,26 @@ defineProps<{
|
||||||
const schedule = await useSchedule();
|
const schedule = await useSchedule();
|
||||||
const accountStore = useAccountStore();
|
const accountStore = useAccountStore();
|
||||||
|
|
||||||
function canEdit(event: ApiScheduleEvent) {
|
function canEdit(event: ClientScheduleEvent) {
|
||||||
return !event.deleted && (event.crew || accountStore.canEditPublic);
|
return event.crew || accountStore.canEditPublic;
|
||||||
}
|
|
||||||
|
|
||||||
const changes = ref<ApiScheduleEvent[]>([]);
|
|
||||||
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
|
|
||||||
function replaceChange(
|
|
||||||
change: ApiScheduleEvent,
|
|
||||||
changes: ApiScheduleEvent[],
|
|
||||||
) {
|
|
||||||
const index = changes.findIndex(item => (
|
|
||||||
item.deleted === change.deleted && item.id === change.id
|
|
||||||
));
|
|
||||||
const copy = [...changes];
|
|
||||||
if (index !== -1)
|
|
||||||
copy.splice(index, 1, change);
|
|
||||||
else
|
|
||||||
copy.push(change);
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
function revertChange(id: number, changes: ApiScheduleEvent[]) {
|
|
||||||
return changes.filter(change => change.id !== id);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const newEventName = ref("");
|
const newEventName = ref("");
|
||||||
const newEventDescription = ref("");
|
const newEventDescription = ref("");
|
||||||
const newEventPublic = ref(false);
|
const newEventPublic = ref(false);
|
||||||
function editEvent(
|
function editEvent(
|
||||||
event: Extract<ApiScheduleEvent, { deleted?: false }>,
|
event: ClientScheduleEvent,
|
||||||
edits: { name?: string, description?: string, crew?: boolean }
|
edits: Parameters<ClientSchedule["editEvent"]>[1],
|
||||||
) {
|
) {
|
||||||
const copy = { ...event };
|
schedule.value.editEvent(event, edits);
|
||||||
if (edits.name !== undefined) {
|
|
||||||
copy.name = edits.name;
|
|
||||||
}
|
|
||||||
if (edits.description !== undefined) {
|
|
||||||
copy.description = edits.description || undefined;
|
|
||||||
}
|
|
||||||
if (edits.crew !== undefined) {
|
|
||||||
copy.crew = edits.crew || undefined;
|
|
||||||
}
|
|
||||||
changes.value = replaceChange(copy, changes.value);
|
|
||||||
}
|
}
|
||||||
function delEvent(id: number) {
|
function revertEvent(id: Id) {
|
||||||
const change = { id, updatedAt: "", deleted: true as const };
|
schedule.value.restoreEvent(id);
|
||||||
changes.value = replaceChange(change, changes.value);
|
|
||||||
}
|
|
||||||
function revertEvent(id: number) {
|
|
||||||
changes.value = revertChange(id, changes.value);
|
|
||||||
}
|
}
|
||||||
function eventExists(name: string) {
|
function eventExists(name: string) {
|
||||||
if (schedule.value.deleted) {
|
|
||||||
throw new Error("Unexpected deleted schedule");
|
|
||||||
}
|
|
||||||
name = toId(name);
|
name = toId(name);
|
||||||
return (
|
return (
|
||||||
schedule.value.events?.some(e => !e.deleted && toId(e.name) === name)
|
[...schedule.value.events.values()].some(e => !e.deleted && toId(e.name) === name)
|
||||||
|| changes.value.some(c => !c.deleted && c.name === name)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function newEvent() {
|
function newEvent() {
|
||||||
|
@ -205,48 +149,23 @@ function newEvent() {
|
||||||
alert(`Event ${newEventName.value} already exists`);
|
alert(`Event ${newEventName.value} already exists`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (schedule.value.deleted) {
|
const event = new ClientScheduleEvent(
|
||||||
throw new Error("Unexpected deleted schedule");
|
schedule.value.nextClientId--,
|
||||||
}
|
DateTime.now(),
|
||||||
const id = Math.max(1, ...schedule.value.events?.map(e => e.id) ?? []) + 1;
|
false,
|
||||||
const change = {
|
newEventName.value,
|
||||||
id,
|
!newEventPublic.value,
|
||||||
updatedAt: "",
|
"",
|
||||||
name: newEventName.value,
|
false,
|
||||||
description: newEventDescription.value || undefined,
|
newEventDescription.value,
|
||||||
crew: !newEventPublic.value || undefined,
|
0,
|
||||||
slots: [],
|
new Map(),
|
||||||
};
|
);
|
||||||
changes.value = replaceChange(change, changes.value);
|
schedule.value.setEvent(event);
|
||||||
newEventName.value = "";
|
newEventName.value = "";
|
||||||
newEventDescription.value = "";
|
newEventDescription.value = "";
|
||||||
newEventPublic.value = false;
|
newEventPublic.value = false;
|
||||||
}
|
}
|
||||||
async function saveEvents() {
|
|
||||||
try {
|
|
||||||
await $fetch("/api/schedule", {
|
|
||||||
method: "PATCH",
|
|
||||||
body: {
|
|
||||||
id: 111,
|
|
||||||
updatedAt: "",
|
|
||||||
events: changes.value,
|
|
||||||
} satisfies ApiSchedule,
|
|
||||||
});
|
|
||||||
changes.value = [];
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
alert(err?.data?.message ?? err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const events = computed(() => {
|
|
||||||
if (schedule.value.deleted) {
|
|
||||||
throw new Error("Unexpected deleted schedule");
|
|
||||||
}
|
|
||||||
const data = [...schedule.value.events ?? []];
|
|
||||||
applyUpdatesToArray(changes.value.filter(change => !change.deleted), data);
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
@ -11,9 +11,9 @@
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<tr
|
<tr
|
||||||
v-for="location in locations.filter(l => !l.deleted)"
|
v-for="location in schedule.locations.values()"
|
||||||
:key="location.id"
|
:key="location.id"
|
||||||
:class="{ removed: removed.has(location.id) }"
|
:class="{ removed: location.deleted }"
|
||||||
>
|
>
|
||||||
<template v-if='edit'>
|
<template v-if='edit'>
|
||||||
<td>{{ location.id }}</td>
|
<td>{{ location.id }}</td>
|
||||||
|
@ -21,24 +21,24 @@
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
:value="location.name"
|
:value="location.name"
|
||||||
@input="setLocation({ ...location, name: ($event as any).target.value })"
|
@input="editLocation(location, { name: ($event as any).target.value })"
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
:value="location.description"
|
:value="location.description"
|
||||||
@input="setLocation({ ...location, description: ($event as any).target.value || undefined })"
|
@input="editLocation(location, { description: ($event as any).target.value })"
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
:disabled="removed.has(location.id)"
|
:disabled="location.deleted"
|
||||||
type="button"
|
type="button"
|
||||||
@click="delLocation(location.id)"
|
@click="editLocation(location, { deleted: true })"
|
||||||
>Remove</button>
|
>Remove</button>
|
||||||
<button
|
<button
|
||||||
v-if="changes.some(c => c.id === location.id)"
|
v-if="schedule.isModifiedLocation(location.id)"
|
||||||
type="button"
|
type="button"
|
||||||
@click="revertLocation(location.id)"
|
@click="revertLocation(location.id)"
|
||||||
>Revert</button>
|
>Revert</button>
|
||||||
|
@ -52,7 +52,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
<tr v-if='edit'>
|
<tr v-if='edit'>
|
||||||
<td>
|
<td>
|
||||||
{{ newLocationId }}
|
{{ schedule.nextClientId }}
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
|
@ -60,37 +60,27 @@
|
||||||
v-model="newLocationName"
|
v-model="newLocationName"
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td colspan="2">
|
<td>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
v-model="newLocationDescription"
|
||||||
|
>
|
||||||
|
</td>
|
||||||
|
<td colspan="1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
@click="newLocation(newLocationName); newLocationName = ''"
|
@click="newLocation()"
|
||||||
>Add Location</button>
|
>Add Location</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p v-if="changes.length">
|
|
||||||
Changes are not save yet.
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="saveLocations"
|
|
||||||
>Save Changes</button>
|
|
||||||
</p>
|
|
||||||
<details>
|
|
||||||
<summary>Debug</summary>
|
|
||||||
<ol>
|
|
||||||
<li v-for="change in changes">
|
|
||||||
{{ JSON.stringify(change) }}
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</details>
|
|
||||||
</figure>
|
</figure>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { ApiSchedule, ApiScheduleLocation } from '~/shared/types/api';
|
import { DateTime } from '~/shared/utils/luxon';
|
||||||
import { toId } from '~/shared/utils/functions';
|
import type { Id } from '~/shared/types/common';
|
||||||
import { applyUpdatesToArray } from '~/shared/utils/update';
|
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
edit?: boolean
|
edit?: boolean
|
||||||
|
@ -98,81 +88,34 @@ defineProps<{
|
||||||
|
|
||||||
const schedule = await useSchedule();
|
const schedule = await useSchedule();
|
||||||
|
|
||||||
const changes = ref<ApiScheduleLocation[]>([]);
|
|
||||||
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
|
|
||||||
|
|
||||||
function replaceChange(
|
|
||||||
change: ApiScheduleLocation,
|
|
||||||
changes: ApiScheduleLocation[],
|
|
||||||
) {
|
|
||||||
const index = changes.findIndex(item => (
|
|
||||||
item.deleted === change.deleted && item.id === change.id
|
|
||||||
));
|
|
||||||
const copy = [...changes];
|
|
||||||
if (index !== -1)
|
|
||||||
copy.splice(index, 1, change);
|
|
||||||
else
|
|
||||||
copy.push(change);
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
function revertChange(id: number, changes: ApiScheduleLocation[]) {
|
|
||||||
return changes.filter(change => change.id !== id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newLocationName = ref("");
|
const newLocationName = ref("");
|
||||||
const newLocationId = computed(() => {
|
const newLocationDescription = ref("");
|
||||||
if (schedule.value.deleted) {
|
|
||||||
throw new Error("Unexpected deleted schedule");
|
|
||||||
}
|
|
||||||
return Math.max(
|
|
||||||
1,
|
|
||||||
...schedule.value.locations?.map(l => l.id) ?? [],
|
|
||||||
...changes.value.map(c => c.id)
|
|
||||||
) + 1;
|
|
||||||
});
|
|
||||||
function setLocation(location: ApiScheduleLocation) {
|
|
||||||
changes.value = replaceChange(location, changes.value);
|
|
||||||
}
|
|
||||||
function delLocation(id: number) {
|
|
||||||
const change = { id, updatedAt: "", deleted: true as const };
|
|
||||||
changes.value = replaceChange(change, changes.value);
|
|
||||||
}
|
|
||||||
function revertLocation(id: number) {
|
|
||||||
changes.value = revertChange(id, changes.value);
|
|
||||||
}
|
|
||||||
function newLocation(name: string) {
|
|
||||||
const change = {
|
|
||||||
id: newLocationId.value,
|
|
||||||
updatedAt: "",
|
|
||||||
name,
|
|
||||||
};
|
|
||||||
changes.value = replaceChange(change, changes.value);
|
|
||||||
}
|
|
||||||
async function saveLocations() {
|
|
||||||
try {
|
|
||||||
await $fetch("/api/schedule", {
|
|
||||||
method: "PATCH",
|
|
||||||
body: {
|
|
||||||
id: 111,
|
|
||||||
updatedAt: "",
|
|
||||||
locations: changes.value
|
|
||||||
} satisfies ApiSchedule,
|
|
||||||
});
|
|
||||||
changes.value = [];
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
alert(err?.data?.message ?? err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const locations = computed(() => {
|
function editLocation(
|
||||||
if (schedule.value.deleted) {
|
location: ClientScheduleLocation,
|
||||||
throw new Error("Unexpected deleted schedule");
|
edits: Parameters<ClientSchedule["editLocation"]>[1],
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
schedule.value.editLocation(location, edits);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message);
|
||||||
}
|
}
|
||||||
const data = [...schedule.value.locations ?? []];
|
}
|
||||||
applyUpdatesToArray(changes.value.filter(change => !change.deleted), data);
|
function revertLocation(id: Id) {
|
||||||
return data;
|
schedule.value.restoreLocation(id);
|
||||||
});
|
}
|
||||||
|
function newLocation() {
|
||||||
|
const location = new ClientScheduleLocation(
|
||||||
|
schedule.value.nextClientId--,
|
||||||
|
DateTime.now(),
|
||||||
|
false,
|
||||||
|
newLocationName.value,
|
||||||
|
newLocationDescription.value,
|
||||||
|
);
|
||||||
|
schedule.value.setLocation(location);
|
||||||
|
newLocationName.value = "";
|
||||||
|
newLocationDescription.value = "";
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="schedule.deleted">
|
<div>
|
||||||
Error: Unexpected deleted schedule.
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -15,9 +12,9 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-if="edit">
|
<template v-if="edit">
|
||||||
<tr
|
<tr
|
||||||
v-for="role in roles.filter(r => !r.deleted)"
|
v-for="role in schedule.roles.values()"
|
||||||
:key="role.id"
|
:key="role.id"
|
||||||
:class="{ removed: removed.has(role.id) }"
|
:class="{ removed: role.deleted }"
|
||||||
>
|
>
|
||||||
<td>{{ role.id }}</td>
|
<td>{{ role.id }}</td>
|
||||||
<td>
|
<td>
|
||||||
|
@ -37,18 +34,18 @@
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="removed.has(role.id)"
|
:disabled="role.deleted"
|
||||||
@click="delRole(role.id)"
|
@click="editRole(role, { deleted: true })"
|
||||||
>Delete</button>
|
>Delete</button>
|
||||||
<button
|
<button
|
||||||
v-if="changes.some(c => c.id === role.id)"
|
v-if="schedule.isModifiedRole(role.id)"
|
||||||
type="button"
|
type="button"
|
||||||
@click="revertRole(role.id)"
|
@click="revertRole(role.id)"
|
||||||
>Revert</button>
|
>Revert</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ newRoleId }}</td>
|
<td>{{ schedule.nextClientId }}</td>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
@ -76,7 +73,7 @@
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<tr
|
<tr
|
||||||
v-for="role in roles.filter(r => !r.deleted)"
|
v-for="role in schedule.roles.values()"
|
||||||
:key="role.id"
|
:key="role.id"
|
||||||
>
|
>
|
||||||
<td>{{ role.id }}</td>
|
<td>{{ role.id }}</td>
|
||||||
|
@ -86,28 +83,13 @@
|
||||||
</template>
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p v-if="changes.length">
|
|
||||||
Changes are not saved yet.
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="saveRoles"
|
|
||||||
>Save Changes</button>
|
|
||||||
</p>
|
|
||||||
<details>
|
|
||||||
<summary>Debug</summary>
|
|
||||||
<ol>
|
|
||||||
<li v-for="change in changes">
|
|
||||||
{{ JSON.stringify(change) }}
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { ApiSchedule, ApiScheduleRole } from '~/shared/types/api';
|
import { DateTime } from '~/shared/utils/luxon';
|
||||||
import { applyUpdatesToArray } from '~/shared/utils/update';
|
|
||||||
import { toId } from '~/shared/utils/functions';
|
import { toId } from '~/shared/utils/functions';
|
||||||
|
import type { Id } from '~/shared/types/common';
|
||||||
|
|
||||||
defineProps<{
|
defineProps<{
|
||||||
edit?: boolean,
|
edit?: boolean,
|
||||||
|
@ -115,66 +97,30 @@ defineProps<{
|
||||||
|
|
||||||
const schedule = await useSchedule();
|
const schedule = await useSchedule();
|
||||||
|
|
||||||
const changes = ref<ApiScheduleRole[]>([]);
|
|
||||||
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
|
|
||||||
function replaceChange(
|
|
||||||
change: ApiScheduleRole,
|
|
||||||
changes: ApiScheduleRole[],
|
|
||||||
) {
|
|
||||||
const index = changes.findIndex(item => (
|
|
||||||
item.deleted === change.deleted && item.id === change.id
|
|
||||||
));
|
|
||||||
const copy = [...changes];
|
|
||||||
if (index !== -1)
|
|
||||||
copy.splice(index, 1, change);
|
|
||||||
else
|
|
||||||
copy.push(change);
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
function revertChange(id: number, changes: ApiScheduleRole[]) {
|
|
||||||
return changes.filter(change => change.id !== id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newRoleName = ref("");
|
const newRoleName = ref("");
|
||||||
const newRoleId = computed(() => {
|
|
||||||
if (schedule.value.deleted) {
|
|
||||||
throw new Error("Unexpected deleted schedule");
|
|
||||||
}
|
|
||||||
return Math.max(
|
|
||||||
1,
|
|
||||||
...schedule.value.roles?.map(r => r.id) ?? [],
|
|
||||||
...changes.value.map(c => c.id)
|
|
||||||
) + 1;
|
|
||||||
});
|
|
||||||
const newRoleDescription = ref("");
|
const newRoleDescription = ref("");
|
||||||
|
|
||||||
function editRole(
|
function editRole(
|
||||||
role: Extract<ApiScheduleRole, { deleted?: false }>,
|
role: ClientScheduleRole,
|
||||||
edits: { name?: string, description?: string }
|
edits: { deleted?: boolean, name?: string, description?: string }
|
||||||
) {
|
) {
|
||||||
const copy = { ...role };
|
const copy = role.clone();
|
||||||
if (edits.name !== undefined) {
|
if (edits.deleted !== undefined) copy.deleted = edits.deleted;
|
||||||
copy.name = edits.name;
|
if (edits.name !== undefined) copy.name = edits.name;
|
||||||
|
if (edits.description !== undefined) copy.description = edits.description;
|
||||||
|
try {
|
||||||
|
schedule.value.setRole(copy);
|
||||||
|
} catch (err: any) {
|
||||||
|
alert(err.message);
|
||||||
}
|
}
|
||||||
if (edits.description !== undefined) {
|
|
||||||
copy.description = edits.description || undefined;
|
|
||||||
}
|
|
||||||
changes.value = replaceChange(copy, changes.value);
|
|
||||||
}
|
}
|
||||||
function delRole(id: number) {
|
function revertRole(id: Id) {
|
||||||
const change = { id, updatedAt: "", deleted: true as const };
|
schedule.value.restoreRole(id);
|
||||||
changes.value = replaceChange(change, changes.value);
|
|
||||||
}
|
|
||||||
function revertRole(id: number) {
|
|
||||||
changes.value = revertChange(id, changes.value);
|
|
||||||
}
|
}
|
||||||
function roleExists(name: string) {
|
function roleExists(name: string) {
|
||||||
if (schedule.value.deleted) {
|
|
||||||
throw new Error("Unexpected deleted schedule");
|
|
||||||
}
|
|
||||||
name = toId(name);
|
name = toId(name);
|
||||||
return (
|
return (
|
||||||
schedule.value.roles?.some(r => !r.deleted && toId(r.name) === name)
|
[...schedule.value.roles.values()].some(r => !r.deleted && toId(r.name) === name)
|
||||||
|| changes.value.some(c => !c.deleted && c.name === name)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function newRole() {
|
function newRole() {
|
||||||
|
@ -182,45 +128,18 @@ function newRole() {
|
||||||
alert(`Role ${newRoleName.value} already exists`);
|
alert(`Role ${newRoleName.value} already exists`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (schedule.value.deleted) {
|
const role = new ClientScheduleRole(
|
||||||
throw new Error("Unexpected deleted schedule");
|
schedule.value.nextClientId--,
|
||||||
}
|
DateTime.now(),
|
||||||
const change = {
|
false,
|
||||||
id: newRoleId.value,
|
newRoleName.value,
|
||||||
updatedAt: "",
|
newRoleDescription.value,
|
||||||
name: newRoleName.value,
|
);
|
||||||
description: newRoleDescription.value || undefined,
|
schedule.value.setRole(role);
|
||||||
slots: [],
|
|
||||||
};
|
|
||||||
changes.value = replaceChange(change, changes.value);
|
|
||||||
newRoleName.value = "";
|
newRoleName.value = "";
|
||||||
newRoleDescription.value = "";
|
newRoleDescription.value = "";
|
||||||
}
|
}
|
||||||
async function saveRoles() {
|
|
||||||
try {
|
|
||||||
await $fetch("/api/schedule", {
|
|
||||||
method: "PATCH",
|
|
||||||
body: {
|
|
||||||
id: 111,
|
|
||||||
updatedAt: "",
|
|
||||||
roles: changes.value
|
|
||||||
} satisfies ApiSchedule,
|
|
||||||
});
|
|
||||||
changes.value = [];
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
alert(err?.data?.message ?? err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const roles = computed(() => {
|
|
||||||
if (schedule.value.deleted) {
|
|
||||||
throw new Error("Unexpected deleted schedule");
|
|
||||||
}
|
|
||||||
const data = [...schedule.value.roles ?? []];
|
|
||||||
applyUpdatesToArray(changes.value.filter(change => !change.deleted), data);
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
@ -1,9 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="schedule.deleted">
|
<div>
|
||||||
Error: Unexpected deleted schedule.
|
<Timetable :schedule :eventSlotFilter :shiftSlotFilter />
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<Timetable :schedule="schedulePreview" :eventSlotFilter :shiftSlotFilter />
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -23,7 +20,7 @@
|
||||||
v-for="es in eventSlots"
|
v-for="es in eventSlots"
|
||||||
:key='es.slot?.id ?? es.start.toMillis()'
|
:key='es.slot?.id ?? es.start.toMillis()'
|
||||||
:class='{
|
:class='{
|
||||||
removed: es.type === "slot" && removed.has(es.id),
|
removed: es.type === "slot" && es.deleted,
|
||||||
gap: es.type === "gap",
|
gap: es.type === "gap",
|
||||||
}'
|
}'
|
||||||
>
|
>
|
||||||
|
@ -50,7 +47,7 @@
|
||||||
v-model="newEventLocation"
|
v-model="newEventLocation"
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-for="location in schedule.locations?.filter(l => !l.deleted)"
|
v-for="location in schedule.locations.values()"
|
||||||
:key="location.id"
|
:key="location.id"
|
||||||
:value="location.id"
|
:value="location.id"
|
||||||
:selected="location.id === newEventLocation"
|
:selected="location.id === newEventLocation"
|
||||||
|
@ -96,20 +93,20 @@
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
:value="es.name"
|
:value="es.name"
|
||||||
@input="editEventSlot(es, { name: ($event as any).target.value })"
|
@input="editEvent(es, { name: ($event as any).target.value })"
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ status(es) }}</td>
|
<td>{{ status(es) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<select
|
<select
|
||||||
:value="es.locationId"
|
:value="es.location.id"
|
||||||
@change="editEventSlot(es, { locationId: parseInt(($event as any).target.value) })"
|
@change="editEventSlot(es, { locationId: parseInt(($event as any).target.value) })"
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-for="location in schedule.locations?.filter(l => !l.deleted)"
|
v-for="location in schedule.locations.values()"
|
||||||
:key="location.id"
|
:key="location.id"
|
||||||
:value="location.id"
|
:value="location.id"
|
||||||
:selected="location.id === es.locationId"
|
:selected="location.id === es.location.id"
|
||||||
>{{ location.name }}</option>
|
>{{ location.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
|
@ -122,12 +119,12 @@
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
:disabled="removed.has(es.id)"
|
:disabled="es.deleted"
|
||||||
type="button"
|
type="button"
|
||||||
@click="delEventSlot(es)"
|
@click="editEventSlot(es, { deleted: true })"
|
||||||
>Remove</button>
|
>Remove</button>
|
||||||
<button
|
<button
|
||||||
v-if="changes.some(c => c.id === es.id)"
|
v-if="schedule.isModifiedEventSlot(es.id)"
|
||||||
type="button"
|
type="button"
|
||||||
@click="revertEventSlot(es.id)"
|
@click="revertEventSlot(es.id)"
|
||||||
>Revert</button>
|
>Revert</button>
|
||||||
|
@ -189,63 +186,37 @@
|
||||||
<td>{{ es.end.diff(es.start).toFormat('hh:mm') }}</td>
|
<td>{{ es.end.diff(es.start).toFormat('hh:mm') }}</td>
|
||||||
<td>{{ es.name }}</td>
|
<td>{{ es.name }}</td>
|
||||||
<td>{{ status(es) }}</td>
|
<td>{{ status(es) }}</td>
|
||||||
<td>{{ es.locationId }}</td>
|
<td>{{ es.location.id }}</td>
|
||||||
<td><AssignedCrew :modelValue="es.assigned" :edit="false" /></td>
|
<td><AssignedCrew :modelValue="es.assigned" :edit="false" /></td>
|
||||||
</template>
|
</template>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p v-if="changes.length">
|
|
||||||
Changes are not save yet.
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="saveEventSlots"
|
|
||||||
>Save Changes</button>
|
|
||||||
</p>
|
|
||||||
<details>
|
|
||||||
<summary>Debug</summary>
|
|
||||||
<b>EventSlot changes</b>
|
|
||||||
<ol>
|
|
||||||
<li v-for="change in changes">
|
|
||||||
<pre><code>{{ JSON.stringify((({ event, slot, ...data }) => data)(change as any), undefined, " ") }}</code></pre>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
<b>ScheduleEvent changes</b>
|
|
||||||
<ol>
|
|
||||||
<li v-for="change in scheduleChanges">
|
|
||||||
<pre><code>{{ JSON.stringify((({ event, slot, ...data }) => data)(change as any), undefined, " ") }}</code></pre>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { DateTime, Duration } from 'luxon';
|
import { DateTime, Duration } from 'luxon';
|
||||||
import type { ApiSchedule, ApiScheduleEvent, ApiScheduleEventSlot, ApiScheduleShift, ApiScheduleShiftSlot } from '~/shared/types/api';
|
import type { Id } from '~/shared/types/common';
|
||||||
import type { Entity } from '~/shared/types/common';
|
import { enumerate, pairs, toId } from '~/shared/utils/functions';
|
||||||
import { enumerate, pairs } from '~/shared/utils/functions';
|
|
||||||
import { applyUpdatesToArray } from '~/shared/utils/update';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
edit?: boolean,
|
edit?: boolean,
|
||||||
locationId?: number,
|
locationId?: number,
|
||||||
eventSlotFilter?: (slot: ApiScheduleEventSlot) => boolean,
|
eventSlotFilter?: (slot: ClientScheduleEventSlot) => boolean,
|
||||||
shiftSlotFilter?: (slot: ApiScheduleShiftSlot) => boolean,
|
shiftSlotFilter?: (slot: ClientScheduleShiftSlot) => boolean,
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
interface EventSlot {
|
interface EventSlot {
|
||||||
type: "slot",
|
type: "slot",
|
||||||
id: number,
|
id: Id,
|
||||||
updatedAt: string,
|
|
||||||
deleted?: boolean,
|
deleted?: boolean,
|
||||||
event?: Extract<ApiScheduleEvent, { deleted?: false }>,
|
event: ClientScheduleEvent,
|
||||||
slot?: ApiScheduleEventSlot,
|
slot: ClientScheduleEventSlot,
|
||||||
origLocation: number,
|
|
||||||
name: string,
|
name: string,
|
||||||
locationId: number,
|
location: ClientScheduleLocation,
|
||||||
assigned: number[],
|
assigned: Set<Id>,
|
||||||
start: DateTime,
|
start: DateTime,
|
||||||
end: DateTime,
|
end: DateTime,
|
||||||
}
|
}
|
||||||
|
@ -262,218 +233,19 @@ interface Gap {
|
||||||
}
|
}
|
||||||
|
|
||||||
function status(eventSlot: EventSlot) {
|
function status(eventSlot: EventSlot) {
|
||||||
if (schedule.value.deleted) {
|
|
||||||
throw new Error("Unexpected deleted schedule");
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
!eventSlot.event
|
!eventSlot.event
|
||||||
|| eventSlot.event.name !== eventSlot.name
|
|| eventSlot.event.name !== eventSlot.name
|
||||||
) {
|
) {
|
||||||
const event = schedule.value.events?.find(event => !event.deleted && event.name === eventSlot.name);
|
const event = [...schedule.value.events.values()].find(event => event.name === eventSlot.name);
|
||||||
return event ? "L" : "N";
|
return event ? "L" : "N";
|
||||||
}
|
}
|
||||||
return eventSlot.event.slots.length === 1 ? "" : eventSlot.event.slots.length;
|
return eventSlot.event.slots.size === 1 ? "" : eventSlot.event.slots.size;
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out set records where a del record exists for the same id.
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
function findEvent(
|
|
||||||
eventSlot: EventSlot,
|
|
||||||
changes: ApiScheduleEvent[],
|
|
||||||
schedule: ApiSchedule,
|
|
||||||
) {
|
|
||||||
if (schedule.deleted) {
|
|
||||||
throw new Error("Unexpected deleted schedule");
|
|
||||||
}
|
|
||||||
let setEvent = changes.filter(
|
|
||||||
c => !c.deleted
|
|
||||||
).find(
|
|
||||||
c => c.name === eventSlot.name
|
|
||||||
);
|
|
||||||
if (!setEvent && eventSlot.event && eventSlot.event.name === eventSlot.name) {
|
|
||||||
setEvent = eventSlot.event;
|
|
||||||
}
|
|
||||||
if (!setEvent) {
|
|
||||||
setEvent = schedule.events?.filter(e => !e.deleted).find(e => e.name === eventSlot.name);
|
|
||||||
}
|
|
||||||
let delEvent;
|
|
||||||
if (eventSlot.event) {
|
|
||||||
delEvent = changes.filter(c => !c.deleted).find(
|
|
||||||
c => c.name === eventSlot.event!.name
|
|
||||||
);
|
|
||||||
if (!delEvent) {
|
|
||||||
delEvent = schedule.events?.filter(e => !e.deleted).find(e => e.name === eventSlot.event!.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { setEvent, delEvent };
|
|
||||||
}
|
|
||||||
|
|
||||||
function removeSlotLocation(
|
|
||||||
event: Extract<ApiScheduleEvent, { deleted?: false }>,
|
|
||||||
oldSlot: ApiScheduleEventSlot,
|
|
||||||
locationId: number
|
|
||||||
) {
|
|
||||||
// If location is an exact match remove the whole slot
|
|
||||||
if (oldSlot.locationIds.length === 1 && oldSlot.locationIds[0] === locationId) {
|
|
||||||
return {
|
|
||||||
...event,
|
|
||||||
slots: event.slots.filter(s => s.id !== oldSlot.id),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
// Otherwise filter out location
|
|
||||||
return {
|
|
||||||
...event,
|
|
||||||
slots: event.slots.map(
|
|
||||||
s => s.id !== oldSlot.id ? s : {
|
|
||||||
...s,
|
|
||||||
locationIds: s.locationIds.filter(id => id !== locationId)
|
|
||||||
}
|
|
||||||
),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeSlot(
|
|
||||||
event: Extract<ApiScheduleEvent, { deleted?: false }>,
|
|
||||||
eventSlot: EventSlot,
|
|
||||||
): Extract<ApiScheduleEvent, { deleted?: false }> {
|
|
||||||
if (schedule.value.deleted) {
|
|
||||||
throw new Error("Unexpected deleted schedule");
|
|
||||||
}
|
|
||||||
const oldSlot = event.slots.find(s => s.id === eventSlot.id);
|
|
||||||
const nextId = Math.max(0, ...schedule.value.events?.filter(e => !e.deleted).flatMap(e => e.slots.map(slot => slot.id)) ?? []) + 1;
|
|
||||||
const start = eventSlot.start.toUTC().toISO({ suppressSeconds: true })!;
|
|
||||||
const end = eventSlot.end.toUTC().toISO({ suppressSeconds: true })!;
|
|
||||||
|
|
||||||
// Edit slot in-place if possible
|
|
||||||
if (
|
|
||||||
oldSlot
|
|
||||||
&& oldSlot.id === eventSlot.id
|
|
||||||
&& (
|
|
||||||
oldSlot.locationIds.length <= 1
|
|
||||||
|| oldSlot.start === start && oldSlot.end === end
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
return {
|
|
||||||
...event,
|
|
||||||
slots: event.slots.map(s => {
|
|
||||||
if (s.id !== oldSlot.id)
|
|
||||||
return s;
|
|
||||||
const locationIds = new Set(s.locationIds);
|
|
||||||
locationIds.delete(eventSlot.origLocation)
|
|
||||||
locationIds.add(eventSlot.locationId);
|
|
||||||
return {
|
|
||||||
...s,
|
|
||||||
locationIds: [...locationIds],
|
|
||||||
assigned: eventSlot.assigned.length ? eventSlot.assigned : undefined,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
};
|
|
||||||
}),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// Else remove old slot if it exist and insert a new one
|
|
||||||
if (oldSlot) {
|
|
||||||
event = removeSlotLocation(event, oldSlot, eventSlot.origLocation);
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
...event,
|
|
||||||
slots: [...event.slots, {
|
|
||||||
id: oldSlot ? oldSlot.id : nextId,
|
|
||||||
locationIds: [eventSlot.locationId],
|
|
||||||
assigned: eventSlot.assigned.length ? eventSlot.assigned : undefined,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const scheduleChanges = computed(() => {
|
|
||||||
let eventChanges: Extract<ApiScheduleEvent, { deleted?: false}>[] = [];
|
|
||||||
for (const change of filterSetOps(changes.value)) {
|
|
||||||
if (!change.deleted) {
|
|
||||||
let { setEvent, delEvent } = findEvent(change, eventChanges, schedule.value);
|
|
||||||
if (delEvent && delEvent !== setEvent) {
|
|
||||||
eventChanges = removeSlot(eventChanges, delEvent, change);
|
|
||||||
}
|
|
||||||
if (!setEvent) {
|
|
||||||
setEvent = {
|
|
||||||
id: Math.floor(Math.random() * -1000), // XXX This wont work.
|
|
||||||
updatedAt: "",
|
|
||||||
name: change.name,
|
|
||||||
crew: true,
|
|
||||||
slots: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
eventChanges = replaceChange(mergeSlot(setEvent, change), eventChanges);
|
|
||||||
|
|
||||||
} else if (change.deleted) {
|
|
||||||
let { delEvent } = findEvent(change, eventChanges, schedule.value);
|
|
||||||
if (delEvent) {
|
|
||||||
eventChanges = removeSlot(eventChanges, delEvent, change);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return eventChanges;
|
|
||||||
});
|
|
||||||
|
|
||||||
const schedulePreview = computed(() => {
|
|
||||||
if (schedule.value.deleted) {
|
|
||||||
throw new Error("Unexpected deleted schedule");
|
|
||||||
}
|
|
||||||
const events = [...schedule.value.events ?? []]
|
|
||||||
applyUpdatesToArray(scheduleChanges.value, events);
|
|
||||||
return {
|
|
||||||
...schedule.value,
|
|
||||||
events,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function removeSlot(
|
|
||||||
eventChanges: Extract<ApiScheduleEvent, { deleted?: false }>[],
|
|
||||||
event: Extract<ApiScheduleEvent, { deleted?: false }>,
|
|
||||||
eventSlot: EventSlot,
|
|
||||||
) {
|
|
||||||
let oldSlot = event.slots.find(s => s.id === eventSlot.id);
|
|
||||||
if (oldSlot) {
|
|
||||||
eventChanges = replaceChange(
|
|
||||||
removeSlotLocation(event, oldSlot, eventSlot.origLocation),
|
|
||||||
eventChanges,
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return eventChanges;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const accountStore = useAccountStore();
|
const accountStore = useAccountStore();
|
||||||
const schedule = await useSchedule();
|
const schedule = await useSchedule();
|
||||||
|
|
||||||
const changes = ref<EventSlot[]>([]);
|
|
||||||
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
|
|
||||||
|
|
||||||
function replaceChange<T extends Entity>(
|
|
||||||
change: T,
|
|
||||||
changes: T[],
|
|
||||||
) {
|
|
||||||
const index = changes.findIndex(item => (
|
|
||||||
item.deleted === change.deleted && item.id === change.id
|
|
||||||
));
|
|
||||||
const copy = [...changes];
|
|
||||||
if (index !== -1)
|
|
||||||
copy.splice(index, 1, change);
|
|
||||||
else
|
|
||||||
copy.push(change);
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
function revertChange<T extends Entity>(id: number, changes: T[]) {
|
|
||||||
return changes.filter(change => change.id !== id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const oneDayMs = 24 * 60 * 60 * 1000;
|
const oneDayMs = 24 * 60 * 60 * 1000;
|
||||||
function dropDay(diff: Duration) {
|
function dropDay(diff: Duration) {
|
||||||
if (diff.toMillis() >= oneDayMs) {
|
if (diff.toMillis() >= oneDayMs) {
|
||||||
|
@ -485,13 +257,17 @@ function dropDay(diff: Duration) {
|
||||||
const newEventStart = ref("");
|
const newEventStart = ref("");
|
||||||
const newEventDuration = ref("01:00");
|
const newEventDuration = ref("01:00");
|
||||||
const newEventEnd = computed({
|
const newEventEnd = computed({
|
||||||
get: () => (
|
get: () => {
|
||||||
DateTime.fromISO(newEventStart.value, { zone: accountStore.activeTimezone, locale: "en-US" })
|
try {
|
||||||
.plus(Duration.fromISOTime(newEventDuration.value, { locale: "en-US" }))
|
return DateTime.fromISO(newEventStart.value, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale })
|
||||||
.toFormat("HH:mm")
|
.plus(Duration.fromISOTime(newEventDuration.value, { locale: accountStore.activeLocale }))
|
||||||
),
|
.toFormat("HH:mm")
|
||||||
|
} catch (err) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
},
|
||||||
set: (value: string) => {
|
set: (value: string) => {
|
||||||
const start = DateTime.fromISO(newEventStart.value, { zone: accountStore.activeTimezone, locale: "en-US" });
|
const start = DateTime.fromISO(newEventStart.value, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
|
||||||
const end = endFromTime(start, value);
|
const end = endFromTime(start, value);
|
||||||
newEventDuration.value = dropDay(end.diff(start)).toFormat("hh:mm");
|
newEventDuration.value = dropDay(end.diff(start)).toFormat("hh:mm");
|
||||||
},
|
},
|
||||||
|
@ -502,85 +278,73 @@ watch(() => props.locationId, () => {
|
||||||
});
|
});
|
||||||
|
|
||||||
function endFromTime(start: DateTime, time: string) {
|
function endFromTime(start: DateTime, time: string) {
|
||||||
let end = start.startOf("day").plus(Duration.fromISOTime(time, { locale: "en-US" }));
|
let end = start.startOf("day").plus(Duration.fromISOTime(time, { locale: accountStore.activeLocale }));
|
||||||
if (end.toMillis() <= start.toMillis()) {
|
if (end.toMillis() <= start.toMillis()) {
|
||||||
end = end.plus({ days: 1 });
|
end = end.plus({ days: 1 });
|
||||||
}
|
}
|
||||||
return end;
|
return end;
|
||||||
}
|
}
|
||||||
function durationFromTime(time: string) {
|
function durationFromTime(time: string) {
|
||||||
let duration = Duration.fromISOTime(time, { locale: "en-US" });
|
let duration = Duration.fromISOTime(time, { locale: accountStore.activeLocale });
|
||||||
if (duration.toMillis() === 0) {
|
if (duration.toMillis() === 0) {
|
||||||
duration = Duration.fromMillis(oneDayMs, { locale: "en-US" });
|
duration = Duration.fromMillis(oneDayMs, { locale: accountStore.activeLocale });
|
||||||
}
|
}
|
||||||
return duration;
|
return duration;
|
||||||
}
|
}
|
||||||
const newEventName = ref("");
|
const newEventName = ref("");
|
||||||
|
function editEvent(
|
||||||
|
eventSlot: EventSlot,
|
||||||
|
edits: Parameters<ClientSchedule["editEvent"]>[1],
|
||||||
|
) {
|
||||||
|
schedule.value.editEvent(eventSlot.event, edits);
|
||||||
|
}
|
||||||
|
|
||||||
function editEventSlot(
|
function editEventSlot(
|
||||||
eventSlot: EventSlot,
|
eventSlot: EventSlot,
|
||||||
edits: {
|
edits: {
|
||||||
|
deleted?: boolean,
|
||||||
start?: string,
|
start?: string,
|
||||||
end?: string,
|
end?: string,
|
||||||
duration?: string,
|
duration?: string,
|
||||||
name?: string,
|
locationId?: Id,
|
||||||
locationId?: number,
|
assigned?: Set<Id>,
|
||||||
assigned?: number[],
|
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
const computedEdits: Parameters<ClientSchedule["editEventSlot"]>[1] = {
|
||||||
|
deleted: edits.deleted,
|
||||||
|
assigned: edits.assigned,
|
||||||
|
};
|
||||||
if (edits.start) {
|
if (edits.start) {
|
||||||
const start = DateTime.fromISO(edits.start, { zone: accountStore.activeTimezone, locale: "en-US" });
|
const start = DateTime.fromISO(edits.start, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
|
||||||
eventSlot = {
|
computedEdits.start = start;
|
||||||
...eventSlot,
|
computedEdits.end = start.plus(eventSlot.end.diff(eventSlot.start));
|
||||||
start,
|
|
||||||
end: start.plus(eventSlot.end.diff(eventSlot.start)),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (edits.end !== undefined) {
|
if (edits.end !== undefined) {
|
||||||
eventSlot = {
|
computedEdits.end = endFromTime(eventSlot.start, edits.end);
|
||||||
...eventSlot,
|
|
||||||
end: endFromTime(eventSlot.start, edits.end),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (edits.duration !== undefined) {
|
if (edits.duration !== undefined) {
|
||||||
eventSlot = {
|
computedEdits.end = eventSlot.start.plus(durationFromTime(edits.duration));
|
||||||
...eventSlot,
|
|
||||||
end: eventSlot.start.plus(durationFromTime(edits.duration)),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (edits.name !== undefined) {
|
|
||||||
eventSlot = {
|
|
||||||
...eventSlot,
|
|
||||||
name: edits.name,
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (edits.locationId !== undefined) {
|
if (edits.locationId !== undefined) {
|
||||||
eventSlot = {
|
const location = schedule.value.locations.get(edits.locationId);
|
||||||
...eventSlot,
|
if (location)
|
||||||
locationId: edits.locationId,
|
computedEdits.locations = [location];
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (edits.assigned !== undefined) {
|
schedule.value.editEventSlot(eventSlot.slot, computedEdits);
|
||||||
eventSlot = {
|
|
||||||
...eventSlot,
|
|
||||||
assigned: edits.assigned,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
changes.value = replaceChange(eventSlot, changes.value);
|
|
||||||
}
|
}
|
||||||
function delEventSlot(eventSlot: EventSlot) {
|
function revertEventSlot(id: Id) {
|
||||||
const change = {
|
schedule.value.restoreEventSlot(id);
|
||||||
...eventSlot,
|
|
||||||
deleted: true,
|
|
||||||
};
|
|
||||||
changes.value = replaceChange(change, changes.value);
|
|
||||||
}
|
|
||||||
function revertEventSlot(id: number) {
|
|
||||||
changes.value = revertChange(id, changes.value);
|
|
||||||
}
|
}
|
||||||
function newEventSlot(options: { start?: DateTime, end?: DateTime } = {}) {
|
function newEventSlot(options: { start?: DateTime, end?: DateTime } = {}) {
|
||||||
const name = newEventName.value;
|
const name = newEventName.value;
|
||||||
const locationId = newEventLocation.value;
|
const nameId = toId(name);
|
||||||
if (!locationId) {
|
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");
|
alert("Invalid location");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -598,43 +362,25 @@ function newEventSlot(options: { start?: DateTime, end?: DateTime } = {}) {
|
||||||
end = options.end;
|
end = options.end;
|
||||||
start = options.end.minus(duration);
|
start = options.end.minus(duration);
|
||||||
} else {
|
} else {
|
||||||
start = DateTime.fromISO(newEventStart.value, { zone: accountStore.activeTimezone, locale: "en-US" });
|
start = DateTime.fromISO(newEventStart.value, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
|
||||||
end = endFromTime(start, newEventEnd.value);
|
end = endFromTime(start, newEventEnd.value);
|
||||||
}
|
}
|
||||||
if (!start.isValid || !end.isValid) {
|
if (!start.isValid || !end.isValid) {
|
||||||
alert("Invalid start and/or end time");
|
alert("Invalid start and/or end time");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const change: EventSlot = {
|
const slot = new ClientScheduleEventSlot(
|
||||||
type: "slot",
|
schedule.value.nextClientId--,
|
||||||
updatedAt: "",
|
false,
|
||||||
id: Math.floor(Math.random() * -1000), // XXX this wont work.
|
event.id,
|
||||||
name,
|
|
||||||
origLocation: locationId,
|
|
||||||
locationId,
|
|
||||||
assigned: [],
|
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
};
|
[location],
|
||||||
|
new Set(),
|
||||||
|
0,
|
||||||
|
);
|
||||||
newEventName.value = "";
|
newEventName.value = "";
|
||||||
changes.value = replaceChange(change, changes.value);
|
schedule.value.setEventSlot(slot);
|
||||||
}
|
|
||||||
|
|
||||||
async function saveEventSlots() {
|
|
||||||
try {
|
|
||||||
await $fetch("/api/schedule", {
|
|
||||||
method: "PATCH",
|
|
||||||
body: {
|
|
||||||
id: 111,
|
|
||||||
updatedAt: "",
|
|
||||||
events: scheduleChanges.value,
|
|
||||||
} satisfies ApiSchedule,
|
|
||||||
});
|
|
||||||
changes.value = [];
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
alert(err?.data?.message ?? err.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const oneHourMs = 60 * 60 * 1000;
|
const oneHourMs = 60 * 60 * 1000;
|
||||||
|
@ -648,36 +394,31 @@ function gapFormat(gap: Gap) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const eventSlots = computed(() => {
|
const eventSlots = computed(() => {
|
||||||
if (schedule.value.deleted) {
|
|
||||||
throw new Error("Unexpected deleted schedule");
|
|
||||||
}
|
|
||||||
const data: (EventSlot | Gap)[] = [];
|
const data: (EventSlot | Gap)[] = [];
|
||||||
for (const event of schedule.value.events ?? []) {
|
for (const event of schedule.value.events.values()) {
|
||||||
if (event.deleted)
|
if (event.deleted)
|
||||||
continue;
|
continue;
|
||||||
for (const slot of event.slots) {
|
for (const slot of event.slots.values()) {
|
||||||
if (props.eventSlotFilter && !props.eventSlotFilter(slot))
|
if (props.eventSlotFilter && !props.eventSlotFilter(slot))
|
||||||
continue;
|
continue;
|
||||||
for (const locationId of slot.locationIds) {
|
for (const location of slot.locations) {
|
||||||
if (props.locationId !== undefined && locationId !== props.locationId)
|
if (props.locationId !== undefined && location.id !== props.locationId)
|
||||||
continue;
|
continue;
|
||||||
data.push({
|
data.push({
|
||||||
type: "slot",
|
type: "slot",
|
||||||
id: slot.id,
|
id: slot.id,
|
||||||
updatedAt: "",
|
deleted: slot.deleted || event.deleted,
|
||||||
event,
|
event,
|
||||||
slot,
|
slot,
|
||||||
name: event.name,
|
name: event.name,
|
||||||
locationId,
|
location,
|
||||||
assigned: slot.assigned ?? [],
|
assigned: slot.assigned ?? [],
|
||||||
origLocation: locationId,
|
start: slot.start,
|
||||||
start: DateTime.fromISO(slot.start, { zone: accountStore.activeTimezone, locale: "en-US" }),
|
end: slot.end,
|
||||||
end: DateTime.fromISO(slot.end, { zone: accountStore.activeTimezone, locale: "en-US" }),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
applyUpdatesToArray(changes.value.filter(c => !c.deleted), data as EventSlot[]);
|
|
||||||
data.sort((a, b) => a.start.toMillis() - b.start.toMillis() || a.end.toMillis() - b.end.toMillis());
|
data.sort((a, b) => a.start.toMillis() - b.start.toMillis() || a.end.toMillis() - b.end.toMillis());
|
||||||
|
|
||||||
// Insert gaps
|
// Insert gaps
|
||||||
|
@ -689,7 +430,7 @@ const eventSlots = computed(() => {
|
||||||
gaps.push([index, {
|
gaps.push([index, {
|
||||||
type: "gap",
|
type: "gap",
|
||||||
locationId: props.locationId,
|
locationId: props.locationId,
|
||||||
start: DateTime.fromMillis(maxEnd, { locale: "en-US" }),
|
start: DateTime.fromMillis(maxEnd, { locale: accountStore.activeLocale }),
|
||||||
end: second.start,
|
end: second.start,
|
||||||
}]);
|
}]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,9 +1,6 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="schedule.deleted">
|
<div>
|
||||||
Error: Unexpected deleted schedule.
|
<Timetable :schedule :eventSlotFilter :shiftSlotFilter />
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<Timetable :schedule="schedulePreview" :eventSlotFilter :shiftSlotFilter />
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -23,7 +20,7 @@
|
||||||
v-for="ss in shiftSlots"
|
v-for="ss in shiftSlots"
|
||||||
:key='ss.slot?.id ?? ss.start.toMillis()'
|
:key='ss.slot?.id ?? ss.start.toMillis()'
|
||||||
:class='{
|
:class='{
|
||||||
removed: ss.type === "slot" && removed.has(ss.id),
|
removed: ss.slot?.deleted || ss.shift?.deleted,
|
||||||
gap: ss.type === "gap",
|
gap: ss.type === "gap",
|
||||||
}'
|
}'
|
||||||
>
|
>
|
||||||
|
@ -39,21 +36,22 @@
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
v-model="newShiftName"
|
v-model="newShiftName"
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td></td>
|
<td></td>
|
||||||
<td>
|
<td>
|
||||||
<select
|
<select
|
||||||
v-model="newShiftRole"
|
v-model="newShiftRoleId"
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-for="role in schedule.roles?.filter(r => !r.deleted)"
|
v-for="role in schedule.roles.values()"
|
||||||
:key="role.id"
|
:key="role.id"
|
||||||
:value="role.id"
|
:value="role.id"
|
||||||
:selected="role.id === newShiftRole"
|
:disabled="role.deleted"
|
||||||
|
:selected="role.id === newShiftRoleId"
|
||||||
>{{ role.name }}</option>
|
>{{ role.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
|
@ -96,20 +94,21 @@
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
:value="ss.name"
|
:value="ss.name"
|
||||||
@input="editShiftSlot(ss, { name: ($event as any).target.value })"
|
@input="editShift(ss, { name: ($event as any).target.value })"
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ status(ss) }}</td>
|
<td>{{ status(ss) }}</td>
|
||||||
<td>
|
<td>
|
||||||
<select
|
<select
|
||||||
:value="ss.roleId"
|
:value="ss.role.id"
|
||||||
@change="editShiftSlot(ss, { roleId: parseInt(($event as any).target.value) })"
|
@change="editShift(ss, { role: schedule.roles.get(parseInt(($event as any).target.value)) })"
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-for="role in schedule.roles?.filter(r => !r.deleted)"
|
v-for="role in schedule.roles.values()"
|
||||||
:key="role.id"
|
:key="role.id"
|
||||||
:value="role.id"
|
:value="role.id"
|
||||||
:selected="role.id === ss.roleId"
|
:disabled="role.deleted"
|
||||||
|
:selected="role.id === ss.role.id"
|
||||||
>{{ role.name }}</option>
|
>{{ role.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
|
@ -122,12 +121,12 @@
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
:disabled="removed.has(ss.id)"
|
:disabled="ss.deleted"
|
||||||
type="button"
|
type="button"
|
||||||
@click="delShiftSlot(ss)"
|
@click="editShiftSlot(ss, { deleted: true })"
|
||||||
>Remove</button>
|
>Remove</button>
|
||||||
<button
|
<button
|
||||||
v-if="changes.some(c => c.id === ss.id)"
|
v-if="schedule.isModifiedShiftSlot(ss.slot.id)"
|
||||||
type="button"
|
type="button"
|
||||||
@click="revertShiftSlot(ss.id)"
|
@click="revertShiftSlot(ss.id)"
|
||||||
>Revert</button>
|
>Revert</button>
|
||||||
|
@ -187,63 +186,37 @@
|
||||||
<td>{{ ss.end.diff(ss.start).toFormat('hh:mm') }}</td>
|
<td>{{ ss.end.diff(ss.start).toFormat('hh:mm') }}</td>
|
||||||
<td>{{ ss.name }}</td>
|
<td>{{ ss.name }}</td>
|
||||||
<td>{{ status(ss) }}</td>
|
<td>{{ status(ss) }}</td>
|
||||||
<td>{{ ss.roleId }}</td>
|
<td>{{ ss.role.id }}</td>
|
||||||
<td><AssignedCrew :modelValue="ss.assigned" :edit="false" /></td>
|
<td><AssignedCrew :modelValue="ss.assigned" :edit="false" /></td>
|
||||||
</template>
|
</template>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</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">
|
|
||||||
<pre><code>{{ JSON.stringify((({ shift, slot, ...data }) => data)(change as any), undefined, " ") }}</code></pre>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
<b>Shift changes</b>
|
|
||||||
<ol>
|
|
||||||
<li v-for="change in shiftChanges">
|
|
||||||
<pre><code>{{ JSON.stringify((({ shift, slot, ...data }) => data)(change as any), undefined, " ") }}</code></pre>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import { DateTime, Duration } from 'luxon';
|
import { DateTime, Duration } from '~/shared/utils/luxon';
|
||||||
import type { ApiSchedule, ApiScheduleEventSlot, ApiScheduleShift, ApiScheduleShiftSlot } from '~/shared/types/api';
|
import { enumerate, pairs, toId } from '~/shared/utils/functions';
|
||||||
import { applyUpdatesToArray } from '~/shared/utils/update';
|
import type { Id } from '~/shared/types/common';
|
||||||
import { enumerate, pairs } from '~/shared/utils/functions';
|
|
||||||
import type { Entity } from '~/shared/types/common';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
edit?: boolean,
|
edit?: boolean,
|
||||||
roleId?: number,
|
roleId?: Id,
|
||||||
eventSlotFilter?: (slot: ApiScheduleEventSlot) => boolean,
|
eventSlotFilter?: (slot: ClientScheduleEventSlot) => boolean,
|
||||||
shiftSlotFilter?: (slot: ApiScheduleShiftSlot) => boolean,
|
shiftSlotFilter?: (slot: ClientScheduleShiftSlot) => boolean,
|
||||||
}>();
|
}>();
|
||||||
|
|
||||||
interface ShiftSlot {
|
interface ShiftSlot {
|
||||||
type: "slot",
|
type: "slot",
|
||||||
id: number,
|
id: Id,
|
||||||
updatedAt: string,
|
deleted: boolean,
|
||||||
deleted?: boolean,
|
shift: ClientScheduleShift,
|
||||||
shift?: Extract<ApiScheduleShift, { deleted?: false }>,
|
slot: ClientScheduleShiftSlot,
|
||||||
slot?: ApiScheduleShiftSlot,
|
|
||||||
origRole: number,
|
|
||||||
name: string,
|
name: string,
|
||||||
roleId: number,
|
role: ClientScheduleRole,
|
||||||
assigned: number[],
|
assigned: Set<Id>,
|
||||||
start: DateTime,
|
start: DateTime,
|
||||||
end: DateTime,
|
end: DateTime,
|
||||||
}
|
}
|
||||||
|
@ -254,191 +227,25 @@ interface Gap {
|
||||||
shift?: undefined,
|
shift?: undefined,
|
||||||
slot?: undefined,
|
slot?: undefined,
|
||||||
name?: undefined,
|
name?: undefined,
|
||||||
roleId?: number,
|
role?: undefined,
|
||||||
start: DateTime,
|
start: DateTime,
|
||||||
end: DateTime,
|
end: DateTime,
|
||||||
}
|
}
|
||||||
|
|
||||||
function status(shiftSlot: ShiftSlot) {
|
function status(shiftSlot: ShiftSlot) {
|
||||||
if (schedule.value.deleted) {
|
|
||||||
throw new Error("Unexpected deleted schedule");
|
|
||||||
}
|
|
||||||
if (
|
if (
|
||||||
!shiftSlot.shift
|
!shiftSlot.shift
|
||||||
|| shiftSlot.shift.name !== shiftSlot.name
|
|| shiftSlot.shift.name !== shiftSlot.name
|
||||||
) {
|
) {
|
||||||
const shift = schedule.value.shifts?.find(shift => !shift.deleted && shift.name === shiftSlot.name);
|
const shift = [...schedule.value.shifts.values()].find(shift => !shift.deleted && shift.name === shiftSlot.name);
|
||||||
return shift ? "L" : "N";
|
return shift ? "L" : "N";
|
||||||
}
|
}
|
||||||
return shiftSlot.shift.slots.length === 1 ? "" : shiftSlot.shift.slots.length;
|
return shiftSlot.shift.slots.size === 1 ? "" : shiftSlot.shift.slots.size;
|
||||||
}
|
|
||||||
|
|
||||||
// Filter out set records where a del record exists for the same id.
|
|
||||||
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));
|
|
||||||
}
|
|
||||||
|
|
||||||
function findShift(
|
|
||||||
shiftSlot: ShiftSlot,
|
|
||||||
changes: ApiScheduleShift[],
|
|
||||||
schedule: ApiSchedule,
|
|
||||||
) {
|
|
||||||
if (schedule.deleted) {
|
|
||||||
throw new Error("Unexpected deleted schedule");
|
|
||||||
}
|
|
||||||
let setShift = changes.filter(
|
|
||||||
c => !c.deleted,
|
|
||||||
).find(
|
|
||||||
c => (
|
|
||||||
c.name === shiftSlot.name
|
|
||||||
&& c.roleId === shiftSlot.roleId
|
|
||||||
)
|
|
||||||
);
|
|
||||||
if (
|
|
||||||
!setShift
|
|
||||||
&& shiftSlot.shift
|
|
||||||
&& shiftSlot.shift.name === shiftSlot.name
|
|
||||||
) {
|
|
||||||
setShift = shiftSlot.shift;
|
|
||||||
}
|
|
||||||
if (!setShift) {
|
|
||||||
setShift = schedule.shifts?.filter(s => !s.deleted).find(s => s.name === shiftSlot.name);
|
|
||||||
}
|
|
||||||
let delShift;
|
|
||||||
if (shiftSlot.shift) {
|
|
||||||
delShift = changes.filter(c => !c.deleted).find(
|
|
||||||
c => c.name === shiftSlot.shift!.name
|
|
||||||
);
|
|
||||||
if (!delShift) {
|
|
||||||
delShift = schedule.shifts?.filter(s => !s.deleted).find(s => s.name === shiftSlot.shift!.name);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return { setShift, delShift };
|
|
||||||
}
|
|
||||||
|
|
||||||
function mergeSlot(
|
|
||||||
shift: Extract<ApiScheduleShift, { deleted?: false }>,
|
|
||||||
shiftSlot: ShiftSlot,
|
|
||||||
): Extract<ApiScheduleShift, { deleted?: false }> {
|
|
||||||
if (schedule.value.deleted) {
|
|
||||||
throw new Error("Unexpected deleted schedule");
|
|
||||||
}
|
|
||||||
const oldSlot = shift.slots.find(s => s.id === shiftSlot.id);
|
|
||||||
const nextId = Math.max(0, ...schedule.value.shifts?.filter(s => !s.deleted).flatMap(s => s.slots.map(slot => slot.id)) ?? []) + 1;
|
|
||||||
const start = shiftSlot.start.toUTC().toISO({ suppressSeconds: true })!;
|
|
||||||
const end = shiftSlot.end.toUTC().toISO({ suppressSeconds: true })!;
|
|
||||||
const assigned = shiftSlot.assigned.length ? shiftSlot.assigned : undefined;
|
|
||||||
|
|
||||||
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}`);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Edit slot in-place if possible
|
|
||||||
if (oldSlot && oldSlot.id === shiftSlot.id) {
|
|
||||||
return {
|
|
||||||
...shift,
|
|
||||||
slots: shift.slots.map(s => s.id !== oldSlot.id ? s : { ...s, assigned, start, end, }),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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), {
|
|
||||||
id: oldSlot ? oldSlot.id : nextId,
|
|
||||||
assigned,
|
|
||||||
start,
|
|
||||||
end,
|
|
||||||
}],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
const shiftChanges = computed(() => {
|
|
||||||
let eventChanges: Extract<ApiScheduleShift, { deleted?: false }>[] = [];
|
|
||||||
for (const change of filterSetOps(changes.value)) {
|
|
||||||
if (!change.deleted) {
|
|
||||||
let { setShift, delShift } = findShift(change, eventChanges, schedule.value);
|
|
||||||
if (delShift && delShift !== setShift) {
|
|
||||||
eventChanges = removeSlot(eventChanges, delShift, change);
|
|
||||||
}
|
|
||||||
if (!setShift) {
|
|
||||||
setShift = {
|
|
||||||
id: Math.floor(Math.random() * -1000), // XXX This wont work.
|
|
||||||
updatedAt: "",
|
|
||||||
name: change.name,
|
|
||||||
roleId: change.roleId,
|
|
||||||
slots: [],
|
|
||||||
};
|
|
||||||
}
|
|
||||||
setShift = {
|
|
||||||
...setShift,
|
|
||||||
roleId: change.roleId,
|
|
||||||
}
|
|
||||||
|
|
||||||
eventChanges = replaceChange(mergeSlot(setShift, change), eventChanges);
|
|
||||||
|
|
||||||
} else if (change.deleted) {
|
|
||||||
let { delShift } = findShift(change, eventChanges, schedule.value);
|
|
||||||
if (delShift) {
|
|
||||||
eventChanges = removeSlot(eventChanges, delShift, change);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return eventChanges;
|
|
||||||
});
|
|
||||||
|
|
||||||
const schedulePreview = computed(() => {
|
|
||||||
if (schedule.value.deleted) {
|
|
||||||
throw new Error("Unexpected deleted schedule");
|
|
||||||
}
|
|
||||||
const shifts = [...schedule.value.shifts ?? []]
|
|
||||||
applyUpdatesToArray(shiftChanges.value, shifts);
|
|
||||||
return {
|
|
||||||
...schedule.value,
|
|
||||||
shifts,
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
||||||
function removeSlot(
|
|
||||||
eventChanges: Extract<ApiScheduleShift, { deleted?: false }>[],
|
|
||||||
shift: Extract<ApiScheduleShift, { deleted?: false }>,
|
|
||||||
shiftSlot: ShiftSlot,
|
|
||||||
) {
|
|
||||||
let oldSlot = shift.slots.find(s => s.id === shiftSlot.id);
|
|
||||||
if (oldSlot) {
|
|
||||||
eventChanges = replaceChange({
|
|
||||||
...shift,
|
|
||||||
slots: shift.slots.filter(s => s.id !== oldSlot.id)
|
|
||||||
}, eventChanges);
|
|
||||||
}
|
|
||||||
return eventChanges;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const accountStore = useAccountStore();
|
const accountStore = useAccountStore();
|
||||||
const schedule = await useSchedule();
|
const schedule = await useSchedule();
|
||||||
|
|
||||||
const changes = ref<ShiftSlot[]>([]);
|
|
||||||
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
|
|
||||||
|
|
||||||
function replaceChange<T extends Entity>(
|
|
||||||
change: T,
|
|
||||||
changes: T[],
|
|
||||||
) {
|
|
||||||
const index = changes.findIndex(item => (
|
|
||||||
item.deleted === change.deleted && item.id === change.id
|
|
||||||
));
|
|
||||||
const copy = [...changes];
|
|
||||||
if (index !== -1)
|
|
||||||
copy.splice(index, 1, change);
|
|
||||||
else
|
|
||||||
copy.push(change);
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
function revertChange<T extends Entity>(id: number, changes: T[]) {
|
|
||||||
return changes.filter(change => change.id !== id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const oneDayMs = 24 * 60 * 60 * 1000;
|
const oneDayMs = 24 * 60 * 60 * 1000;
|
||||||
function dropDay(diff: Duration) {
|
function dropDay(diff: Duration) {
|
||||||
if (diff.toMillis() >= oneDayMs) {
|
if (diff.toMillis() >= oneDayMs) {
|
||||||
|
@ -450,113 +257,88 @@ function dropDay(diff: Duration) {
|
||||||
const newShiftStart = ref("");
|
const newShiftStart = ref("");
|
||||||
const newShiftDuration = ref("01:00");
|
const newShiftDuration = ref("01:00");
|
||||||
const newShiftEnd = computed({
|
const newShiftEnd = computed({
|
||||||
get: () => (
|
get: () => {
|
||||||
DateTime.fromISO(newShiftStart.value, { zone: accountStore.activeTimezone, locale: "en-US" })
|
try {
|
||||||
.plus(Duration.fromISOTime(newShiftDuration.value, { locale: "en-US" }))
|
return DateTime.fromISO(newShiftStart.value, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale })
|
||||||
.toFormat("HH:mm")
|
.plus(Duration.fromISOTime(newShiftDuration.value, { locale: accountStore.activeLocale }))
|
||||||
),
|
.toFormat("HH:mm")
|
||||||
|
} catch (err) {
|
||||||
|
return "";
|
||||||
|
}
|
||||||
|
},
|
||||||
set: (value: string) => {
|
set: (value: string) => {
|
||||||
const start = DateTime.fromISO(newShiftStart.value, { zone: accountStore.activeTimezone, locale: "en-US" });
|
const start = DateTime.fromISO(newShiftStart.value, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
|
||||||
const end = endFromTime(start, value);
|
const end = endFromTime(start, value);
|
||||||
newShiftDuration.value = dropDay(end.diff(start)).toFormat("hh:mm");
|
newShiftDuration.value = dropDay(end.diff(start)).toFormat("hh:mm");
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
const newShiftRole = ref(props.roleId);
|
const newShiftRoleId = ref(props.roleId);
|
||||||
watch(() => props.roleId, () => {
|
watch(() => props.roleId, () => {
|
||||||
newShiftRole.value = props.roleId;
|
newShiftRoleId.value = props.roleId;
|
||||||
});
|
});
|
||||||
|
|
||||||
function endFromTime(start: DateTime, time: string) {
|
function endFromTime(start: DateTime, time: string) {
|
||||||
let end = start.startOf("day").plus(Duration.fromISOTime(time, { locale: "en-US" }));
|
let end = start.startOf("day").plus(Duration.fromISOTime(time, { locale: accountStore.activeLocale }));
|
||||||
if (end.toMillis() <= start.toMillis()) {
|
if (end.toMillis() <= start.toMillis()) {
|
||||||
end = end.plus({ days: 1 });
|
end = end.plus({ days: 1 });
|
||||||
}
|
}
|
||||||
return end;
|
return end;
|
||||||
}
|
}
|
||||||
function durationFromTime(time: string) {
|
function durationFromTime(time: string) {
|
||||||
let duration = Duration.fromISOTime(time, { locale: "en-US" });
|
let duration = Duration.fromISOTime(time, { locale: accountStore.activeLocale });
|
||||||
if (duration.toMillis() === 0) {
|
if (duration.toMillis() === 0) {
|
||||||
duration = Duration.fromMillis(oneDayMs, { locale: "en-US" });
|
duration = Duration.fromMillis(oneDayMs, { locale: accountStore.activeLocale });
|
||||||
}
|
}
|
||||||
return duration;
|
return duration;
|
||||||
}
|
}
|
||||||
const newShiftName = ref("");
|
const newShiftName = ref("");
|
||||||
|
function editShift(
|
||||||
|
shiftSlot: ShiftSlot,
|
||||||
|
edits: Parameters<ClientSchedule["editShift"]>[1],
|
||||||
|
) {
|
||||||
|
schedule.value.editShift(shiftSlot.shift, edits);
|
||||||
|
}
|
||||||
|
|
||||||
function editShiftSlot(
|
function editShiftSlot(
|
||||||
shiftSlot: ShiftSlot,
|
shiftSlot: ShiftSlot,
|
||||||
edits: {
|
edits: {
|
||||||
|
deleted?: boolean,
|
||||||
start?: string,
|
start?: string,
|
||||||
end?: string,
|
end?: string,
|
||||||
duration?: string,
|
duration?: string,
|
||||||
name?: string,
|
assigned?: Set<Id>,
|
||||||
roleId?: number,
|
|
||||||
assigned?: number[],
|
|
||||||
}
|
}
|
||||||
) {
|
) {
|
||||||
|
const computedEdits: Parameters<ClientSchedule["editShiftSlot"]>[1] = {
|
||||||
|
deleted: edits.deleted,
|
||||||
|
assigned: edits.assigned,
|
||||||
|
};
|
||||||
if (edits.start) {
|
if (edits.start) {
|
||||||
const start = DateTime.fromISO(edits.start, { zone: accountStore.activeTimezone, locale: "en-US" });
|
const start = DateTime.fromISO(edits.start, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
|
||||||
shiftSlot = {
|
computedEdits.start = start;
|
||||||
...shiftSlot,
|
computedEdits.end = start.plus(shiftSlot.slot.end.diff(shiftSlot.slot.start));
|
||||||
start,
|
|
||||||
end: start.plus(shiftSlot.end.diff(shiftSlot.start)),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (edits.end !== undefined) {
|
if (edits.end !== undefined) {
|
||||||
shiftSlot = {
|
computedEdits.end = endFromTime(shiftSlot.start, edits.end);
|
||||||
...shiftSlot,
|
|
||||||
end: endFromTime(shiftSlot.start, edits.end),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (edits.duration !== undefined) {
|
if (edits.duration !== undefined) {
|
||||||
shiftSlot = {
|
computedEdits.end = shiftSlot.start.plus(durationFromTime(edits.duration));
|
||||||
...shiftSlot,
|
|
||||||
end: shiftSlot.start.plus(durationFromTime(edits.duration)),
|
|
||||||
};
|
|
||||||
}
|
}
|
||||||
if (edits.name !== undefined) {
|
schedule.value.editShiftSlot(shiftSlot.slot, computedEdits);
|
||||||
shiftSlot = {
|
|
||||||
...shiftSlot,
|
|
||||||
name: edits.name,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
if (edits.roleId !== undefined) {
|
|
||||||
let changesCopy = changes.value;
|
|
||||||
for (const slot of shiftSlots.value) {
|
|
||||||
if (slot.type === "slot" && slot.shift?.name === shiftSlot.name) {
|
|
||||||
changesCopy = replaceChange({
|
|
||||||
...slot,
|
|
||||||
roleId: edits.roleId,
|
|
||||||
}, changesCopy);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
changesCopy = replaceChange({
|
|
||||||
...shiftSlot,
|
|
||||||
roleId: edits.roleId,
|
|
||||||
}, changesCopy);
|
|
||||||
changes.value = changesCopy;
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (edits.assigned !== undefined) {
|
|
||||||
shiftSlot = {
|
|
||||||
...shiftSlot,
|
|
||||||
assigned: edits.assigned,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
changes.value = replaceChange(shiftSlot, changes.value);
|
|
||||||
}
|
}
|
||||||
function delShiftSlot(shiftSlot: ShiftSlot) {
|
function revertShiftSlot(id: Id) {
|
||||||
const change = {
|
schedule.value.restoreShiftSlot(id);
|
||||||
...shiftSlot,
|
|
||||||
deleted: true,
|
|
||||||
};
|
|
||||||
changes.value = replaceChange(change, changes.value);
|
|
||||||
}
|
|
||||||
function revertShiftSlot(id: number) {
|
|
||||||
changes.value = revertChange(id, changes.value);
|
|
||||||
}
|
}
|
||||||
function newShiftSlot(options: { start?: DateTime, end?: DateTime } = {}) {
|
function newShiftSlot(options: { start?: DateTime, end?: DateTime } = {}) {
|
||||||
const name = newShiftName.value;
|
const name = newShiftName.value;
|
||||||
const roleId = newShiftRole.value;
|
const nameId = toId(name);
|
||||||
if (!roleId) {
|
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) {
|
||||||
alert("Invalid role");
|
alert("Invalid role");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
@ -574,42 +356,23 @@ function newShiftSlot(options: { start?: DateTime, end?: DateTime } = {}) {
|
||||||
end = options.end;
|
end = options.end;
|
||||||
start = options.end.minus(duration);
|
start = options.end.minus(duration);
|
||||||
} else {
|
} else {
|
||||||
start = DateTime.fromISO(newShiftStart.value, { zone: accountStore.activeTimezone, locale: "en-US" });
|
start = DateTime.fromISO(newShiftStart.value, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
|
||||||
end = endFromTime(start, newShiftEnd.value);
|
end = endFromTime(start, newShiftEnd.value);
|
||||||
}
|
}
|
||||||
if (!start.isValid || !end.isValid) {
|
if (!start.isValid || !end.isValid) {
|
||||||
alert("Invalid start and/or end time");
|
alert("Invalid start and/or end time");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const change: ShiftSlot = {
|
const slot = new ClientScheduleShiftSlot(
|
||||||
type: "slot",
|
schedule.value.nextClientId--,
|
||||||
updatedAt: "",
|
false,
|
||||||
id: Math.floor(Math.random() * -1000), // XXX this wont work.
|
shift.id,
|
||||||
name,
|
|
||||||
origRole: roleId,
|
|
||||||
roleId,
|
|
||||||
assigned: [],
|
|
||||||
start,
|
start,
|
||||||
end,
|
end,
|
||||||
};
|
new Set(),
|
||||||
|
);
|
||||||
|
schedule.value.setShiftSlot(slot);
|
||||||
newShiftName.value = "";
|
newShiftName.value = "";
|
||||||
changes.value = replaceChange(change, changes.value);
|
|
||||||
}
|
|
||||||
async function saveShiftSlots() {
|
|
||||||
try {
|
|
||||||
await $fetch("/api/schedule", {
|
|
||||||
method: "PATCH",
|
|
||||||
body: {
|
|
||||||
id: 111,
|
|
||||||
updatedAt: "",
|
|
||||||
shifts: shiftChanges.value
|
|
||||||
} satisfies ApiSchedule,
|
|
||||||
});
|
|
||||||
changes.value = [];
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
alert(err?.data?.message ?? err.message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const oneHourMs = 60 * 60 * 1000;
|
const oneHourMs = 60 * 60 * 1000;
|
||||||
|
@ -623,33 +386,29 @@ function gapFormat(gap: Gap) {
|
||||||
}
|
}
|
||||||
|
|
||||||
const shiftSlots = computed(() => {
|
const shiftSlots = computed(() => {
|
||||||
if (schedule.value.deleted) {
|
|
||||||
throw new Error("Unexpected deleted schedule");
|
|
||||||
}
|
|
||||||
const data: (ShiftSlot | Gap)[] = [];
|
const data: (ShiftSlot | Gap)[] = [];
|
||||||
for (const shift of schedule.value.shifts ?? []) {
|
for (const shift of schedule.value.shifts.values()) {
|
||||||
if (shift.deleted || props.roleId !== undefined && shift.roleId !== props.roleId)
|
if (props.roleId !== undefined && shift.role.id !== props.roleId)
|
||||||
continue;
|
continue;
|
||||||
for (const slot of shift.slots) {
|
for (const slot of shift.slots.values()) {
|
||||||
if (props.shiftSlotFilter && !props.shiftSlotFilter(slot))
|
if (props.shiftSlotFilter && !props.shiftSlotFilter(slot))
|
||||||
continue;
|
continue;
|
||||||
data.push({
|
data.push({
|
||||||
type: "slot",
|
type: "slot",
|
||||||
id: slot.id,
|
id: slot.id,
|
||||||
updatedAt: "",
|
deleted: slot.deleted || shift.deleted,
|
||||||
shift,
|
shift,
|
||||||
slot,
|
slot,
|
||||||
name: shift.name,
|
name: shift.name,
|
||||||
roleId: shift.roleId,
|
role: shift.role,
|
||||||
assigned: slot.assigned ?? [],
|
assigned: slot.assigned,
|
||||||
origRole: shift.roleId,
|
start: slot.start,
|
||||||
start: DateTime.fromISO(slot.start, { zone: accountStore.activeTimezone, locale: "en-US" }),
|
end: slot.end,
|
||||||
end: DateTime.fromISO(slot.end, { zone: accountStore.activeTimezone, locale: "en-US" }),
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
applyUpdatesToArray(changes.value.filter(c => !c.deleted), data as ShiftSlot[]);
|
const byTime = (a: DateTime, b: DateTime) => a.toMillis() - b.toMillis();
|
||||||
data.sort((a, b) => a.start.toMillis() - b.start.toMillis() || a.end.toMillis() - b.end.toMillis());
|
data.sort((a, b) => byTime(a.start, b.start) || byTime(a.end, b.end));
|
||||||
|
|
||||||
// Insert gaps
|
// Insert gaps
|
||||||
let maxEnd = 0;
|
let maxEnd = 0;
|
||||||
|
@ -659,8 +418,7 @@ const shiftSlots = computed(() => {
|
||||||
if (maxEnd < second.start.toMillis()) {
|
if (maxEnd < second.start.toMillis()) {
|
||||||
gaps.push([index, {
|
gaps.push([index, {
|
||||||
type: "gap",
|
type: "gap",
|
||||||
roleId: props.roleId,
|
start: DateTime.fromMillis(maxEnd, { locale: accountStore.activeLocale }),
|
||||||
start: DateTime.fromMillis(maxEnd, { locale: "en-US" }),
|
|
||||||
end: second.start,
|
end: second.start,
|
||||||
}]);
|
}]);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,8 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<div v-if="schedule.deleted">
|
<div>
|
||||||
Error: Unexpected deleted schedule.
|
|
||||||
</div>
|
|
||||||
<div v-else>
|
|
||||||
<table>
|
<table>
|
||||||
<thead>
|
<thead>
|
||||||
<tr>
|
<tr>
|
||||||
|
@ -17,9 +14,9 @@
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-if="edit">
|
<template v-if="edit">
|
||||||
<tr
|
<tr
|
||||||
v-for="shift in shifts?.filter(s => !s.deleted)"
|
v-for="shift in schedule.shifts.values()"
|
||||||
:key="shift.id"
|
:key="shift.id"
|
||||||
:class="{ removed: removed.has(shift.id) }"
|
:class="{ removed: shift.deleted }"
|
||||||
>
|
>
|
||||||
<td>{{ shift.id }}</td>
|
<td>{{ shift.id }}</td>
|
||||||
<td>
|
<td>
|
||||||
|
@ -31,18 +28,19 @@
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<select
|
<select
|
||||||
:value="shift.roleId"
|
:value="shift.role.id"
|
||||||
@change="editShift(shift, { roleId: ($event as any).target.value })"
|
@change="editShift(shift, { role: schedule.roles.get(parseInt(($event as any).target.value, 10)) })"
|
||||||
>
|
>
|
||||||
<option
|
<option
|
||||||
v-for="role in schedule.roles?.filter(r => !r.deleted)"
|
v-for="role in schedule.roles.values()"
|
||||||
:key="role.id"
|
:key="role.id"
|
||||||
:value="role.id"
|
:value="role.id"
|
||||||
:selected="shift.roleId === role.id"
|
:disabled="shift.deleted"
|
||||||
|
:selected="shift.role.id === role.id"
|
||||||
>{{ role.name }}</option>
|
>{{ role.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
<td>{{ shift.slots.length ? shift.slots.length : "" }}</td>
|
<td>{{ shift.slots.size ? shift.slots.size : "" }}</td>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
@ -53,18 +51,18 @@
|
||||||
<td>
|
<td>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
:disabled="removed.has(shift.id)"
|
:disabled="shift.deleted"
|
||||||
@click="delShift(shift.id)"
|
@click="editShift(shift, { deleted: true })"
|
||||||
>Delete</button>
|
>Delete</button>
|
||||||
<button
|
<button
|
||||||
v-if="changes.some(c => c.id === shift.id)"
|
v-if="schedule.isModifiedShift(shift.id)"
|
||||||
type="button"
|
type="button"
|
||||||
@click="revertShift(shift.id)"
|
@click="revertShift(shift.id)"
|
||||||
>Revert</button>
|
>Revert</button>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td>{{ newShiftId }}</td>
|
<td>{{ schedule.nextClientId }}</td>
|
||||||
<td>
|
<td>
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
|
@ -72,12 +70,13 @@
|
||||||
>
|
>
|
||||||
</td>
|
</td>
|
||||||
<td>
|
<td>
|
||||||
<select v-model="newShiftRole">
|
<select v-model="newShiftRoleId">
|
||||||
<option
|
<option
|
||||||
v-for="role in schedule.roles?.filter(r => !r.deleted)"
|
v-for="role in schedule.roles.values()"
|
||||||
:key="role.id"
|
:key="role.id"
|
||||||
:value="role.id"
|
:value="role.id"
|
||||||
:selected="role.id === newShiftRole"
|
:disabled="role.deleted"
|
||||||
|
:selected="role.id === newShiftRoleId"
|
||||||
>{{ role.name }}</option>
|
>{{ role.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
</td>
|
</td>
|
||||||
|
@ -103,40 +102,26 @@
|
||||||
</template>
|
</template>
|
||||||
<template v-else>
|
<template v-else>
|
||||||
<tr
|
<tr
|
||||||
v-for="shift in shifts?.filter(s => !s.deleted)"
|
v-for="shift in schedule.shifts.values()"
|
||||||
:key="shift.id"
|
:key="shift.id"
|
||||||
>
|
>
|
||||||
<td>{{ shift.id }}</td>
|
<td>{{ shift.id }}</td>
|
||||||
<td>{{ shift.name }}</td>
|
<td>{{ shift.name }}</td>
|
||||||
<td>{{ shift.roleId }}</td>
|
<td>{{ shift.role.id }}</td>
|
||||||
<td>{{ shift.slots.length ? shift.slots.length : "" }}</td>
|
<td>{{ shift.slots.size ? shift.slots.size : "" }}</td>
|
||||||
<td>{{ shift.description }}</td>
|
<td>{{ shift.description }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
<p v-if="changes.length">
|
|
||||||
Changes are not saved yet.
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
@click="saveShifts"
|
|
||||||
>Save Changes</button>
|
|
||||||
</p>
|
|
||||||
<details>
|
|
||||||
<summary>Debug</summary>
|
|
||||||
<ol>
|
|
||||||
<li v-for="change in changes">
|
|
||||||
<pre><code>{{ JSON.stringify(change, undefined, 2) }}</code></pre>
|
|
||||||
</li>
|
|
||||||
</ol>
|
|
||||||
</details>
|
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
|
import { DateTime } from 'luxon';
|
||||||
import type { ApiSchedule, ApiScheduleShift } from '~/shared/types/api';
|
import type { ApiSchedule, ApiScheduleShift } from '~/shared/types/api';
|
||||||
|
import type { Id } from '~/shared/types/common';
|
||||||
import { toId } from '~/shared/utils/functions';
|
import { toId } from '~/shared/utils/functions';
|
||||||
import { applyUpdatesToArray } from '~/shared/utils/update';
|
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
edit?: boolean,
|
edit?: boolean,
|
||||||
|
@ -145,73 +130,25 @@ const props = defineProps<{
|
||||||
|
|
||||||
const schedule = await useSchedule();
|
const schedule = await useSchedule();
|
||||||
|
|
||||||
const changes = ref<ApiScheduleShift[]>([]);
|
|
||||||
const removed = computed(() => new Set(changes.value.filter(c => c.deleted).map(c => c.id)));
|
|
||||||
function replaceChange(
|
|
||||||
change: ApiScheduleShift,
|
|
||||||
changes: ApiScheduleShift[],
|
|
||||||
) {
|
|
||||||
const index = changes.findIndex(item => (
|
|
||||||
item.deleted === change.deleted && item.id === change.id
|
|
||||||
));
|
|
||||||
const copy = [...changes];
|
|
||||||
if (index !== -1)
|
|
||||||
copy.splice(index, 1, change);
|
|
||||||
else
|
|
||||||
copy.push(change);
|
|
||||||
return copy;
|
|
||||||
}
|
|
||||||
function revertChange(id: number, changes: ApiScheduleShift[]) {
|
|
||||||
return changes.filter(change => change.id !== id);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newShiftName = ref("");
|
const newShiftName = ref("");
|
||||||
const newShiftId = computed(() => {
|
const newShiftRoleId = ref(props.roleId);
|
||||||
if (schedule.value.deleted) {
|
|
||||||
throw new Error("Unexpected deleted schedule");
|
|
||||||
}
|
|
||||||
return Math.max(
|
|
||||||
1,
|
|
||||||
...schedule.value.shifts?.map(r => r.id) ?? [],
|
|
||||||
...changes.value.map(c => c.id)
|
|
||||||
) + 1;
|
|
||||||
});
|
|
||||||
const newShiftRole = ref(props.roleId);
|
|
||||||
watch(() => props.roleId, () => {
|
watch(() => props.roleId, () => {
|
||||||
newShiftRole.value = props.roleId;
|
newShiftRoleId.value = props.roleId;
|
||||||
});
|
});
|
||||||
const newShiftDescription = ref("");
|
const newShiftDescription = ref("");
|
||||||
function editShift(
|
function editShift(
|
||||||
shift: Extract<ApiScheduleShift, { deleted?: false }>,
|
shift: ClientScheduleShift,
|
||||||
edits: { name?: string, description?: string, roleId?: number }
|
edits: Parameters<ClientSchedule["editShift"]>[1],
|
||||||
) {
|
) {
|
||||||
const copy = { ...shift };
|
schedule.value.editShift(shift, edits);
|
||||||
if (edits.name !== undefined) {
|
|
||||||
copy.name = edits.name;
|
|
||||||
}
|
|
||||||
if (edits.description !== undefined) {
|
|
||||||
copy.description = edits.description || undefined;
|
|
||||||
}
|
|
||||||
if (edits.roleId !== undefined) {
|
|
||||||
copy.roleId = edits.roleId;
|
|
||||||
}
|
|
||||||
changes.value = replaceChange(copy, changes.value);
|
|
||||||
}
|
}
|
||||||
function delShift(id: number) {
|
function revertShift(id: Id) {
|
||||||
const change = { id, updatedAt: "", deleted: true as const };
|
schedule.value.restoreShift(id);
|
||||||
changes.value = replaceChange(change, changes.value);
|
|
||||||
}
|
|
||||||
function revertShift(id: number) {
|
|
||||||
changes.value = revertChange(id, changes.value);
|
|
||||||
}
|
}
|
||||||
function shiftExists(name: string) {
|
function shiftExists(name: string) {
|
||||||
if (schedule.value.deleted) {
|
|
||||||
throw new Error("Unexpected deleted schedule");
|
|
||||||
}
|
|
||||||
name = toId(name);
|
name = toId(name);
|
||||||
return (
|
return (
|
||||||
schedule.value.shifts?.some(s => !s.deleted && toId(s.name) === name)
|
[...schedule.value.shifts.values()].some(s => !s.deleted && toId(s.name) === name)
|
||||||
|| changes.value.some(c => !c.deleted && c.name === name)
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
function newShift() {
|
function newShift() {
|
||||||
|
@ -219,50 +156,24 @@ function newShift() {
|
||||||
alert(`Shift ${newShiftName.value} already exists`);
|
alert(`Shift ${newShiftName.value} already exists`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (schedule.value.deleted) {
|
const role = schedule.value.roles.get(newShiftRoleId.value!);
|
||||||
throw new Error("Unexpected deleted schedule");
|
if (!role) {
|
||||||
}
|
|
||||||
if (!newShiftRole.value) {
|
|
||||||
alert(`Invalid role`);
|
alert(`Invalid role`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const change = {
|
const shift = new ClientScheduleShift(
|
||||||
id: newShiftId.value,
|
schedule.value.nextClientId--,
|
||||||
updatedAt: "",
|
DateTime.now(),
|
||||||
name: newShiftName.value,
|
false,
|
||||||
roleId: newShiftRole.value,
|
role,
|
||||||
description: newShiftDescription.value || undefined,
|
newShiftName.value,
|
||||||
slots: [],
|
newShiftDescription.value,
|
||||||
};
|
new Map(),
|
||||||
changes.value = replaceChange(change, changes.value);
|
);
|
||||||
|
schedule.value.setShift(shift);
|
||||||
newShiftName.value = "";
|
newShiftName.value = "";
|
||||||
newShiftDescription.value = "";
|
newShiftDescription.value = "";
|
||||||
}
|
}
|
||||||
async function saveShifts() {
|
|
||||||
try {
|
|
||||||
await $fetch("/api/schedule", {
|
|
||||||
method: "PATCH",
|
|
||||||
body: {
|
|
||||||
id: 111,
|
|
||||||
updatedAt: "",
|
|
||||||
shifts: changes.value
|
|
||||||
} satisfies ApiSchedule,
|
|
||||||
});
|
|
||||||
changes.value = [];
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
alert(err?.data?.message ?? err.message);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const shifts = computed(() => {
|
|
||||||
if (schedule.value.deleted) {
|
|
||||||
throw new Error("Unexpected deleted schedule");
|
|
||||||
}
|
|
||||||
const data = [...schedule.value.shifts ?? []].filter(shift => !shift.deleted && (!props.roleId || shift.roleId === props.roleId));
|
|
||||||
applyUpdatesToArray(changes.value.filter(change => !change.deleted), data);
|
|
||||||
return data;
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|
|
@ -1,10 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<figure class="timetable" v-if="schedule.deleted">
|
<figure class="timetable">
|
||||||
<p>
|
|
||||||
Error: Schedule is deleted.
|
|
||||||
</p>
|
|
||||||
</figure>
|
|
||||||
<figure class="timetable" v-else>
|
|
||||||
<details>
|
<details>
|
||||||
<summary>Debug</summary>
|
<summary>Debug</summary>
|
||||||
<details>
|
<details>
|
||||||
|
@ -61,7 +56,7 @@
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody>
|
<tbody>
|
||||||
<template v-for="location in schedule.locations?.filter(l => !l.deleted)" :key="location.id">
|
<template v-for="location in schedule.locations.values()" :key="location.id">
|
||||||
<tr v-if="locationRows.has(location.id)">
|
<tr v-if="locationRows.has(location.id)">
|
||||||
<th>{{ location.name }}</th>
|
<th>{{ location.name }}</th>
|
||||||
<td
|
<td
|
||||||
|
@ -75,12 +70,12 @@
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
<template v-if="schedule.roles">
|
<template v-if="schedule.roles.size">
|
||||||
<tr>
|
<tr>
|
||||||
<th>Shifts</th>
|
<th>Shifts</th>
|
||||||
<td :colSpan="totalColumns"></td>
|
<td :colSpan="totalColumns"></td>
|
||||||
</tr>
|
</tr>
|
||||||
<template v-for="role in schedule.roles?.filter(r => !r.deleted)" :key="role.id">
|
<template v-for="role in schedule.roles.values()" :key="role.id">
|
||||||
<tr v-if="roleRows.has(role.id)">
|
<tr v-if="roleRows.has(role.id)">
|
||||||
<th>{{ role.name }}</th>
|
<th>{{ role.name }}</th>
|
||||||
<td
|
<td
|
||||||
|
@ -102,7 +97,6 @@
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { DateTime } from "luxon";
|
import { DateTime } from "luxon";
|
||||||
import type { ApiSchedule, ApiScheduleEvent, ApiScheduleEventSlot, ApiScheduleLocation, ApiScheduleRole, ApiScheduleShift, ApiScheduleShiftSlot } from "~/shared/types/api";
|
|
||||||
import type { Id } from "~/shared/types/common";
|
import type { Id } from "~/shared/types/common";
|
||||||
import { pairs, setEquals } from "~/shared/utils/functions";
|
import { pairs, setEquals } from "~/shared/utils/functions";
|
||||||
|
|
||||||
|
@ -113,8 +107,8 @@ const oneMinMs = 60 * 1000;
|
||||||
|
|
||||||
/** Point in time where a time slots starts or ends. */
|
/** Point in time where a time slots starts or ends. */
|
||||||
type Edge =
|
type Edge =
|
||||||
| { type: "start" | "end", source: "event", slot: ApiScheduleEventSlot }
|
| { type: "start" | "end", source: "event", slot: ClientScheduleEventSlot }
|
||||||
| { type: "start" | "end", source: "shift", roleId: Id, slot: ApiScheduleShiftSlot }
|
| { type: "start" | "end", source: "shift", roleId: Id, slot: ClientScheduleShiftSlot }
|
||||||
;
|
;
|
||||||
|
|
||||||
/** Point in time where multiple edges meet. */
|
/** Point in time where multiple edges meet. */
|
||||||
|
@ -124,8 +118,8 @@ type Junction = { ts: number, edges: Edge[] };
|
||||||
type Span = {
|
type Span = {
|
||||||
start: Junction;
|
start: Junction;
|
||||||
end: Junction,
|
end: Junction,
|
||||||
locations: Map<number, Set<ApiScheduleEventSlot>>,
|
locations: Map<number, Set<ClientScheduleEventSlot>>,
|
||||||
roles: Map<number, Set<ApiScheduleShiftSlot>>,
|
roles: Map<number, Set<ClientScheduleShiftSlot>>,
|
||||||
};
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
@ -140,11 +134,15 @@ type Stretch = {
|
||||||
}
|
}
|
||||||
|
|
||||||
function* edgesFromEvents(
|
function* edgesFromEvents(
|
||||||
events: Iterable<Extract<ApiScheduleEvent, { deleted?: false }>>,
|
events: Iterable<ClientScheduleEvent>,
|
||||||
filter = (slot: ApiScheduleEventSlot) => true,
|
filter = (slot: ClientScheduleEventSlot) => true,
|
||||||
): Generator<Edge> {
|
): Generator<Edge> {
|
||||||
for (const event of events) {
|
for (const event of events) {
|
||||||
for (const slot of event.slots.filter(filter)) {
|
if (event.deleted)
|
||||||
|
continue;
|
||||||
|
for (const slot of event.slots.values()) {
|
||||||
|
if (!filter(slot) || slot.deleted)
|
||||||
|
continue;
|
||||||
if (slot.start > slot.end) {
|
if (slot.start > slot.end) {
|
||||||
throw new Error(`Slot ${slot.id} ends before it starts.`);
|
throw new Error(`Slot ${slot.id} ends before it starts.`);
|
||||||
}
|
}
|
||||||
|
@ -155,16 +153,20 @@ function* edgesFromEvents(
|
||||||
}
|
}
|
||||||
|
|
||||||
function* edgesFromShifts(
|
function* edgesFromShifts(
|
||||||
shifts: Iterable<Extract<ApiScheduleShift, { deleted?: false }>>,
|
shifts: Iterable<ClientScheduleShift>,
|
||||||
filter = (slot: ApiScheduleShiftSlot) => true,
|
filter = (slot: ClientScheduleShiftSlot) => true,
|
||||||
): Generator<Edge> {
|
): Generator<Edge> {
|
||||||
for (const shift of shifts) {
|
for (const shift of shifts) {
|
||||||
for (const slot of shift.slots.filter(filter)) {
|
if (shift.deleted)
|
||||||
|
continue;
|
||||||
|
for (const slot of shift.slots.values()) {
|
||||||
|
if (!filter(slot) || slot.deleted)
|
||||||
|
continue;
|
||||||
if (slot.start > slot.end) {
|
if (slot.start > slot.end) {
|
||||||
throw new Error(`Slot ${slot.id} ends before it starts.`);
|
throw new Error(`Slot ${slot.id} ends before it starts.`);
|
||||||
}
|
}
|
||||||
yield { type: "start", source: "shift", roleId: shift.roleId, slot };
|
yield { type: "start", source: "shift", roleId: shift.role.id, slot };
|
||||||
yield { type: "end", source: "shift", roleId: shift.roleId, slot };
|
yield { type: "end", source: "shift", roleId: shift.role.id, slot };
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -172,7 +174,7 @@ function* edgesFromShifts(
|
||||||
function junctionsFromEdges(edges: Iterable<Edge>) {
|
function junctionsFromEdges(edges: Iterable<Edge>) {
|
||||||
const junctions = new Map<number, Junction>();
|
const junctions = new Map<number, Junction>();
|
||||||
for (const edge of edges) {
|
for (const edge of edges) {
|
||||||
const ts = Date.parse(edge.slot[edge.type]);
|
const ts = edge.slot[edge.type].toMillis();
|
||||||
const junction = junctions.get(ts);
|
const junction = junctions.get(ts);
|
||||||
if (junction) {
|
if (junction) {
|
||||||
junction.edges.push(edge);
|
junction.edges.push(edge);
|
||||||
|
@ -186,21 +188,21 @@ function junctionsFromEdges(edges: Iterable<Edge>) {
|
||||||
|
|
||||||
function* spansFromJunctions(
|
function* spansFromJunctions(
|
||||||
junctions: Iterable<Junction>,
|
junctions: Iterable<Junction>,
|
||||||
locations: Extract<ApiScheduleLocation, { deleted?: false }>[],
|
locations: Map<Id, ClientScheduleLocation>,
|
||||||
roles: Extract<ApiScheduleRole, { deleted?: false }>[],
|
roles: Map<Id, ClientScheduleRole>,
|
||||||
): Generator<Span> {
|
): Generator<Span> {
|
||||||
const activeLocations = new Map(
|
const activeLocations = new Map(
|
||||||
locations.map(location => [location.id, new Set<ApiScheduleEventSlot>()])
|
[...locations.keys()].map(id => [id, new Set<ClientScheduleEventSlot>()])
|
||||||
);
|
);
|
||||||
const activeRoles = new Map(
|
const activeRoles = new Map(
|
||||||
roles?.map(role => [role.id, new Set<ApiScheduleShiftSlot>()])
|
[...roles.keys()].map(id => [id, new Set<ClientScheduleShiftSlot>()])
|
||||||
);
|
);
|
||||||
for (const [start, end] of pairs(junctions)) {
|
for (const [start, end] of pairs(junctions)) {
|
||||||
for (const edge of start.edges) {
|
for (const edge of start.edges) {
|
||||||
if (edge.type === "start") {
|
if (edge.type === "start") {
|
||||||
if (edge.source === "event") {
|
if (edge.source === "event") {
|
||||||
for (const id of edge.slot.locationIds) {
|
for (const location of edge.slot.locations) {
|
||||||
activeLocations.get(id)?.add(edge.slot)
|
activeLocations.get(location.id)?.add(edge.slot)
|
||||||
}
|
}
|
||||||
} else if (edge.source === "shift") {
|
} else if (edge.source === "shift") {
|
||||||
activeRoles.get(edge.roleId)?.add(edge.slot)
|
activeRoles.get(edge.roleId)?.add(edge.slot)
|
||||||
|
@ -224,8 +226,8 @@ function* spansFromJunctions(
|
||||||
for (const edge of end.edges) {
|
for (const edge of end.edges) {
|
||||||
if (edge.type === "end") {
|
if (edge.type === "end") {
|
||||||
if (edge.source === "event") {
|
if (edge.source === "event") {
|
||||||
for (const id of edge.slot.locationIds) {
|
for (const location of edge.slot.locations) {
|
||||||
activeLocations.get(id)?.delete(edge.slot)
|
activeLocations.get(location.id)?.delete(edge.slot)
|
||||||
}
|
}
|
||||||
} else if (edge.source === "shift") {
|
} else if (edge.source === "shift") {
|
||||||
activeRoles.get(edge.roleId)?.delete(edge.slot);
|
activeRoles.get(edge.roleId)?.delete(edge.slot);
|
||||||
|
@ -265,7 +267,7 @@ function* stretchesFromSpans(spans: Iterable<Span>, minSeparation: number): Gene
|
||||||
|
|
||||||
/** Cuts up a span by whole hours that crosses it */
|
/** Cuts up a span by whole hours that crosses it */
|
||||||
function* cutSpansByHours(span: Span, timezone: string): Generator<Span> {
|
function* cutSpansByHours(span: Span, timezone: string): Generator<Span> {
|
||||||
const startHour = DateTime.fromMillis(span.start.ts, { zone: timezone, locale: "en-US" })
|
const startHour = DateTime.fromMillis(span.start.ts, { zone: timezone, locale: accountStore.activeLocale })
|
||||||
.startOf("hour")
|
.startOf("hour")
|
||||||
;
|
;
|
||||||
const end = span.end.ts;
|
const end = span.end.ts;
|
||||||
|
@ -308,11 +310,11 @@ function* cutSpansByHours(span: Span, timezone: string): Generator<Span> {
|
||||||
|
|
||||||
function padStretch(stretch: Stretch, timezone: string): Stretch {
|
function padStretch(stretch: Stretch, timezone: string): Stretch {
|
||||||
// Pad by one hour and extend it to the nearest whole hour.
|
// Pad by one hour and extend it to the nearest whole hour.
|
||||||
let start = DateTime.fromMillis(stretch.start, { zone: timezone, locale: "en-US" })
|
let start = DateTime.fromMillis(stretch.start, { zone: timezone, locale: accountStore.activeLocale })
|
||||||
.minus(oneHourMs)
|
.minus(oneHourMs)
|
||||||
.startOf("hour")
|
.startOf("hour")
|
||||||
;
|
;
|
||||||
let end = DateTime.fromMillis(stretch.end, { zone: timezone, locale: "en-US" })
|
let end = DateTime.fromMillis(stretch.end, { zone: timezone, locale: accountStore.activeLocale })
|
||||||
.plus(2 * oneHourMs - 1)
|
.plus(2 * oneHourMs - 1)
|
||||||
.startOf("hour")
|
.startOf("hour")
|
||||||
;
|
;
|
||||||
|
@ -339,24 +341,24 @@ function padStretch(stretch: Stretch, timezone: string): Stretch {
|
||||||
|
|
||||||
function tableElementsFromStretches(
|
function tableElementsFromStretches(
|
||||||
stretches: Iterable<Stretch>,
|
stretches: Iterable<Stretch>,
|
||||||
events: Extract<ApiScheduleEvent, { deleted?: false }>[],
|
events: Map<Id, ClientScheduleEvent>,
|
||||||
locations: Extract<ApiScheduleLocation, { deleted?: false }>[],
|
locations: Map<Id, ClientScheduleLocation>,
|
||||||
shifts: Extract<ApiScheduleShift, { deleted?: false }>[],
|
shifts: Map<Id, ClientScheduleShift>,
|
||||||
roles: Extract<ApiScheduleRole, { deleted?: false }>[],
|
roles: Map<Id, ClientScheduleRole>,
|
||||||
timezone: string,
|
timezone: string,
|
||||||
) {
|
) {
|
||||||
type Col = { minutes?: number };
|
type Col = { minutes?: number };
|
||||||
type DayHead = { span: number, content?: string }
|
type DayHead = { span: number, content?: string }
|
||||||
type HourHead = { span: number, content?: string }
|
type HourHead = { span: number, content?: string }
|
||||||
type LocationCell = { span: number, slots: Set<ApiScheduleEventSlot>, title: string, crew?: boolean }
|
type LocationCell = { span: number, slots: Set<ClientScheduleEventSlot>, title: string, crew?: boolean }
|
||||||
type RoleCell = { span: number, slots: Set<ApiScheduleShiftSlot>, title: string };
|
type RoleCell = { span: number, slots: Set<ClientScheduleShiftSlot>, title: string };
|
||||||
const columnGroups: { className?: string, cols: Col[] }[] = [];
|
const columnGroups: { className?: string, cols: Col[] }[] = [];
|
||||||
const dayHeaders: DayHead[] = [];
|
const dayHeaders: DayHead[] = [];
|
||||||
const hourHeaders: HourHead[]= [];
|
const hourHeaders: HourHead[]= [];
|
||||||
const locationRows = new Map<number, LocationCell[]>(locations.map(location => [location.id, []]));
|
const locationRows = new Map<number, LocationCell[]>([...locations.keys()].map(id => [id, []]));
|
||||||
const roleRows = new Map<number, RoleCell[]>(roles.map(role => [role.id, []]));
|
const roleRows = new Map<number, RoleCell[]>([...roles.keys()].map(id => [id, []]));
|
||||||
const eventBySlotId = new Map(events.flatMap(event => event.slots.map(slot => [slot.id, event])));
|
const eventBySlotId = new Map([...events.values()].flatMap(event => [...event.slots.values()].map(slot => [slot.id, event])));
|
||||||
const shiftBySlotId = new Map(shifts?.flatMap?.(shift => shift.slots.map(slot =>[slot.id, shift])))
|
const shiftBySlotId = new Map([...shifts.values()].flatMap?.(shift => [...shift.slots.values()].map(slot =>[slot.id, shift])));
|
||||||
let totalColumns = 0;
|
let totalColumns = 0;
|
||||||
|
|
||||||
function startColumnGroup(className?: string) {
|
function startColumnGroup(className?: string) {
|
||||||
|
@ -368,7 +370,7 @@ function tableElementsFromStretches(
|
||||||
function startHour(content?: string) {
|
function startHour(content?: string) {
|
||||||
hourHeaders.push({ span: 0, content })
|
hourHeaders.push({ span: 0, content })
|
||||||
}
|
}
|
||||||
function startLocation(id: number, slots = new Set<ApiScheduleEventSlot>()) {
|
function startLocation(id: number, slots = new Set<ClientScheduleEventSlot>()) {
|
||||||
const rows = locationRows.get(id)!;
|
const rows = locationRows.get(id)!;
|
||||||
if (rows.length) {
|
if (rows.length) {
|
||||||
const row = rows[rows.length - 1];
|
const row = rows[rows.length - 1];
|
||||||
|
@ -377,7 +379,7 @@ function tableElementsFromStretches(
|
||||||
}
|
}
|
||||||
rows.push({ span: 0, slots, title: "" });
|
rows.push({ span: 0, slots, title: "" });
|
||||||
}
|
}
|
||||||
function startRole(id: number, slots = new Set<ApiScheduleShiftSlot>()) {
|
function startRole(id: number, slots = new Set<ClientScheduleShiftSlot>()) {
|
||||||
const rows = roleRows.get(id)!;
|
const rows = roleRows.get(id)!;
|
||||||
if (rows.length) {
|
if (rows.length) {
|
||||||
const row = rows[rows.length - 1];
|
const row = rows[rows.length - 1];
|
||||||
|
@ -390,11 +392,11 @@ function tableElementsFromStretches(
|
||||||
columnGroups[columnGroups.length - 1].cols.push({ minutes })
|
columnGroups[columnGroups.length - 1].cols.push({ minutes })
|
||||||
dayHeaders[dayHeaders.length - 1].span += 1;
|
dayHeaders[dayHeaders.length - 1].span += 1;
|
||||||
hourHeaders[hourHeaders.length - 1].span += 1;
|
hourHeaders[hourHeaders.length - 1].span += 1;
|
||||||
for(const location of locations) {
|
for(const location of locations.values()) {
|
||||||
const row = locationRows.get(location.id)!;
|
const row = locationRows.get(location.id)!;
|
||||||
row[row.length - 1].span += 1;
|
row[row.length - 1].span += 1;
|
||||||
}
|
}
|
||||||
for(const role of roles ?? []) {
|
for(const role of roles.values()) {
|
||||||
const row = roleRows.get(role.id)!;
|
const row = roleRows.get(role.id)!;
|
||||||
row[row.length - 1].span += 1;
|
row[row.length - 1].span += 1;
|
||||||
}
|
}
|
||||||
|
@ -403,16 +405,16 @@ function tableElementsFromStretches(
|
||||||
let first = true;
|
let first = true;
|
||||||
for (let stretch of stretches) {
|
for (let stretch of stretches) {
|
||||||
stretch = padStretch(stretch, timezone);
|
stretch = padStretch(stretch, timezone);
|
||||||
const startDate = DateTime.fromMillis(stretch.start, { zone: timezone, locale: "en-US" });
|
const startDate = DateTime.fromMillis(stretch.start, { zone: timezone, locale: accountStore.activeLocale });
|
||||||
if (first) {
|
if (first) {
|
||||||
first = false;
|
first = false;
|
||||||
startColumnGroup();
|
startColumnGroup();
|
||||||
startDay(startDate.toFormat("yyyy-LL-dd"));
|
startDay(startDate.toFormat("yyyy-LL-dd"));
|
||||||
startHour(startDate.toFormat("HH:mm"));
|
startHour(startDate.toFormat("HH:mm"));
|
||||||
for(const location of locations) {
|
for(const location of locations.values()) {
|
||||||
startLocation(location.id);
|
startLocation(location.id);
|
||||||
}
|
}
|
||||||
for(const role of roles ?? []) {
|
for(const role of roles.values()) {
|
||||||
startRole(role.id);
|
startRole(role.id);
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
|
@ -423,10 +425,10 @@ function tableElementsFromStretches(
|
||||||
if (!sameDay)
|
if (!sameDay)
|
||||||
startDay();
|
startDay();
|
||||||
startHour("break");
|
startHour("break");
|
||||||
for(const location of locations) {
|
for(const location of locations.values()) {
|
||||||
startLocation(location.id);
|
startLocation(location.id);
|
||||||
}
|
}
|
||||||
for(const role of roles ?? []) {
|
for(const role of roles.values()) {
|
||||||
startRole(role.id);
|
startRole(role.id);
|
||||||
}
|
}
|
||||||
pushColumn();
|
pushColumn();
|
||||||
|
@ -435,10 +437,10 @@ function tableElementsFromStretches(
|
||||||
if (!sameDay)
|
if (!sameDay)
|
||||||
startDay(dayName);
|
startDay(dayName);
|
||||||
startHour(startDate.toFormat("HH:mm"));
|
startHour(startDate.toFormat("HH:mm"));
|
||||||
for(const location of locations) {
|
for(const location of locations.values()) {
|
||||||
startLocation(location.id);
|
startLocation(location.id);
|
||||||
}
|
}
|
||||||
for(const role of roles ?? []) {
|
for(const role of roles.values()) {
|
||||||
startRole(role.id);
|
startRole(role.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -448,7 +450,7 @@ function tableElementsFromStretches(
|
||||||
const end = cutSpan.end.ts;
|
const end = cutSpan.end.ts;
|
||||||
const durationMs = end - cutSpan.start.ts;
|
const durationMs = end - cutSpan.start.ts;
|
||||||
|
|
||||||
for (const location of locations) {
|
for (const location of locations.values()) {
|
||||||
const rows = locationRows.get(location.id)!;
|
const rows = locationRows.get(location.id)!;
|
||||||
const row = rows[rows.length - 1];
|
const row = rows[rows.length - 1];
|
||||||
const slots = cutSpan.locations.get(location.id) ?? new Set();
|
const slots = cutSpan.locations.get(location.id) ?? new Set();
|
||||||
|
@ -456,7 +458,7 @@ function tableElementsFromStretches(
|
||||||
startLocation(location.id, slots);
|
startLocation(location.id, slots);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for (const role of roles ?? []) {
|
for (const role of roles.values()) {
|
||||||
const rows = roleRows.get(role.id)!;
|
const rows = roleRows.get(role.id)!;
|
||||||
const row = rows[rows.length - 1];
|
const row = rows[rows.length - 1];
|
||||||
const slots = cutSpan.roles.get(role.id) ?? new Set();
|
const slots = cutSpan.roles.get(role.id) ?? new Set();
|
||||||
|
@ -466,16 +468,16 @@ function tableElementsFromStretches(
|
||||||
}
|
}
|
||||||
|
|
||||||
pushColumn(durationMs / oneMinMs);
|
pushColumn(durationMs / oneMinMs);
|
||||||
const endDate = DateTime.fromMillis(end, { zone: timezone, locale: "en-US" });
|
const endDate = DateTime.fromMillis(end, { zone: timezone, locale: accountStore.activeLocale });
|
||||||
if (end === endDate.startOf("day").toMillis()) {
|
if (end === endDate.startOf("day").toMillis()) {
|
||||||
startDay(
|
startDay(
|
||||||
DateTime.fromMillis(cutSpan.end.ts, { zone: timezone, locale: "en-US" })
|
DateTime.fromMillis(cutSpan.end.ts, { zone: timezone, locale: accountStore.activeLocale })
|
||||||
.toFormat("yyyy-LL-dd")
|
.toFormat("yyyy-LL-dd")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
if (end === endDate.startOf("hour").toMillis()) {
|
if (end === endDate.startOf("hour").toMillis()) {
|
||||||
startHour(
|
startHour(
|
||||||
DateTime.fromMillis(cutSpan.end.ts, { zone: timezone, locale: "en-US" })
|
DateTime.fromMillis(cutSpan.end.ts, { zone: timezone, locale: accountStore.activeLocale })
|
||||||
.toFormat("HH:mm")
|
.toFormat("HH:mm")
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -501,30 +503,24 @@ function tableElementsFromStretches(
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
schedule: ApiSchedule,
|
schedule: ClientSchedule,
|
||||||
eventSlotFilter?: (slot: ApiScheduleEventSlot) => boolean,
|
eventSlotFilter?: (slot: ClientScheduleEventSlot) => boolean,
|
||||||
shiftSlotFilter?: (slot: ApiScheduleShiftSlot) => boolean,
|
shiftSlotFilter?: (slot: ClientScheduleShiftSlot) => boolean,
|
||||||
}>();
|
}>();
|
||||||
const schedule = computed(() => props.schedule);
|
const schedule = computed(() => props.schedule);
|
||||||
const junctions = computed(() => {
|
const junctions = computed(() => {
|
||||||
if (schedule.value.deleted) {
|
|
||||||
throw Error("Unhandled deleted schedule");
|
|
||||||
}
|
|
||||||
return junctionsFromEdges([
|
return junctionsFromEdges([
|
||||||
...edgesFromEvents(schedule.value.events?.filter(e => !e.deleted) ?? [], props.eventSlotFilter),
|
...edgesFromEvents(schedule.value.events.values(), props.eventSlotFilter),
|
||||||
...edgesFromShifts(schedule.value.shifts?.filter(s => !s.deleted) ?? [], props.shiftSlotFilter),
|
...edgesFromShifts(schedule.value.shifts.values(), props.shiftSlotFilter),
|
||||||
])
|
])
|
||||||
});
|
});
|
||||||
const stretches = computed(() => {
|
const stretches = computed(() => {
|
||||||
if (schedule.value.deleted) {
|
|
||||||
throw Error("Unhandled deleted schedule");
|
|
||||||
}
|
|
||||||
return [
|
return [
|
||||||
...stretchesFromSpans(
|
...stretchesFromSpans(
|
||||||
spansFromJunctions(
|
spansFromJunctions(
|
||||||
junctions.value,
|
junctions.value,
|
||||||
schedule.value.locations?.filter(l => !l.deleted) ?? [],
|
schedule.value.locations,
|
||||||
schedule.value.roles?.filter(r => !r.deleted) ?? [],
|
schedule.value.roles,
|
||||||
),
|
),
|
||||||
oneHourMs * 5
|
oneHourMs * 5
|
||||||
)
|
)
|
||||||
|
@ -538,15 +534,12 @@ const timezone = computed({
|
||||||
});
|
});
|
||||||
|
|
||||||
const elements = computed(() => {
|
const elements = computed(() => {
|
||||||
if (schedule.value.deleted) {
|
|
||||||
throw Error("Unhandled deleted schedule");
|
|
||||||
}
|
|
||||||
return tableElementsFromStretches(
|
return tableElementsFromStretches(
|
||||||
stretches.value,
|
stretches.value,
|
||||||
schedule.value.events?.filter(e => !e.deleted) ?? [],
|
schedule.value.events,
|
||||||
schedule.value.locations?.filter(l => !l.deleted) ?? [],
|
schedule.value.locations,
|
||||||
schedule.value.shifts?.filter(s => !s.deleted) ?? [],
|
schedule.value.shifts,
|
||||||
schedule.value.roles?.filter(r => !r.deleted) ?? [],
|
schedule.value.roles,
|
||||||
accountStore.activeTimezone
|
accountStore.activeTimezone
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
|
@ -1,11 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<main v-if="schedule.deleted">
|
<main>
|
||||||
<h1>Error</h1>
|
|
||||||
<p>
|
|
||||||
Schedule has been deleted.
|
|
||||||
</p>
|
|
||||||
</main>
|
|
||||||
<main v-else>
|
|
||||||
<h1>Edit</h1>
|
<h1>Edit</h1>
|
||||||
<label>
|
<label>
|
||||||
Crew Filter:
|
Crew Filter:
|
||||||
|
@ -37,14 +31,15 @@
|
||||||
:selected="locationFilter === undefined"
|
:selected="locationFilter === undefined"
|
||||||
><All locations></option>
|
><All locations></option>
|
||||||
<option
|
<option
|
||||||
v-for="location in schedule.locations?.filter(l => !l.deleted)"
|
v-for="location in schedule.locations.values()"
|
||||||
:key="location.id"
|
:key="location.id"
|
||||||
:value="location.id"
|
:value="location.id"
|
||||||
|
:disabled="location.deleted"
|
||||||
:selected="locationFilter === location.id"
|
:selected="locationFilter === location.id"
|
||||||
>{{ location.name }}</option>
|
>{{ location.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
</label>
|
</label>
|
||||||
<ScheduleTable :edit="true" :location="locationFilter" :eventSlotFilter :shiftSlotFilter />
|
<ScheduleTable :edit="true" :locationId="locationFilter" :eventSlotFilter :shiftSlotFilter />
|
||||||
<h2>Events</h2>
|
<h2>Events</h2>
|
||||||
<EventsTable :edit="true" />
|
<EventsTable :edit="true" />
|
||||||
<h2>Roles</h2>
|
<h2>Roles</h2>
|
||||||
|
@ -60,9 +55,10 @@
|
||||||
:selected="roleFilter === undefined"
|
:selected="roleFilter === undefined"
|
||||||
><All roles></option>
|
><All roles></option>
|
||||||
<option
|
<option
|
||||||
v-for="role in schedule.roles?.filter(r => !r.deleted)"
|
v-for="role in schedule.roles.values()"
|
||||||
:key="role.id"
|
:key="role.id"
|
||||||
:value="role.id"
|
:value="role.id"
|
||||||
|
:disabled="role.deleted"
|
||||||
:selected="roleFilter === role.id"
|
:selected="roleFilter === role.id"
|
||||||
>{{ role.name }}</option>
|
>{{ role.name }}</option>
|
||||||
</select>
|
</select>
|
||||||
|
@ -70,12 +66,17 @@
|
||||||
<ShiftScheduleTable :edit="true" :roleId="roleFilter" :eventSlotFilter :shiftSlotFilter />
|
<ShiftScheduleTable :edit="true" :roleId="roleFilter" :eventSlotFilter :shiftSlotFilter />
|
||||||
<h2>Shifts</h2>
|
<h2>Shifts</h2>
|
||||||
<ShiftsTable :edit="true" :roleId="roleFilter" />
|
<ShiftsTable :edit="true" :roleId="roleFilter" />
|
||||||
|
<p v-if="schedule.modified">
|
||||||
|
Changes are not saved yet.
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
@click="saveChanges"
|
||||||
|
>Save Changes</button>
|
||||||
|
</p>
|
||||||
</main>
|
</main>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script lang="ts" setup>
|
<script lang="ts" setup>
|
||||||
import type { ApiScheduleEventSlot, ApiScheduleShiftSlot } from '~/shared/types/api';
|
|
||||||
|
|
||||||
definePageMeta({
|
definePageMeta({
|
||||||
middleware: ["authenticated"],
|
middleware: ["authenticated"],
|
||||||
allowedAccountTypes: ["crew", "admin"],
|
allowedAccountTypes: ["crew", "admin"],
|
||||||
|
@ -101,14 +102,14 @@ const eventSlotFilter = computed(() => {
|
||||||
return () => true;
|
return () => true;
|
||||||
}
|
}
|
||||||
const cid = parseInt(crewFilter.value);
|
const cid = parseInt(crewFilter.value);
|
||||||
return (slot: ApiScheduleEventSlot) => slot.assigned?.some(id => id === cid) || false;
|
return (slot: ClientScheduleEventSlot) => slot.assigned.has(cid);
|
||||||
});
|
});
|
||||||
const shiftSlotFilter = computed(() => {
|
const shiftSlotFilter = computed(() => {
|
||||||
if (crewFilter.value === undefined || !accountStore.valid) {
|
if (crewFilter.value === undefined || !accountStore.valid) {
|
||||||
return () => true;
|
return () => true;
|
||||||
}
|
}
|
||||||
const cid = parseInt(crewFilter.value);
|
const cid = parseInt(crewFilter.value);
|
||||||
return (slot: ApiScheduleShiftSlot) => slot.assigned?.some(id => id === cid) || false;
|
return (slot: ClientScheduleShiftSlot) => slot.assigned.has(cid);
|
||||||
});
|
});
|
||||||
|
|
||||||
const locationFilter = computed({
|
const locationFilter = computed({
|
||||||
|
@ -132,4 +133,16 @@ const roleFilter = computed({
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
async function saveChanges() {
|
||||||
|
try {
|
||||||
|
await $fetch("/api/schedule", {
|
||||||
|
method: "PATCH",
|
||||||
|
body: schedule.value.toApi(true),
|
||||||
|
});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error(err);
|
||||||
|
alert(err?.data?.message ?? err.message);
|
||||||
|
}
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
|
@ -1,11 +1,5 @@
|
||||||
<template>
|
<template>
|
||||||
<main v-if="schedule.deleted">
|
<main>
|
||||||
<h1>Error</h1>
|
|
||||||
<p>
|
|
||||||
Schedule has been deleted.
|
|
||||||
</p>
|
|
||||||
</main>
|
|
||||||
<main v-else>
|
|
||||||
<h1>Schedule & Events</h1>
|
<h1>Schedule & Events</h1>
|
||||||
<p>
|
<p>
|
||||||
Study carefully, we only hold these events once a year.
|
Study carefully, we only hold these events once a year.
|
||||||
|
@ -48,10 +42,10 @@
|
||||||
</label>
|
</label>
|
||||||
<Timetable :schedule :eventSlotFilter :shiftSlotFilter />
|
<Timetable :schedule :eventSlotFilter :shiftSlotFilter />
|
||||||
<h2>Events</h2>
|
<h2>Events</h2>
|
||||||
<EventCard v-for="event in schedule.events?.filter(e => !e.deleted && e.slots.some(eventSlotFilter))" :event/>
|
<EventCard v-for="event in [...schedule.events.values()].filter(e => !e.deleted && [...e.slots.values()].some(eventSlotFilter))" :event/>
|
||||||
<h2>Locations</h2>
|
<h2>Locations</h2>
|
||||||
<ul>
|
<ul>
|
||||||
<li v-for="location in schedule.locations?.filter(l => !l.deleted)" :key="location.id">
|
<li v-for="location in schedule.locations.values()" :key="location.id">
|
||||||
<h3>{{ location.name }}</h3>
|
<h3>{{ location.name }}</h3>
|
||||||
{{ location.description ?? "No description provided" }}
|
{{ location.description ?? "No description provided" }}
|
||||||
</li>
|
</li>
|
||||||
|
@ -60,8 +54,6 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import type { ApiScheduleShiftSlot, ApiScheduleEventSlot } from '~/shared/types/api';
|
|
||||||
|
|
||||||
const accountStore = useAccountStore();
|
const accountStore = useAccountStore();
|
||||||
const { data: accounts } = await useAccounts();
|
const { data: accounts } = await useAccounts();
|
||||||
const schedule = await useSchedule();
|
const schedule = await useSchedule();
|
||||||
|
@ -85,21 +77,21 @@ const eventSlotFilter = computed(() => {
|
||||||
const aid = accountStore.id;
|
const aid = accountStore.id;
|
||||||
if (filter.value === "my-schedule") {
|
if (filter.value === "my-schedule") {
|
||||||
const slotIds = new Set(accountStore.interestedEventSlotIds);
|
const slotIds = new Set(accountStore.interestedEventSlotIds);
|
||||||
for (const event of schedule.value.events ?? []) {
|
for (const event of schedule.value.events.values()) {
|
||||||
if (!event.deleted && accountStore.interestedEventIds.has(event.id)) {
|
if (!event.deleted && accountStore.interestedEventIds.has(event.id)) {
|
||||||
for (const slot of event.slots) {
|
for (const slot of event.slots.values()) {
|
||||||
slotIds.add(slot.id);
|
slotIds.add(slot.id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return (slot: ApiScheduleEventSlot) => slotIds.has(slot.id) || slot.assigned?.some(id => id === aid) || false;
|
return (slot: ClientScheduleEventSlot) => slotIds.has(slot.id) || slot.assigned.has(aid!) || false;
|
||||||
}
|
}
|
||||||
if (filter.value === "assigned") {
|
if (filter.value === "assigned") {
|
||||||
return (slot: ApiScheduleEventSlot) => slot.assigned?.some(id => id === aid) || false;
|
return (slot: ClientScheduleEventSlot) => slot.assigned.has(aid!) || false;
|
||||||
}
|
}
|
||||||
if (filter.value.startsWith("crew-")) {
|
if (filter.value.startsWith("crew-")) {
|
||||||
const cid = parseInt(filter.value.slice(5));
|
const cid = parseInt(filter.value.slice(5));
|
||||||
return (slot: ApiScheduleEventSlot) => slot.assigned?.some(id => id === cid) || false;
|
return (slot: ClientScheduleEventSlot) => slot.assigned.has(cid) || false;
|
||||||
}
|
}
|
||||||
return () => false;
|
return () => false;
|
||||||
});
|
});
|
||||||
|
@ -109,11 +101,11 @@ const shiftSlotFilter = computed(() => {
|
||||||
}
|
}
|
||||||
if (filter.value === "my-schedule" || filter.value === "assigned") {
|
if (filter.value === "my-schedule" || filter.value === "assigned") {
|
||||||
const aid = accountStore.id;
|
const aid = accountStore.id;
|
||||||
return (slot: ApiScheduleShiftSlot) => slot.assigned?.some(id => id === aid) || false;
|
return (slot: ClientScheduleShiftSlot) => slot.assigned.has(aid!) || false;
|
||||||
}
|
}
|
||||||
if (filter.value.startsWith("crew-")) {
|
if (filter.value.startsWith("crew-")) {
|
||||||
const cid = parseInt(filter.value.slice(5));
|
const cid = parseInt(filter.value.slice(5));
|
||||||
return (slot: ApiScheduleShiftSlot) => slot.assigned?.some(id => id === cid) || false;
|
return (slot: ClientScheduleShiftSlot) => slot.assigned.has(cid) || false;
|
||||||
}
|
}
|
||||||
return () => false;
|
return () => false;
|
||||||
});
|
});
|
||||||
|
|
|
@ -7,7 +7,9 @@ export function* enumerate<T>(iterable: Iterable<T>) {
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Filters an iterable based on the passed predicate function */
|
/** Filters an iterable based on the passed predicate function */
|
||||||
export function* filter<T, S extends T>(it: Iterable<T>, predicate: (value: T) => value is S) {
|
export function filter<T, S extends T>(it: Iterable<T>, predicate: (value: T) => value is S): Generator<S, void, unknown>;
|
||||||
|
export function filter<T>(it: Iterable<T>, predicate: (value: T) => unknown): Generator<T, void, unknown>;
|
||||||
|
export function* filter(it: Iterable<unknown>, predicate: (value: unknown) => unknown): Generator<unknown, void, unknown> {
|
||||||
for (const value of it) {
|
for (const value of it) {
|
||||||
if (predicate(value)) {
|
if (predicate(value)) {
|
||||||
yield value;
|
yield value;
|
||||||
|
|
|
@ -1,5 +1,5 @@
|
||||||
// Wrapper around Luxon to make sure the throwOnInvalid option is set
|
// Wrapper around Luxon to make sure the throwOnInvalid option is set
|
||||||
import { DateTime, FixedOffsetZone, Info, Settings, Zone } from "luxon";
|
import { DateTime, Duration, FixedOffsetZone, Info, Settings, Zone } from "luxon";
|
||||||
|
|
||||||
Settings.throwOnInvalid = true;
|
Settings.throwOnInvalid = true;
|
||||||
declare module 'luxon' {
|
declare module 'luxon' {
|
||||||
|
@ -8,4 +8,4 @@ declare module 'luxon' {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export { DateTime, FixedOffsetZone, Info, Zone }
|
export { DateTime, Duration, FixedOffsetZone, Info, Zone }
|
||||||
|
|
|
@ -1,17 +1,17 @@
|
||||||
import type { ApiSchedule } from "~/shared/types/api";
|
import { Info } from "~/shared/utils/luxon";
|
||||||
import { applyUpdatesToArray } from "~/shared/utils/update";
|
|
||||||
|
|
||||||
interface SyncOperation {
|
interface SyncOperation {
|
||||||
controller: AbortController,
|
controller: AbortController,
|
||||||
promise: Promise<Ref<ApiSchedule>>,
|
promise: Promise<Ref<ClientSchedule>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
export const useSchedulesStore = defineStore("schedules", () => {
|
export const useSchedulesStore = defineStore("schedules", () => {
|
||||||
const sessionStore = useSessionStore();
|
const sessionStore = useSessionStore();
|
||||||
|
const accountStore = useAccountStore();
|
||||||
|
|
||||||
const state = {
|
const state = {
|
||||||
activeScheduleId: ref<number | undefined>(111),
|
activeScheduleId: ref<number | undefined>(111),
|
||||||
schedules: ref<Map<number, Ref<ApiSchedule>>>(new Map()),
|
schedules: ref<Map<number, Ref<ClientSchedule>>>(new Map()),
|
||||||
pendingSyncs: ref<Map<number, SyncOperation>>(new Map()),
|
pendingSyncs: ref<Map<number, SyncOperation>>(new Map()),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -44,9 +44,15 @@ export const useSchedulesStore = defineStore("schedules", () => {
|
||||||
console.log("return new fetch");
|
console.log("return new fetch");
|
||||||
const requestFetch = useRequestFetch();
|
const requestFetch = useRequestFetch();
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
const zone = Info.normalizeZone(accountStore.activeTimezone);
|
||||||
|
const locale = accountStore.activeLocale;
|
||||||
const promise = (async () => {
|
const promise = (async () => {
|
||||||
try {
|
try {
|
||||||
const schedule = ref(await requestFetch("/api/schedule", { signal: controller.signal }));
|
const apiSchedule = await requestFetch("/api/schedule", { signal: controller.signal });
|
||||||
|
if (apiSchedule.deleted) {
|
||||||
|
throw new Error("Unexpecetd deleted schedule");
|
||||||
|
}
|
||||||
|
const schedule = ref(ClientSchedule.fromApi(apiSchedule, { zone, locale })) as Ref<ClientSchedule>;
|
||||||
state.schedules.value.set(id, schedule);
|
state.schedules.value.set(id, schedule);
|
||||||
state.pendingSyncs.value.delete(id);
|
state.pendingSyncs.value.delete(id);
|
||||||
return schedule;
|
return schedule;
|
||||||
|
@ -90,18 +96,9 @@ export const useSchedulesStore = defineStore("schedules", () => {
|
||||||
const update = event.data.data;
|
const update = event.data.data;
|
||||||
// XXX validate updatedFrom/updatedAt here
|
// XXX validate updatedFrom/updatedAt here
|
||||||
if (schedule && !schedule.value.deleted && !update.deleted) {
|
if (schedule && !schedule.value.deleted && !update.deleted) {
|
||||||
if (update.locations) {
|
const zone = Info.normalizeZone(accountStore.activeTimezone);
|
||||||
applyUpdatesToArray(update.locations, schedule.value.locations = schedule.value.locations ?? []);
|
const locale = accountStore.activeLocale;
|
||||||
}
|
schedule.value.applyUpdate(update, { zone, locale })
|
||||||
if (update.events) {
|
|
||||||
applyUpdatesToArray(update.events, schedule.value.events = schedule.value.events ?? []);
|
|
||||||
}
|
|
||||||
if (update.roles) {
|
|
||||||
applyUpdatesToArray(update.roles, schedule.value.roles = schedule.value.roles ?? []);
|
|
||||||
}
|
|
||||||
if (update.shifts) {
|
|
||||||
applyUpdatesToArray(update.shifts, schedule.value.shifts = schedule.value.shifts ?? []);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -457,6 +457,7 @@ export class ClientSchedule extends ClientEntity {
|
||||||
shiftSlots: Map<Id, ClientScheduleShiftSlot>;
|
shiftSlots: Map<Id, ClientScheduleShiftSlot>;
|
||||||
eventSlots: Map<Id, ClientScheduleEventSlot>;
|
eventSlots: Map<Id, ClientScheduleEventSlot>;
|
||||||
modified: boolean;
|
modified: boolean;
|
||||||
|
nextClientId = -1;
|
||||||
|
|
||||||
constructor(
|
constructor(
|
||||||
id: 111,
|
id: 111,
|
||||||
|
@ -478,6 +479,14 @@ export class ClientSchedule extends ClientEntity {
|
||||||
this.shiftSlots = idMap([...shifts.values()].flatMap(shift => [...shift.slots.values()]));
|
this.shiftSlots = idMap([...shifts.values()].flatMap(shift => [...shift.slots.values()]));
|
||||||
this.originalShiftSlots = new Map(this.shiftSlots);
|
this.originalShiftSlots = new Map(this.shiftSlots);
|
||||||
this.modified = false;
|
this.modified = false;
|
||||||
|
// XXX For now the prototype server is assigning client ids instead of remapping them.
|
||||||
|
this.nextClientId = Math.min(
|
||||||
|
0,
|
||||||
|
...locations.keys(),
|
||||||
|
...events.keys(),
|
||||||
|
...roles.keys(),
|
||||||
|
...shifts.keys(),
|
||||||
|
) - 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
equals(other: ClientSchedule): boolean {
|
equals(other: ClientSchedule): boolean {
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue