@lavalogic/scoria 0.40.13 → 0.40.15
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/README.md +58 -58
- package/dist/Components/AccordionGroup.svelte +35 -46
- package/dist/Components/AccordionGroupButton.svelte +68 -76
- package/dist/Components/Action.svelte +103 -135
- package/dist/Components/AlertModal.svelte +171 -190
- package/dist/Components/AppointmentCalendar/AppointmentCalendar.svelte +614 -697
- package/dist/Components/Base/BaseModal.svelte +90 -116
- package/dist/Components/Base/ContextWrapper.svelte +67 -72
- package/dist/Components/Base/Pagination.svelte +173 -196
- package/dist/Components/Base/Snippets.svelte +34 -36
- package/dist/Components/BigRadioSet.svelte +36 -38
- package/dist/Components/Bubble.svelte +78 -101
- package/dist/Components/Button.svelte +175 -208
- package/dist/Components/Checkbox.svelte +75 -86
- package/dist/Components/CollapsibleCard.svelte +72 -87
- package/dist/Components/CollapsibleMenuGroup.svelte +53 -57
- package/dist/Components/ConditionBuilder/ConditionBuilder.svelte +39 -64
- package/dist/Components/Contexts/ContextMenu.svelte +141 -156
- package/dist/Components/Contexts/ContextMenuDivider.svelte +15 -15
- package/dist/Components/Contexts/ContextMenuOption.svelte +53 -60
- package/dist/Components/Contexts/Toast.svelte +81 -86
- package/dist/Components/Contexts/ToastContainer.svelte +58 -62
- package/dist/Components/Contexts/Tooltip.svelte +36 -37
- package/dist/Components/DataMatrixIcon.svelte +95 -108
- package/dist/Components/DateInput.svelte +118 -146
- package/dist/Components/DatePicker.svelte +255 -294
- package/dist/Components/DesktopModal.svelte +70 -90
- package/dist/Components/Dial.svelte +167 -197
- package/dist/Components/DimensionInput.svelte +124 -169
- package/dist/Components/DragMenu/DragMenuHolder.svelte +98 -114
- package/dist/Components/DragMenu/DraggableCard.svelte +64 -76
- package/dist/Components/FileCard.svelte +21 -24
- package/dist/Components/FileUpload.svelte +190 -225
- package/dist/Components/GridInput.svelte +159 -196
- package/dist/Components/GridInputSet.svelte +24 -29
- package/dist/Components/HorizontalTabGroup.svelte +73 -89
- package/dist/Components/HorizontalTabGroupButton.svelte +40 -44
- package/dist/Components/Icon.svelte +99 -128
- package/dist/Components/Icons.js +357 -357
- package/dist/Components/InfoAlert.svelte +35 -38
- package/dist/Components/LoadingAnimation.svelte +691 -755
- package/dist/Components/LoadingModal.svelte +36 -44
- package/dist/Components/LoadingOverlay.svelte +22 -26
- package/dist/Components/Modal.svelte +204 -254
- package/dist/Components/MultiSelect.svelte +186 -216
- package/dist/Components/Nav/Nav.svelte +94 -110
- package/dist/Components/Nav/NavServiceMenuList.svelte +57 -61
- package/dist/Components/Nav/NavTab.svelte +58 -62
- package/dist/Components/Nav/ServiceMenuItem.svelte +103 -111
- package/dist/Components/Nav/ServicesNav.svelte +72 -79
- package/dist/Components/NumberInput.svelte +121 -155
- package/dist/Components/OptionCards.svelte +62 -73
- package/dist/Components/PageHeading.svelte +23 -25
- package/dist/Components/PasswordInput.svelte +69 -88
- package/dist/Components/PopoverButton.svelte +151 -0
- package/dist/Components/PopoverButton.svelte.d.ts +9 -0
- package/dist/Components/PopoverButtonProps.d.ts +21 -0
- package/dist/Components/PopoverButtonProps.js +1 -0
- package/dist/Components/Portal.svelte +29 -35
- package/dist/Components/ProgressBubble.svelte +91 -106
- package/dist/Components/QuerySelect.svelte +260 -346
- package/dist/Components/SidebarPanel.svelte +34 -36
- package/dist/Components/SingleSelect.svelte +196 -244
- package/dist/Components/StepButton.svelte +65 -70
- package/dist/Components/StepForm.svelte +115 -135
- package/dist/Components/Switch.svelte +57 -69
- package/dist/Components/Table/ARCHITECTURE.md +298 -298
- package/dist/Components/Table/Body/QuickSearchCell.svelte +578 -655
- package/dist/Components/Table/Body/Rows/ColumnHighlight.svelte +42 -51
- package/dist/Components/Table/Body/Rows/ColumnList.svelte +42 -58
- package/dist/Components/Table/Body/Rows/ColumnListRow.svelte +403 -442
- package/dist/Components/Table/Body/Rows/Columns/AccessorComponent.svelte +51 -78
- package/dist/Components/Table/Body/Rows/Columns/ActionsCell.svelte +165 -184
- package/dist/Components/Table/Body/Rows/Columns/BubbleCell.svelte +110 -123
- package/dist/Components/Table/Body/Rows/Columns/ExpandCell.svelte +134 -160
- package/dist/Components/Table/Body/Rows/Columns/FocusableImmutableCell.svelte +190 -231
- package/dist/Components/Table/Body/Rows/Columns/ProgressCell.svelte +82 -104
- package/dist/Components/Table/Body/Rows/Columns/TableAction.svelte +52 -87
- package/dist/Components/Table/Body/Rows/Columns/TableCheckbox.svelte +122 -150
- package/dist/Components/Table/Body/Rows/Columns/TableColumn.svelte +132 -159
- package/dist/Components/Table/Body/Rows/Columns/TableDatePicker.svelte +190 -236
- package/dist/Components/Table/Body/Rows/Columns/TableNumberInput.svelte +173 -211
- package/dist/Components/Table/Body/Rows/Columns/TableQuerySelect.svelte +204 -244
- package/dist/Components/Table/Body/Rows/Columns/TableRowSelectionCheckbox.svelte +140 -177
- package/dist/Components/Table/Body/Rows/Columns/TableSelect.svelte +188 -217
- package/dist/Components/Table/Body/Rows/Columns/TableTextInput.svelte +173 -193
- package/dist/Components/Table/Body/Rows/Columns/ValidityCell.svelte +166 -187
- package/dist/Components/Table/Body/Rows/TableRow.svelte +245 -288
- package/dist/Components/Table/Body/TableBody.svelte +141 -154
- package/dist/Components/Table/Headers/HighlightAllHeader.svelte +35 -41
- package/dist/Components/Table/Headers/SelectAllHeader.svelte +24 -30
- package/dist/Components/Table/Headers/TableHead.svelte +348 -402
- package/dist/Components/Table/Misc/ColumnPanel.svelte +37 -39
- package/dist/Components/Table/Misc/ColumnPanelModal.svelte +35 -39
- package/dist/Components/Table/Misc/FilterInput.svelte +556 -619
- package/dist/Components/Table/Misc/FilterPanel.svelte +170 -183
- package/dist/Components/Table/Misc/FilterPanelModal.svelte +25 -31
- package/dist/Components/Table/Misc/ShareViewModal.svelte +252 -281
- package/dist/Components/Table/Misc/SidePanel.svelte +32 -35
- package/dist/Components/Table/Misc/TableConfigurationModal.svelte +574 -629
- package/dist/Components/Table/Misc/TableFooter.svelte +326 -332
- package/dist/Components/Table/Misc/TableHorizontalBar.svelte +134 -140
- package/dist/Components/Table/Misc/TableSidebar.svelte +183 -198
- package/dist/Components/Table/Misc/TableViewDropdown.svelte +83 -103
- package/dist/Components/Table/Misc/ToolboxPanel.svelte +83 -100
- package/dist/Components/Table/Misc/ToolboxPanelModal.svelte +88 -107
- package/dist/Components/Table/Misc/UpdateViewOptionsModal.svelte +163 -190
- package/dist/Components/Table/NestedTable.svelte +102 -128
- package/dist/Components/Table/Table.svelte +531 -663
- package/dist/Components/Test/TestContextConsumer.svelte +11 -13
- package/dist/Components/TextArea.svelte +95 -130
- package/dist/Components/TextInput.svelte +125 -176
- package/dist/Components/ThreeStateRadio.svelte +111 -130
- package/dist/Components/Touch/BoolCard.svelte +33 -45
- package/dist/Components/Touch/DateModal.svelte +164 -204
- package/dist/Components/Touch/EditCard.svelte +48 -59
- package/dist/Components/Touch/InfoCard.svelte +28 -31
- package/dist/Components/Touch/InfoTextCard.svelte +26 -28
- package/dist/Components/Touch/ModalExplanation.svelte +15 -17
- package/dist/Components/Touch/NumberKeyboard.svelte +181 -218
- package/dist/Components/Touch/NumberModal.svelte +59 -79
- package/dist/Components/Touch/PlusMinusCard.svelte +43 -46
- package/dist/Components/Touch/SelectModal.svelte +111 -126
- package/dist/Components/Touch/SummaryCard.svelte +80 -93
- package/dist/Components/Touch/TextAreaModal.svelte +72 -94
- package/dist/Components/Touch/TextModal.svelte +77 -105
- package/dist/Components/Touch/TouchCard.svelte +31 -33
- package/dist/Components/Touch/TouchModal.svelte +214 -270
- package/dist/Components/Touch/UtilityButton.svelte +88 -109
- package/dist/Components/VerticalTabGroup.svelte +60 -78
- package/dist/Components/VerticalTabGroupButton.svelte +54 -58
- package/dist/Helpers/Datamatrix.d.ts +19 -19
- package/dist/Helpers/Datamatrix.js +291 -291
- package/dist/Types/Examples/ExampleModal.svelte +31 -35
- package/dist/index.d.ts +2 -0
- package/dist/index.js +1 -0
- package/dist/scss/_basics.scss +12 -12
- package/dist/scss/_colours.scss +134 -134
- package/dist/scss/_mixins.scss +3392 -3392
- package/dist/scss/_motion.scss +57 -57
- package/dist/scss/_sizing.scss +85 -85
- package/dist/scss/_transitions.scss +8 -8
- package/package.json +1 -1
|
@@ -1,731 +1,648 @@
|
|
|
1
|
-
<svelte:options runes />
|
|
2
|
-
|
|
3
|
-
<script lang="ts">
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
|
|
53
|
-
const monthNames = [
|
|
54
|
-
'January',
|
|
55
|
-
'February',
|
|
56
|
-
'March',
|
|
57
|
-
'April',
|
|
58
|
-
'May',
|
|
59
|
-
'June',
|
|
60
|
-
'July',
|
|
61
|
-
'August',
|
|
62
|
-
'September',
|
|
63
|
-
'October',
|
|
64
|
-
'November',
|
|
65
|
-
'December',
|
|
66
|
-
];
|
|
67
|
-
|
|
68
|
-
function formatDate(d: Date): string {
|
|
69
|
-
if (viewMode === CalendarViewMode.Month) {
|
|
70
|
-
return `${monthNames[d.getMonth()]} ${d.getFullYear()}`;
|
|
71
|
-
}
|
|
72
|
-
return `${dayNames[d.getDay()]} ${d.getDate()} ${monthNames[d.getMonth()]} ${d.getFullYear()}`;
|
|
1
|
+
<svelte:options runes />
|
|
2
|
+
|
|
3
|
+
<script lang="ts">import { Colour } from "../../scss/colours.js";
|
|
4
|
+
import { Size } from "../../Types/Internal/Size.js";
|
|
5
|
+
import Button from "../Button.svelte";
|
|
6
|
+
import Icon from "../Icon.svelte";
|
|
7
|
+
import LoadingOverlay from "../LoadingOverlay.svelte";
|
|
8
|
+
import { CalendarViewMode } from "./AppointmentCalendarProps.js";
|
|
9
|
+
let { doors, appointments, selectedDate, viewMode, ondatechange, onviewmodechange, ondragcreate, onappointmentclick, onappointmentmove, startHour = 0, endHour = 24, slotIntervalMinutes = 15, isLoading = false, headerExtra } = $props();
|
|
10
|
+
/**
|
|
11
|
+
* `selectedDate` is always read by the component, never written. The
|
|
12
|
+
* runtime type may be either a plain `Date` or a `SvelteDate`; both
|
|
13
|
+
* expose the same `Date` interface that we use here. Cast to `Date`
|
|
14
|
+
* for narrowing inside the script.
|
|
15
|
+
*/
|
|
16
|
+
const selected = $derived(selectedDate);
|
|
17
|
+
/**
|
|
18
|
+
* Today's reference Date hoisted out of the per-cell loops so that
|
|
19
|
+
* rendering the month grid does not allocate 42 fresh `Date`
|
|
20
|
+
* instances on every state change. `$derived` does not invalidate
|
|
21
|
+
* over time on its own; if you need real day-rollover semantics
|
|
22
|
+
* track it with a `SvelteDate` and a `setInterval`.
|
|
23
|
+
*/
|
|
24
|
+
const today = $derived(new Date());
|
|
25
|
+
// ---- Date helpers ----
|
|
26
|
+
const dayNames = [
|
|
27
|
+
"Sunday",
|
|
28
|
+
"Monday",
|
|
29
|
+
"Tuesday",
|
|
30
|
+
"Wednesday",
|
|
31
|
+
"Thursday",
|
|
32
|
+
"Friday",
|
|
33
|
+
"Saturday"
|
|
34
|
+
];
|
|
35
|
+
const monthNames = [
|
|
36
|
+
"January",
|
|
37
|
+
"February",
|
|
38
|
+
"March",
|
|
39
|
+
"April",
|
|
40
|
+
"May",
|
|
41
|
+
"June",
|
|
42
|
+
"July",
|
|
43
|
+
"August",
|
|
44
|
+
"September",
|
|
45
|
+
"October",
|
|
46
|
+
"November",
|
|
47
|
+
"December"
|
|
48
|
+
];
|
|
49
|
+
function formatDate(d) {
|
|
50
|
+
if (viewMode === CalendarViewMode.Month) {
|
|
51
|
+
return `${monthNames[d.getMonth()]} ${d.getFullYear()}`;
|
|
73
52
|
}
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
53
|
+
return `${dayNames[d.getDay()]} ${d.getDate()} ${monthNames[d.getMonth()]} ${d.getFullYear()}`;
|
|
54
|
+
}
|
|
55
|
+
function sameDay(a, b) {
|
|
56
|
+
return a.getFullYear() === b.getFullYear() && a.getMonth() === b.getMonth() && a.getDate() === b.getDate();
|
|
57
|
+
}
|
|
58
|
+
function addDays(d, n) {
|
|
59
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
60
|
+
const result = new Date(d);
|
|
61
|
+
result.setDate(result.getDate() + n);
|
|
62
|
+
return result;
|
|
63
|
+
}
|
|
64
|
+
function navigateDate(direction) {
|
|
65
|
+
if (viewMode === CalendarViewMode.Day) {
|
|
66
|
+
ondatechange(addDays(selected, direction));
|
|
67
|
+
} else if (viewMode === CalendarViewMode.Week) {
|
|
68
|
+
ondatechange(addDays(selected, direction * 7));
|
|
69
|
+
} else {
|
|
84
70
|
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
85
|
-
const
|
|
86
|
-
|
|
87
|
-
|
|
71
|
+
const d = new Date(selected);
|
|
72
|
+
d.setMonth(d.getMonth() + direction);
|
|
73
|
+
ondatechange(d);
|
|
88
74
|
}
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
99
|
-
|
|
75
|
+
}
|
|
76
|
+
function goToToday() {
|
|
77
|
+
ondatechange(new Date());
|
|
78
|
+
}
|
|
79
|
+
// ---- Time slot generation ----
|
|
80
|
+
const timeSlots = $derived.by(() => {
|
|
81
|
+
const slots = [];
|
|
82
|
+
for (let h = startHour; h < endHour; h++) {
|
|
83
|
+
for (let m = 0; m < 60; m += slotIntervalMinutes) {
|
|
84
|
+
const hh = String(h).padStart(2, "0");
|
|
85
|
+
const mm = String(m).padStart(2, "0");
|
|
86
|
+
slots.push({
|
|
87
|
+
hour: h,
|
|
88
|
+
minute: m,
|
|
89
|
+
label: `${hh}:${mm}`
|
|
90
|
+
});
|
|
100
91
|
}
|
|
101
92
|
}
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
const
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
|
|
93
|
+
return slots;
|
|
94
|
+
});
|
|
95
|
+
/**
|
|
96
|
+
* Build a `YYYY-MM-DD` key for date bucketing. Avoids `toISOString`
|
|
97
|
+
* timezone conversions that would smear appointments across days.
|
|
98
|
+
*/
|
|
99
|
+
function dayKey(d) {
|
|
100
|
+
const y = d.getFullYear();
|
|
101
|
+
const m = String(d.getMonth() + 1).padStart(2, "0");
|
|
102
|
+
const day = String(d.getDate()).padStart(2, "0");
|
|
103
|
+
return `${y}-${m}-${day}`;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* Precomputed `Map<doorId, Map<dayKey, PositionedAppointment[]>>` for
|
|
107
|
+
* day and week views. Replaces the previous per-cell
|
|
108
|
+
* `appointments.filter(...)` calls which were `O(doors * appointments)`
|
|
109
|
+
* per render and allocated a fresh `Date` per appointment per pass.
|
|
110
|
+
*/
|
|
111
|
+
const positionedByDoorAndDay = $derived.by(() => {
|
|
112
|
+
const totalMinutes = (endHour - startHour) * 60;
|
|
113
|
+
// Local indexes built inside a $derived; outer reactivity already covers reads.
|
|
114
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
115
|
+
const byDoor = new Map();
|
|
116
|
+
for (const apt of appointments) {
|
|
117
|
+
const from = new Date(apt.dateFrom);
|
|
118
|
+
const to = new Date(apt.dateTo);
|
|
119
|
+
const startMin = (from.getHours() - startHour) * 60 + from.getMinutes();
|
|
120
|
+
const endMin = (to.getHours() - startHour) * 60 + to.getMinutes();
|
|
121
|
+
const durationMin = Math.max(endMin - startMin, slotIntervalMinutes);
|
|
122
|
+
const positioned = {
|
|
123
|
+
...apt,
|
|
124
|
+
topPercent: Math.max(0, startMin) / totalMinutes * 100,
|
|
125
|
+
heightPercent: Math.min(durationMin, totalMinutes - startMin) / totalMinutes * 100
|
|
126
|
+
};
|
|
127
|
+
const key = dayKey(from);
|
|
128
|
+
let perDay = byDoor.get(apt.segmentId);
|
|
129
|
+
if (!perDay) {
|
|
130
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
131
|
+
perDay = new Map();
|
|
132
|
+
byDoor.set(apt.segmentId, perDay);
|
|
133
|
+
}
|
|
134
|
+
const list = perDay.get(key);
|
|
135
|
+
if (list) {
|
|
136
|
+
list.push(positioned);
|
|
137
|
+
} else {
|
|
138
|
+
perDay.set(key, [positioned]);
|
|
117
139
|
}
|
|
118
|
-
return slots;
|
|
119
|
-
});
|
|
120
|
-
|
|
121
|
-
// ---- Appointment positioning (day view) ----
|
|
122
|
-
|
|
123
|
-
type PositionedAppointment = CalendarAppointment & {
|
|
124
|
-
readonly topPercent: number;
|
|
125
|
-
readonly heightPercent: number;
|
|
126
|
-
};
|
|
127
|
-
|
|
128
|
-
/**
|
|
129
|
-
* Build a `YYYY-MM-DD` key for date bucketing. Avoids `toISOString`
|
|
130
|
-
* timezone conversions that would smear appointments across days.
|
|
131
|
-
*/
|
|
132
|
-
function dayKey(d: Date): string {
|
|
133
|
-
const y = d.getFullYear();
|
|
134
|
-
const m = String(d.getMonth() + 1).padStart(2, '0');
|
|
135
|
-
const day = String(d.getDate()).padStart(2, '0');
|
|
136
|
-
return `${y}-${m}-${day}`;
|
|
137
140
|
}
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
|
|
156
|
-
|
|
157
|
-
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
if (!perDay) {
|
|
164
|
-
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
165
|
-
perDay = new Map();
|
|
166
|
-
byDoor.set(apt.segmentId, perDay);
|
|
167
|
-
}
|
|
168
|
-
const list = perDay.get(key);
|
|
169
|
-
if (list) {
|
|
170
|
-
list.push(positioned);
|
|
171
|
-
} else {
|
|
172
|
-
perDay.set(key, [positioned]);
|
|
173
|
-
}
|
|
141
|
+
return byDoor;
|
|
142
|
+
});
|
|
143
|
+
function appointmentsAt(doorId, day) {
|
|
144
|
+
return positionedByDoorAndDay.get(doorId)?.get(dayKey(day)) ?? [];
|
|
145
|
+
}
|
|
146
|
+
/**
|
|
147
|
+
* Precomputed appointment-count badges per day for the month view.
|
|
148
|
+
* Replaces the previous `O(cells * doors * appointments)` recomputation
|
|
149
|
+
* which scaled to ~42 000 ops on a single render with 10 doors and
|
|
150
|
+
* 100 appointments.
|
|
151
|
+
*/
|
|
152
|
+
const countsByDay = $derived.by(() => {
|
|
153
|
+
// Local indexes built inside a $derived; outer reactivity already covers reads.
|
|
154
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
155
|
+
const map = new Map();
|
|
156
|
+
// One scan over appointments builds a nested Map<dayKey, Map<doorId, count>>.
|
|
157
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
158
|
+
const intermediate = new Map();
|
|
159
|
+
for (const apt of appointments) {
|
|
160
|
+
const key = dayKey(new Date(apt.dateFrom));
|
|
161
|
+
let perDay = intermediate.get(key);
|
|
162
|
+
if (!perDay) {
|
|
163
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
164
|
+
perDay = new Map();
|
|
165
|
+
intermediate.set(key, perDay);
|
|
174
166
|
}
|
|
175
|
-
|
|
176
|
-
});
|
|
177
|
-
|
|
178
|
-
function appointmentsAt(doorId: number, day: Date): ReadonlyArray<PositionedAppointment> {
|
|
179
|
-
return positionedByDoorAndDay.get(doorId)?.get(dayKey(day)) ?? [];
|
|
167
|
+
perDay.set(apt.segmentId, (perDay.get(apt.segmentId) ?? 0) + 1);
|
|
180
168
|
}
|
|
181
|
-
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
// One scan over appointments builds a nested Map<dayKey, Map<doorId, count>>.
|
|
193
|
-
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
194
|
-
const intermediate = new Map<string, Map<number, number>>();
|
|
195
|
-
for (const apt of appointments) {
|
|
196
|
-
const key = dayKey(new Date(apt.dateFrom));
|
|
197
|
-
let perDay = intermediate.get(key);
|
|
198
|
-
if (!perDay) {
|
|
199
|
-
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
200
|
-
perDay = new Map();
|
|
201
|
-
intermediate.set(key, perDay);
|
|
169
|
+
// Flatten to the door-ordered list the markup expects.
|
|
170
|
+
for (const [key, perDay] of intermediate) {
|
|
171
|
+
const list = [];
|
|
172
|
+
for (const door of doors) {
|
|
173
|
+
const count = perDay.get(door.id);
|
|
174
|
+
if (count) {
|
|
175
|
+
list.push({
|
|
176
|
+
doorId: door.id,
|
|
177
|
+
doorName: door.name,
|
|
178
|
+
count
|
|
179
|
+
});
|
|
202
180
|
}
|
|
203
|
-
perDay.set(apt.segmentId, (perDay.get(apt.segmentId) ?? 0) + 1);
|
|
204
181
|
}
|
|
205
|
-
|
|
206
|
-
|
|
207
|
-
const list: Array<{ doorId: number; doorName: string; count: number }> = [];
|
|
208
|
-
for (const door of doors) {
|
|
209
|
-
const count = perDay.get(door.id);
|
|
210
|
-
if (count) {
|
|
211
|
-
list.push({ doorId: door.id, doorName: door.name, count });
|
|
212
|
-
}
|
|
213
|
-
}
|
|
214
|
-
if (list.length > 0) {
|
|
215
|
-
map.set(key, list);
|
|
216
|
-
}
|
|
182
|
+
if (list.length > 0) {
|
|
183
|
+
map.set(key, list);
|
|
217
184
|
}
|
|
218
|
-
return map;
|
|
219
|
-
});
|
|
220
|
-
|
|
221
|
-
function countsAt(day: Date): ReadonlyArray<{ doorId: number; doorName: string; count: number }> {
|
|
222
|
-
return countsByDay.get(dayKey(day)) ?? [];
|
|
223
185
|
}
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
|
|
239
|
-
return;
|
|
240
|
-
}
|
|
241
|
-
isDragging = true;
|
|
242
|
-
dragDoorId = doorId;
|
|
243
|
-
dragStartSlotIndex = slotIndex;
|
|
244
|
-
dragEndSlotIndex = slotIndex;
|
|
186
|
+
return map;
|
|
187
|
+
});
|
|
188
|
+
function countsAt(day) {
|
|
189
|
+
return countsByDay.get(dayKey(day)) ?? [];
|
|
190
|
+
}
|
|
191
|
+
// ---- Click and drag logic ----
|
|
192
|
+
let isDragging = $state(false);
|
|
193
|
+
let dragDoorId = $state(null);
|
|
194
|
+
let dragStartSlotIndex = $state(null);
|
|
195
|
+
let dragEndSlotIndex = $state(null);
|
|
196
|
+
function onSlotMouseDown(doorId, slotIndex, e) {
|
|
197
|
+
// Only start a drag-create on the primary (left) button. Otherwise a
|
|
198
|
+
// middle-click (autoscroll) or right-click would open the create modal.
|
|
199
|
+
if (e.button !== 0) {
|
|
200
|
+
return;
|
|
245
201
|
}
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
// If a drag is in progress but the primary button is no longer held, the
|
|
249
|
-
// pointer-up happened somewhere we did not observe (e.g. outside the
|
|
250
|
-
// calendar). Reset so we do not "drag" without the button being pressed.
|
|
251
|
-
if ((isDragging || appointmentDragStarted) && (e.buttons & 1) === 0) {
|
|
252
|
-
isDragging = false;
|
|
253
|
-
dragDoorId = null;
|
|
254
|
-
dragStartSlotIndex = null;
|
|
255
|
-
dragEndSlotIndex = null;
|
|
256
|
-
resetAppointmentDrag();
|
|
257
|
-
return;
|
|
258
|
-
}
|
|
259
|
-
// Appointment move drag
|
|
260
|
-
if (appointmentDragStarted) {
|
|
261
|
-
isDraggingAppointment = true;
|
|
262
|
-
moveTargetDoorId = doorId;
|
|
263
|
-
moveTargetSlotIndex = slotIndex;
|
|
264
|
-
return;
|
|
265
|
-
}
|
|
266
|
-
// Slot drag-create
|
|
267
|
-
if (!isDragging || doorId !== dragDoorId) {
|
|
268
|
-
return;
|
|
269
|
-
}
|
|
270
|
-
dragEndSlotIndex = slotIndex;
|
|
202
|
+
if (!ondragcreate || appointmentDragStarted) {
|
|
203
|
+
return;
|
|
271
204
|
}
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
|
|
276
|
-
|
|
277
|
-
|
|
278
|
-
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
|
|
282
|
-
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
283
|
-
const newDateFrom = new Date(selected);
|
|
284
|
-
newDateFrom.setHours(slot.hour, slot.minute, 0, 0);
|
|
285
|
-
const newDateTo = new Date(
|
|
286
|
-
newDateFrom.getTime() + draggedAptSlotCount * slotIntervalMinutes * 60000
|
|
287
|
-
);
|
|
288
|
-
|
|
289
|
-
// Check whether the position actually changed
|
|
290
|
-
const origFrom = new Date(draggedAppointment.dateFrom);
|
|
291
|
-
const samePosition =
|
|
292
|
-
moveTargetDoorId === draggedAppointment.segmentId &&
|
|
293
|
-
newDateFrom.getTime() === origFrom.getTime();
|
|
294
|
-
|
|
295
|
-
if (samePosition) {
|
|
296
|
-
// Dropped in the same spot - treat as a click
|
|
297
|
-
onappointmentclick?.(draggedAppointment);
|
|
298
|
-
} else if (!hasOverlap(moveTargetDoorId, newDateFrom, newDateTo, draggedAppointment.id)) {
|
|
299
|
-
const door = doors.find((d) => d.id === moveTargetDoorId);
|
|
300
|
-
onappointmentmove?.({
|
|
301
|
-
appointment: draggedAppointment,
|
|
302
|
-
newDoorId: moveTargetDoorId,
|
|
303
|
-
newDoorName: door?.name ?? '',
|
|
304
|
-
newDateFrom,
|
|
305
|
-
newDateTo,
|
|
306
|
-
});
|
|
307
|
-
}
|
|
308
|
-
|
|
309
|
-
resetAppointmentDrag();
|
|
310
|
-
return;
|
|
311
|
-
}
|
|
312
|
-
|
|
313
|
-
// ---- Handle appointment click (mousedown + mouseup without leaving the block) ----
|
|
314
|
-
if (appointmentDragStarted && !isDraggingAppointment && draggedAppointment) {
|
|
315
|
-
onappointmentclick?.(draggedAppointment);
|
|
316
|
-
resetAppointmentDrag();
|
|
317
|
-
return;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
resetAppointmentDrag();
|
|
321
|
-
|
|
322
|
-
// ---- Handle drag-create ----
|
|
323
|
-
if (
|
|
324
|
-
!isDragging ||
|
|
325
|
-
dragDoorId == null ||
|
|
326
|
-
dragStartSlotIndex == null ||
|
|
327
|
-
dragEndSlotIndex == null
|
|
328
|
-
) {
|
|
329
|
-
return;
|
|
330
|
-
}
|
|
331
|
-
|
|
332
|
-
const startIdx = Math.min(dragStartSlotIndex, dragEndSlotIndex);
|
|
333
|
-
const endIdx = Math.max(dragStartSlotIndex, dragEndSlotIndex);
|
|
334
|
-
|
|
335
|
-
const startSlot = timeSlots[startIdx];
|
|
336
|
-
const endSlot = timeSlots[endIdx];
|
|
337
|
-
|
|
338
|
-
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
339
|
-
const dateFrom = new Date(selected);
|
|
340
|
-
dateFrom.setHours(startSlot.hour, startSlot.minute, 0, 0);
|
|
341
|
-
|
|
342
|
-
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
343
|
-
const dateTo = new Date(selected);
|
|
344
|
-
// End slot is the start of the last selected slot; add one interval for the end time
|
|
345
|
-
dateTo.setHours(endSlot.hour, endSlot.minute + slotIntervalMinutes, 0, 0);
|
|
346
|
-
|
|
347
|
-
const door = doors.find((d) => d.id === dragDoorId);
|
|
348
|
-
|
|
349
|
-
const selection: DragSelection = {
|
|
350
|
-
doorId: dragDoorId,
|
|
351
|
-
doorName: door?.name ?? '',
|
|
352
|
-
dateFrom,
|
|
353
|
-
dateTo,
|
|
354
|
-
};
|
|
355
|
-
|
|
205
|
+
isDragging = true;
|
|
206
|
+
dragDoorId = doorId;
|
|
207
|
+
dragStartSlotIndex = slotIndex;
|
|
208
|
+
dragEndSlotIndex = slotIndex;
|
|
209
|
+
}
|
|
210
|
+
function onSlotMouseEnter(doorId, slotIndex, e) {
|
|
211
|
+
// If a drag is in progress but the primary button is no longer held, the
|
|
212
|
+
// pointer-up happened somewhere we did not observe (e.g. outside the
|
|
213
|
+
// calendar). Reset so we do not "drag" without the button being pressed.
|
|
214
|
+
if ((isDragging || appointmentDragStarted) && (e.buttons & 1) === 0) {
|
|
356
215
|
isDragging = false;
|
|
357
216
|
dragDoorId = null;
|
|
358
217
|
dragStartSlotIndex = null;
|
|
359
218
|
dragEndSlotIndex = null;
|
|
360
|
-
|
|
361
|
-
|
|
219
|
+
resetAppointmentDrag();
|
|
220
|
+
return;
|
|
362
221
|
}
|
|
363
|
-
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
return false;
|
|
370
|
-
}
|
|
371
|
-
const min = Math.min(dragStartSlotIndex, dragEndSlotIndex);
|
|
372
|
-
const max = Math.max(dragStartSlotIndex, dragEndSlotIndex);
|
|
373
|
-
return slotIndex >= min && slotIndex <= max;
|
|
222
|
+
// Appointment move drag
|
|
223
|
+
if (appointmentDragStarted) {
|
|
224
|
+
isDraggingAppointment = true;
|
|
225
|
+
moveTargetDoorId = doorId;
|
|
226
|
+
moveTargetSlotIndex = slotIndex;
|
|
227
|
+
return;
|
|
374
228
|
}
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
383
|
-
|
|
384
|
-
|
|
385
|
-
|
|
386
|
-
|
|
387
|
-
|
|
388
|
-
|
|
389
|
-
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
if (
|
|
393
|
-
|
|
394
|
-
|
|
395
|
-
if (!
|
|
396
|
-
|
|
229
|
+
// Slot drag-create
|
|
230
|
+
if (!isDragging || doorId !== dragDoorId) {
|
|
231
|
+
return;
|
|
232
|
+
}
|
|
233
|
+
dragEndSlotIndex = slotIndex;
|
|
234
|
+
}
|
|
235
|
+
function onSlotMouseUp() {
|
|
236
|
+
// ---- Handle appointment drag-move ----
|
|
237
|
+
if (isDraggingAppointment && draggedAppointment && moveTargetDoorId != null && moveTargetSlotIndex != null) {
|
|
238
|
+
const slot = timeSlots[moveTargetSlotIndex];
|
|
239
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
240
|
+
const newDateFrom = new Date(selected);
|
|
241
|
+
newDateFrom.setHours(slot.hour, slot.minute, 0, 0);
|
|
242
|
+
const newDateTo = new Date(newDateFrom.getTime() + draggedAptSlotCount * slotIntervalMinutes * 6e4);
|
|
243
|
+
// Check whether the position actually changed
|
|
244
|
+
const origFrom = new Date(draggedAppointment.dateFrom);
|
|
245
|
+
const samePosition = moveTargetDoorId === draggedAppointment.segmentId && newDateFrom.getTime() === origFrom.getTime();
|
|
246
|
+
if (samePosition) {
|
|
247
|
+
// Dropped in the same spot - treat as a click
|
|
248
|
+
onappointmentclick?.(draggedAppointment);
|
|
249
|
+
} else if (!hasOverlap(moveTargetDoorId, newDateFrom, newDateTo, draggedAppointment.id)) {
|
|
250
|
+
const door = doors.find((d) => d.id === moveTargetDoorId);
|
|
251
|
+
onappointmentmove?.({
|
|
252
|
+
appointment: draggedAppointment,
|
|
253
|
+
newDoorId: moveTargetDoorId,
|
|
254
|
+
newDoorName: door?.name ?? "",
|
|
255
|
+
newDateFrom,
|
|
256
|
+
newDateTo
|
|
257
|
+
});
|
|
397
258
|
}
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
draggedAppointment = apt;
|
|
401
|
-
|
|
402
|
-
const from = new Date(apt.dateFrom);
|
|
403
|
-
const to = new Date(apt.dateTo);
|
|
404
|
-
const durationMin = (to.getTime() - from.getTime()) / 60000;
|
|
405
|
-
draggedAptSlotCount = Math.max(1, Math.ceil(durationMin / slotIntervalMinutes));
|
|
259
|
+
resetAppointmentDrag();
|
|
260
|
+
return;
|
|
406
261
|
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
draggedAptSlotCount = 0;
|
|
413
|
-
moveTargetDoorId = null;
|
|
414
|
-
moveTargetSlotIndex = null;
|
|
262
|
+
// ---- Handle appointment click (mousedown + mouseup without leaving the block) ----
|
|
263
|
+
if (appointmentDragStarted && !isDraggingAppointment && draggedAppointment) {
|
|
264
|
+
onappointmentclick?.(draggedAppointment);
|
|
265
|
+
resetAppointmentDrag();
|
|
266
|
+
return;
|
|
415
267
|
}
|
|
416
|
-
|
|
417
|
-
|
|
418
|
-
|
|
268
|
+
resetAppointmentDrag();
|
|
269
|
+
// ---- Handle drag-create ----
|
|
270
|
+
if (!isDragging || dragDoorId == null || dragStartSlotIndex == null || dragEndSlotIndex == null) {
|
|
271
|
+
return;
|
|
272
|
+
}
|
|
273
|
+
const startIdx = Math.min(dragStartSlotIndex, dragEndSlotIndex);
|
|
274
|
+
const endIdx = Math.max(dragStartSlotIndex, dragEndSlotIndex);
|
|
275
|
+
const startSlot = timeSlots[startIdx];
|
|
276
|
+
const endSlot = timeSlots[endIdx];
|
|
277
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
278
|
+
const dateFrom = new Date(selected);
|
|
279
|
+
dateFrom.setHours(startSlot.hour, startSlot.minute, 0, 0);
|
|
280
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
281
|
+
const dateTo = new Date(selected);
|
|
282
|
+
// End slot is the start of the last selected slot; add one interval for the end time
|
|
283
|
+
dateTo.setHours(endSlot.hour, endSlot.minute + slotIntervalMinutes, 0, 0);
|
|
284
|
+
const door = doors.find((d) => d.id === dragDoorId);
|
|
285
|
+
const selection = {
|
|
286
|
+
doorId: dragDoorId,
|
|
287
|
+
doorName: door?.name ?? "",
|
|
288
|
+
dateFrom,
|
|
289
|
+
dateTo
|
|
290
|
+
};
|
|
291
|
+
isDragging = false;
|
|
292
|
+
dragDoorId = null;
|
|
293
|
+
dragStartSlotIndex = null;
|
|
294
|
+
dragEndSlotIndex = null;
|
|
295
|
+
ondragcreate?.(selection);
|
|
296
|
+
}
|
|
297
|
+
function isSlotInDragRange(doorId, slotIndex) {
|
|
298
|
+
if (!isDragging || doorId !== dragDoorId) {
|
|
299
|
+
return false;
|
|
300
|
+
}
|
|
301
|
+
if (dragStartSlotIndex == null || dragEndSlotIndex == null) {
|
|
302
|
+
return false;
|
|
303
|
+
}
|
|
304
|
+
const min = Math.min(dragStartSlotIndex, dragEndSlotIndex);
|
|
305
|
+
const max = Math.max(dragStartSlotIndex, dragEndSlotIndex);
|
|
306
|
+
return slotIndex >= min && slotIndex <= max;
|
|
307
|
+
}
|
|
308
|
+
// ---- Row hover tracking ----
|
|
309
|
+
let hoveredSlotIndex = $state(null);
|
|
310
|
+
// ---- Appointment drag-move logic ----
|
|
311
|
+
let appointmentDragStarted = $state(false);
|
|
312
|
+
let isDraggingAppointment = $state(false);
|
|
313
|
+
let draggedAppointment = $state(null);
|
|
314
|
+
let draggedAptSlotCount = $state(0);
|
|
315
|
+
let moveTargetDoorId = $state(null);
|
|
316
|
+
let moveTargetSlotIndex = $state(null);
|
|
317
|
+
function onAppointmentMouseDown(apt, e) {
|
|
318
|
+
// Only react to the primary (left) button so middle/right clicks don't
|
|
319
|
+
// start a move or register as an appointment click.
|
|
320
|
+
if (e.button !== 0) {
|
|
321
|
+
return;
|
|
322
|
+
}
|
|
323
|
+
if (!onappointmentmove) {
|
|
324
|
+
return;
|
|
325
|
+
}
|
|
326
|
+
e.preventDefault();
|
|
327
|
+
appointmentDragStarted = true;
|
|
328
|
+
draggedAppointment = apt;
|
|
329
|
+
const from = new Date(apt.dateFrom);
|
|
330
|
+
const to = new Date(apt.dateTo);
|
|
331
|
+
const durationMin = (to.getTime() - from.getTime()) / 6e4;
|
|
332
|
+
draggedAptSlotCount = Math.max(1, Math.ceil(durationMin / slotIntervalMinutes));
|
|
333
|
+
}
|
|
334
|
+
function resetAppointmentDrag() {
|
|
335
|
+
appointmentDragStarted = false;
|
|
336
|
+
isDraggingAppointment = false;
|
|
337
|
+
draggedAppointment = null;
|
|
338
|
+
draggedAptSlotCount = 0;
|
|
339
|
+
moveTargetDoorId = null;
|
|
340
|
+
moveTargetSlotIndex = null;
|
|
341
|
+
}
|
|
342
|
+
function isSlotInMoveTarget(doorId, slotIndex) {
|
|
343
|
+
if (!isDraggingAppointment || moveTargetDoorId !== doorId) {
|
|
344
|
+
return false;
|
|
345
|
+
}
|
|
346
|
+
if (moveTargetSlotIndex == null) {
|
|
347
|
+
return false;
|
|
348
|
+
}
|
|
349
|
+
return slotIndex >= moveTargetSlotIndex && slotIndex < moveTargetSlotIndex + draggedAptSlotCount;
|
|
350
|
+
}
|
|
351
|
+
function hasOverlap(doorId, dateFrom, dateTo, excludeAptId) {
|
|
352
|
+
return appointments.some((apt) => {
|
|
353
|
+
if (apt.id === excludeAptId) {
|
|
419
354
|
return false;
|
|
420
355
|
}
|
|
421
|
-
if (
|
|
356
|
+
if (apt.segmentId !== doorId) {
|
|
422
357
|
return false;
|
|
423
358
|
}
|
|
424
|
-
|
|
425
|
-
|
|
426
|
-
|
|
427
|
-
}
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
359
|
+
const aptFrom = new Date(apt.dateFrom);
|
|
360
|
+
const aptTo = new Date(apt.dateTo);
|
|
361
|
+
return dateFrom < aptTo && dateTo > aptFrom;
|
|
362
|
+
});
|
|
363
|
+
}
|
|
364
|
+
// ---- Month view helpers ----
|
|
365
|
+
function getMonthDays(date) {
|
|
366
|
+
const year = date.getFullYear();
|
|
367
|
+
const month = date.getMonth();
|
|
368
|
+
const firstDayOfMonth = new Date(year, month, 1);
|
|
369
|
+
// Start from Monday of the week containing the first day
|
|
370
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
371
|
+
const startDate = new Date(firstDayOfMonth);
|
|
372
|
+
const dayOfWeek = startDate.getDay();
|
|
373
|
+
const daysFromMonday = dayOfWeek === 0 ? 6 : dayOfWeek - 1;
|
|
374
|
+
startDate.setDate(startDate.getDate() - daysFromMonday);
|
|
375
|
+
const days = [];
|
|
376
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
377
|
+
const current = new Date(startDate);
|
|
378
|
+
// Generate 6 weeks (42 days) to fill the grid
|
|
379
|
+
for (let i = 0; i < 42; i++) {
|
|
380
|
+
days.push({
|
|
381
|
+
date: new Date(current),
|
|
382
|
+
isCurrentMonth: current.getMonth() === month
|
|
440
383
|
});
|
|
384
|
+
current.setDate(current.getDate() + 1);
|
|
441
385
|
}
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
445
|
-
|
|
446
|
-
|
|
447
|
-
|
|
448
|
-
|
|
449
|
-
|
|
450
|
-
|
|
451
|
-
|
|
452
|
-
|
|
453
|
-
|
|
454
|
-
|
|
455
|
-
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
const days: Array<{ date: Date; isCurrentMonth: boolean }> = [];
|
|
459
|
-
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
460
|
-
const current = new Date(startDate);
|
|
461
|
-
|
|
462
|
-
// Generate 6 weeks (42 days) to fill the grid
|
|
463
|
-
for (let i = 0; i < 42; i++) {
|
|
464
|
-
days.push({
|
|
465
|
-
date: new Date(current),
|
|
466
|
-
isCurrentMonth: current.getMonth() === month,
|
|
467
|
-
});
|
|
468
|
-
current.setDate(current.getDate() + 1);
|
|
469
|
-
}
|
|
470
|
-
|
|
471
|
-
return days;
|
|
386
|
+
return days;
|
|
387
|
+
}
|
|
388
|
+
/** Cached month grid; rebuilt only when `selected` changes. */
|
|
389
|
+
const monthDays = $derived(getMonthDays(selected));
|
|
390
|
+
// ---- Week view helpers ----
|
|
391
|
+
function getWeekDays(date) {
|
|
392
|
+
// eslint-disable-next-line svelte/prefer-svelte-reactivity
|
|
393
|
+
const d = new Date(date);
|
|
394
|
+
const day = d.getDay();
|
|
395
|
+
const mondayOffset = day === 0 ? -6 : 1 - day;
|
|
396
|
+
d.setDate(d.getDate() + mondayOffset);
|
|
397
|
+
const week = [];
|
|
398
|
+
for (let i = 0; i < 7; i++) {
|
|
399
|
+
week.push(new Date(d));
|
|
400
|
+
d.setDate(d.getDate() + 1);
|
|
472
401
|
}
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
|
|
477
|
-
|
|
478
|
-
|
|
479
|
-
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
|
|
488
|
-
|
|
489
|
-
d.setDate(d.getDate() + 1);
|
|
490
|
-
}
|
|
491
|
-
return week;
|
|
402
|
+
return week;
|
|
403
|
+
}
|
|
404
|
+
const weekDays = $derived(getWeekDays(selected));
|
|
405
|
+
// ---- View mode buttons ----
|
|
406
|
+
const viewModes = [
|
|
407
|
+
{
|
|
408
|
+
mode: CalendarViewMode.Day,
|
|
409
|
+
label: "Day"
|
|
410
|
+
},
|
|
411
|
+
{
|
|
412
|
+
mode: CalendarViewMode.Week,
|
|
413
|
+
label: "Week"
|
|
414
|
+
},
|
|
415
|
+
{
|
|
416
|
+
mode: CalendarViewMode.Month,
|
|
417
|
+
label: "Month"
|
|
492
418
|
}
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
|
|
502
|
-
|
|
503
|
-
|
|
504
|
-
|
|
505
|
-
|
|
506
|
-
|
|
507
|
-
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
|
|
513
|
-
|
|
514
|
-
|
|
515
|
-
|
|
516
|
-
|
|
517
|
-
|
|
518
|
-
|
|
519
|
-
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
|
|
523
|
-
|
|
524
|
-
|
|
525
|
-
|
|
526
|
-
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
530
|
-
|
|
531
|
-
|
|
532
|
-
|
|
533
|
-
|
|
534
|
-
|
|
535
|
-
|
|
536
|
-
|
|
537
|
-
|
|
538
|
-
|
|
539
|
-
|
|
540
|
-
|
|
541
|
-
|
|
542
|
-
|
|
543
|
-
|
|
544
|
-
|
|
545
|
-
|
|
546
|
-
|
|
547
|
-
|
|
548
|
-
|
|
549
|
-
|
|
550
|
-
|
|
551
|
-
|
|
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
|
-
|
|
603
|
-
|
|
604
|
-
|
|
605
|
-
|
|
606
|
-
|
|
607
|
-
|
|
608
|
-
|
|
609
|
-
|
|
610
|
-
|
|
611
|
-
|
|
612
|
-
|
|
613
|
-
|
|
614
|
-
|
|
615
|
-
|
|
616
|
-
|
|
617
|
-
|
|
618
|
-
|
|
619
|
-
|
|
620
|
-
|
|
621
|
-
|
|
622
|
-
|
|
623
|
-
|
|
624
|
-
|
|
625
|
-
|
|
626
|
-
|
|
627
|
-
|
|
628
|
-
|
|
629
|
-
|
|
630
|
-
|
|
631
|
-
|
|
632
|
-
|
|
633
|
-
|
|
634
|
-
|
|
635
|
-
|
|
636
|
-
|
|
637
|
-
class="
|
|
638
|
-
class
|
|
639
|
-
|
|
640
|
-
|
|
641
|
-
|
|
642
|
-
|
|
643
|
-
|
|
644
|
-
|
|
645
|
-
>
|
|
646
|
-
|
|
647
|
-
|
|
648
|
-
|
|
649
|
-
|
|
650
|
-
|
|
651
|
-
|
|
652
|
-
|
|
653
|
-
|
|
654
|
-
|
|
655
|
-
|
|
656
|
-
|
|
657
|
-
</div>
|
|
658
|
-
{/
|
|
659
|
-
</div>
|
|
660
|
-
|
|
661
|
-
|
|
662
|
-
{#
|
|
663
|
-
|
|
664
|
-
|
|
665
|
-
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
676
|
-
|
|
677
|
-
|
|
678
|
-
|
|
679
|
-
|
|
680
|
-
|
|
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
|
-
class
|
|
709
|
-
class
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
|
|
715
|
-
|
|
716
|
-
|
|
717
|
-
|
|
718
|
-
|
|
719
|
-
|
|
720
|
-
<span class="apt-sublabel">{apt.sublabel}</span>
|
|
721
|
-
{/if}
|
|
722
|
-
</div>
|
|
723
|
-
{/each}
|
|
724
|
-
</div>
|
|
725
|
-
{/each}
|
|
726
|
-
</div>
|
|
727
|
-
{/snippet}
|
|
728
|
-
|
|
419
|
+
];
|
|
420
|
+
</script>
|
|
421
|
+
|
|
422
|
+
<!-- A drag can end anywhere on the page (the pointer is often released outside
|
|
423
|
+
the calendar when sweeping across columns). Listen on the window so the
|
|
424
|
+
drag state is always finalised and never left "stuck" for the next drag. -->
|
|
425
|
+
<svelte:window onmouseup={onSlotMouseUp} />
|
|
426
|
+
|
|
427
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
428
|
+
<!-- svelte-ignore a11y_no_noninteractive_element_interactions -->
|
|
429
|
+
<div
|
|
430
|
+
class="appointment-calendar"
|
|
431
|
+
class:is-moving-appointment={isDraggingAppointment}
|
|
432
|
+
role="application"
|
|
433
|
+
>
|
|
434
|
+
<!-- Header: navigation + view toggle -->
|
|
435
|
+
<div class="calendar-header">
|
|
436
|
+
<div class="header-left">
|
|
437
|
+
<Button
|
|
438
|
+
label="Today"
|
|
439
|
+
size={Size.Small}
|
|
440
|
+
onclick={goToToday}
|
|
441
|
+
>
|
|
442
|
+
Today
|
|
443
|
+
</Button>
|
|
444
|
+
<div class="nav-arrows">
|
|
445
|
+
<button
|
|
446
|
+
class="nav-btn"
|
|
447
|
+
onclick={() => {
|
|
448
|
+
navigateDate(-1);
|
|
449
|
+
}}
|
|
450
|
+
aria-label="Previous"
|
|
451
|
+
>
|
|
452
|
+
<Icon
|
|
453
|
+
name="chevron-left"
|
|
454
|
+
size={Size.Small}
|
|
455
|
+
colour={Colour['$ui-blue-14']}
|
|
456
|
+
/>
|
|
457
|
+
</button>
|
|
458
|
+
<button
|
|
459
|
+
class="nav-btn"
|
|
460
|
+
onclick={() => {
|
|
461
|
+
navigateDate(1);
|
|
462
|
+
}}
|
|
463
|
+
aria-label="Next"
|
|
464
|
+
>
|
|
465
|
+
<Icon
|
|
466
|
+
name="chevron-right"
|
|
467
|
+
size={Size.Small}
|
|
468
|
+
colour={Colour['$ui-blue-14']}
|
|
469
|
+
/>
|
|
470
|
+
</button>
|
|
471
|
+
</div>
|
|
472
|
+
<h2 class="current-date-label">{formatDate(selected)}</h2>
|
|
473
|
+
</div>
|
|
474
|
+
<div class="header-right">
|
|
475
|
+
{#if headerExtra}
|
|
476
|
+
{@render headerExtra()}
|
|
477
|
+
{/if}
|
|
478
|
+
<div class="view-toggle">
|
|
479
|
+
{#each viewModes as { mode, label } (mode)}
|
|
480
|
+
<button
|
|
481
|
+
class="view-btn"
|
|
482
|
+
class:active={viewMode === mode}
|
|
483
|
+
onclick={() => {
|
|
484
|
+
onviewmodechange(mode);
|
|
485
|
+
}}
|
|
486
|
+
>
|
|
487
|
+
{label}
|
|
488
|
+
</button>
|
|
489
|
+
{/each}
|
|
490
|
+
</div>
|
|
491
|
+
</div>
|
|
492
|
+
</div>
|
|
493
|
+
|
|
494
|
+
<!-- Week day tabs rendered OUTSIDE the scrollable body so they stay frozen -->
|
|
495
|
+
{#if viewMode === CalendarViewMode.Week}
|
|
496
|
+
<div class="week-day-tabs">
|
|
497
|
+
{#each weekDays as day (day.getTime())}
|
|
498
|
+
<button
|
|
499
|
+
class="week-day-tab"
|
|
500
|
+
class:active={sameDay(day, selected)}
|
|
501
|
+
class:today={sameDay(day, today)}
|
|
502
|
+
onclick={() => {
|
|
503
|
+
ondatechange(day);
|
|
504
|
+
}}
|
|
505
|
+
>
|
|
506
|
+
<span class="day-name">{dayNames[day.getDay()].slice(0, 3)}</span>
|
|
507
|
+
<span class="day-num">{day.getDate()}</span>
|
|
508
|
+
</button>
|
|
509
|
+
{/each}
|
|
510
|
+
</div>
|
|
511
|
+
{/if}
|
|
512
|
+
|
|
513
|
+
<!-- Calendar body -->
|
|
514
|
+
<div class="calendar-body">
|
|
515
|
+
{#if isLoading}
|
|
516
|
+
<LoadingOverlay />
|
|
517
|
+
{/if}
|
|
518
|
+
|
|
519
|
+
{#if viewMode === CalendarViewMode.Day}
|
|
520
|
+
<!-- DAY VIEW -->
|
|
521
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
522
|
+
<div
|
|
523
|
+
class="day-view"
|
|
524
|
+
onmouseleave={() => {
|
|
525
|
+
hoveredSlotIndex = null;
|
|
526
|
+
}}
|
|
527
|
+
>
|
|
528
|
+
<!-- eslint-disable-next-line @typescript-eslint/no-confusing-void-expression -->
|
|
529
|
+
{@render doorGrid(selected)}
|
|
530
|
+
</div>
|
|
531
|
+
{:else if viewMode === CalendarViewMode.Week}
|
|
532
|
+
<!-- WEEK VIEW: day grid only, tabs are above calendar-body -->
|
|
533
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
534
|
+
<div
|
|
535
|
+
class="day-view"
|
|
536
|
+
onmouseleave={() => {
|
|
537
|
+
hoveredSlotIndex = null;
|
|
538
|
+
}}
|
|
539
|
+
>
|
|
540
|
+
<!-- eslint-disable-next-line @typescript-eslint/no-confusing-void-expression -->
|
|
541
|
+
{@render doorGrid(selected)}
|
|
542
|
+
</div>
|
|
543
|
+
{:else}
|
|
544
|
+
<!-- MONTH VIEW -->
|
|
545
|
+
<div class="month-view">
|
|
546
|
+
<div class="month-header-row">
|
|
547
|
+
{#each ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] as dayLabel (dayLabel)}
|
|
548
|
+
<div class="month-day-header">{dayLabel}</div>
|
|
549
|
+
{/each}
|
|
550
|
+
</div>
|
|
551
|
+
<div class="month-grid">
|
|
552
|
+
{#each monthDays as { date: dayDate, isCurrentMonth } (dayDate.getTime())}
|
|
553
|
+
<button
|
|
554
|
+
class="month-day-cell"
|
|
555
|
+
class:other-month={!isCurrentMonth}
|
|
556
|
+
class:today={sameDay(dayDate, today)}
|
|
557
|
+
class:selected={sameDay(dayDate, selected)}
|
|
558
|
+
onclick={() => {
|
|
559
|
+
ondatechange(dayDate);
|
|
560
|
+
onviewmodechange(CalendarViewMode.Day);
|
|
561
|
+
}}
|
|
562
|
+
>
|
|
563
|
+
<span class="day-number">{dayDate.getDate()}</span>
|
|
564
|
+
<div class="day-badges">
|
|
565
|
+
{#each countsAt(dayDate) as { doorId, doorName, count } (doorId)}
|
|
566
|
+
<span class="door-badge">
|
|
567
|
+
{doorName}: {count}
|
|
568
|
+
</span>
|
|
569
|
+
{/each}
|
|
570
|
+
</div>
|
|
571
|
+
</button>
|
|
572
|
+
{/each}
|
|
573
|
+
</div>
|
|
574
|
+
</div>
|
|
575
|
+
{/if}
|
|
576
|
+
</div>
|
|
577
|
+
</div>
|
|
578
|
+
|
|
579
|
+
{#snippet doorGrid(day: Date)}
|
|
580
|
+
<div class="grid-header">
|
|
581
|
+
<div class="time-header">Time</div>
|
|
582
|
+
{#each doors as door (door.id)}
|
|
583
|
+
<div class="door-header">{door.name}</div>
|
|
584
|
+
{/each}
|
|
585
|
+
</div>
|
|
586
|
+
<div class="grid-body">
|
|
587
|
+
<div class="time-column">
|
|
588
|
+
{#each timeSlots as slot, slotIdx (slot.hour * 60 + slot.minute)}
|
|
589
|
+
<div
|
|
590
|
+
class="time-label"
|
|
591
|
+
class:hour-start={slot.minute === 0}
|
|
592
|
+
class:row-hover={hoveredSlotIndex === slotIdx}
|
|
593
|
+
>
|
|
594
|
+
{#if slot.minute === 0}
|
|
595
|
+
<span>{slot.label}</span>
|
|
596
|
+
{/if}
|
|
597
|
+
</div>
|
|
598
|
+
{/each}
|
|
599
|
+
</div>
|
|
600
|
+
{#each doors as door (door.id)}
|
|
601
|
+
<div class="door-column">
|
|
602
|
+
<!-- Slot cells for drag interaction (rendered first, sit below appointments) -->
|
|
603
|
+
{#each timeSlots as slot, slotIdx (slot.hour * 60 + slot.minute)}
|
|
604
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
605
|
+
<div
|
|
606
|
+
class="slot-cell"
|
|
607
|
+
class:hour-start={slot.minute === 0}
|
|
608
|
+
class:drag-selected={isSlotInDragRange(door.id, slotIdx)}
|
|
609
|
+
class:move-target={isSlotInMoveTarget(door.id, slotIdx)}
|
|
610
|
+
class:row-hover={hoveredSlotIndex === slotIdx}
|
|
611
|
+
onmousedown={(e) => {
|
|
612
|
+
onSlotMouseDown(door.id, slotIdx, e);
|
|
613
|
+
}}
|
|
614
|
+
onmouseenter={(e) => {
|
|
615
|
+
hoveredSlotIndex = slotIdx;
|
|
616
|
+
onSlotMouseEnter(door.id, slotIdx, e);
|
|
617
|
+
}}
|
|
618
|
+
></div>
|
|
619
|
+
{/each}
|
|
620
|
+
<!-- Appointment blocks positioned absolutely (rendered after, sit above slots) -->
|
|
621
|
+
{#each appointmentsAt(door.id, day) as apt (apt.id)}
|
|
622
|
+
<!-- svelte-ignore a11y_no_static_element_interactions -->
|
|
623
|
+
<div
|
|
624
|
+
class="appointment-block"
|
|
625
|
+
class:inbound={apt.typeCode === 'INBOUND'}
|
|
626
|
+
class:outbound={apt.typeCode === 'OUTBOUND'}
|
|
627
|
+
class:dragging={isDraggingAppointment && draggedAppointment?.id === apt.id}
|
|
628
|
+
class:movable={!!onappointmentmove}
|
|
629
|
+
style="top: {apt.topPercent}%; height: {apt.heightPercent}%;"
|
|
630
|
+
onmousedown={(e) => {
|
|
631
|
+
onAppointmentMouseDown(apt, e);
|
|
632
|
+
}}
|
|
633
|
+
>
|
|
634
|
+
<span class="apt-type">{apt.typeCode}</span>
|
|
635
|
+
<span class="apt-label">{apt.label}</span>
|
|
636
|
+
{#if apt.sublabel}
|
|
637
|
+
<span class="apt-sublabel">{apt.sublabel}</span>
|
|
638
|
+
{/if}
|
|
639
|
+
</div>
|
|
640
|
+
{/each}
|
|
641
|
+
</div>
|
|
642
|
+
{/each}
|
|
643
|
+
</div>
|
|
644
|
+
{/snippet}
|
|
645
|
+
|
|
729
646
|
<style>.appointment-calendar {
|
|
730
647
|
display: flex;
|
|
731
648
|
flex-flow: column nowrap;
|
|
@@ -1150,4 +1067,4 @@
|
|
|
1150
1067
|
white-space: nowrap;
|
|
1151
1068
|
overflow: hidden;
|
|
1152
1069
|
text-overflow: ellipsis;
|
|
1153
|
-
}</style>
|
|
1070
|
+
}</style>
|