Refactor slot editing to use searchable selections
All checks were successful
/ build (push) Successful in 1m36s
/ deploy (push) Successful in 16s

Instead of having to type in exactly the name of events or shifts and
then hope you remembered it right, replace these interactions with the
custom select component that gives a complete list of the available
choices and allows quickly searching for the right one.
This commit is contained in:
Hornwitser 2025-06-27 18:59:23 +02:00
parent da65103e05
commit b0d5cdf791
4 changed files with 125 additions and 215 deletions

View file

@ -7,7 +7,6 @@
<th>end</th>
<th>duration</th>
<th>event</th>
<th>s</th>
<th>location</th>
<th>assigned</th>
<th v-if="edit"></th>
@ -19,7 +18,7 @@
v-for="es in eventSlots"
:key='es.slot?.id ?? es.start.toMillis()'
:class='{
removed: es.type === "slot" && es.deleted,
removed: es.type === "slot" && es.slot.deleted,
gap: es.type === "gap",
}'
>
@ -35,23 +34,16 @@
>
</td>
<td>
<input
type="text"
v-model="newEventName"
>
<SelectSingleEntity
:entities="schedule.events"
v-model="newEventId"
/>
</td>
<td></td>
<td>
<select
v-model="newEventLocation"
>
<option
v-for="location in schedule.locations.values()"
:key="location.id"
:value="location.id"
:selected="location.id === newEventLocation"
>{{ location.name }}</option>
</select>
<SelectMultiEntity
:entities="schedule.locations"
v-model="newEventLocationIds"
/>
</td>
<td></td>
<td>
@ -89,34 +81,27 @@
>
</td>
<td>
<input
type="text"
v-model="es.event.name"
>
</td>
<td>{{ status(es) }}</td>
<td>
<select
:value="es.locationId"
@change="editEventSlot(es, { locationId: parseInt(($event as any).target.value) })"
>
<option
v-for="location in schedule.locations.values()"
:key="location.id"
:value="location.id"
:selected="location.id === es.locationId"
>{{ location.name }}</option>
</select>
<SelectSingleEntity
:entities="schedule.events"
:modelValue="es.slot.eventId"
@update:modelValue="es.slot.setEventId($event)"
/>
</td>
<td>
<AssignedCrew
:edit="true"
<SelectMultiEntity
:entities="schedule.locations"
v-model="es.slot.locationIds"
/>
</td>
<td>
<SelectMultiEntity
:entities="usersStore.users"
v-model="es.slot.assigned"
/>
</td>
<td>
<button
:disabled="es.deleted"
:disabled="es.slot.deleted"
type="button"
@click="es.slot.deleted = true"
>Remove</button>
@ -148,12 +133,17 @@
>
</td>
<td>
<input
type="text"
v-model="newEventName"
>
<SelectSingleEntity
:entities="schedule.events"
v-model="newEventId"
/>
</td>
<td>
<SelectMultiEntity
:entities="schedule.locations"
v-model="newEventLocationIds"
/>
</td>
<td></td>
<td></td>
<td colspan="2">
<button
@ -181,10 +171,9 @@
<td>{{ es.start.toFormat("yyyy-LL-dd HH:mm") }}</td>
<td>{{ es.end.toFormat("HH:mm") }}</td>
<td>{{ es.end.diff(es.start).toFormat('hh:mm') }}</td>
<td>{{ es.name }}</td>
<td>{{ status(es) }}</td>
<td>{{ es.locationId }}</td>
<td><AssignedCrew :modelValue="es.assigned" :edit="false" /></td>
<td>{{ es.event?.name }}</td>
<td></td>
<td><AssignedCrew :modelValue="es.slot.assigned" :edit="false" /></td>
</template>
</tr>
</template>
@ -206,40 +195,23 @@ const props = defineProps<{
interface EventSlot {
type: "slot",
id: Id,
deleted?: boolean,
event: ClientScheduleEvent,
event?: ClientScheduleEvent,
slot: ClientScheduleEventSlot,
name: string,
locationId: Id,
assigned: Set<Id>,
start: DateTime,
end: DateTime,
}
interface Gap {
type: "gap",
id?: undefined,
event?: undefined,
slot?: undefined,
name?: undefined,
locationId?: number,
start: DateTime,
end: DateTime,
}
function status(eventSlot: EventSlot) {
if (
!eventSlot.event
|| eventSlot.event.name !== eventSlot.name
) {
const event = [...schedule.value.events.values()].find(event => event.name === eventSlot.name);
return event ? "L" : "N";
}
return eventSlot.event.slots.size === 1 ? "" : eventSlot.event.slots.size;
}
const accountStore = useAccountStore();
const usersStore = useUsersStore();
await usersStore.fetch();
const schedule = await useSchedule();
const oneDayMs = 24 * 60 * 60 * 1000;
@ -268,9 +240,9 @@ const newEventEnd = computed({
newEventDuration.value = dropDay(end.diff(start)).toFormat("hh:mm");
},
});
const newEventLocation = ref(props.locationId);
const newEventLocationIds = ref(new Set(props.locationId === undefined ? undefined : [props.locationId]));
watch(() => props.locationId, () => {
newEventLocation.value = props.locationId;
newEventLocationIds.value = new Set(props.locationId === undefined ? undefined : [props.locationId]);
});
function endFromTime(start: DateTime, time: string) {
@ -287,7 +259,7 @@ function durationFromTime(time: string) {
}
return duration;
}
const newEventName = ref("");
const newEventId = ref<Id>();
function editEventSlot(
eventSlot: EventSlot,
@ -295,7 +267,6 @@ function editEventSlot(
start?: string,
end?: string,
duration?: string,
locationId?: Id,
}
) {
if (edits.start) {
@ -309,22 +280,13 @@ function editEventSlot(
if (edits.duration !== undefined) {
eventSlot.slot.end = eventSlot.start.plus(durationFromTime(edits.duration));
}
if (edits.locationId !== undefined) {
eventSlot.slot.locationIds = new Set([edits.locationId]);
}
}
function newEventSlot(options: { start?: DateTime, end?: DateTime } = {}) {
const name = newEventName.value;
const nameId = toId(name);
const event = [...schedule.value.events.values()].find(event => toId(event.name) === nameId);
const event = schedule.value.events.get(newEventId.value!);
if (!event) {
alert("Invalid event");
return;
}
if (newEventLocation.value === undefined) {
alert("Invalid location");
return;
}
let start;
let end;
const duration = durationFromTime(newEventDuration.value);
@ -352,13 +314,13 @@ function newEventSlot(options: { start?: DateTime, end?: DateTime } = {}) {
event.id,
start,
end,
new Set([newEventLocation.value]),
new Set(newEventLocationIds.value),
new Set(),
0,
);
schedule.value.eventSlots.set(slot.id, slot);
event.slotIds.add(slot.id);
newEventName.value = "";
newEventId.value = undefined;
}
const oneHourMs = 60 * 60 * 1000;
@ -373,29 +335,21 @@ function gapFormat(gap: Gap) {
const eventSlots = computed(() => {
const data: (EventSlot | Gap)[] = [];
for (const event of schedule.value.events.values()) {
if (event.deleted)
for (const slot of schedule.value.eventSlots.values()) {
const event = schedule.value.events.get(slot.eventId!);
if (event?.deleted)
continue;
for (const slot of event.slots.values()) {
if (props.eventSlotFilter && !props.eventSlotFilter(slot))
continue;
for (const locationId of slot.locationIds) {
if (props.locationId !== undefined && locationId !== props.locationId)
continue;
data.push({
type: "slot",
id: slot.id,
deleted: slot.deleted || event.deleted,
event,
slot,
name: event.name,
locationId,
assigned: slot.assigned ?? [],
start: slot.start,
end: slot.end,
});
}
}
if (props.eventSlotFilter && !props.eventSlotFilter(slot))
continue;
if (props.locationId !== undefined && !slot.locationIds.has(props.locationId))
continue;
data.push({
type: "slot",
event,
slot,
start: slot.start,
end: slot.end,
});
}
data.sort((a, b) => a.start.toMillis() - b.start.toMillis() || a.end.toMillis() - b.end.toMillis());
@ -407,7 +361,6 @@ const eventSlots = computed(() => {
if (maxEnd < second.start.toMillis()) {
gaps.push([index, {
type: "gap",
locationId: props.locationId,
start: DateTime.fromMillis(maxEnd, { locale: accountStore.activeLocale }),
end: second.start,
}]);

View file

@ -37,9 +37,8 @@
<input
type="checkbox"
:disabled="!accountStore.canEditPublic"
:value="!event.crew"
:checked="!event.crew"
@change="event.crew = !($event.target as HTMLInputElement).value"
@change="event.crew = !event.crew"
>
</td>
<td>{{ event.slots.size ? event.slots.size : "" }}</td>

View file

@ -7,7 +7,6 @@
<th>end</th>
<th>duration</th>
<th>shift</th>
<th>s</th>
<th>role</th>
<th>assigned</th>
<th v-if="edit"></th>
@ -35,24 +34,16 @@
>
</td>
<td>
<input
type="text"
v-model="newShiftName"
>
<SelectSingleEntity
:entities="schedule.shifts"
v-model="newShiftId"
/>
</td>
<td></td>
<td>
<select
<SelectSingleEntity
:entities="schedule.roles"
v-model="newShiftRoleId"
>
<option
v-for="role in schedule.roles.values()"
:key="role.id"
:value="role.id"
:disabled="role.deleted"
:selected="role.id === newShiftRoleId"
>{{ role.name }}</option>
</select>
/>
</td>
<td></td>
<td>
@ -90,34 +81,28 @@
>
</td>
<td>
<input
type="text"
v-model="ss.name"
>
<SelectSingleEntity
:entities="schedule.shifts"
:modelValue="ss.slot.shiftId"
@update:modelValue="ss.slot.setShiftId($event)"
/>
</td>
<td>{{ status(ss) }}</td>
<td>
<select
<SelectSingleEntity
v-if="ss.shift"
:entities="schedule.roles"
v-model="ss.shift.roleId"
>
<option
v-for="role in schedule.roles.values()"
:key="role.id"
:value="role.id"
:disabled="role.deleted"
:selected="role.id === ss.shift.roleId"
>{{ role.name }}</option>
</select>
/>
</td>
<td>
<AssignedCrew
:edit="true"
<SelectMultiEntity
:entities="usersStore.users"
v-model="ss.slot.assigned"
/>
</td>
<td>
<button
:disabled="ss.deleted"
:disabled="ss.slot.deleted"
type="button"
@click="ss.slot.deleted = true"
>Remove</button>
@ -149,12 +134,18 @@
>
</td>
<td>
<input
type="text"
v-model="newShiftName"
>
<SelectSingleEntity
:entities="schedule.shifts"
v-model="newShiftId"
/>
</td>
<td colspan="3">
<td>
<SelectSingleEntity
:entities="schedule.roles"
v-model="newShiftRoleId"
/>
</td>
<td colspan="2">
<button
type="button"
@click="newShiftSlot()"
@ -180,10 +171,11 @@
<td>{{ ss.start.toFormat("yyyy-LL-dd HH:mm") }}</td>
<td>{{ ss.end.toFormat("HH:mm") }}</td>
<td>{{ ss.end.diff(ss.start).toFormat('hh:mm') }}</td>
<td>{{ ss.name }}</td>
<td>{{ status(ss) }}</td>
<td>{{ ss.roleId }}</td>
<td><AssignedCrew :modelValue="ss.assigned" :edit="false" /></td>
<td>{{ ss.shift?.name }}</td>
<td>{{ ss.shift?.roleId }}</td>
<td>
<AssignedCrew :modelValue="ss.slot.assigned" :edit="false" />
</td>
</template>
</tr>
</template>
@ -206,12 +198,8 @@ const props = defineProps<{
interface ShiftSlot {
type: "slot",
id: Id,
deleted: boolean,
shift: ClientScheduleShift,
shift?: ClientScheduleShift,
slot: ClientScheduleShiftSlot,
name: string,
roleId: Id,
assigned: Set<Id>,
start: DateTime,
end: DateTime,
}
@ -221,24 +209,13 @@ interface Gap {
id?: undefined,
shift?: undefined,
slot?: undefined,
name?: undefined,
role?: undefined,
start: DateTime,
end: DateTime,
}
function status(shiftSlot: ShiftSlot) {
if (
!shiftSlot.shift
|| shiftSlot.shift.name !== shiftSlot.name
) {
const shift = [...schedule.value.shifts.values()].find(shift => !shift.deleted && shift.name === shiftSlot.name);
return shift ? "L" : "N";
}
return shiftSlot.shift.slots.size === 1 ? "" : shiftSlot.shift.slots.size;
}
const accountStore = useAccountStore();
const usersStore = useUsersStore();
await usersStore.fetch();
const schedule = await useSchedule();
const oneDayMs = 24 * 60 * 60 * 1000;
@ -286,7 +263,7 @@ function durationFromTime(time: string) {
}
return duration;
}
const newShiftName = ref("");
const newShiftId = ref<Id>();
function editShiftSlot(
shiftSlot: ShiftSlot,
@ -298,20 +275,18 @@ function editShiftSlot(
) {
if (edits.start) {
const start = DateTime.fromISO(edits.start, { zone: accountStore.activeTimezone, locale: accountStore.activeLocale });
shiftSlot.start = start;
shiftSlot.end = start.plus(shiftSlot.slot.end.diff(shiftSlot.slot.start));
shiftSlot.slot.start = start;
shiftSlot.slot.end = start.plus(shiftSlot.slot.end.diff(shiftSlot.slot.start));
}
if (edits.end !== undefined) {
shiftSlot.end = endFromTime(shiftSlot.start, edits.end);
shiftSlot.slot.end = endFromTime(shiftSlot.slot.start, edits.end);
}
if (edits.duration !== undefined) {
shiftSlot.end = shiftSlot.start.plus(durationFromTime(edits.duration));
shiftSlot.slot.end = shiftSlot.slot.start.plus(durationFromTime(edits.duration));
}
}
function newShiftSlot(options: { start?: DateTime, end?: DateTime } = {}) {
const name = newShiftName.value;
const nameId = toId(name);
const shift = [...schedule.value.shifts.values()].find(shift => toId(shift.name) === nameId);
const shift = schedule.value.shifts.get(newShiftId.value!);
if (!shift) {
alert("Invalid shift");
return;
@ -352,7 +327,7 @@ function newShiftSlot(options: { start?: DateTime, end?: DateTime } = {}) {
);
schedule.value.shiftSlots.set(slot.id, slot);
shift.slotIds.add(slot.id);
newShiftName.value = "";
newShiftId.value = undefined;
}
const oneHourMs = 60 * 60 * 1000;
@ -367,25 +342,20 @@ function gapFormat(gap: Gap) {
const shiftSlots = computed(() => {
const data: (ShiftSlot | Gap)[] = [];
for (const shift of schedule.value.shifts.values()) {
if (props.roleId !== undefined && shift.roleId !== props.roleId)
for (const slot of schedule.value.shiftSlots.values()) {
const shift = schedule.value.shifts.get(slot.shiftId!);
if (shift && props.roleId !== undefined && shift.roleId !== props.roleId)
continue;
for (const slot of shift.slots.values()) {
if (props.shiftSlotFilter && !props.shiftSlotFilter(slot))
continue;
data.push({
type: "slot",
id: slot.id,
deleted: slot.deleted || shift.deleted,
shift,
slot,
name: shift.name,
roleId: shift.roleId,
assigned: slot.assigned,
start: slot.start,
end: slot.end,
});
}
if (props.shiftSlotFilter && !props.shiftSlotFilter(slot))
continue;
data.push({
type: "slot",
id: slot.id,
shift,
slot,
start: slot.start,
end: slot.end,
});
}
const byTime = (a: DateTime, b: DateTime) => a.toMillis() - b.toMillis();
data.sort((a, b) => byTime(a.start, b.start) || byTime(a.end, b.end));

View file

@ -26,17 +26,10 @@
>
</td>
<td>
<select
<SelectSingleEntity
:entities="schedule.roles"
v-model="shift.roleId"
>
<option
v-for="role in schedule.roles.values()"
:key="role.id"
:value="role.id"
:disabled="shift.deleted"
:selected="shift.roleId === role.id"
>{{ role.name }}</option>
</select>
/>
</td>
<td>{{ shift.slots.size ? shift.slots.size : "" }}</td>
<td>
@ -67,15 +60,10 @@
>
</td>
<td>
<select v-model="newShiftRoleId">
<option
v-for="role in schedule.roles.values()"
:key="role.id"
:value="role.id"
:disabled="role.deleted"
:selected="role.id === newShiftRoleId"
>{{ role.name }}</option>
</select>
<SelectSingleEntity
:entities="schedule.roles"
v-model="newShiftRoleId"
/>
</td>
<td></td>
<td>