@lavalogic/scoria 0.40.21 → 0.40.24
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/Components/{AppointmentCalendar/AppointmentCalendar.svelte → ResourceCalendar/ResourceCalendar.svelte} +412 -494
- package/dist/Components/ResourceCalendar/ResourceCalendar.svelte.d.ts +25 -0
- package/dist/Components/ResourceCalendar/ResourceCalendarProps.d.ts +116 -0
- package/dist/Components/{AppointmentCalendar/AppointmentCalendarProps.js → ResourceCalendar/ResourceCalendarProps.js} +1 -2
- package/dist/Helpers/loaderDefaults.svelte.d.ts +1 -1
- package/dist/Helpers/loaderDefaults.svelte.js +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.js +1 -1
- package/package.json +1 -1
- package/dist/Components/AppointmentCalendar/AppointmentCalendar.svelte.d.ts +0 -4
- package/dist/Components/AppointmentCalendar/AppointmentCalendarProps.d.ts +0 -123
|
@@ -1,25 +1,25 @@
|
|
|
1
|
-
<svelte:options runes />
|
|
2
|
-
|
|
3
|
-
<script
|
|
1
|
+
<svelte:options runes />
|
|
2
|
+
|
|
3
|
+
<script
|
|
4
|
+
lang="ts"
|
|
5
|
+
generics="E extends CalendarEvent"
|
|
6
|
+
>import { Colour } from "../../scss/colours.js";
|
|
4
7
|
import { Size } from "../../Types/Internal/Size.js";
|
|
5
8
|
import Button from "../Button.svelte";
|
|
6
9
|
import Icon from "../Icon.svelte";
|
|
7
10
|
import LoadingOverlay from "../LoadingOverlay.svelte";
|
|
8
|
-
import { CalendarViewMode } from "./
|
|
9
|
-
let {
|
|
11
|
+
import { CalendarViewMode } from "./ResourceCalendarProps.js";
|
|
12
|
+
let { resources, events, event: eventContent, selectedDate, viewMode, ondatechange, onviewmodechange, onrangeselect, oneventclick, oneventmove, startHour = 0, endHour = 24, slotIntervalMinutes = 15, availability, centreHour = 12, isLoading = false, headerExtra } = $props();
|
|
10
13
|
/**
|
|
11
|
-
* `selectedDate` is always read by the component, never written. The
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
* for narrowing inside the script.
|
|
14
|
+
* `selectedDate` is always read by the component, never written. The runtime
|
|
15
|
+
* type may be a plain `Date` or a `SvelteDate`; both expose the same `Date`
|
|
16
|
+
* interface. Cast to `Date` for narrowing inside the script.
|
|
15
17
|
*/
|
|
16
18
|
const selected = $derived(selectedDate);
|
|
17
19
|
/**
|
|
18
|
-
* Today's reference Date hoisted out of the per-cell loops so
|
|
19
|
-
*
|
|
20
|
-
*
|
|
21
|
-
* over time on its own; if you need real day-rollover semantics
|
|
22
|
-
* track it with a `SvelteDate` and a `setInterval`.
|
|
20
|
+
* Today's reference Date hoisted out of the per-cell loops so rendering the
|
|
21
|
+
* month grid does not allocate 42 fresh `Date` instances on every state
|
|
22
|
+
* change. `$derived` does not invalidate over time on its own.
|
|
23
23
|
*/
|
|
24
24
|
const today = $derived(new Date());
|
|
25
25
|
// ---- Date helpers ----
|
|
@@ -93,13 +93,6 @@ const timeSlots = $derived.by(() => {
|
|
|
93
93
|
return slots;
|
|
94
94
|
});
|
|
95
95
|
// ---- Availability shading ----
|
|
96
|
-
/**
|
|
97
|
-
* Whether the given slot is outside the door's available windows.
|
|
98
|
-
*
|
|
99
|
-
* A door absent from {@link availability} (or no `availability` map at
|
|
100
|
-
* all) is treated as fully available, so nothing is shaded until the
|
|
101
|
-
* caller supplies windows for that door.
|
|
102
|
-
*/
|
|
103
96
|
function slotUnavailableInWindows(windows, slot) {
|
|
104
97
|
const minutes = slot.hour * 60 + slot.minute;
|
|
105
98
|
for (const window of windows) {
|
|
@@ -109,11 +102,11 @@ function slotUnavailableInWindows(windows, slot) {
|
|
|
109
102
|
}
|
|
110
103
|
return true;
|
|
111
104
|
}
|
|
112
|
-
function isSlotUnavailable(
|
|
105
|
+
function isSlotUnavailable(resourceId, slotIndex) {
|
|
113
106
|
if (!availability) {
|
|
114
107
|
return false;
|
|
115
108
|
}
|
|
116
|
-
const windows = availability.get(
|
|
109
|
+
const windows = availability.get(resourceId);
|
|
117
110
|
if (!windows) {
|
|
118
111
|
return false;
|
|
119
112
|
}
|
|
@@ -124,28 +117,26 @@ function isSlotUnavailable(doorId, slotIndex) {
|
|
|
124
117
|
return slotUnavailableInWindows(windows, slot);
|
|
125
118
|
}
|
|
126
119
|
/**
|
|
127
|
-
* Contiguous unavailable runs per
|
|
128
|
-
*
|
|
129
|
-
*
|
|
130
|
-
* rather than restarting (and visibly seaming) at every slot cell.
|
|
120
|
+
* Contiguous unavailable runs per resource, as top/height percentages of the
|
|
121
|
+
* day column. Rendered as a single overlay block per run so the shading is
|
|
122
|
+
* continuous rather than restarting (and visibly seaming) at every slot.
|
|
131
123
|
*/
|
|
132
|
-
const
|
|
133
|
-
// Local index built inside a $derived; outer reactivity covers reads.
|
|
124
|
+
const unavailableRunsByResource = $derived.by(() => {
|
|
134
125
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
135
|
-
const
|
|
126
|
+
const byResource = new Map();
|
|
136
127
|
const slotCount = timeSlots.length;
|
|
137
128
|
if (!availability || slotCount === 0) {
|
|
138
|
-
return
|
|
129
|
+
return byResource;
|
|
139
130
|
}
|
|
140
|
-
for (const
|
|
141
|
-
const windows = availability.get(
|
|
131
|
+
for (const resource of resources) {
|
|
132
|
+
const windows = availability.get(resource.id);
|
|
142
133
|
if (!windows) {
|
|
143
134
|
continue;
|
|
144
135
|
}
|
|
145
136
|
const runs = [];
|
|
146
137
|
let runStart = null;
|
|
147
|
-
// One extra iteration (i === slotCount) flushes a run that reaches
|
|
148
|
-
//
|
|
138
|
+
// One extra iteration (i === slotCount) flushes a run that reaches the
|
|
139
|
+
// end of the day.
|
|
149
140
|
for (let i = 0; i <= slotCount; i++) {
|
|
150
141
|
const unavailable = i < slotCount && slotUnavailableInWindows(windows, timeSlots[i]);
|
|
151
142
|
if (unavailable) {
|
|
@@ -161,18 +152,15 @@ const unavailableRunsByDoor = $derived.by(() => {
|
|
|
161
152
|
}
|
|
162
153
|
}
|
|
163
154
|
if (runs.length > 0) {
|
|
164
|
-
|
|
155
|
+
byResource.set(resource.id, runs);
|
|
165
156
|
}
|
|
166
157
|
}
|
|
167
|
-
return
|
|
158
|
+
return byResource;
|
|
168
159
|
});
|
|
169
|
-
function unavailableRunsAt(
|
|
170
|
-
return
|
|
160
|
+
function unavailableRunsAt(resourceId) {
|
|
161
|
+
return unavailableRunsByResource.get(resourceId) ?? [];
|
|
171
162
|
}
|
|
172
|
-
/**
|
|
173
|
-
* Build a `YYYY-MM-DD` key for date bucketing. Avoids `toISOString`
|
|
174
|
-
* timezone conversions that would smear appointments across days.
|
|
175
|
-
*/
|
|
163
|
+
/** Build a `YYYY-MM-DD` key for date bucketing without timezone smearing. */
|
|
176
164
|
function dayKey(d) {
|
|
177
165
|
const y = d.getFullYear();
|
|
178
166
|
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
@@ -180,33 +168,31 @@ function dayKey(d) {
|
|
|
180
168
|
return `${y}-${m}-${day}`;
|
|
181
169
|
}
|
|
182
170
|
/**
|
|
183
|
-
* Precomputed `Map<
|
|
184
|
-
*
|
|
185
|
-
*
|
|
186
|
-
* per render and allocated a fresh `Date` per appointment per pass.
|
|
171
|
+
* Precomputed `Map<resourceId, Map<dayKey, PositionedEvent[]>>` for day and
|
|
172
|
+
* week views — avoids per-cell `events.filter(...)` and per-event `Date`
|
|
173
|
+
* allocations on every render.
|
|
187
174
|
*/
|
|
188
|
-
const
|
|
175
|
+
const positionedByResourceAndDay = $derived.by(() => {
|
|
189
176
|
const totalMinutes = (endHour - startHour) * 60;
|
|
190
|
-
// Local indexes built inside a $derived; outer reactivity already covers reads.
|
|
191
177
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
192
|
-
const
|
|
193
|
-
for (const
|
|
194
|
-
const from = new Date(
|
|
195
|
-
const to = new Date(
|
|
178
|
+
const byResource = new Map();
|
|
179
|
+
for (const ev of events) {
|
|
180
|
+
const from = new Date(ev.start);
|
|
181
|
+
const to = new Date(ev.end);
|
|
196
182
|
const startMin = (from.getHours() - startHour) * 60 + from.getMinutes();
|
|
197
183
|
const endMin = (to.getHours() - startHour) * 60 + to.getMinutes();
|
|
198
184
|
const durationMin = Math.max(endMin - startMin, slotIntervalMinutes);
|
|
199
185
|
const positioned = {
|
|
200
|
-
...
|
|
186
|
+
...ev,
|
|
201
187
|
topPercent: Math.max(0, startMin) / totalMinutes * 100,
|
|
202
188
|
heightPercent: Math.min(durationMin, totalMinutes - startMin) / totalMinutes * 100
|
|
203
189
|
};
|
|
204
190
|
const key = dayKey(from);
|
|
205
|
-
let perDay =
|
|
191
|
+
let perDay = byResource.get(ev.resourceId);
|
|
206
192
|
if (!perDay) {
|
|
207
193
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
208
194
|
perDay = new Map();
|
|
209
|
-
|
|
195
|
+
byResource.set(ev.resourceId, perDay);
|
|
210
196
|
}
|
|
211
197
|
const list = perDay.get(key);
|
|
212
198
|
if (list) {
|
|
@@ -215,43 +201,35 @@ const positionedByDoorAndDay = $derived.by(() => {
|
|
|
215
201
|
perDay.set(key, [positioned]);
|
|
216
202
|
}
|
|
217
203
|
}
|
|
218
|
-
return
|
|
204
|
+
return byResource;
|
|
219
205
|
});
|
|
220
|
-
function
|
|
221
|
-
return
|
|
206
|
+
function eventsAt(resourceId, day) {
|
|
207
|
+
return positionedByResourceAndDay.get(resourceId)?.get(dayKey(day)) ?? [];
|
|
222
208
|
}
|
|
223
|
-
/**
|
|
224
|
-
* Precomputed appointment-count badges per day for the month view.
|
|
225
|
-
* Replaces the previous `O(cells * doors * appointments)` recomputation
|
|
226
|
-
* which scaled to ~42 000 ops on a single render with 10 doors and
|
|
227
|
-
* 100 appointments.
|
|
228
|
-
*/
|
|
209
|
+
/** Precomputed event-count badges per day for the month view. */
|
|
229
210
|
const countsByDay = $derived.by(() => {
|
|
230
|
-
// Local indexes built inside a $derived; outer reactivity already covers reads.
|
|
231
211
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
232
212
|
const map = new Map();
|
|
233
|
-
// One scan over appointments builds a nested Map<dayKey, Map<doorId, count>>.
|
|
234
213
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
235
214
|
const intermediate = new Map();
|
|
236
|
-
for (const
|
|
237
|
-
const key = dayKey(new Date(
|
|
215
|
+
for (const ev of events) {
|
|
216
|
+
const key = dayKey(new Date(ev.start));
|
|
238
217
|
let perDay = intermediate.get(key);
|
|
239
218
|
if (!perDay) {
|
|
240
219
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
241
220
|
perDay = new Map();
|
|
242
221
|
intermediate.set(key, perDay);
|
|
243
222
|
}
|
|
244
|
-
perDay.set(
|
|
223
|
+
perDay.set(ev.resourceId, (perDay.get(ev.resourceId) ?? 0) + 1);
|
|
245
224
|
}
|
|
246
|
-
// Flatten to the door-ordered list the markup expects.
|
|
247
225
|
for (const [key, perDay] of intermediate) {
|
|
248
226
|
const list = [];
|
|
249
|
-
for (const
|
|
250
|
-
const count = perDay.get(
|
|
227
|
+
for (const resource of resources) {
|
|
228
|
+
const count = perDay.get(resource.id);
|
|
251
229
|
if (count) {
|
|
252
230
|
list.push({
|
|
253
|
-
|
|
254
|
-
|
|
231
|
+
resourceId: resource.id,
|
|
232
|
+
resourceLabel: resource.label,
|
|
255
233
|
count
|
|
256
234
|
});
|
|
257
235
|
}
|
|
@@ -267,124 +245,137 @@ function countsAt(day) {
|
|
|
267
245
|
}
|
|
268
246
|
// ---- Click and drag logic ----
|
|
269
247
|
let isDragging = $state(false);
|
|
270
|
-
let
|
|
248
|
+
let dragResourceId = $state(null);
|
|
271
249
|
let dragStartSlotIndex = $state(null);
|
|
272
250
|
let dragEndSlotIndex = $state(null);
|
|
273
|
-
function onSlotMouseDown(
|
|
274
|
-
// Only start a drag-create on the primary (left) button.
|
|
275
|
-
// middle-click (autoscroll) or right-click would open the create modal.
|
|
251
|
+
function onSlotMouseDown(resourceId, slotIndex, e) {
|
|
252
|
+
// Only start a drag-create on the primary (left) button.
|
|
276
253
|
if (e.button !== 0) {
|
|
277
254
|
return;
|
|
278
255
|
}
|
|
279
|
-
if (!
|
|
256
|
+
if (!onrangeselect || eventDragStarted) {
|
|
280
257
|
return;
|
|
281
258
|
}
|
|
282
|
-
|
|
283
|
-
if (isSlotUnavailable(doorId, slotIndex)) {
|
|
259
|
+
if (isSlotUnavailable(resourceId, slotIndex)) {
|
|
284
260
|
return;
|
|
285
261
|
}
|
|
286
262
|
isDragging = true;
|
|
287
|
-
|
|
263
|
+
dragResourceId = resourceId;
|
|
288
264
|
dragStartSlotIndex = slotIndex;
|
|
289
265
|
dragEndSlotIndex = slotIndex;
|
|
290
266
|
}
|
|
291
|
-
function onSlotMouseEnter(
|
|
267
|
+
function onSlotMouseEnter(resourceId, slotIndex, e) {
|
|
292
268
|
// If a drag is in progress but the primary button is no longer held, the
|
|
293
|
-
// pointer-up happened somewhere we did not observe
|
|
294
|
-
|
|
295
|
-
if ((isDragging || appointmentDragStarted) && (e.buttons & 1) === 0) {
|
|
269
|
+
// pointer-up happened somewhere we did not observe. Reset.
|
|
270
|
+
if ((isDragging || eventDragStarted) && (e.buttons & 1) === 0) {
|
|
296
271
|
isDragging = false;
|
|
297
|
-
|
|
272
|
+
dragResourceId = null;
|
|
298
273
|
dragStartSlotIndex = null;
|
|
299
274
|
dragEndSlotIndex = null;
|
|
300
|
-
|
|
275
|
+
resetEventDrag();
|
|
301
276
|
return;
|
|
302
277
|
}
|
|
303
|
-
//
|
|
304
|
-
if (
|
|
305
|
-
|
|
306
|
-
if (isSlotUnavailable(doorId, slotIndex)) {
|
|
278
|
+
// Event move drag
|
|
279
|
+
if (eventDragStarted) {
|
|
280
|
+
if (isSlotUnavailable(resourceId, slotIndex)) {
|
|
307
281
|
return;
|
|
308
282
|
}
|
|
309
|
-
|
|
310
|
-
|
|
283
|
+
isDraggingEvent = true;
|
|
284
|
+
moveTargetResourceId = resourceId;
|
|
311
285
|
moveTargetSlotIndex = slotIndex;
|
|
312
286
|
return;
|
|
313
287
|
}
|
|
314
288
|
// Slot drag-create
|
|
315
|
-
if (!isDragging ||
|
|
289
|
+
if (!isDragging || resourceId !== dragResourceId) {
|
|
316
290
|
return;
|
|
317
291
|
}
|
|
318
|
-
|
|
319
|
-
if (isSlotUnavailable(doorId, slotIndex)) {
|
|
292
|
+
if (isSlotUnavailable(resourceId, slotIndex)) {
|
|
320
293
|
return;
|
|
321
294
|
}
|
|
322
295
|
dragEndSlotIndex = slotIndex;
|
|
323
296
|
}
|
|
297
|
+
/** Builds a range selection spanning slots [startIdx, endIdx] inclusive. */
|
|
298
|
+
function buildSlotRange(resourceId, startIdx, endIdx) {
|
|
299
|
+
const startSlot = timeSlots[startIdx];
|
|
300
|
+
const endSlot = timeSlots[endIdx];
|
|
301
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
302
|
+
const start = new Date(selected);
|
|
303
|
+
start.setHours(startSlot.hour, startSlot.minute, 0, 0);
|
|
304
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
305
|
+
const end = new Date(selected);
|
|
306
|
+
// End slot is the start of the last selected slot; add one interval.
|
|
307
|
+
end.setHours(endSlot.hour, endSlot.minute + slotIntervalMinutes, 0, 0);
|
|
308
|
+
return {
|
|
309
|
+
resourceId,
|
|
310
|
+
start,
|
|
311
|
+
end
|
|
312
|
+
};
|
|
313
|
+
}
|
|
314
|
+
/**
|
|
315
|
+
* Double-clicking a single slot creates a one-interval range there. A single
|
|
316
|
+
* click is intentionally inert (see `onSlotMouseUp`): only a double-click or a
|
|
317
|
+
* multi-cell drag opens the consumer's range-select flow.
|
|
318
|
+
*/
|
|
319
|
+
function onSlotDoubleClick(resourceId, slotIndex) {
|
|
320
|
+
if (!onrangeselect || isSlotUnavailable(resourceId, slotIndex)) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
onrangeselect(buildSlotRange(resourceId, slotIndex, slotIndex));
|
|
324
|
+
}
|
|
324
325
|
function onSlotMouseUp() {
|
|
325
|
-
// ---- Handle
|
|
326
|
-
if (
|
|
326
|
+
// ---- Handle event drag-move ----
|
|
327
|
+
if (isDraggingEvent && draggedEvent && moveTargetResourceId != null && moveTargetSlotIndex != null) {
|
|
327
328
|
const slot = timeSlots[moveTargetSlotIndex];
|
|
328
329
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
329
|
-
const
|
|
330
|
-
|
|
331
|
-
const
|
|
332
|
-
|
|
333
|
-
const
|
|
334
|
-
const samePosition = moveTargetDoorId === draggedAppointment.segmentId && newDateFrom.getTime() === origFrom.getTime();
|
|
330
|
+
const newStart = new Date(selected);
|
|
331
|
+
newStart.setHours(slot.hour, slot.minute, 0, 0);
|
|
332
|
+
const newEnd = new Date(newStart.getTime() + draggedSlotCount * slotIntervalMinutes * 6e4);
|
|
333
|
+
const origFrom = new Date(draggedEvent.start);
|
|
334
|
+
const samePosition = moveTargetResourceId === draggedEvent.resourceId && newStart.getTime() === origFrom.getTime();
|
|
335
335
|
if (samePosition) {
|
|
336
|
-
// Dropped in the same spot - treat as a click
|
|
337
|
-
|
|
338
|
-
} else if (!hasOverlap(
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
newDateFrom,
|
|
345
|
-
newDateTo
|
|
336
|
+
// Dropped in the same spot - treat as a click.
|
|
337
|
+
oneventclick?.(draggedEvent);
|
|
338
|
+
} else if (!hasOverlap(moveTargetResourceId, newStart, newEnd, draggedEvent.id)) {
|
|
339
|
+
oneventmove?.({
|
|
340
|
+
event: draggedEvent,
|
|
341
|
+
newResourceId: moveTargetResourceId,
|
|
342
|
+
newStart,
|
|
343
|
+
newEnd
|
|
346
344
|
});
|
|
347
345
|
}
|
|
348
|
-
|
|
346
|
+
resetEventDrag();
|
|
349
347
|
return;
|
|
350
348
|
}
|
|
351
|
-
// ---- Handle
|
|
352
|
-
if (
|
|
353
|
-
|
|
354
|
-
|
|
349
|
+
// ---- Handle event click (mousedown + mouseup without leaving the block) ----
|
|
350
|
+
if (eventDragStarted && !isDraggingEvent && draggedEvent) {
|
|
351
|
+
oneventclick?.(draggedEvent);
|
|
352
|
+
resetEventDrag();
|
|
355
353
|
return;
|
|
356
354
|
}
|
|
357
|
-
|
|
355
|
+
resetEventDrag();
|
|
358
356
|
// ---- Handle drag-create ----
|
|
359
|
-
if (!isDragging ||
|
|
357
|
+
if (!isDragging || dragResourceId == null || dragStartSlotIndex == null || dragEndSlotIndex == null) {
|
|
360
358
|
return;
|
|
361
359
|
}
|
|
362
360
|
const startIdx = Math.min(dragStartSlotIndex, dragEndSlotIndex);
|
|
363
361
|
const endIdx = Math.max(dragStartSlotIndex, dragEndSlotIndex);
|
|
364
|
-
const
|
|
365
|
-
|
|
366
|
-
//
|
|
367
|
-
const dateFrom = new Date(selected);
|
|
368
|
-
dateFrom.setHours(startSlot.hour, startSlot.minute, 0, 0);
|
|
369
|
-
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
370
|
-
const dateTo = new Date(selected);
|
|
371
|
-
// End slot is the start of the last selected slot; add one interval for the end time
|
|
372
|
-
dateTo.setHours(endSlot.hour, endSlot.minute + slotIntervalMinutes, 0, 0);
|
|
373
|
-
const door = doors.find((d) => d.id === dragDoorId);
|
|
374
|
-
const selection = {
|
|
375
|
-
doorId: dragDoorId,
|
|
376
|
-
doorName: door?.name ?? "",
|
|
377
|
-
dateFrom,
|
|
378
|
-
dateTo
|
|
379
|
-
};
|
|
362
|
+
const resourceId = dragResourceId;
|
|
363
|
+
// Capture done; clear the drag state before any early return so it is
|
|
364
|
+
// never left stuck for the next interaction.
|
|
380
365
|
isDragging = false;
|
|
381
|
-
|
|
366
|
+
dragResourceId = null;
|
|
382
367
|
dragStartSlotIndex = null;
|
|
383
368
|
dragEndSlotIndex = null;
|
|
384
|
-
|
|
369
|
+
// A single-cell selection is a plain click - intentionally inert. Creating
|
|
370
|
+
// from one cell requires a double-click (onSlotDoubleClick); only a drag
|
|
371
|
+
// across multiple cells opens the range-select flow here.
|
|
372
|
+
if (startIdx === endIdx) {
|
|
373
|
+
return;
|
|
374
|
+
}
|
|
375
|
+
onrangeselect?.(buildSlotRange(resourceId, startIdx, endIdx));
|
|
385
376
|
}
|
|
386
|
-
function isSlotInDragRange(
|
|
387
|
-
if (!isDragging ||
|
|
377
|
+
function isSlotInDragRange(resourceId, slotIndex) {
|
|
378
|
+
if (!isDragging || resourceId !== dragResourceId) {
|
|
388
379
|
return false;
|
|
389
380
|
}
|
|
390
381
|
if (dragStartSlotIndex == null || dragEndSlotIndex == null) {
|
|
@@ -396,58 +387,57 @@ function isSlotInDragRange(doorId, slotIndex) {
|
|
|
396
387
|
}
|
|
397
388
|
// ---- Row hover tracking ----
|
|
398
389
|
let hoveredSlotIndex = $state(null);
|
|
399
|
-
// ----
|
|
400
|
-
let
|
|
401
|
-
let
|
|
402
|
-
let
|
|
403
|
-
let
|
|
404
|
-
let
|
|
390
|
+
// ---- Event drag-move logic ----
|
|
391
|
+
let eventDragStarted = $state(false);
|
|
392
|
+
let isDraggingEvent = $state(false);
|
|
393
|
+
let draggedEvent = $state(null);
|
|
394
|
+
let draggedSlotCount = $state(0);
|
|
395
|
+
let moveTargetResourceId = $state(null);
|
|
405
396
|
let moveTargetSlotIndex = $state(null);
|
|
406
|
-
function
|
|
407
|
-
// Only react to the primary (left) button
|
|
408
|
-
// start a move or register as an appointment click.
|
|
397
|
+
function onEventMouseDown(ev, e) {
|
|
398
|
+
// Only react to the primary (left) button.
|
|
409
399
|
if (e.button !== 0) {
|
|
410
400
|
return;
|
|
411
401
|
}
|
|
412
|
-
if (!
|
|
402
|
+
if (!oneventmove) {
|
|
413
403
|
return;
|
|
414
404
|
}
|
|
415
405
|
e.preventDefault();
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
const from = new Date(
|
|
419
|
-
const to = new Date(
|
|
406
|
+
eventDragStarted = true;
|
|
407
|
+
draggedEvent = ev;
|
|
408
|
+
const from = new Date(ev.start);
|
|
409
|
+
const to = new Date(ev.end);
|
|
420
410
|
const durationMin = (to.getTime() - from.getTime()) / 6e4;
|
|
421
|
-
|
|
422
|
-
}
|
|
423
|
-
function
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
|
|
428
|
-
|
|
411
|
+
draggedSlotCount = Math.max(1, Math.ceil(durationMin / slotIntervalMinutes));
|
|
412
|
+
}
|
|
413
|
+
function resetEventDrag() {
|
|
414
|
+
eventDragStarted = false;
|
|
415
|
+
isDraggingEvent = false;
|
|
416
|
+
draggedEvent = null;
|
|
417
|
+
draggedSlotCount = 0;
|
|
418
|
+
moveTargetResourceId = null;
|
|
429
419
|
moveTargetSlotIndex = null;
|
|
430
420
|
}
|
|
431
|
-
function isSlotInMoveTarget(
|
|
432
|
-
if (!
|
|
421
|
+
function isSlotInMoveTarget(resourceId, slotIndex) {
|
|
422
|
+
if (!isDraggingEvent || moveTargetResourceId !== resourceId) {
|
|
433
423
|
return false;
|
|
434
424
|
}
|
|
435
425
|
if (moveTargetSlotIndex == null) {
|
|
436
426
|
return false;
|
|
437
427
|
}
|
|
438
|
-
return slotIndex >= moveTargetSlotIndex && slotIndex < moveTargetSlotIndex +
|
|
428
|
+
return slotIndex >= moveTargetSlotIndex && slotIndex < moveTargetSlotIndex + draggedSlotCount;
|
|
439
429
|
}
|
|
440
|
-
function hasOverlap(
|
|
441
|
-
return
|
|
442
|
-
if (
|
|
430
|
+
function hasOverlap(resourceId, start, end, excludeId) {
|
|
431
|
+
return events.some((ev) => {
|
|
432
|
+
if (ev.id === excludeId) {
|
|
443
433
|
return false;
|
|
444
434
|
}
|
|
445
|
-
if (
|
|
435
|
+
if (ev.resourceId !== resourceId) {
|
|
446
436
|
return false;
|
|
447
437
|
}
|
|
448
|
-
const
|
|
449
|
-
const
|
|
450
|
-
return
|
|
438
|
+
const evFrom = new Date(ev.start);
|
|
439
|
+
const evTo = new Date(ev.end);
|
|
440
|
+
return start < evTo && end > evFrom;
|
|
451
441
|
});
|
|
452
442
|
}
|
|
453
443
|
// ---- Month view helpers ----
|
|
@@ -455,7 +445,7 @@ function getMonthDays(date) {
|
|
|
455
445
|
const year = date.getFullYear();
|
|
456
446
|
const month = date.getMonth();
|
|
457
447
|
const firstDayOfMonth = new Date(year, month, 1);
|
|
458
|
-
// Start from Monday of the week containing the first day
|
|
448
|
+
// Start from Monday of the week containing the first day.
|
|
459
449
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
460
450
|
const startDate = new Date(firstDayOfMonth);
|
|
461
451
|
const dayOfWeek = startDate.getDay();
|
|
@@ -464,7 +454,7 @@ function getMonthDays(date) {
|
|
|
464
454
|
const days = [];
|
|
465
455
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
466
456
|
const current = new Date(startDate);
|
|
467
|
-
// Generate 6 weeks (42 days) to fill the grid
|
|
457
|
+
// Generate 6 weeks (42 days) to fill the grid.
|
|
468
458
|
for (let i = 0; i < 42; i++) {
|
|
469
459
|
days.push({
|
|
470
460
|
date: new Date(current),
|
|
@@ -507,18 +497,10 @@ const viewModes = [
|
|
|
507
497
|
}
|
|
508
498
|
];
|
|
509
499
|
// ---- Centre-on-hour scrolling ----
|
|
510
|
-
/** The whole hour whose row carries the `data-centre-row` marker. */
|
|
511
500
|
const centreRowHour = $derived(centreHour == null ? null : Math.floor(centreHour));
|
|
512
|
-
/** The scrollable calendar body; bound so we can centre it on the marker. */
|
|
513
501
|
let calendarBody = $state();
|
|
514
|
-
/**
|
|
515
|
-
* Whether the grid has been centred for the current spell in a
|
|
516
|
-
* day/week view. Plain (non-reactive) so re-centring only happens when
|
|
517
|
-
* the grid (re)appears, never while the user scrolls or data loads.
|
|
518
|
-
*/
|
|
519
502
|
let hasCentred = false;
|
|
520
503
|
$effect(() => {
|
|
521
|
-
// Re-arm whenever we leave the time-grid views so re-entry recentres.
|
|
522
504
|
if (viewMode !== CalendarViewMode.Day && viewMode !== CalendarViewMode.Week) {
|
|
523
505
|
hasCentred = false;
|
|
524
506
|
return;
|
|
@@ -530,265 +512,255 @@ $effect(() => {
|
|
|
530
512
|
if (!marker) {
|
|
531
513
|
return;
|
|
532
514
|
}
|
|
533
|
-
// Centre the marker row in the viewport via rect maths so nested
|
|
534
|
-
// sticky/relative offset parents don't skew the target.
|
|
535
515
|
const bodyRect = calendarBody.getBoundingClientRect();
|
|
536
516
|
const markerRect = marker.getBoundingClientRect();
|
|
537
517
|
const delta = markerRect.top + markerRect.height / 2 - (bodyRect.top + bodyRect.height / 2);
|
|
538
518
|
calendarBody.scrollTop += delta;
|
|
539
519
|
hasCentred = true;
|
|
540
520
|
});
|
|
541
|
-
</script>
|
|
542
|
-
|
|
543
|
-
<!-- A drag can end anywhere on the page
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
<!-- svelte-ignore
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
class=
|
|
552
|
-
|
|
553
|
-
|
|
554
|
-
|
|
555
|
-
|
|
556
|
-
|
|
557
|
-
|
|
558
|
-
|
|
559
|
-
|
|
560
|
-
|
|
561
|
-
|
|
562
|
-
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
|
|
566
|
-
|
|
567
|
-
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
|
|
586
|
-
|
|
587
|
-
|
|
588
|
-
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
</
|
|
593
|
-
|
|
594
|
-
|
|
595
|
-
|
|
596
|
-
|
|
597
|
-
|
|
598
|
-
|
|
599
|
-
|
|
600
|
-
|
|
601
|
-
|
|
602
|
-
class=
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
class=
|
|
621
|
-
class:
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
<span class="day-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
|
|
638
|
-
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
<!--
|
|
645
|
-
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
<!--
|
|
657
|
-
|
|
658
|
-
|
|
659
|
-
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
class=
|
|
679
|
-
class:
|
|
680
|
-
class:
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
684
|
-
|
|
685
|
-
|
|
686
|
-
|
|
687
|
-
<
|
|
688
|
-
|
|
689
|
-
|
|
690
|
-
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
class=
|
|
715
|
-
class:
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
724
|
-
|
|
725
|
-
|
|
726
|
-
|
|
727
|
-
|
|
728
|
-
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
class=
|
|
734
|
-
class:
|
|
735
|
-
class:
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
}}
|
|
746
|
-
></div>
|
|
747
|
-
{/each}
|
|
748
|
-
<!-- Unavailable shading: one continuous block per contiguous run -->
|
|
749
|
-
{#each unavailableRunsAt(
|
|
750
|
-
<div
|
|
751
|
-
class="unavailable-overlay"
|
|
752
|
-
style="top: {run.topPercent}%; height: {run.heightPercent}%;"
|
|
753
|
-
></div>
|
|
754
|
-
{/each}
|
|
755
|
-
<!--
|
|
756
|
-
|
|
757
|
-
|
|
758
|
-
|
|
759
|
-
|
|
760
|
-
class
|
|
761
|
-
class:
|
|
762
|
-
class:
|
|
763
|
-
|
|
764
|
-
|
|
765
|
-
|
|
766
|
-
|
|
767
|
-
|
|
768
|
-
|
|
769
|
-
|
|
770
|
-
|
|
771
|
-
|
|
772
|
-
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
|
|
777
|
-
</div>
|
|
778
|
-
{/each}
|
|
779
|
-
</div>
|
|
780
|
-
{/each}
|
|
781
|
-
</div>
|
|
782
|
-
{/snippet}
|
|
783
|
-
|
|
784
|
-
<style>.appointment-calendar {
|
|
521
|
+
</script>
|
|
522
|
+
|
|
523
|
+
<!-- A drag can end anywhere on the page; listen on the window so the drag state
|
|
524
|
+
is always finalised and never left "stuck" for the next drag. -->
|
|
525
|
+
<svelte:window onmouseup={onSlotMouseUp} />
|
|
526
|
+
|
|
527
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
528
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
529
|
+
<div
|
|
530
|
+
class="resource-calendar"
|
|
531
|
+
class:is-moving-event={isDraggingEvent}
|
|
532
|
+
role="application"
|
|
533
|
+
>
|
|
534
|
+
<!-- Header: navigation + view toggle -->
|
|
535
|
+
<div class="calendar-header">
|
|
536
|
+
<div class="header-left">
|
|
537
|
+
<Button
|
|
538
|
+
label="Today"
|
|
539
|
+
size={Size.Small}
|
|
540
|
+
onclick={goToToday}
|
|
541
|
+
>
|
|
542
|
+
Today
|
|
543
|
+
</Button>
|
|
544
|
+
<div class="nav-arrows">
|
|
545
|
+
<button
|
|
546
|
+
class="nav-btn"
|
|
547
|
+
onclick={() => {
|
|
548
|
+
navigateDate(-1);
|
|
549
|
+
}}
|
|
550
|
+
aria-label="Previous"
|
|
551
|
+
>
|
|
552
|
+
<Icon
|
|
553
|
+
name="chevron-left"
|
|
554
|
+
size={Size.Small}
|
|
555
|
+
colour={Colour['$ui-blue-14']}
|
|
556
|
+
/>
|
|
557
|
+
</button>
|
|
558
|
+
<button
|
|
559
|
+
class="nav-btn"
|
|
560
|
+
onclick={() => {
|
|
561
|
+
navigateDate(1);
|
|
562
|
+
}}
|
|
563
|
+
aria-label="Next"
|
|
564
|
+
>
|
|
565
|
+
<Icon
|
|
566
|
+
name="chevron-right"
|
|
567
|
+
size={Size.Small}
|
|
568
|
+
colour={Colour['$ui-blue-14']}
|
|
569
|
+
/>
|
|
570
|
+
</button>
|
|
571
|
+
</div>
|
|
572
|
+
<h2 class="current-date-label">{formatDate(selected)}</h2>
|
|
573
|
+
</div>
|
|
574
|
+
<div class="header-right">
|
|
575
|
+
{#if headerExtra}
|
|
576
|
+
{@render headerExtra()}
|
|
577
|
+
{/if}
|
|
578
|
+
<div class="view-toggle">
|
|
579
|
+
{#each viewModes as { mode, label } (mode)}
|
|
580
|
+
<button
|
|
581
|
+
class="view-btn"
|
|
582
|
+
class:active={viewMode === mode}
|
|
583
|
+
onclick={() => {
|
|
584
|
+
onviewmodechange(mode);
|
|
585
|
+
}}
|
|
586
|
+
>
|
|
587
|
+
{label}
|
|
588
|
+
</button>
|
|
589
|
+
{/each}
|
|
590
|
+
</div>
|
|
591
|
+
</div>
|
|
592
|
+
</div>
|
|
593
|
+
|
|
594
|
+
<!-- Week day tabs rendered OUTSIDE the scrollable body so they stay frozen -->
|
|
595
|
+
{#if viewMode === CalendarViewMode.Week}
|
|
596
|
+
<div class="week-day-tabs">
|
|
597
|
+
{#each weekDays as day (day.getTime())}
|
|
598
|
+
<button
|
|
599
|
+
class="week-day-tab"
|
|
600
|
+
class:active={sameDay(day, selected)}
|
|
601
|
+
class:today={sameDay(day, today)}
|
|
602
|
+
onclick={() => {
|
|
603
|
+
ondatechange(day);
|
|
604
|
+
}}
|
|
605
|
+
>
|
|
606
|
+
<span class="day-name">{dayNames[day.getDay()].slice(0, 3)}</span>
|
|
607
|
+
<span class="day-num">{day.getDate()}</span>
|
|
608
|
+
</button>
|
|
609
|
+
{/each}
|
|
610
|
+
</div>
|
|
611
|
+
{/if}
|
|
612
|
+
|
|
613
|
+
<!-- Calendar body -->
|
|
614
|
+
<div
|
|
615
|
+
class="calendar-body"
|
|
616
|
+
bind:this={calendarBody}
|
|
617
|
+
>
|
|
618
|
+
{#if isLoading}
|
|
619
|
+
<LoadingOverlay />
|
|
620
|
+
{/if}
|
|
621
|
+
|
|
622
|
+
{#if viewMode === CalendarViewMode.Day}
|
|
623
|
+
<!-- DAY VIEW -->
|
|
624
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
625
|
+
<div
|
|
626
|
+
class="day-view"
|
|
627
|
+
onmouseleave={() => {
|
|
628
|
+
hoveredSlotIndex = null;
|
|
629
|
+
}}
|
|
630
|
+
>
|
|
631
|
+
<!-- eslint-disable-next-line @typescript-eslint/no-confusing-void-expression -->
|
|
632
|
+
{@render resourceGrid(selected)}
|
|
633
|
+
</div>
|
|
634
|
+
{:else if viewMode === CalendarViewMode.Week}
|
|
635
|
+
<!-- WEEK VIEW: day grid only, tabs are above calendar-body -->
|
|
636
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
637
|
+
<div
|
|
638
|
+
class="day-view"
|
|
639
|
+
onmouseleave={() => {
|
|
640
|
+
hoveredSlotIndex = null;
|
|
641
|
+
}}
|
|
642
|
+
>
|
|
643
|
+
<!-- eslint-disable-next-line @typescript-eslint/no-confusing-void-expression -->
|
|
644
|
+
{@render resourceGrid(selected)}
|
|
645
|
+
</div>
|
|
646
|
+
{:else}
|
|
647
|
+
<!-- MONTH VIEW -->
|
|
648
|
+
<div class="month-view">
|
|
649
|
+
<div class="month-header-row">
|
|
650
|
+
{#each ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as dayLabel (dayLabel)}
|
|
651
|
+
<div class="month-day-header">{dayLabel}</div>
|
|
652
|
+
{/each}
|
|
653
|
+
</div>
|
|
654
|
+
<div class="month-grid">
|
|
655
|
+
{#each monthDays as { date: dayDate, isCurrentMonth } (dayDate.getTime())}
|
|
656
|
+
<button
|
|
657
|
+
class="month-day-cell"
|
|
658
|
+
class:other-month={!isCurrentMonth}
|
|
659
|
+
class:today={sameDay(dayDate, today)}
|
|
660
|
+
class:selected={sameDay(dayDate, selected)}
|
|
661
|
+
onclick={() => {
|
|
662
|
+
ondatechange(dayDate);
|
|
663
|
+
onviewmodechange(CalendarViewMode.Day);
|
|
664
|
+
}}
|
|
665
|
+
>
|
|
666
|
+
<span class="day-number">{dayDate.getDate()}</span>
|
|
667
|
+
<div class="day-badges">
|
|
668
|
+
{#each countsAt(dayDate) as { resourceId, resourceLabel, count } (resourceId)}
|
|
669
|
+
<span class="resource-badge">
|
|
670
|
+
{resourceLabel}: {count}
|
|
671
|
+
</span>
|
|
672
|
+
{/each}
|
|
673
|
+
</div>
|
|
674
|
+
</button>
|
|
675
|
+
{/each}
|
|
676
|
+
</div>
|
|
677
|
+
</div>
|
|
678
|
+
{/if}
|
|
679
|
+
</div>
|
|
680
|
+
</div>
|
|
681
|
+
|
|
682
|
+
{#snippet resourceGrid(day: Date)}
|
|
683
|
+
<div class="grid-header">
|
|
684
|
+
<div class="time-header">Time</div>
|
|
685
|
+
{#each resources as resource (resource.id)}
|
|
686
|
+
<div class="resource-header">{resource.label}</div>
|
|
687
|
+
{/each}
|
|
688
|
+
</div>
|
|
689
|
+
<div class="grid-body">
|
|
690
|
+
<div class="time-column">
|
|
691
|
+
{#each timeSlots as slot, slotIdx (slot.hour * 60 + slot.minute)}
|
|
692
|
+
<div
|
|
693
|
+
class="time-label"
|
|
694
|
+
class:hour-start={slot.minute === 0}
|
|
695
|
+
class:row-hover={hoveredSlotIndex === slotIdx}
|
|
696
|
+
data-centre-row={slot.hour === centreRowHour && slot.minute === 0 ? '' : undefined}
|
|
697
|
+
>
|
|
698
|
+
{#if slot.minute === 0}
|
|
699
|
+
<span>{slot.label}</span>
|
|
700
|
+
{/if}
|
|
701
|
+
</div>
|
|
702
|
+
{/each}
|
|
703
|
+
</div>
|
|
704
|
+
{#each resources as resource (resource.id)}
|
|
705
|
+
<div class="resource-column">
|
|
706
|
+
<!-- Slot cells for drag interaction (rendered first, below events) -->
|
|
707
|
+
{#each timeSlots as slot, slotIdx (slot.hour * 60 + slot.minute)}
|
|
708
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
709
|
+
<div
|
|
710
|
+
class="slot-cell"
|
|
711
|
+
class:hour-start={slot.minute === 0}
|
|
712
|
+
class:drag-selected={isSlotInDragRange(resource.id, slotIdx)}
|
|
713
|
+
class:move-target={isSlotInMoveTarget(resource.id, slotIdx)}
|
|
714
|
+
class:row-hover={hoveredSlotIndex === slotIdx}
|
|
715
|
+
class:unavailable={isSlotUnavailable(resource.id, slotIdx)}
|
|
716
|
+
onmousedown={(e) => {
|
|
717
|
+
onSlotMouseDown(resource.id, slotIdx, e);
|
|
718
|
+
}}
|
|
719
|
+
onmouseenter={(e) => {
|
|
720
|
+
hoveredSlotIndex = slotIdx;
|
|
721
|
+
onSlotMouseEnter(resource.id, slotIdx, e);
|
|
722
|
+
}}
|
|
723
|
+
ondblclick={() => {
|
|
724
|
+
onSlotDoubleClick(resource.id, slotIdx);
|
|
725
|
+
}}
|
|
726
|
+
></div>
|
|
727
|
+
{/each}
|
|
728
|
+
<!-- Unavailable shading: one continuous block per contiguous run -->
|
|
729
|
+
{#each unavailableRunsAt(resource.id) as run (run.topPercent)}
|
|
730
|
+
<div
|
|
731
|
+
class="unavailable-overlay"
|
|
732
|
+
style="top: {run.topPercent}%; height: {run.heightPercent}%;"
|
|
733
|
+
></div>
|
|
734
|
+
{/each}
|
|
735
|
+
<!-- Event blocks positioned absolutely (rendered after, above slots).
|
|
736
|
+
The consumer renders the card body via the `event` snippet. -->
|
|
737
|
+
{#each eventsAt(resource.id, day) as ev (ev.id)}
|
|
738
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
739
|
+
<div
|
|
740
|
+
class="event-block"
|
|
741
|
+
class:dragging={isDraggingEvent && draggedEvent?.id === ev.id}
|
|
742
|
+
class:movable={!!oneventmove}
|
|
743
|
+
style="top: {ev.topPercent}%; height: {ev.heightPercent}%;"
|
|
744
|
+
onmousedown={(e) => {
|
|
745
|
+
onEventMouseDown(ev, e);
|
|
746
|
+
}}
|
|
747
|
+
>
|
|
748
|
+
{@render eventContent(ev)}
|
|
749
|
+
</div>
|
|
750
|
+
{/each}
|
|
751
|
+
</div>
|
|
752
|
+
{/each}
|
|
753
|
+
</div>
|
|
754
|
+
{/snippet}
|
|
755
|
+
|
|
756
|
+
<style>.resource-calendar {
|
|
785
757
|
display: flex;
|
|
786
758
|
flex-flow: column nowrap;
|
|
787
759
|
height: 100%;
|
|
788
760
|
background-color: #ffffff;
|
|
789
761
|
overflow: hidden;
|
|
790
762
|
}
|
|
791
|
-
.
|
|
763
|
+
.resource-calendar.is-moving-event {
|
|
792
764
|
cursor: grabbing;
|
|
793
765
|
}
|
|
794
766
|
|
|
@@ -899,7 +871,7 @@ $effect(() => {
|
|
|
899
871
|
}
|
|
900
872
|
|
|
901
873
|
.time-header,
|
|
902
|
-
.
|
|
874
|
+
.resource-header {
|
|
903
875
|
padding: 0.5rem;
|
|
904
876
|
font-size: 0.875rem;
|
|
905
877
|
font-weight: 600;
|
|
@@ -919,7 +891,7 @@ $effect(() => {
|
|
|
919
891
|
background-color: #e9ecf1;
|
|
920
892
|
}
|
|
921
893
|
|
|
922
|
-
.
|
|
894
|
+
.resource-header {
|
|
923
895
|
flex: 1;
|
|
924
896
|
min-width: 8rem;
|
|
925
897
|
}
|
|
@@ -964,7 +936,7 @@ $effect(() => {
|
|
|
964
936
|
white-space: nowrap;
|
|
965
937
|
}
|
|
966
938
|
|
|
967
|
-
.
|
|
939
|
+
.resource-column {
|
|
968
940
|
flex: 1;
|
|
969
941
|
min-width: 8rem;
|
|
970
942
|
position: relative;
|
|
@@ -1010,75 +982,21 @@ $effect(() => {
|
|
|
1010
982
|
background-image: repeating-linear-gradient(45deg, transparent, transparent 6px, rgba(128, 150, 165, 0.14) 6px, rgba(128, 150, 165, 0.14) 12px);
|
|
1011
983
|
}
|
|
1012
984
|
|
|
1013
|
-
.
|
|
985
|
+
.event-block {
|
|
1014
986
|
position: absolute;
|
|
1015
987
|
left: 2px;
|
|
1016
988
|
right: 2px;
|
|
1017
989
|
z-index: 5;
|
|
1018
|
-
border-radius: 4px;
|
|
1019
|
-
border: solid 1px #9fdaec;
|
|
1020
|
-
background-color: #f4fbfd;
|
|
1021
|
-
padding: 0.25rem 0.4rem;
|
|
1022
|
-
display: flex;
|
|
1023
|
-
flex-flow: column nowrap;
|
|
1024
|
-
gap: 0.15rem;
|
|
1025
|
-
overflow: hidden;
|
|
1026
990
|
cursor: pointer;
|
|
1027
|
-
font-size: 0.75rem;
|
|
1028
|
-
text-align: left;
|
|
1029
|
-
transition: border-color ease-out 0.15s 0s, background-color ease-out 0.15s 0s, color ease-out 0.15s 0s, fill ease-out 0.15s 0s;
|
|
1030
991
|
user-select: none;
|
|
1031
992
|
}
|
|
1032
|
-
.
|
|
993
|
+
.event-block.movable {
|
|
1033
994
|
cursor: grab;
|
|
1034
995
|
}
|
|
1035
|
-
.
|
|
996
|
+
.event-block.dragging {
|
|
1036
997
|
opacity: 0.4;
|
|
1037
998
|
cursor: grabbing;
|
|
1038
999
|
}
|
|
1039
|
-
.appointment-block:hover {
|
|
1040
|
-
background-color: #e6f8fd;
|
|
1041
|
-
border-color: #74c9e4;
|
|
1042
|
-
}
|
|
1043
|
-
.appointment-block.inbound {
|
|
1044
|
-
border-color: #9fdaec;
|
|
1045
|
-
}
|
|
1046
|
-
.appointment-block.inbound .apt-type {
|
|
1047
|
-
color: #259fc7;
|
|
1048
|
-
}
|
|
1049
|
-
.appointment-block.outbound {
|
|
1050
|
-
border-color: #66bb6a;
|
|
1051
|
-
background-color: #eaf7ec;
|
|
1052
|
-
}
|
|
1053
|
-
.appointment-block.outbound .apt-type {
|
|
1054
|
-
color: #2e7d32;
|
|
1055
|
-
}
|
|
1056
|
-
.appointment-block.outbound:hover {
|
|
1057
|
-
background-color: #dcefdd;
|
|
1058
|
-
border-color: #43a047;
|
|
1059
|
-
}
|
|
1060
|
-
|
|
1061
|
-
.apt-type {
|
|
1062
|
-
font-weight: 600;
|
|
1063
|
-
font-size: 0.75rem;
|
|
1064
|
-
text-transform: uppercase;
|
|
1065
|
-
}
|
|
1066
|
-
|
|
1067
|
-
.apt-label {
|
|
1068
|
-
color: #3a4952;
|
|
1069
|
-
font-weight: 500;
|
|
1070
|
-
}
|
|
1071
|
-
|
|
1072
|
-
.apt-sublabel {
|
|
1073
|
-
color: #647d8e;
|
|
1074
|
-
font-size: 0.75rem;
|
|
1075
|
-
}
|
|
1076
|
-
|
|
1077
|
-
.apt-reference {
|
|
1078
|
-
color: #647d8e;
|
|
1079
|
-
font-size: 0.75rem;
|
|
1080
|
-
font-weight: 600;
|
|
1081
|
-
}
|
|
1082
1000
|
|
|
1083
1001
|
.week-day-tabs {
|
|
1084
1002
|
display: flex;
|
|
@@ -1222,7 +1140,7 @@ $effect(() => {
|
|
|
1222
1140
|
overflow: hidden;
|
|
1223
1141
|
}
|
|
1224
1142
|
|
|
1225
|
-
.
|
|
1143
|
+
.resource-badge {
|
|
1226
1144
|
font-size: 0.75rem;
|
|
1227
1145
|
color: #259fc7;
|
|
1228
1146
|
background-color: #e6f8fd;
|
|
@@ -1231,4 +1149,4 @@ $effect(() => {
|
|
|
1231
1149
|
white-space: nowrap;
|
|
1232
1150
|
overflow: hidden;
|
|
1233
1151
|
text-overflow: ellipsis;
|
|
1234
|
-
}</style>
|
|
1152
|
+
}</style>
|