@ninthforge/scheduler 0.0.0-stage → 1.0.0-rc.1
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/LICENSE.md +9 -0
- package/README.md +94 -3
- package/dist/index.cjs +3790 -0
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.cts +585 -0
- package/dist/index.d.ts +585 -0
- package/dist/index.js +3724 -0
- package/dist/index.js.map +1 -0
- package/package.json +52 -3
package/dist/index.js
ADDED
|
@@ -0,0 +1,3724 @@
|
|
|
1
|
+
// src/Scheduler.tsx
|
|
2
|
+
import * as React6 from "react";
|
|
3
|
+
|
|
4
|
+
// src/version.ts
|
|
5
|
+
var VERSION = "1.0.0-rc.1";
|
|
6
|
+
|
|
7
|
+
// src/Scheduler.tsx
|
|
8
|
+
import { useEffect as useEffect4, useLayoutEffect, useMemo as useMemo2, useRef as useRef3, useState as useState5 } from "react";
|
|
9
|
+
|
|
10
|
+
// ../core/dist/chunk-ZAYD57MJ.js
|
|
11
|
+
var MAX = 80;
|
|
12
|
+
function clean(value) {
|
|
13
|
+
const s = String(value ?? "").replace(/[\u0000-\u001f\u007f]+/g, " ").trim();
|
|
14
|
+
return s.length > MAX ? `${s.slice(0, MAX - 1)}\u2026` : s;
|
|
15
|
+
}
|
|
16
|
+
function envOf(win) {
|
|
17
|
+
if (win) return win;
|
|
18
|
+
return typeof window === "undefined" ? {} : window;
|
|
19
|
+
}
|
|
20
|
+
function ancestors(env) {
|
|
21
|
+
try {
|
|
22
|
+
const list = env.location?.ancestorOrigins;
|
|
23
|
+
return list ? Array.from(list, String) : [];
|
|
24
|
+
} catch {
|
|
25
|
+
return [];
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
function hostnameOf(origin) {
|
|
29
|
+
return origin.replace(/^[a-z]+:\/\//i, "").replace(/[:/].*$/, "").toLowerCase();
|
|
30
|
+
}
|
|
31
|
+
var endsWith = (host, suffix) => host === suffix || host.endsWith(`.${suffix}`);
|
|
32
|
+
function detectHost(win) {
|
|
33
|
+
const env = envOf(win);
|
|
34
|
+
const xrm = env.Xrm;
|
|
35
|
+
if (xrm) {
|
|
36
|
+
try {
|
|
37
|
+
if (xrm.Utility?.getPageContext?.()?.input?.pageType === "custom") return "custom page";
|
|
38
|
+
} catch {
|
|
39
|
+
}
|
|
40
|
+
return "model-driven";
|
|
41
|
+
}
|
|
42
|
+
const up = ancestors(env).map(hostnameOf);
|
|
43
|
+
if (up.some((h) => endsWith(h, "powerbi.com") || endsWith(h, "fabric.microsoft.com"))) return "Power BI";
|
|
44
|
+
const host = (env.location?.hostname ?? "").toLowerCase();
|
|
45
|
+
if (endsWith(host, "powerbi.com")) return "Power BI";
|
|
46
|
+
if (up.some((h) => endsWith(h, "dynamics.com"))) return "custom page";
|
|
47
|
+
if (host.startsWith("authoring.") && endsWith(host, "powerapps.com")) return "canvas (Studio)";
|
|
48
|
+
if (endsWith(host, "powerapps.com") || endsWith(host, "powerplatformusercontent.com")) return "canvas";
|
|
49
|
+
return "web page";
|
|
50
|
+
}
|
|
51
|
+
var BRANDS = [
|
|
52
|
+
[/Edg(?:e|A|iOS)?\/(\d+)/, "Edge"],
|
|
53
|
+
[/OPR\/(\d+)/, "Opera"],
|
|
54
|
+
[/Firefox\/(\d+)/, "Firefox"],
|
|
55
|
+
[/FxiOS\/(\d+)/, "Firefox"],
|
|
56
|
+
[/CriOS\/(\d+)/, "Chrome"],
|
|
57
|
+
[/Chrome\/(\d+)/, "Chrome"],
|
|
58
|
+
[/Version\/(\d+)[.\d]* (?:Mobile\/\S+ )?Safari\//, "Safari"]
|
|
59
|
+
];
|
|
60
|
+
var PLATFORMS = [
|
|
61
|
+
[/iPhone|iPad|iPod/, "iOS"],
|
|
62
|
+
[/Android/, "Android"],
|
|
63
|
+
[/Windows/, "Windows"],
|
|
64
|
+
[/Mac OS X|Macintosh/, "macOS"],
|
|
65
|
+
[/CrOS/, "ChromeOS"],
|
|
66
|
+
[/Linux/, "Linux"]
|
|
67
|
+
];
|
|
68
|
+
function describeBrowser(win) {
|
|
69
|
+
const nav = envOf(win).navigator;
|
|
70
|
+
const ua = nav?.userAgent ?? "";
|
|
71
|
+
let name = "";
|
|
72
|
+
for (const [re, brand2] of BRANDS) {
|
|
73
|
+
const m = re.exec(ua);
|
|
74
|
+
if (m) {
|
|
75
|
+
name = `${brand2} ${m[1]}`;
|
|
76
|
+
break;
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
if (!name) {
|
|
80
|
+
const brands = nav?.userAgentData?.brands ?? [];
|
|
81
|
+
const b = brands.find((x) => !/not.?a.?brand|chromium/i.test(x.brand)) ?? brands[0];
|
|
82
|
+
name = b ? `${b.brand.replace("Microsoft ", "")} ${b.version}` : "Unknown browser";
|
|
83
|
+
}
|
|
84
|
+
const platform = PLATFORMS.find(([re]) => re.test(ua))?.[1] ?? nav?.userAgentData?.platform;
|
|
85
|
+
return clean(platform ? `${name} on ${platform}` : name);
|
|
86
|
+
}
|
|
87
|
+
function offsetLabel(minutes) {
|
|
88
|
+
const sign = minutes < 0 ? "\u2212" : "+";
|
|
89
|
+
const abs = Math.abs(minutes);
|
|
90
|
+
return `UTC${sign}${String(Math.floor(abs / 60)).padStart(2, "0")}:${String(abs % 60).padStart(2, "0")}`;
|
|
91
|
+
}
|
|
92
|
+
function licenceOf(l) {
|
|
93
|
+
if (!l) return { state: "not checked" };
|
|
94
|
+
const p = l.payload ?? void 0;
|
|
95
|
+
let state = clean(l.status) || "unknown";
|
|
96
|
+
if ((state === "valid" || state === "unverifiable") && p?.tier === "trial") state = "trial";
|
|
97
|
+
else if (state === "unverifiable") state = "valid (browser cannot verify)";
|
|
98
|
+
const out = { state };
|
|
99
|
+
if (p?.org) out.org = clean(p.org);
|
|
100
|
+
if (p?.exp && /^\d{4}-\d{2}-\d{2}$/.test(p.exp)) out.expires = p.exp;
|
|
101
|
+
return out;
|
|
102
|
+
}
|
|
103
|
+
function propertiesOf(p) {
|
|
104
|
+
if (!p) return [];
|
|
105
|
+
if (Array.isArray(p)) return p.map(clean).filter(Boolean);
|
|
106
|
+
return Object.keys(p).map((name) => {
|
|
107
|
+
const column = p[name];
|
|
108
|
+
return column ? `${clean(name)} \u2192 ${clean(column)}` : `${clean(name)} (not mapped)`;
|
|
109
|
+
}).filter(Boolean);
|
|
110
|
+
}
|
|
111
|
+
function collectDiagnostics(input, win) {
|
|
112
|
+
const env = envOf(win);
|
|
113
|
+
const now = input.now ?? /* @__PURE__ */ new Date();
|
|
114
|
+
let locale = clean(env.navigator?.language);
|
|
115
|
+
let zone = "";
|
|
116
|
+
try {
|
|
117
|
+
const opts = Intl.DateTimeFormat().resolvedOptions();
|
|
118
|
+
if (!locale) locale = opts.locale;
|
|
119
|
+
else if (opts.locale && opts.locale !== locale) locale = `${locale} (formats ${opts.locale})`;
|
|
120
|
+
zone = opts.timeZone ?? "";
|
|
121
|
+
} catch {
|
|
122
|
+
}
|
|
123
|
+
const offset = offsetLabel(-now.getTimezoneOffset());
|
|
124
|
+
const extra = Object.keys(input.extra ?? {}).filter((k) => input.extra[k] !== void 0 && input.extra[k] !== null).map((k) => [clean(k), clean(input.extra[k])]);
|
|
125
|
+
const d = {
|
|
126
|
+
product: clean(input.product),
|
|
127
|
+
control: clean(input.control),
|
|
128
|
+
version: clean(input.version),
|
|
129
|
+
licence: licenceOf(input.licence),
|
|
130
|
+
host: input.host ?? detectHost(env),
|
|
131
|
+
browser: describeBrowser(env),
|
|
132
|
+
locale: locale || "unknown",
|
|
133
|
+
timeZone: zone ? `${clean(zone)} (${offset})` : offset,
|
|
134
|
+
properties: propertiesOf(input.properties),
|
|
135
|
+
extra,
|
|
136
|
+
at: now.toISOString().replace(/\.\d{3}Z$/, "Z")
|
|
137
|
+
};
|
|
138
|
+
if (typeof input.rows === "number" && Number.isFinite(input.rows)) d.rows = Math.max(0, Math.round(input.rows));
|
|
139
|
+
return d;
|
|
140
|
+
}
|
|
141
|
+
function formatDiagnostics(d) {
|
|
142
|
+
const lic = [d.licence.state, d.licence.org, d.licence.expires && `expires ${d.licence.expires}`].filter(Boolean).join(" \xB7 ");
|
|
143
|
+
const lines = [
|
|
144
|
+
"Ninth Forge diagnostics",
|
|
145
|
+
`Product: ${d.product} \xB7 ${d.control} ${d.version}`,
|
|
146
|
+
`Licence: ${lic}`,
|
|
147
|
+
`Host: ${d.host}`,
|
|
148
|
+
`Browser: ${d.browser}`,
|
|
149
|
+
`Locale: ${d.locale}`,
|
|
150
|
+
`Time zone: ${d.timeZone}`,
|
|
151
|
+
`Properties: ${d.properties.length ? d.properties.join(", ") : "none mapped"}`
|
|
152
|
+
];
|
|
153
|
+
if (d.rows !== void 0) lines.push(`Rows: ${d.rows}`);
|
|
154
|
+
for (const [k, v] of d.extra) lines.push(`${k}: ${v}`);
|
|
155
|
+
lines.push(`Copied: ${d.at}`);
|
|
156
|
+
return lines.join("\n");
|
|
157
|
+
}
|
|
158
|
+
async function copyText(text2, win) {
|
|
159
|
+
const env = win ?? (typeof window === "undefined" ? {} : window);
|
|
160
|
+
const fallback = () => {
|
|
161
|
+
const doc = env.document;
|
|
162
|
+
if (!doc?.body) return false;
|
|
163
|
+
const active = doc.activeElement;
|
|
164
|
+
const area = doc.createElement("textarea");
|
|
165
|
+
area.value = text2;
|
|
166
|
+
area.setAttribute("readonly", "");
|
|
167
|
+
area.setAttribute("aria-hidden", "true");
|
|
168
|
+
area.style.cssText = "position:fixed;top:0;left:0;width:1px;height:1px;opacity:0;pointer-events:none";
|
|
169
|
+
doc.body.appendChild(area);
|
|
170
|
+
let ok = false;
|
|
171
|
+
try {
|
|
172
|
+
area.select();
|
|
173
|
+
ok = doc.execCommand("copy");
|
|
174
|
+
} catch {
|
|
175
|
+
ok = false;
|
|
176
|
+
}
|
|
177
|
+
area.remove();
|
|
178
|
+
active?.focus?.();
|
|
179
|
+
return ok;
|
|
180
|
+
};
|
|
181
|
+
const write = env.navigator?.clipboard?.writeText;
|
|
182
|
+
if (write) {
|
|
183
|
+
try {
|
|
184
|
+
await write.call(env.navigator.clipboard, text2);
|
|
185
|
+
return true;
|
|
186
|
+
} catch {
|
|
187
|
+
return fallback();
|
|
188
|
+
}
|
|
189
|
+
}
|
|
190
|
+
return fallback();
|
|
191
|
+
}
|
|
192
|
+
var DIAGNOSTICS_COPIED = "Diagnostics copied. Paste them into your e-mail to support.";
|
|
193
|
+
var DIAGNOSTICS_NOT_COPIED = "Couldn't copy diagnostics. Your browser blocked the clipboard.";
|
|
194
|
+
async function copyDiagnostics(input, win) {
|
|
195
|
+
const text2 = formatDiagnostics(collectDiagnostics(input, win));
|
|
196
|
+
const ok = await copyText(text2, win);
|
|
197
|
+
return { ok, text: text2, message: ok ? DIAGNOSTICS_COPIED : DIAGNOSTICS_NOT_COPIED };
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
// ../core/dist/index.js
|
|
201
|
+
var DAY_MS = 864e5;
|
|
202
|
+
var ISO = /^(\d{4})-(\d{2})-(\d{2})/;
|
|
203
|
+
var MONTHS = ["Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", "Aug", "Sep", "Oct", "Nov", "Dec"];
|
|
204
|
+
var MONTHS_LONG = [
|
|
205
|
+
"January",
|
|
206
|
+
"February",
|
|
207
|
+
"March",
|
|
208
|
+
"April",
|
|
209
|
+
"May",
|
|
210
|
+
"June",
|
|
211
|
+
"July",
|
|
212
|
+
"August",
|
|
213
|
+
"September",
|
|
214
|
+
"October",
|
|
215
|
+
"November",
|
|
216
|
+
"December"
|
|
217
|
+
];
|
|
218
|
+
var WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
219
|
+
function toDay(iso3) {
|
|
220
|
+
if (!iso3) return NaN;
|
|
221
|
+
const m = ISO.exec(iso3);
|
|
222
|
+
if (!m) return NaN;
|
|
223
|
+
const y = Number(m[1]);
|
|
224
|
+
const mo = Number(m[2]);
|
|
225
|
+
const d = Number(m[3]);
|
|
226
|
+
if (mo < 1 || mo > 12 || d < 1 || d > 31) return NaN;
|
|
227
|
+
return Math.round(Date.UTC(y, mo - 1, d) / DAY_MS);
|
|
228
|
+
}
|
|
229
|
+
function weekday(day) {
|
|
230
|
+
return ((day + 3) % 7 + 7) % 7;
|
|
231
|
+
}
|
|
232
|
+
function parts(day) {
|
|
233
|
+
const d = new Date(day * DAY_MS);
|
|
234
|
+
return { year: d.getUTCFullYear(), month: d.getUTCMonth(), date: d.getUTCDate() };
|
|
235
|
+
}
|
|
236
|
+
function dayOf(year, month, date = 1) {
|
|
237
|
+
return Math.round(Date.UTC(year, month, date) / DAY_MS);
|
|
238
|
+
}
|
|
239
|
+
function startOfMonth(day) {
|
|
240
|
+
const p = parts(day);
|
|
241
|
+
return dayOf(p.year, p.month, 1);
|
|
242
|
+
}
|
|
243
|
+
function isWorkingDay(day, week = "mon-fri") {
|
|
244
|
+
return week === "all" || weekday(day) < 5;
|
|
245
|
+
}
|
|
246
|
+
function workingDaysBetween(start, end, week = "mon-fri") {
|
|
247
|
+
if (Number.isNaN(start) || Number.isNaN(end) || end < start) return 0;
|
|
248
|
+
const total = end - start + 1;
|
|
249
|
+
if (week === "all") return total;
|
|
250
|
+
const full = Math.floor(total / 7);
|
|
251
|
+
let count = full * 5;
|
|
252
|
+
for (let d = start + full * 7; d <= end; d++) if (isWorkingDay(d, week)) count++;
|
|
253
|
+
return count;
|
|
254
|
+
}
|
|
255
|
+
var MINUTE = 6e4;
|
|
256
|
+
var UTC = () => 0;
|
|
257
|
+
function fixedZone(minutesEast) {
|
|
258
|
+
return () => minutesEast;
|
|
259
|
+
}
|
|
260
|
+
function browserZone() {
|
|
261
|
+
return (ms) => -new Date(ms).getTimezoneOffset() || 0;
|
|
262
|
+
}
|
|
263
|
+
var formatters = /* @__PURE__ */ new Map();
|
|
264
|
+
function ianaZone(name) {
|
|
265
|
+
let fmt = formatters.get(name);
|
|
266
|
+
if (!fmt) {
|
|
267
|
+
fmt = new Intl.DateTimeFormat("en-US", {
|
|
268
|
+
timeZone: name,
|
|
269
|
+
hourCycle: "h23",
|
|
270
|
+
year: "numeric",
|
|
271
|
+
month: "numeric",
|
|
272
|
+
day: "numeric",
|
|
273
|
+
hour: "numeric",
|
|
274
|
+
minute: "numeric",
|
|
275
|
+
second: "numeric"
|
|
276
|
+
});
|
|
277
|
+
formatters.set(name, fmt);
|
|
278
|
+
}
|
|
279
|
+
const f = fmt;
|
|
280
|
+
return (ms) => {
|
|
281
|
+
const p = {};
|
|
282
|
+
for (const part of f.formatToParts(new Date(ms))) if (part.type !== "literal") p[part.type] = Number(part.value);
|
|
283
|
+
const wall = Date.UTC(p.year, p.month - 1, p.day, p.hour === 24 ? 0 : p.hour, p.minute, p.second);
|
|
284
|
+
return Math.round((wall - Math.floor(ms / 1e3) * 1e3) / MINUTE);
|
|
285
|
+
};
|
|
286
|
+
}
|
|
287
|
+
function pcfZone(getOffset, sign = 1) {
|
|
288
|
+
return (ms) => sign * getOffset(new Date(ms)) || 0;
|
|
289
|
+
}
|
|
290
|
+
var MINUTE2 = 6e4;
|
|
291
|
+
var HOUR = 36e5;
|
|
292
|
+
var DAY = 864e5;
|
|
293
|
+
function instantToWall(ms, zone) {
|
|
294
|
+
const t = new Date(ms + zone(ms) * MINUTE2);
|
|
295
|
+
return { y: t.getUTCFullYear(), m: t.getUTCMonth() + 1, d: t.getUTCDate(), h: t.getUTCHours(), min: t.getUTCMinutes(), s: t.getUTCSeconds() };
|
|
296
|
+
}
|
|
297
|
+
function wallToInstant(w, zone) {
|
|
298
|
+
const local = Date.UTC(w.y, w.m - 1, w.d, w.h ?? 0, w.min ?? 0, w.s ?? 0);
|
|
299
|
+
const before = zone(local - DAY);
|
|
300
|
+
const after = zone(local + DAY);
|
|
301
|
+
const tBefore = local - before * MINUTE2;
|
|
302
|
+
if (before === after) return tBefore;
|
|
303
|
+
const tAfter = local - after * MINUTE2;
|
|
304
|
+
const okBefore = zone(tBefore) === before;
|
|
305
|
+
const okAfter = zone(tAfter) === after;
|
|
306
|
+
if (okBefore && okAfter) return Math.min(tBefore, tAfter);
|
|
307
|
+
if (okBefore) return tBefore;
|
|
308
|
+
if (okAfter) return tAfter;
|
|
309
|
+
return tBefore;
|
|
310
|
+
}
|
|
311
|
+
function instantToDay(ms, zone) {
|
|
312
|
+
const w = instantToWall(ms, zone);
|
|
313
|
+
return Math.round(Date.UTC(w.y, w.m - 1, w.d) / DAY);
|
|
314
|
+
}
|
|
315
|
+
function dayToInstant(day, zone) {
|
|
316
|
+
const t = new Date(day * DAY);
|
|
317
|
+
return wallToInstant({ y: t.getUTCFullYear(), m: t.getUTCMonth() + 1, d: t.getUTCDate() }, zone);
|
|
318
|
+
}
|
|
319
|
+
function addDaysIn(ms, n, zone) {
|
|
320
|
+
const w = instantToWall(ms, zone);
|
|
321
|
+
const t = new Date(Date.UTC(w.y, w.m - 1, w.d + n));
|
|
322
|
+
return wallToInstant({ y: t.getUTCFullYear(), m: t.getUTCMonth() + 1, d: t.getUTCDate(), h: w.h, min: w.min, s: w.s }, zone);
|
|
323
|
+
}
|
|
324
|
+
function atHour(day, hours, zone) {
|
|
325
|
+
const t = new Date(day * DAY);
|
|
326
|
+
const whole = Math.floor(hours);
|
|
327
|
+
const min = Math.round((hours - whole) * 60);
|
|
328
|
+
return wallToInstant({ y: t.getUTCFullYear(), m: t.getUTCMonth() + 1, d: t.getUTCDate(), h: whole, min }, zone);
|
|
329
|
+
}
|
|
330
|
+
function snap(ms, minutes, zone, dir = "nearest") {
|
|
331
|
+
if (minutes <= 0) return ms;
|
|
332
|
+
const w = instantToWall(ms, zone);
|
|
333
|
+
const ofDay = w.h * 60 + w.min + w.s / 60 + ms % 1e3 / 6e4;
|
|
334
|
+
const steps = ofDay / minutes;
|
|
335
|
+
const n = dir === "floor" ? Math.floor(steps) : dir === "ceil" ? Math.ceil(steps) : Math.round(steps);
|
|
336
|
+
const target = n * minutes;
|
|
337
|
+
const t = new Date(Date.UTC(w.y, w.m - 1, w.d, 0, target));
|
|
338
|
+
return wallToInstant({ y: t.getUTCFullYear(), m: t.getUTCMonth() + 1, d: t.getUTCDate(), h: t.getUTCHours(), min: t.getUTCMinutes() }, zone);
|
|
339
|
+
}
|
|
340
|
+
function timeLabel(ms, zone) {
|
|
341
|
+
const w = instantToWall(ms, zone);
|
|
342
|
+
return `${String(w.h).padStart(2, "0")}:${String(w.min).padStart(2, "0")}`;
|
|
343
|
+
}
|
|
344
|
+
function dayLabelIn(ms, zone) {
|
|
345
|
+
const day = instantToDay(ms, zone);
|
|
346
|
+
const w = instantToWall(ms, zone);
|
|
347
|
+
return `${WEEKDAYS[weekday(day)]} ${w.d} ${MONTHS[w.m - 1]}`;
|
|
348
|
+
}
|
|
349
|
+
function createInstantScale(input) {
|
|
350
|
+
const { zone, pxPerHour, workStart = 9, workEnd = 17, workingWeek = "mon-fri", hideNonWorking = false, hourStep = 1 } = input;
|
|
351
|
+
const startDay = instantToDay(Math.min(input.from, input.to), zone);
|
|
352
|
+
const endDay = instantToDay(Math.max(input.from, input.to), zone) + 1;
|
|
353
|
+
const pxPerMs = pxPerHour / HOUR;
|
|
354
|
+
const isWorkingDay2 = (day) => workingWeek === "all" || weekday(day) < 5;
|
|
355
|
+
const segments = [];
|
|
356
|
+
let x0 = 0;
|
|
357
|
+
const days = [];
|
|
358
|
+
const nonWorking = [];
|
|
359
|
+
for (let day = startDay; day < endDay; day++) {
|
|
360
|
+
const dayStart = dayToInstant(day, zone);
|
|
361
|
+
const dayEnd = dayToInstant(day + 1, zone);
|
|
362
|
+
const working = isWorkingDay2(day);
|
|
363
|
+
const ws = Math.max(dayStart, Math.min(dayEnd, atHour(day, workStart, zone)));
|
|
364
|
+
const we = Math.max(ws, Math.min(dayEnd, atHour(day, workEnd, zone)));
|
|
365
|
+
let segStart = dayStart;
|
|
366
|
+
let segEnd = dayEnd;
|
|
367
|
+
if (hideNonWorking) {
|
|
368
|
+
if (!working) continue;
|
|
369
|
+
segStart = ws;
|
|
370
|
+
segEnd = we;
|
|
371
|
+
}
|
|
372
|
+
const width2 = (segEnd - segStart) * pxPerMs;
|
|
373
|
+
segments.push({ start: segStart, end: segEnd, x0 });
|
|
374
|
+
days.push({ day, ms: segStart, x: x0, width: width2, label: dayLabelIn(dayStart, zone), hours: (dayEnd - dayStart) / HOUR, working });
|
|
375
|
+
if (!hideNonWorking) {
|
|
376
|
+
if (!working) nonWorking.push({ x: x0, width: width2 });
|
|
377
|
+
else {
|
|
378
|
+
if (ws > dayStart) nonWorking.push({ x: x0, width: (ws - dayStart) * pxPerMs });
|
|
379
|
+
if (we < dayEnd) nonWorking.push({ x: x0 + (we - dayStart) * pxPerMs, width: (dayEnd - we) * pxPerMs });
|
|
380
|
+
}
|
|
381
|
+
}
|
|
382
|
+
x0 += width2;
|
|
383
|
+
}
|
|
384
|
+
const width = x0;
|
|
385
|
+
const start = segments[0]?.start ?? dayToInstant(startDay, zone);
|
|
386
|
+
const end = segments[segments.length - 1]?.end ?? dayToInstant(endDay, zone);
|
|
387
|
+
const x = (ms) => {
|
|
388
|
+
if (segments.length === 0) return 0;
|
|
389
|
+
let lo = 0;
|
|
390
|
+
let hi = segments.length - 1;
|
|
391
|
+
while (lo < hi) {
|
|
392
|
+
const mid = lo + hi + 1 >> 1;
|
|
393
|
+
if (segments[mid].start <= ms) lo = mid;
|
|
394
|
+
else hi = mid - 1;
|
|
395
|
+
}
|
|
396
|
+
const s = segments[lo];
|
|
397
|
+
if (ms <= s.start) return s.x0;
|
|
398
|
+
if (ms >= s.end) return s.x0 + (s.end - s.start) * pxPerMs;
|
|
399
|
+
return s.x0 + (ms - s.start) * pxPerMs;
|
|
400
|
+
};
|
|
401
|
+
const msAt = (px) => {
|
|
402
|
+
if (segments.length === 0) return start;
|
|
403
|
+
let lo = 0;
|
|
404
|
+
let hi = segments.length - 1;
|
|
405
|
+
while (lo < hi) {
|
|
406
|
+
const mid = lo + hi + 1 >> 1;
|
|
407
|
+
if (segments[mid].x0 <= px) lo = mid;
|
|
408
|
+
else hi = mid - 1;
|
|
409
|
+
}
|
|
410
|
+
const s = segments[lo];
|
|
411
|
+
return Math.min(s.end, Math.max(s.start, s.start + (px - s.x0) / pxPerMs));
|
|
412
|
+
};
|
|
413
|
+
const hours = [];
|
|
414
|
+
for (const s of segments) {
|
|
415
|
+
let t = snap(s.start, hourStep * 60, zone, "ceil");
|
|
416
|
+
while (t < s.end) {
|
|
417
|
+
const next2 = Math.min(s.end, t + hourStep * HOUR);
|
|
418
|
+
hours.push({ ms: t, x: x(t), width: (next2 - t) * pxPerMs, label: timeLabel(t, zone) });
|
|
419
|
+
t += hourStep * HOUR;
|
|
420
|
+
}
|
|
421
|
+
}
|
|
422
|
+
return { zone, start, end, startDay, endDay, pxPerHour, width, x, msAt, days, hours, nonWorking };
|
|
423
|
+
}
|
|
424
|
+
function parseHour(value) {
|
|
425
|
+
if (typeof value === "number") return value >= 0 && value <= 24 ? value : NaN;
|
|
426
|
+
const m = /^\s*(\d{1,2})(?::(\d{2}))?\s*$/.exec(value);
|
|
427
|
+
if (!m) return NaN;
|
|
428
|
+
const h = Number(m[1]);
|
|
429
|
+
const min = m[2] ? Number(m[2]) : 0;
|
|
430
|
+
return h <= 24 && min < 60 && (h < 24 || min === 0) ? h + min / 60 : NaN;
|
|
431
|
+
}
|
|
432
|
+
function escapeHtml(s) {
|
|
433
|
+
return s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c]);
|
|
434
|
+
}
|
|
435
|
+
function exportFileName(title, isoDay, ext = "png", fallback = "plan") {
|
|
436
|
+
const slug = title.toLowerCase().replace(/[^a-z0-9]+/g, "-").replace(/^-|-$/g, "") || fallback;
|
|
437
|
+
return `${slug}-${isoDay}.${ext}`;
|
|
438
|
+
}
|
|
439
|
+
var MAX_CANVAS_SIDE = 16e3;
|
|
440
|
+
var MAX_CANVAS_AREA = 12e7;
|
|
441
|
+
function exportRatio(width, height, preferred = 2) {
|
|
442
|
+
const ratio2 = Math.min(preferred, MAX_CANVAS_SIDE / width, MAX_CANVAS_SIDE / height, Math.sqrt(MAX_CANVAS_AREA / (width * height)));
|
|
443
|
+
return ratio2 >= 0.5 ? ratio2 : null;
|
|
444
|
+
}
|
|
445
|
+
function canvasColor(ctx, color, fallback) {
|
|
446
|
+
ctx.fillStyle = fallback;
|
|
447
|
+
if (color) ctx.fillStyle = color;
|
|
448
|
+
return String(ctx.fillStyle);
|
|
449
|
+
}
|
|
450
|
+
function downloadCanvas(canvas, fileName, doc = document) {
|
|
451
|
+
canvas.toBlob((blob) => {
|
|
452
|
+
if (!blob) return;
|
|
453
|
+
const url = URL.createObjectURL(blob);
|
|
454
|
+
const a = doc.createElement("a");
|
|
455
|
+
a.href = url;
|
|
456
|
+
a.download = fileName;
|
|
457
|
+
a.style.display = "none";
|
|
458
|
+
doc.body.appendChild(a);
|
|
459
|
+
a.click();
|
|
460
|
+
a.remove();
|
|
461
|
+
setTimeout(() => URL.revokeObjectURL(url), 1e4);
|
|
462
|
+
}, "image/png");
|
|
463
|
+
}
|
|
464
|
+
function printDocument(html, doc = document) {
|
|
465
|
+
const frame = doc.createElement("iframe");
|
|
466
|
+
frame.setAttribute("aria-hidden", "true");
|
|
467
|
+
frame.setAttribute("tabindex", "-1");
|
|
468
|
+
frame.style.cssText = "position:fixed;right:0;bottom:0;width:0;height:0;border:0;visibility:hidden";
|
|
469
|
+
doc.body.appendChild(frame);
|
|
470
|
+
const win = frame.contentWindow;
|
|
471
|
+
const fdoc = frame.contentDocument;
|
|
472
|
+
if (!win || !fdoc) {
|
|
473
|
+
frame.remove();
|
|
474
|
+
return;
|
|
475
|
+
}
|
|
476
|
+
fdoc.open();
|
|
477
|
+
fdoc.write(html);
|
|
478
|
+
fdoc.close();
|
|
479
|
+
let done = false;
|
|
480
|
+
const cleanup = () => {
|
|
481
|
+
if (done) return;
|
|
482
|
+
done = true;
|
|
483
|
+
frame.remove();
|
|
484
|
+
};
|
|
485
|
+
win.addEventListener("afterprint", () => setTimeout(cleanup, 0));
|
|
486
|
+
setTimeout(cleanup, 6e4);
|
|
487
|
+
setTimeout(() => {
|
|
488
|
+
win.focus();
|
|
489
|
+
win.print();
|
|
490
|
+
}, 50);
|
|
491
|
+
}
|
|
492
|
+
var KEY_SEPARATOR = "";
|
|
493
|
+
function isMultiSelect(e2) {
|
|
494
|
+
return !!(e2.ctrlKey || e2.metaKey);
|
|
495
|
+
}
|
|
496
|
+
var HOST_DIM = 0.3;
|
|
497
|
+
function isHighlighted(id, highlighted) {
|
|
498
|
+
if (!highlighted) return true;
|
|
499
|
+
if (isSet(highlighted)) return highlighted.size === 0 || highlighted.has(id);
|
|
500
|
+
return highlighted.length === 0 || highlighted.indexOf(id) !== -1;
|
|
501
|
+
}
|
|
502
|
+
function isSet(v) {
|
|
503
|
+
return typeof v.has === "function" && !Array.isArray(v);
|
|
504
|
+
}
|
|
505
|
+
function isContextMenuKey(e2) {
|
|
506
|
+
return e2.key === "ContextMenu" || e2.key === "F10" && !!e2.shiftKey;
|
|
507
|
+
}
|
|
508
|
+
var ratio = (drawn, layout) => layout > 0 && drawn > 0 && Number.isFinite(drawn / layout) ? drawn / layout : 1;
|
|
509
|
+
function cssScale(el) {
|
|
510
|
+
if (!el) return { x: 1, y: 1 };
|
|
511
|
+
const r = el.getBoundingClientRect();
|
|
512
|
+
return { x: ratio(r.width, el.offsetWidth), y: ratio(r.height, el.offsetHeight) };
|
|
513
|
+
}
|
|
514
|
+
function localPoint(el, clientX, clientY) {
|
|
515
|
+
if (!el) return { x: 0, y: 0 };
|
|
516
|
+
const r = el.getBoundingClientRect();
|
|
517
|
+
return { x: (clientX - r.left) / ratio(r.width, el.offsetWidth), y: (clientY - r.top) / ratio(r.height, el.offsetHeight) };
|
|
518
|
+
}
|
|
519
|
+
|
|
520
|
+
// ../tokens/dist/index.js
|
|
521
|
+
var TEXT_ROLES = ["caption", "body", "label", "value", "callout", "header", "title", "hero"];
|
|
522
|
+
var TEXT_SIZES = {
|
|
523
|
+
caption: 11,
|
|
524
|
+
body: 13,
|
|
525
|
+
label: 12,
|
|
526
|
+
value: 12,
|
|
527
|
+
callout: 12,
|
|
528
|
+
header: 13,
|
|
529
|
+
title: 16,
|
|
530
|
+
hero: 28
|
|
531
|
+
};
|
|
532
|
+
var TEXT_SCALE_MIN = 0.75;
|
|
533
|
+
var TEXT_SCALE_MAX = 2;
|
|
534
|
+
function clampTextScale(scale) {
|
|
535
|
+
if (typeof scale !== "number" || !Number.isFinite(scale) || scale <= 0) return 1;
|
|
536
|
+
return Math.min(TEXT_SCALE_MAX, Math.max(TEXT_SCALE_MIN, scale));
|
|
537
|
+
}
|
|
538
|
+
var half = (px) => Math.round(px * 2) / 2;
|
|
539
|
+
function textSize(role, scale = 1, sizes) {
|
|
540
|
+
const base = sizes?.[role] ?? TEXT_SIZES[role];
|
|
541
|
+
return half(base * clampTextScale(scale));
|
|
542
|
+
}
|
|
543
|
+
function textVars(scale = 1, sizes) {
|
|
544
|
+
const out = {};
|
|
545
|
+
for (const role of TEXT_ROLES) out[`--nf-text-${role}`] = `${textSize(role, scale, sizes)}px`;
|
|
546
|
+
return out;
|
|
547
|
+
}
|
|
548
|
+
function scaleWithText(px, scale = 1) {
|
|
549
|
+
const s = clampTextScale(scale);
|
|
550
|
+
if (s === 1) return px;
|
|
551
|
+
return Math.ceil(px * s / 8) * 8;
|
|
552
|
+
}
|
|
553
|
+
var brand = {
|
|
554
|
+
ink: "#0E1116",
|
|
555
|
+
ember: "#D9480F",
|
|
556
|
+
paper: "#F6F6F3",
|
|
557
|
+
line: "#E4E5E9",
|
|
558
|
+
muted: "#5D6472",
|
|
559
|
+
cobalt: "#2E54FF"
|
|
560
|
+
};
|
|
561
|
+
var semantic = {
|
|
562
|
+
milestone: "#E8930C",
|
|
563
|
+
positive: "#138A52",
|
|
564
|
+
negative: "#D93F2B"
|
|
565
|
+
};
|
|
566
|
+
var font = {
|
|
567
|
+
sans: "'Geist', 'Segoe UI', system-ui, -apple-system, sans-serif",
|
|
568
|
+
mono: "'Geist Mono', 'Cascadia Mono', ui-monospace, monospace",
|
|
569
|
+
/** Components inherit the host app's font unless a theme says otherwise. */
|
|
570
|
+
inherit: "inherit"
|
|
571
|
+
};
|
|
572
|
+
var motion = {
|
|
573
|
+
fast: "150ms",
|
|
574
|
+
base: "220ms",
|
|
575
|
+
ease: "cubic-bezier(0.2, 0.8, 0.2, 1)"
|
|
576
|
+
};
|
|
577
|
+
var lightTheme = {
|
|
578
|
+
accent: brand.cobalt,
|
|
579
|
+
accentTint: "#DCE3FF",
|
|
580
|
+
accentText: "#FFFFFF",
|
|
581
|
+
surface: "#FFFFFF",
|
|
582
|
+
surfaceAlt: "#F6F6F3",
|
|
583
|
+
ink: brand.ink,
|
|
584
|
+
muted: brand.muted,
|
|
585
|
+
line: brand.line,
|
|
586
|
+
lineSoft: "#F2F3F5",
|
|
587
|
+
phase: brand.ink,
|
|
588
|
+
milestone: semantic.milestone,
|
|
589
|
+
baseline: "#B9BDC7",
|
|
590
|
+
selected: "#F4F6FF",
|
|
591
|
+
focus: brand.cobalt,
|
|
592
|
+
positive: semantic.positive,
|
|
593
|
+
negative: semantic.negative,
|
|
594
|
+
tooltipBg: brand.ink,
|
|
595
|
+
tooltipInk: "#FFFFFF",
|
|
596
|
+
font: font.inherit,
|
|
597
|
+
monoFont: font.mono
|
|
598
|
+
};
|
|
599
|
+
var darkTheme = {
|
|
600
|
+
accent: "#6F8BFF",
|
|
601
|
+
accentTint: "#28305A",
|
|
602
|
+
accentText: "#0E1116",
|
|
603
|
+
surface: "#14171D",
|
|
604
|
+
surfaceAlt: "#0E1116",
|
|
605
|
+
ink: "#F2F3F5",
|
|
606
|
+
muted: "#A3A9B6",
|
|
607
|
+
line: "#2A2F3A",
|
|
608
|
+
lineSoft: "#1D2129",
|
|
609
|
+
phase: "#E4E5E9",
|
|
610
|
+
milestone: "#F2A53A",
|
|
611
|
+
baseline: "#5D6472",
|
|
612
|
+
selected: "#1C2238",
|
|
613
|
+
focus: "#8FA6FF",
|
|
614
|
+
positive: "#3FBF7F",
|
|
615
|
+
negative: "#F06A58",
|
|
616
|
+
tooltipBg: "#F2F3F5",
|
|
617
|
+
tooltipInk: "#0E1116",
|
|
618
|
+
font: font.inherit,
|
|
619
|
+
monoFont: font.mono
|
|
620
|
+
};
|
|
621
|
+
var kebab = (s) => s.replace(/[A-Z]/g, (m) => "-" + m.toLowerCase());
|
|
622
|
+
function themeVars(theme = {}, base = lightTheme) {
|
|
623
|
+
const merged = { ...base, ...theme };
|
|
624
|
+
const out = {};
|
|
625
|
+
Object.keys(merged).forEach((k) => {
|
|
626
|
+
out[`--nf-${kebab(k)}`] = merged[k];
|
|
627
|
+
});
|
|
628
|
+
return out;
|
|
629
|
+
}
|
|
630
|
+
function markSvg({ size = 40, tile = true, ink = brand.ink, ember = brand.ember } = {}) {
|
|
631
|
+
const stroke = tile ? "#FFFFFF" : ink;
|
|
632
|
+
return `<svg xmlns="http://www.w3.org/2000/svg" width="${size}" height="${size}" viewBox="0 0 40 40" role="img" aria-label="Ninth Forge">` + (tile ? `<rect width="40" height="40" rx="10" fill="${ink}"/>` : "") + `<rect x="8.5" y="10" width="4.6" height="20" fill="${stroke}"/><path d="M17 10 L31.5 30" stroke="${stroke}" stroke-width="4.6"/><path d="M31.5 10 L17 30" stroke="${stroke}" stroke-width="4.6"/><path d="M31.5 10 L27.6 15.4" stroke="${ember}" stroke-width="4.6"/></svg>`;
|
|
633
|
+
}
|
|
634
|
+
function luminance(hex) {
|
|
635
|
+
const m = /^#?([0-9a-f]{3}|[0-9a-f]{6})$/i.exec(hex.trim());
|
|
636
|
+
if (!m) return null;
|
|
637
|
+
const h = m[1].length === 3 ? m[1].replace(/./g, (c) => c + c) : m[1];
|
|
638
|
+
const ch = [0, 2, 4].map((i) => {
|
|
639
|
+
const v = parseInt(h.slice(i, i + 2), 16) / 255;
|
|
640
|
+
return v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4;
|
|
641
|
+
});
|
|
642
|
+
return 0.2126 * ch[0] + 0.7152 * ch[1] + 0.0722 * ch[2];
|
|
643
|
+
}
|
|
644
|
+
function contrastRatio(a, b) {
|
|
645
|
+
const la = luminance(a);
|
|
646
|
+
const lb = luminance(b);
|
|
647
|
+
if (la === null || lb === null) return null;
|
|
648
|
+
const [hi, lo] = la > lb ? [la, lb] : [lb, la];
|
|
649
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
650
|
+
}
|
|
651
|
+
function readableTextOn(background, light = "#FFFFFF", dark = brand.ink) {
|
|
652
|
+
const onLight = contrastRatio(background, light);
|
|
653
|
+
const onDark = contrastRatio(background, dark);
|
|
654
|
+
if (onLight === null || onDark === null) return light;
|
|
655
|
+
return onLight >= onDark ? light : dark;
|
|
656
|
+
}
|
|
657
|
+
function mixHex(a, b, t) {
|
|
658
|
+
const p = (h) => {
|
|
659
|
+
const s = h.replace("#", "");
|
|
660
|
+
const f = s.length === 3 ? s.replace(/./g, (c) => c + c) : s;
|
|
661
|
+
return [0, 2, 4].map((i) => parseInt(f.slice(i, i + 2), 16));
|
|
662
|
+
};
|
|
663
|
+
const [x, y] = [p(a), p(b)];
|
|
664
|
+
return "#" + x.map((v, i) => Math.round(v + (y[i] - v) * t).toString(16).padStart(2, "0")).join("").toUpperCase();
|
|
665
|
+
}
|
|
666
|
+
function accentForText(accent, light = "#FFFFFF", dark = brand.ink) {
|
|
667
|
+
const text2 = readableTextOn(accent, light, dark);
|
|
668
|
+
if ((contrastRatio(accent, text2) ?? 21) >= 4.5) return { background: accent, text: text2 };
|
|
669
|
+
const toward = text2 === light ? "#000000" : "#FFFFFF";
|
|
670
|
+
for (let t = 0.05; t <= 1.0001; t += 0.05) {
|
|
671
|
+
const bg = mixHex(accent, toward, t);
|
|
672
|
+
if ((contrastRatio(bg, text2) ?? 0) >= 4.5) return { background: bg, text: text2 };
|
|
673
|
+
}
|
|
674
|
+
return { background: toward === "#000000" ? "#000000" : "#FFFFFF", text: text2 };
|
|
675
|
+
}
|
|
676
|
+
|
|
677
|
+
// src/presets.ts
|
|
678
|
+
var PRESETS = {
|
|
679
|
+
people: {
|
|
680
|
+
labels: { resource: "Person", resources: "People", booking: "Shift", bookings: "Shifts", group: "Team" },
|
|
681
|
+
view: "week",
|
|
682
|
+
snapMinutes: 30,
|
|
683
|
+
conflictMode: "warn",
|
|
684
|
+
workStart: 9,
|
|
685
|
+
workEnd: 17,
|
|
686
|
+
capacity: "hours",
|
|
687
|
+
capacityHours: 8,
|
|
688
|
+
showUnassigned: false
|
|
689
|
+
},
|
|
690
|
+
rooms: {
|
|
691
|
+
labels: { resource: "Room", resources: "Rooms", booking: "Booking", bookings: "Bookings", group: "Building" },
|
|
692
|
+
view: "day",
|
|
693
|
+
snapMinutes: 15,
|
|
694
|
+
conflictMode: "block",
|
|
695
|
+
workStart: 8,
|
|
696
|
+
workEnd: 18,
|
|
697
|
+
capacity: "seats",
|
|
698
|
+
capacityHours: 10,
|
|
699
|
+
showUnassigned: false
|
|
700
|
+
},
|
|
701
|
+
vehicles: {
|
|
702
|
+
labels: { resource: "Vehicle", resources: "Vehicles", booking: "Hire", bookings: "Hires", group: "Depot" },
|
|
703
|
+
view: "twoWeeks",
|
|
704
|
+
snapMinutes: 60,
|
|
705
|
+
conflictMode: "block",
|
|
706
|
+
workStart: 7,
|
|
707
|
+
workEnd: 17,
|
|
708
|
+
capacity: "hours",
|
|
709
|
+
capacityHours: 10,
|
|
710
|
+
showUnassigned: false
|
|
711
|
+
},
|
|
712
|
+
equipment: {
|
|
713
|
+
labels: { resource: "Asset", resources: "Equipment", booking: "Hire", bookings: "Hires", group: "Category" },
|
|
714
|
+
view: "twoWeeks",
|
|
715
|
+
snapMinutes: 60,
|
|
716
|
+
conflictMode: "block",
|
|
717
|
+
workStart: 7,
|
|
718
|
+
workEnd: 17,
|
|
719
|
+
capacity: "hours",
|
|
720
|
+
capacityHours: 10,
|
|
721
|
+
showUnassigned: false
|
|
722
|
+
},
|
|
723
|
+
jobs: {
|
|
724
|
+
labels: { resource: "Technician", resources: "Technicians", booking: "Job", bookings: "Jobs", group: "Area" },
|
|
725
|
+
view: "day",
|
|
726
|
+
snapMinutes: 15,
|
|
727
|
+
conflictMode: "warn",
|
|
728
|
+
workStart: 7.5,
|
|
729
|
+
workEnd: 16,
|
|
730
|
+
capacity: "hours",
|
|
731
|
+
capacityHours: 8,
|
|
732
|
+
showUnassigned: true
|
|
733
|
+
},
|
|
734
|
+
custom: {
|
|
735
|
+
labels: { resource: "Resource", resources: "Resources", booking: "Booking", bookings: "Bookings", group: "Group" },
|
|
736
|
+
view: "week",
|
|
737
|
+
snapMinutes: 15,
|
|
738
|
+
conflictMode: "allow",
|
|
739
|
+
workStart: 9,
|
|
740
|
+
workEnd: 17,
|
|
741
|
+
capacity: "hours",
|
|
742
|
+
capacityHours: 8,
|
|
743
|
+
showUnassigned: false
|
|
744
|
+
}
|
|
745
|
+
};
|
|
746
|
+
var VIEW_DAYS = { day: 1, week: 7, twoWeeks: 14, month: 31, agenda: 7 };
|
|
747
|
+
var VIEW_LABEL = { day: "Day", week: "Week", twoWeeks: "Two weeks", month: "Month", agenda: "Agenda" };
|
|
748
|
+
var VIEW_STEP = { day: "day", week: "week", twoWeeks: "two weeks", month: "month", agenda: "7 days" };
|
|
749
|
+
|
|
750
|
+
// src/layout.ts
|
|
751
|
+
var HEADER_H = 56;
|
|
752
|
+
var GROUP_H = 32;
|
|
753
|
+
var LANE_H = 30;
|
|
754
|
+
var ROW_PAD = 7;
|
|
755
|
+
var ROW_MIN_H = 52;
|
|
756
|
+
var RESOURCE_W = 232;
|
|
757
|
+
var MINUTE3 = 6e4;
|
|
758
|
+
function hourLabelStep(pxPerHour, captionPx) {
|
|
759
|
+
const need = 5 * 0.62 * captionPx + 4 + 8;
|
|
760
|
+
for (const step of [1, 2, 3, 4, 6, 12]) if (pxPerHour * step >= need) return step;
|
|
761
|
+
return 24;
|
|
762
|
+
}
|
|
763
|
+
function hourLabelFits(x, boardWidth, captionPx) {
|
|
764
|
+
return x + 4 + 5 * 0.62 * captionPx <= boardWidth;
|
|
765
|
+
}
|
|
766
|
+
function toMs(v) {
|
|
767
|
+
if (typeof v === "number") return Number.isFinite(v) ? v : NaN;
|
|
768
|
+
if (typeof v !== "string" || !v) return NaN;
|
|
769
|
+
return Date.parse(v);
|
|
770
|
+
}
|
|
771
|
+
var TEXT_FIELDS = ["name", "status", "location", "subtitle", "color"];
|
|
772
|
+
var text = (v) => v === null || v === void 0 ? void 0 : typeof v === "string" ? v : String(v);
|
|
773
|
+
function cleanBooking(b) {
|
|
774
|
+
const raw = b;
|
|
775
|
+
const dirty = typeof raw.id !== "string" || TEXT_FIELDS.some((k) => raw[k] !== void 0 && typeof raw[k] !== "string") || raw.attendees !== void 0 && typeof raw.attendees !== "number";
|
|
776
|
+
if (!dirty) return b;
|
|
777
|
+
const fixed = { ...raw, id: String(raw.id ?? "") };
|
|
778
|
+
for (const k of TEXT_FIELDS) fixed[k] = text(raw[k]);
|
|
779
|
+
fixed.name = text(raw.name) ?? "";
|
|
780
|
+
const n = Number(raw.attendees);
|
|
781
|
+
fixed.attendees = raw.attendees === void 0 || !Number.isFinite(n) ? void 0 : n;
|
|
782
|
+
return fixed;
|
|
783
|
+
}
|
|
784
|
+
function cleanResources(resources) {
|
|
785
|
+
const seen = /* @__PURE__ */ new Set();
|
|
786
|
+
const out = [];
|
|
787
|
+
for (const r of resources) {
|
|
788
|
+
const raw = r;
|
|
789
|
+
const id = text(raw.id);
|
|
790
|
+
if (!id || seen.has(id)) continue;
|
|
791
|
+
seen.add(id);
|
|
792
|
+
const num = (v) => typeof v === "number" && Number.isFinite(v) && v >= 0 ? v : void 0;
|
|
793
|
+
const clean2 = typeof raw.name === "string" && ["group", "subtitle", "color", "image"].every((k) => raw[k] === void 0 || typeof raw[k] === "string") && ["capacityHours", "seats", "sort"].every((k) => raw[k] === void 0 || num(raw[k]) !== void 0);
|
|
794
|
+
if (clean2) {
|
|
795
|
+
out.push(r);
|
|
796
|
+
continue;
|
|
797
|
+
}
|
|
798
|
+
out.push({
|
|
799
|
+
...r,
|
|
800
|
+
id,
|
|
801
|
+
name: text(raw.name) ?? "",
|
|
802
|
+
group: text(raw.group),
|
|
803
|
+
subtitle: text(raw.subtitle),
|
|
804
|
+
color: text(raw.color),
|
|
805
|
+
image: text(raw.image),
|
|
806
|
+
capacityHours: num(raw.capacityHours),
|
|
807
|
+
seats: num(raw.seats),
|
|
808
|
+
sort: num(raw.sort)
|
|
809
|
+
});
|
|
810
|
+
}
|
|
811
|
+
return out;
|
|
812
|
+
}
|
|
813
|
+
function normalise(bookings) {
|
|
814
|
+
const out = [];
|
|
815
|
+
for (const raw of bookings) {
|
|
816
|
+
if (!raw || typeof raw !== "object") continue;
|
|
817
|
+
const b = cleanBooking(raw);
|
|
818
|
+
const start = toMs(b.start);
|
|
819
|
+
let end = toMs(b.end);
|
|
820
|
+
if (Number.isNaN(start)) continue;
|
|
821
|
+
if (Number.isNaN(end) || end <= start) end = start + 15 * MINUTE3;
|
|
822
|
+
out.push({
|
|
823
|
+
booking: b,
|
|
824
|
+
id: b.id,
|
|
825
|
+
start,
|
|
826
|
+
end,
|
|
827
|
+
resourceId: b.resourceId || null,
|
|
828
|
+
kind: b.kind === "unavailable" ? "unavailable" : "booking",
|
|
829
|
+
cancelled: /^cancel/i.test(b.status ?? "")
|
|
830
|
+
});
|
|
831
|
+
}
|
|
832
|
+
return out;
|
|
833
|
+
}
|
|
834
|
+
function viewRange(date, view) {
|
|
835
|
+
const day = toDay(date);
|
|
836
|
+
if (view === "month") {
|
|
837
|
+
const p = parts(day);
|
|
838
|
+
const first = startOfMonth(day);
|
|
839
|
+
return { startDay: first, days: dayOf(p.year, p.month + 1, 1) - first };
|
|
840
|
+
}
|
|
841
|
+
const startDay = view === "day" || view === "agenda" ? day : day - weekday(day);
|
|
842
|
+
return { startDay, days: VIEW_DAYS[view] };
|
|
843
|
+
}
|
|
844
|
+
function stepDate(date, view, dir) {
|
|
845
|
+
const day = toDay(date);
|
|
846
|
+
if (view === "month") {
|
|
847
|
+
const p = parts(day);
|
|
848
|
+
const last = dayOf(p.year, p.month + dir + 1, 1) - 1;
|
|
849
|
+
return Math.min(dayOf(p.year, p.month + dir, p.date), last);
|
|
850
|
+
}
|
|
851
|
+
return day + dir * VIEW_DAYS[view];
|
|
852
|
+
}
|
|
853
|
+
function agendaTime(it, day, zone) {
|
|
854
|
+
const ds = dayToInstant(day, zone);
|
|
855
|
+
const de = dayToInstant(day + 1, zone);
|
|
856
|
+
if (it.booking.allDay || it.start <= ds && it.end >= de) return "All day";
|
|
857
|
+
if (it.start < ds) return `until ${timeLabel(it.end, zone)}`;
|
|
858
|
+
if (it.end > de) return `from ${timeLabel(it.start, zone)}`;
|
|
859
|
+
return `${timeLabel(it.start, zone)}\u2013${timeLabel(it.end, zone)}`;
|
|
860
|
+
}
|
|
861
|
+
function assignLanes(items) {
|
|
862
|
+
const sorted = [...items].sort((a, b) => a.start - b.start || b.end - b.start - (a.end - a.start) || a.id.localeCompare(b.id));
|
|
863
|
+
const ends = [];
|
|
864
|
+
const lane = /* @__PURE__ */ new Map();
|
|
865
|
+
for (const it of sorted) {
|
|
866
|
+
let i = ends.findIndex((e2) => e2 <= it.start);
|
|
867
|
+
if (i < 0) {
|
|
868
|
+
i = ends.length;
|
|
869
|
+
ends.push(it.end);
|
|
870
|
+
} else ends[i] = it.end;
|
|
871
|
+
lane.set(it.id, i);
|
|
872
|
+
}
|
|
873
|
+
return { lane, lanes: Math.max(1, ends.length) };
|
|
874
|
+
}
|
|
875
|
+
function conflicts(items) {
|
|
876
|
+
const out = /* @__PURE__ */ new Set();
|
|
877
|
+
const live = items.filter((i) => !i.cancelled).sort((a, b) => a.start - b.start);
|
|
878
|
+
for (let i = 0; i < live.length; i++)
|
|
879
|
+
for (let j = i + 1; j < live.length && live[j].start < live[i].end; j++) {
|
|
880
|
+
out.add(live[i].id);
|
|
881
|
+
out.add(live[j].id);
|
|
882
|
+
}
|
|
883
|
+
return out;
|
|
884
|
+
}
|
|
885
|
+
function sortResources(resources) {
|
|
886
|
+
return [...resources].sort(
|
|
887
|
+
(a, b) => (a.group ?? "").localeCompare(b.group ?? "") || (a.sort ?? Number.MAX_SAFE_INTEGER) - (b.sort ?? Number.MAX_SAFE_INTEGER) || a.name.localeCompare(b.name)
|
|
888
|
+
);
|
|
889
|
+
}
|
|
890
|
+
function rowHeight(lanes, textScale = 1) {
|
|
891
|
+
return Math.max(scaleWithText(ROW_MIN_H, textScale), ROW_PAD * 2 + lanes * scaleWithText(LANE_H, textScale));
|
|
892
|
+
}
|
|
893
|
+
function buildRows(resources, items, opts) {
|
|
894
|
+
const q = (opts.filter ?? "").trim().toLowerCase();
|
|
895
|
+
const byResource = /* @__PURE__ */ new Map();
|
|
896
|
+
for (const it of items) {
|
|
897
|
+
if (!it.resourceId || it.end <= opts.from || it.start >= opts.to) continue;
|
|
898
|
+
const list = byResource.get(it.resourceId) ?? [];
|
|
899
|
+
list.push(it);
|
|
900
|
+
byResource.set(it.resourceId, list);
|
|
901
|
+
}
|
|
902
|
+
const visible = sortResources(resources).filter(
|
|
903
|
+
(r) => !q || r.name.toLowerCase().includes(q) || (r.subtitle ?? "").toLowerCase().includes(q) || (r.group ?? "").toLowerCase().includes(q)
|
|
904
|
+
);
|
|
905
|
+
const rows = [];
|
|
906
|
+
let top = 0;
|
|
907
|
+
let index = 0;
|
|
908
|
+
let currentGroup;
|
|
909
|
+
for (const r of visible) {
|
|
910
|
+
const g = r.group ?? "";
|
|
911
|
+
if (opts.groupBy && g !== currentGroup) {
|
|
912
|
+
currentGroup = g;
|
|
913
|
+
const collapsed = !!opts.collapsed?.has(g);
|
|
914
|
+
rows.push({ kind: "group", key: `g:${g}`, label: g || "Other", count: visible.filter((x) => (x.group ?? "") === g).length, collapsed, top, height: scaleWithText(GROUP_H, opts.textScale) });
|
|
915
|
+
top += scaleWithText(GROUP_H, opts.textScale);
|
|
916
|
+
}
|
|
917
|
+
if (opts.groupBy && opts.collapsed?.has(g)) continue;
|
|
918
|
+
const its = (byResource.get(r.id) ?? []).sort((a, b) => a.start - b.start);
|
|
919
|
+
const { lane, lanes } = assignLanes(its);
|
|
920
|
+
const height = rowHeight(lanes, opts.textScale);
|
|
921
|
+
rows.push({ kind: "resource", key: `r:${r.id}`, resource: r, items: its, lane, lanes, conflicts: conflicts(its), top, height, index: index++ });
|
|
922
|
+
top += height;
|
|
923
|
+
}
|
|
924
|
+
return rows;
|
|
925
|
+
}
|
|
926
|
+
function totalHeight(rows) {
|
|
927
|
+
const last = rows[rows.length - 1];
|
|
928
|
+
return last ? last.top + last.height : 0;
|
|
929
|
+
}
|
|
930
|
+
function visibleRows(rows, scrollTop, viewportHeight, overscan = 400) {
|
|
931
|
+
if (!rows.length) return [];
|
|
932
|
+
const from = Math.max(0, scrollTop - overscan);
|
|
933
|
+
const to = scrollTop + viewportHeight + overscan;
|
|
934
|
+
let lo = 0;
|
|
935
|
+
let hi = rows.length - 1;
|
|
936
|
+
while (lo < hi) {
|
|
937
|
+
const mid = lo + hi >> 1;
|
|
938
|
+
if (rows[mid].top + rows[mid].height <= from) lo = mid + 1;
|
|
939
|
+
else hi = mid;
|
|
940
|
+
}
|
|
941
|
+
const out = [];
|
|
942
|
+
for (let i = lo; i < rows.length && rows[i].top < to; i++) out.push(rows[i]);
|
|
943
|
+
return out;
|
|
944
|
+
}
|
|
945
|
+
function unassigned(items, from, to) {
|
|
946
|
+
return items.filter((i) => !i.resourceId && i.end > from && i.start < to).sort((a, b) => a.start - b.start);
|
|
947
|
+
}
|
|
948
|
+
var QUEUE_SORT_LABEL = { soonest: "Soonest first", latest: "Latest first", name: "Name", location: "Location" };
|
|
949
|
+
var byText = (a, b) => {
|
|
950
|
+
if (!a && !b) return 0;
|
|
951
|
+
if (!a) return 1;
|
|
952
|
+
if (!b) return -1;
|
|
953
|
+
return a.localeCompare(b, void 0, { numeric: true, sensitivity: "base" });
|
|
954
|
+
};
|
|
955
|
+
function queueView(items, query, sort) {
|
|
956
|
+
const q = query.trim().toLowerCase();
|
|
957
|
+
const hit = (it) => !q || [it.booking.name, it.booking.location, it.booking.status, it.booking.subtitle].some((v) => (v ?? "").toLowerCase().includes(q));
|
|
958
|
+
const soonest = (a, b) => a.start - b.start || byText(a.booking.name, b.booking.name);
|
|
959
|
+
const order = {
|
|
960
|
+
soonest,
|
|
961
|
+
latest: (a, b) => b.start - a.start || byText(a.booking.name, b.booking.name),
|
|
962
|
+
name: (a, b) => byText(a.booking.name, b.booking.name) || soonest(a, b),
|
|
963
|
+
location: (a, b) => byText(a.booking.location, b.booking.location) || soonest(a, b)
|
|
964
|
+
};
|
|
965
|
+
return items.filter((i) => !i.resourceId && hit(i)).sort(order[sort]);
|
|
966
|
+
}
|
|
967
|
+
var STATUS_PALETTE = ["#2E54FF", "#0E8C7F", "#6B4EFF", "#B54708", "#0B6E99", "#9E1F63"];
|
|
968
|
+
var KNOWN = {
|
|
969
|
+
confirmed: "accent",
|
|
970
|
+
booked: "accent",
|
|
971
|
+
scheduled: "accent",
|
|
972
|
+
tentative: "tentative",
|
|
973
|
+
proposed: "tentative",
|
|
974
|
+
done: "done",
|
|
975
|
+
completed: "done",
|
|
976
|
+
cancelled: "cancelled",
|
|
977
|
+
canceled: "cancelled"
|
|
978
|
+
};
|
|
979
|
+
function bookingAppearance(b, accent, statusColors = {}, seen = /* @__PURE__ */ new Map()) {
|
|
980
|
+
if (b.kind === "unavailable") return { color: "#7A808C", text: "#FFFFFF", style: "unavailable" };
|
|
981
|
+
const status = (b.status ?? "").trim();
|
|
982
|
+
const known = KNOWN[status.toLowerCase()];
|
|
983
|
+
const style = known === "tentative" ? "tentative" : known === "done" ? "done" : known === "cancelled" ? "cancelled" : "solid";
|
|
984
|
+
let color = b.color || statusColors[status];
|
|
985
|
+
if (!color) {
|
|
986
|
+
if (!status || known) color = accent;
|
|
987
|
+
else {
|
|
988
|
+
color = seen.get(status) ?? "";
|
|
989
|
+
if (!color) {
|
|
990
|
+
color = STATUS_PALETTE[seen.size % STATUS_PALETTE.length];
|
|
991
|
+
seen.set(status, color);
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
}
|
|
995
|
+
const pair = accentForText(color);
|
|
996
|
+
return { color: pair.background, text: pair.text, style };
|
|
997
|
+
}
|
|
998
|
+
function initials(name) {
|
|
999
|
+
return name.split(/\s+/).filter(Boolean).slice(0, 2).map((w) => w[0].toUpperCase()).join("");
|
|
1000
|
+
}
|
|
1001
|
+
function boardHours(resources, start, end) {
|
|
1002
|
+
let s = Infinity;
|
|
1003
|
+
let e2 = -Infinity;
|
|
1004
|
+
for (const r of resources) {
|
|
1005
|
+
const rs = r.workStart === void 0 ? NaN : parseHour(r.workStart);
|
|
1006
|
+
const re = r.workEnd === void 0 ? NaN : parseHour(r.workEnd);
|
|
1007
|
+
if (!Number.isNaN(rs)) s = Math.min(s, rs);
|
|
1008
|
+
if (!Number.isNaN(re)) e2 = Math.max(e2, re);
|
|
1009
|
+
}
|
|
1010
|
+
return { start: Number.isFinite(s) ? s : start, end: Number.isFinite(e2) && e2 > (Number.isFinite(s) ? s : start) ? e2 : end };
|
|
1011
|
+
}
|
|
1012
|
+
function neighbour(rows, id, key) {
|
|
1013
|
+
const resourceRows = rows.filter((r) => r.kind === "resource");
|
|
1014
|
+
const firstWithItems = resourceRows.find((r) => r.items.length);
|
|
1015
|
+
if (!id) return firstWithItems?.items[0]?.id ?? null;
|
|
1016
|
+
const at = resourceRows.findIndex((r) => r.items.some((i) => i.id === id));
|
|
1017
|
+
if (at < 0) return firstWithItems?.items[0]?.id ?? null;
|
|
1018
|
+
const row = resourceRows[at];
|
|
1019
|
+
const pos = row.items.findIndex((i) => i.id === id);
|
|
1020
|
+
const current = row.items[pos];
|
|
1021
|
+
if (key === "ArrowLeft") return row.items[pos - 1]?.id ?? id;
|
|
1022
|
+
if (key === "ArrowRight") return row.items[pos + 1]?.id ?? id;
|
|
1023
|
+
const step = key === "ArrowUp" ? -1 : 1;
|
|
1024
|
+
for (let i = at + step; i >= 0 && i < resourceRows.length; i += step) {
|
|
1025
|
+
const its = resourceRows[i].items;
|
|
1026
|
+
if (!its.length) continue;
|
|
1027
|
+
let best = its[0];
|
|
1028
|
+
for (const it of its) if (Math.abs(it.start - current.start) < Math.abs(best.start - current.start)) best = it;
|
|
1029
|
+
return best.id;
|
|
1030
|
+
}
|
|
1031
|
+
return id;
|
|
1032
|
+
}
|
|
1033
|
+
function isoOfDay(day) {
|
|
1034
|
+
return new Date(day * 864e5).toISOString().slice(0, 10);
|
|
1035
|
+
}
|
|
1036
|
+
function todayIn(zone, now = Date.now()) {
|
|
1037
|
+
return isoOfDay(instantToDay(now, zone));
|
|
1038
|
+
}
|
|
1039
|
+
function bookedWorkingHours(items, startDay, days, workStart, workEnd, week, zone, capacityHours = Infinity) {
|
|
1040
|
+
let ms = 0;
|
|
1041
|
+
for (let d = startDay; d < startDay + days; d++) {
|
|
1042
|
+
if (week === "mon-fri" && weekday(d) >= 5) continue;
|
|
1043
|
+
ms += bookedMsOn(items, d, workStart, workEnd, zone, capacityHours);
|
|
1044
|
+
}
|
|
1045
|
+
return Math.round(ms / 36e5 * 10) / 10;
|
|
1046
|
+
}
|
|
1047
|
+
var hourCache = /* @__PURE__ */ new WeakMap();
|
|
1048
|
+
function hourIn(day, hour, zone) {
|
|
1049
|
+
let m = hourCache.get(zone);
|
|
1050
|
+
if (!m) hourCache.set(zone, m = /* @__PURE__ */ new Map());
|
|
1051
|
+
const key = day * 100 + hour;
|
|
1052
|
+
let v = m.get(key);
|
|
1053
|
+
if (v === void 0) {
|
|
1054
|
+
if (m.size > 2e4) m.clear();
|
|
1055
|
+
v = atHour(day, hour, zone);
|
|
1056
|
+
m.set(key, v);
|
|
1057
|
+
}
|
|
1058
|
+
return v;
|
|
1059
|
+
}
|
|
1060
|
+
function bookedMsOn(items, day, workStart, workEnd, zone, capacityHours = Infinity) {
|
|
1061
|
+
const ws = hourIn(day, workStart, zone);
|
|
1062
|
+
const we = hourIn(day, workEnd, zone);
|
|
1063
|
+
let booked = 0;
|
|
1064
|
+
let off = 0;
|
|
1065
|
+
for (const it of items) {
|
|
1066
|
+
if (it.cancelled) continue;
|
|
1067
|
+
const s = Math.max(it.start, ws);
|
|
1068
|
+
const e2 = Math.min(it.end, we);
|
|
1069
|
+
if (e2 <= s) continue;
|
|
1070
|
+
if (it.kind === "unavailable") off += e2 - s;
|
|
1071
|
+
else booked += e2 - s;
|
|
1072
|
+
}
|
|
1073
|
+
return booked + Math.min(off, capacityHours * 36e5);
|
|
1074
|
+
}
|
|
1075
|
+
var NEAR_RATIO = 0.75;
|
|
1076
|
+
function utilLevel(booked, capacity) {
|
|
1077
|
+
if (capacity <= 0) return booked > 0 ? "over" : "under";
|
|
1078
|
+
const ratio2 = booked / capacity;
|
|
1079
|
+
if (ratio2 > 1 + 1e-9) return "over";
|
|
1080
|
+
return ratio2 >= NEAR_RATIO ? "near" : "under";
|
|
1081
|
+
}
|
|
1082
|
+
function dailyUtilisation(items, days, workStart, workEnd, capacityHours, week, zone) {
|
|
1083
|
+
const out = [];
|
|
1084
|
+
for (const day of days) {
|
|
1085
|
+
if (week === "mon-fri" && weekday(day) >= 5) continue;
|
|
1086
|
+
const booked = Math.round(bookedMsOn(items, day, workStart, workEnd, zone, capacityHours) / 36e5 * 10) / 10;
|
|
1087
|
+
const ratio2 = capacityHours > 0 ? booked / capacityHours : booked > 0 ? Infinity : 0;
|
|
1088
|
+
out.push({ day, booked, capacity: capacityHours, ratio: ratio2, level: utilLevel(booked, capacityHours) });
|
|
1089
|
+
}
|
|
1090
|
+
return out;
|
|
1091
|
+
}
|
|
1092
|
+
function barTimeLabel(it, zone) {
|
|
1093
|
+
const d1 = instantToDay(it.start, zone);
|
|
1094
|
+
const d2 = instantToDay(it.end - 1, zone);
|
|
1095
|
+
if (d1 === d2) return `${timeLabel(it.start, zone)}\u2013${timeLabel(it.end, zone)}`;
|
|
1096
|
+
const a = parts(d1);
|
|
1097
|
+
const b = parts(d2);
|
|
1098
|
+
return a.month === b.month ? `${a.date}\u2013${b.date} ${MONTHS[b.month]}` : `${a.date} ${MONTHS[a.month]}\u2013${b.date} ${MONTHS[b.month]}`;
|
|
1099
|
+
}
|
|
1100
|
+
|
|
1101
|
+
// ../editing/dist/chunk-S4GYYU33.js
|
|
1102
|
+
var isEmpty = (v) => v === void 0 || v === null || v === "";
|
|
1103
|
+
function defaultEqual(_key, a, b) {
|
|
1104
|
+
if (isEmpty(a) && isEmpty(b)) return true;
|
|
1105
|
+
return a === b;
|
|
1106
|
+
}
|
|
1107
|
+
function fieldsOf(change, spec) {
|
|
1108
|
+
const out = {};
|
|
1109
|
+
for (const k of spec.keys) if (k in change) out[k] = change[k];
|
|
1110
|
+
return out;
|
|
1111
|
+
}
|
|
1112
|
+
function applyOverrides(items, overrides) {
|
|
1113
|
+
if (overrides.size === 0) return items;
|
|
1114
|
+
return items.map((t) => {
|
|
1115
|
+
const o = overrides.get(t.id);
|
|
1116
|
+
return o ? { ...t, ...o.patch } : t;
|
|
1117
|
+
});
|
|
1118
|
+
}
|
|
1119
|
+
function reconcileOverrides(items, overrides, spec = {}) {
|
|
1120
|
+
const eq = spec.equal ?? defaultEqual;
|
|
1121
|
+
const byId = new Map(items.map((t) => [t.id, t]));
|
|
1122
|
+
const next2 = new Map(overrides);
|
|
1123
|
+
for (const [id, o] of overrides) {
|
|
1124
|
+
const t = byId.get(id);
|
|
1125
|
+
if (!t) {
|
|
1126
|
+
next2.delete(id);
|
|
1127
|
+
continue;
|
|
1128
|
+
}
|
|
1129
|
+
const matches = Object.keys(o.patch).every(
|
|
1130
|
+
(k) => eq(k, t[k], o.patch[k])
|
|
1131
|
+
);
|
|
1132
|
+
if (matches) next2.delete(id);
|
|
1133
|
+
}
|
|
1134
|
+
return next2;
|
|
1135
|
+
}
|
|
1136
|
+
var HISTORY_LIMIT = 20;
|
|
1137
|
+
function emptyHistory() {
|
|
1138
|
+
return { undo: [], redo: [] };
|
|
1139
|
+
}
|
|
1140
|
+
function pushHistory(h, change) {
|
|
1141
|
+
return { undo: [...h.undo, change].slice(-HISTORY_LIMIT), redo: [] };
|
|
1142
|
+
}
|
|
1143
|
+
function dropFromHistory(h, change) {
|
|
1144
|
+
return { undo: h.undo.filter((c) => c !== change), redo: h.redo.filter((c) => c !== change) };
|
|
1145
|
+
}
|
|
1146
|
+
function invertChange(change, source, spec) {
|
|
1147
|
+
const forward = fieldsOf(change, spec);
|
|
1148
|
+
const back = {};
|
|
1149
|
+
for (const k of Object.keys(forward)) {
|
|
1150
|
+
const v = change.previous[k];
|
|
1151
|
+
back[k] = v !== void 0 ? v : spec.blank ? spec.blank(k) : void 0;
|
|
1152
|
+
}
|
|
1153
|
+
return { id: change.id, ...back, previous: forward, source };
|
|
1154
|
+
}
|
|
1155
|
+
function undoStep(h, undoSource, spec) {
|
|
1156
|
+
const entry = h.undo[h.undo.length - 1];
|
|
1157
|
+
if (!entry) return null;
|
|
1158
|
+
return { commit: invertChange(entry, undoSource, spec), entry, history: { undo: h.undo.slice(0, -1), redo: [...h.redo, entry] } };
|
|
1159
|
+
}
|
|
1160
|
+
function redoStep(h, redoSource) {
|
|
1161
|
+
const entry = h.redo[h.redo.length - 1];
|
|
1162
|
+
if (!entry) return null;
|
|
1163
|
+
const commit = { ...entry, source: redoSource };
|
|
1164
|
+
return { commit, entry, history: { undo: [...h.undo, entry].slice(-HISTORY_LIMIT), redo: h.redo.slice(0, -1) } };
|
|
1165
|
+
}
|
|
1166
|
+
function errorText(err, fallback = "The change was not saved.") {
|
|
1167
|
+
if (err instanceof Error && err.message) return err.message;
|
|
1168
|
+
if (typeof err === "string" && err) return err;
|
|
1169
|
+
const m = err?.message;
|
|
1170
|
+
return typeof m === "string" && m ? m : fallback;
|
|
1171
|
+
}
|
|
1172
|
+
|
|
1173
|
+
// ../editing/dist/index.js
|
|
1174
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
1175
|
+
import * as React from "react";
|
|
1176
|
+
import * as React2 from "react";
|
|
1177
|
+
import * as React3 from "react";
|
|
1178
|
+
import * as React4 from "react";
|
|
1179
|
+
var INFO_MS = 5e3;
|
|
1180
|
+
var ERROR_MS = 9e3;
|
|
1181
|
+
function trialUrl(ref) {
|
|
1182
|
+
return `https://ninthforge.com/trial?ref=${encodeURIComponent(ref)}`;
|
|
1183
|
+
}
|
|
1184
|
+
function lockedMessage(product) {
|
|
1185
|
+
return `Saving changes needs a Ninth Forge ${product} key. Start a free 30-day trial at ninthforge.com/trial.`;
|
|
1186
|
+
}
|
|
1187
|
+
function lockFor(license, unlocked, product, trialRef) {
|
|
1188
|
+
if (unlocked) return { locked: false };
|
|
1189
|
+
if (!license) return { locked: true };
|
|
1190
|
+
return trialRef ? { locked: true, lockedMessage: lockedMessage(product), lockedTrialUrl: trialUrl(trialRef) } : { locked: true, lockedMessage: lockedMessage(product) };
|
|
1191
|
+
}
|
|
1192
|
+
var toastedLocks = /* @__PURE__ */ new Set();
|
|
1193
|
+
function firstLockToast(message) {
|
|
1194
|
+
if (toastedLocks.has(message)) return false;
|
|
1195
|
+
toastedLocks.add(message);
|
|
1196
|
+
return true;
|
|
1197
|
+
}
|
|
1198
|
+
function openTrial(url) {
|
|
1199
|
+
if (typeof window !== "undefined") window.open(url, "_blank", "noopener");
|
|
1200
|
+
}
|
|
1201
|
+
function useEditing(items, onChange, spec) {
|
|
1202
|
+
const [overrides, setOverrides] = useState(() => /* @__PURE__ */ new Map());
|
|
1203
|
+
const historyRef = useRef(emptyHistory());
|
|
1204
|
+
const [, setHistoryVersion] = useState(0);
|
|
1205
|
+
const setHistory = (fn) => {
|
|
1206
|
+
historyRef.current = fn(historyRef.current);
|
|
1207
|
+
setHistoryVersion((v) => v + 1);
|
|
1208
|
+
};
|
|
1209
|
+
const seqRef = useRef(0);
|
|
1210
|
+
const [toasts, setToasts] = useState([]);
|
|
1211
|
+
const [announcement, setAnnouncement] = useState({ text: "", n: 0 });
|
|
1212
|
+
const timers = useRef(/* @__PURE__ */ new Map());
|
|
1213
|
+
const itemsRef = useRef(items);
|
|
1214
|
+
itemsRef.current = items;
|
|
1215
|
+
const handlerRef = useRef(onChange);
|
|
1216
|
+
handlerRef.current = onChange;
|
|
1217
|
+
const specRef = useRef(spec);
|
|
1218
|
+
specRef.current = spec;
|
|
1219
|
+
useEffect(() => {
|
|
1220
|
+
setOverrides((o) => o.size ? reconcileOverrides(items, o, specRef.current) : o);
|
|
1221
|
+
}, [items]);
|
|
1222
|
+
useEffect(() => {
|
|
1223
|
+
const map = timers.current;
|
|
1224
|
+
return () => map.forEach((t) => clearTimeout(t));
|
|
1225
|
+
}, []);
|
|
1226
|
+
const dismiss = useCallback((id) => {
|
|
1227
|
+
const t = timers.current.get(id);
|
|
1228
|
+
if (t) clearTimeout(t);
|
|
1229
|
+
timers.current.delete(id);
|
|
1230
|
+
setToasts((list) => list.filter((x) => x.id !== id));
|
|
1231
|
+
}, []);
|
|
1232
|
+
const toast = useCallback(
|
|
1233
|
+
(item) => {
|
|
1234
|
+
const id = ++seqRef.current;
|
|
1235
|
+
setToasts((list) => [...list.filter((x) => x.tone === "error"), { ...item, id }].slice(-4));
|
|
1236
|
+
timers.current.set(
|
|
1237
|
+
id,
|
|
1238
|
+
setTimeout(() => dismiss(id), item.tone === "error" ? ERROR_MS : INFO_MS)
|
|
1239
|
+
);
|
|
1240
|
+
},
|
|
1241
|
+
[dismiss]
|
|
1242
|
+
);
|
|
1243
|
+
const announce = useCallback((text2) => setAnnouncement((a) => ({ text: text2, n: a.n + 1 })), []);
|
|
1244
|
+
const [lockNote, setLockNote] = useState(null);
|
|
1245
|
+
const refuse = useCallback(() => {
|
|
1246
|
+
const msg = specRef.current.lockedMessage;
|
|
1247
|
+
if (!msg) return;
|
|
1248
|
+
announce(msg);
|
|
1249
|
+
setLockNote(msg);
|
|
1250
|
+
if (!firstLockToast(msg)) return;
|
|
1251
|
+
const url = specRef.current.lockedTrialUrl;
|
|
1252
|
+
toast({ text: msg, tone: "info", action: url ? { label: specRef.current.lockedTrialLabel ?? "Start a free trial", run: () => openTrial(url) } : void 0 });
|
|
1253
|
+
}, [announce, toast]);
|
|
1254
|
+
const nameOf = (id) => {
|
|
1255
|
+
const item = itemsRef.current.find((t) => t.id === id);
|
|
1256
|
+
const s = specRef.current;
|
|
1257
|
+
if (!item) return s.noun ?? "item";
|
|
1258
|
+
return s.name ? s.name(item) : item.name ?? s.noun ?? "item";
|
|
1259
|
+
};
|
|
1260
|
+
const undoRef = useRef(() => void 0);
|
|
1261
|
+
const redoRef = useRef(() => void 0);
|
|
1262
|
+
const commit = (change, mode, entry) => {
|
|
1263
|
+
if (specRef.current.locked) return refuse();
|
|
1264
|
+
const seq = ++seqRef.current;
|
|
1265
|
+
setOverrides((m) => {
|
|
1266
|
+
const next2 = new Map(m);
|
|
1267
|
+
next2.set(change.id, { patch: { ...m.get(change.id)?.patch, ...fieldsOf(change, specRef.current) }, seq });
|
|
1268
|
+
return next2;
|
|
1269
|
+
});
|
|
1270
|
+
if (mode === "new") setHistory((h) => pushHistory(h, change));
|
|
1271
|
+
const text2 = specRef.current.describe(change, nameOf);
|
|
1272
|
+
announce(text2);
|
|
1273
|
+
toast({
|
|
1274
|
+
text: text2,
|
|
1275
|
+
tone: "info",
|
|
1276
|
+
action: mode === "undo" ? { label: "Redo", run: () => redoRef.current() } : { label: "Undo", run: () => undoRef.current() }
|
|
1277
|
+
});
|
|
1278
|
+
let result;
|
|
1279
|
+
try {
|
|
1280
|
+
result = handlerRef.current?.(change);
|
|
1281
|
+
} catch (err) {
|
|
1282
|
+
result = Promise.reject(err);
|
|
1283
|
+
}
|
|
1284
|
+
if (result && typeof result.then === "function") {
|
|
1285
|
+
result.then(void 0, (err) => {
|
|
1286
|
+
setOverrides((m) => {
|
|
1287
|
+
const o = m.get(change.id);
|
|
1288
|
+
if (!o || o.seq !== seq) return m;
|
|
1289
|
+
const next2 = new Map(m);
|
|
1290
|
+
next2.delete(change.id);
|
|
1291
|
+
return next2;
|
|
1292
|
+
});
|
|
1293
|
+
setHistory((h) => {
|
|
1294
|
+
if (mode === "new") return dropFromHistory(h, change);
|
|
1295
|
+
if (!entry) return h;
|
|
1296
|
+
return mode === "undo" ? { undo: [...h.undo, entry], redo: h.redo.filter((c) => c !== entry) } : { undo: h.undo.filter((c) => c !== entry), redo: [...h.redo, entry] };
|
|
1297
|
+
});
|
|
1298
|
+
const msg = errorText(err);
|
|
1299
|
+
toast({ text: `Couldn't save '${nameOf(change.id)}', so it was put back. ${msg}`, tone: "error" });
|
|
1300
|
+
announce(`Not saved, put back. ${msg}`);
|
|
1301
|
+
});
|
|
1302
|
+
}
|
|
1303
|
+
};
|
|
1304
|
+
const undo = () => {
|
|
1305
|
+
const step = undoStep(historyRef.current, "undo", specRef.current);
|
|
1306
|
+
if (!step) return announce("Nothing to undo");
|
|
1307
|
+
setHistory(() => step.history);
|
|
1308
|
+
commit(step.commit, "undo", step.entry);
|
|
1309
|
+
};
|
|
1310
|
+
const redo = () => {
|
|
1311
|
+
const step = redoStep(historyRef.current, "redo");
|
|
1312
|
+
if (!step) return announce("Nothing to redo");
|
|
1313
|
+
setHistory(() => step.history);
|
|
1314
|
+
commit(step.commit, "redo", step.entry);
|
|
1315
|
+
};
|
|
1316
|
+
undoRef.current = undo;
|
|
1317
|
+
redoRef.current = redo;
|
|
1318
|
+
const shown = useMemo(() => applyOverrides(items, overrides), [items, overrides]);
|
|
1319
|
+
return {
|
|
1320
|
+
overrides,
|
|
1321
|
+
shown,
|
|
1322
|
+
commit: (change) => commit(change, "new"),
|
|
1323
|
+
undo,
|
|
1324
|
+
redo,
|
|
1325
|
+
canUndo: historyRef.current.undo.length > 0,
|
|
1326
|
+
canRedo: historyRef.current.redo.length > 0,
|
|
1327
|
+
toasts,
|
|
1328
|
+
toast,
|
|
1329
|
+
dismiss,
|
|
1330
|
+
announcement,
|
|
1331
|
+
announce,
|
|
1332
|
+
/** View-only: Pro actions are refused (see `EditingSpec.locked`). */
|
|
1333
|
+
locked: !!spec.locked,
|
|
1334
|
+
/** Call instead of an action that bypasses `commit` (create, add) while `locked`. */
|
|
1335
|
+
refuse,
|
|
1336
|
+
/** The locked message once an action was refused, for a quiet status line. */
|
|
1337
|
+
lockNote: spec.locked ? lockNote : null
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
function Toasts({
|
|
1341
|
+
items,
|
|
1342
|
+
announcement,
|
|
1343
|
+
onDismiss,
|
|
1344
|
+
prefix = "nf-g"
|
|
1345
|
+
}) {
|
|
1346
|
+
return /* @__PURE__ */ React.createElement(React.Fragment, null, /* @__PURE__ */ React.createElement("div", { className: `${prefix}-sr`, "aria-live": "polite", "aria-atomic": "true" }, /* @__PURE__ */ React.createElement("span", { key: announcement.n }, announcement.text)), items.length ? /* @__PURE__ */ React.createElement("div", { className: `${prefix}-toasts` }, items.map((t) => /* @__PURE__ */ React.createElement("div", { key: t.id, className: `${prefix}-toast`, "data-tone": t.tone, role: t.tone === "error" ? "alert" : "status" }, /* @__PURE__ */ React.createElement("span", { className: `${prefix}-toast-text` }, t.text), t.action ? /* @__PURE__ */ React.createElement(
|
|
1347
|
+
"button",
|
|
1348
|
+
{
|
|
1349
|
+
type: "button",
|
|
1350
|
+
className: `${prefix}-toast-btn`,
|
|
1351
|
+
onClick: () => {
|
|
1352
|
+
t.action.run();
|
|
1353
|
+
onDismiss(t.id);
|
|
1354
|
+
}
|
|
1355
|
+
},
|
|
1356
|
+
t.action.label
|
|
1357
|
+
) : null, /* @__PURE__ */ React.createElement("button", { type: "button", className: `${prefix}-toast-x`, "aria-label": "Dismiss", onClick: () => onDismiss(t.id) }, "\xD7")))) : null);
|
|
1358
|
+
}
|
|
1359
|
+
function EmptyActionView({ action, prefix, buttonClass }) {
|
|
1360
|
+
return /* @__PURE__ */ React2.createElement("div", { className: `${prefix}-emptyaction` }, action.busy ? /* @__PURE__ */ React2.createElement("span", { role: "status" }, action.status ?? `${action.label}\u2026`) : /* @__PURE__ */ React2.createElement("button", { type: "button", className: buttonClass, onClick: action.onClick }, action.label), action.hint && !action.busy ? /* @__PURE__ */ React2.createElement("small", null, action.hint) : null, action.error ? /* @__PURE__ */ React2.createElement("span", { role: "alert" }, action.error) : null);
|
|
1361
|
+
}
|
|
1362
|
+
var PLAN_WATERMARK_URL = "https://ninthforge.com/plan?ref=watermark";
|
|
1363
|
+
function Watermark({ prefix, title, markHtml, variant = "mark", onClick, href = PLAN_WATERMARK_URL }) {
|
|
1364
|
+
const mark = variant === "text" ? null : /* @__PURE__ */ React4.createElement("span", { "aria-hidden": "true", style: { display: "inline-flex" }, dangerouslySetInnerHTML: { __html: markHtml } });
|
|
1365
|
+
return onClick ? /* @__PURE__ */ React4.createElement("button", { type: "button", className: `${prefix}-mark`, title, onClick }, mark, "Ninth Forge \xB7 Unlicensed") : /* @__PURE__ */ React4.createElement("a", { className: `${prefix}-mark`, href, target: "_blank", rel: "noreferrer", title }, mark, "Ninth Forge \xB7 Unlicensed");
|
|
1366
|
+
}
|
|
1367
|
+
var next = 0;
|
|
1368
|
+
function nextUid(prefix) {
|
|
1369
|
+
next += 1;
|
|
1370
|
+
return `${prefix}${next.toString(36)}`;
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
// ../license/dist/chunk-Q7LEK2CU.js
|
|
1374
|
+
var PREFIX = "NF1";
|
|
1375
|
+
function utf8(s) {
|
|
1376
|
+
return new TextEncoder().encode(s);
|
|
1377
|
+
}
|
|
1378
|
+
function b64urlDecode(s) {
|
|
1379
|
+
if (!/^[A-Za-z0-9_-]*$/.test(s)) throw new Error("Invalid base64url");
|
|
1380
|
+
const pad = s.length % 4 === 0 ? "" : "=".repeat(4 - s.length % 4);
|
|
1381
|
+
const bin = atob(s.replace(/-/g, "+").replace(/_/g, "/") + pad);
|
|
1382
|
+
const out = new Uint8Array(bin.length);
|
|
1383
|
+
for (let i = 0; i < bin.length; i++) out[i] = bin.charCodeAt(i);
|
|
1384
|
+
return out;
|
|
1385
|
+
}
|
|
1386
|
+
function hexToBytes(hex) {
|
|
1387
|
+
if (!/^[0-9a-f]*$/i.test(hex) || hex.length % 2 !== 0) throw new Error("Invalid hex");
|
|
1388
|
+
const out = new Uint8Array(hex.length / 2);
|
|
1389
|
+
for (let i = 0; i < out.length; i++) out[i] = parseInt(hex.slice(i * 2, i * 2 + 2), 16);
|
|
1390
|
+
return out;
|
|
1391
|
+
}
|
|
1392
|
+
function subtle() {
|
|
1393
|
+
const c = globalThis.crypto;
|
|
1394
|
+
return c && c.subtle ? c.subtle : void 0;
|
|
1395
|
+
}
|
|
1396
|
+
var ED25519 = { name: "Ed25519" };
|
|
1397
|
+
|
|
1398
|
+
// ../license/dist/index.js
|
|
1399
|
+
function normaliseHost(host) {
|
|
1400
|
+
return host.trim().toLowerCase().replace(/\.+$/, "");
|
|
1401
|
+
}
|
|
1402
|
+
function browserHost() {
|
|
1403
|
+
const loc = globalThis.location;
|
|
1404
|
+
const name = loc && typeof loc.hostname === "string" ? normaliseHost(loc.hostname) : "";
|
|
1405
|
+
return name || void 0;
|
|
1406
|
+
}
|
|
1407
|
+
function hostMatches(host, pattern) {
|
|
1408
|
+
const h = normaliseHost(host);
|
|
1409
|
+
const p = normaliseHost(pattern);
|
|
1410
|
+
if (!h || !p) return false;
|
|
1411
|
+
if (p.startsWith("*.")) {
|
|
1412
|
+
const suffix = p.slice(1);
|
|
1413
|
+
return suffix.length > 1 && h.length > suffix.length && h.endsWith(suffix);
|
|
1414
|
+
}
|
|
1415
|
+
return h === p;
|
|
1416
|
+
}
|
|
1417
|
+
var GRACE_DAYS = 14;
|
|
1418
|
+
var SUITE_PRODUCTS = ["plan", "charts"];
|
|
1419
|
+
var PUBLIC_KEYS = {
|
|
1420
|
+
"nf-2027-1": "ad97c33249018681b3b315a303551442552d917d337690cac9b4783e931f88d1"
|
|
1421
|
+
};
|
|
1422
|
+
var DAY2 = 864e5;
|
|
1423
|
+
function isoToUtcMs(iso3) {
|
|
1424
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(iso3);
|
|
1425
|
+
if (!m) return NaN;
|
|
1426
|
+
return Date.UTC(Number(m[1]), Number(m[2]) - 1, Number(m[3]));
|
|
1427
|
+
}
|
|
1428
|
+
function parsePayload(segment) {
|
|
1429
|
+
try {
|
|
1430
|
+
const json = JSON.parse(new TextDecoder().decode(b64urlDecode(segment)));
|
|
1431
|
+
if (!json || typeof json.kid !== "string" || typeof json.exp !== "string") return void 0;
|
|
1432
|
+
if (json.v === 1) return json;
|
|
1433
|
+
if (json.v === 2 && Array.isArray(json.hosts) && json.hosts.length > 0 && json.hosts.every((h) => typeof h === "string" && h.trim() !== "")) {
|
|
1434
|
+
return json;
|
|
1435
|
+
}
|
|
1436
|
+
} catch {
|
|
1437
|
+
}
|
|
1438
|
+
return void 0;
|
|
1439
|
+
}
|
|
1440
|
+
async function verifySignature(pubHex, message, signature) {
|
|
1441
|
+
const s = subtle();
|
|
1442
|
+
if (!s) return "unsupported";
|
|
1443
|
+
let key;
|
|
1444
|
+
try {
|
|
1445
|
+
key = await s.importKey("raw", hexToBytes(pubHex), ED25519, false, ["verify"]);
|
|
1446
|
+
} catch (err) {
|
|
1447
|
+
return err?.name === "NotSupportedError" ? "unsupported" : false;
|
|
1448
|
+
}
|
|
1449
|
+
try {
|
|
1450
|
+
return await s.verify(ED25519, key, signature, message);
|
|
1451
|
+
} catch {
|
|
1452
|
+
return false;
|
|
1453
|
+
}
|
|
1454
|
+
}
|
|
1455
|
+
async function verifyLicense(key, opts) {
|
|
1456
|
+
const trimmed = (key ?? "").trim();
|
|
1457
|
+
if (!trimmed) return { status: "missing", licensed: false };
|
|
1458
|
+
const parts2 = trimmed.split(".");
|
|
1459
|
+
if (parts2.length !== 3 || parts2[0] !== PREFIX) return { status: "invalid", licensed: false };
|
|
1460
|
+
const [, payloadSeg, sigSeg] = parts2;
|
|
1461
|
+
const payload = parsePayload(payloadSeg);
|
|
1462
|
+
const keys = opts.publicKeys ?? PUBLIC_KEYS;
|
|
1463
|
+
const pubHex = payload ? keys[payload.kid] : void 0;
|
|
1464
|
+
if (!payload || !pubHex) return { status: "invalid", licensed: false };
|
|
1465
|
+
let sig;
|
|
1466
|
+
try {
|
|
1467
|
+
sig = b64urlDecode(sigSeg);
|
|
1468
|
+
} catch {
|
|
1469
|
+
return { status: "invalid", licensed: false };
|
|
1470
|
+
}
|
|
1471
|
+
const ok = await verifySignature(pubHex, utf8(payloadSeg), sig);
|
|
1472
|
+
if (ok === false) return { status: "invalid", licensed: false };
|
|
1473
|
+
if (payload.v === 2) {
|
|
1474
|
+
const host = opts.host === void 0 ? "" : normaliseHost(opts.host);
|
|
1475
|
+
const onHost = host !== "" && (payload.hosts ?? []).some((p) => hostMatches(host, p));
|
|
1476
|
+
if (opts.tenantId !== void 0 && opts.tenantId !== null || !onHost) return { status: "wrong-host", licensed: false, payload };
|
|
1477
|
+
}
|
|
1478
|
+
if (ok === "unsupported") return { status: "unverifiable", licensed: true, payload };
|
|
1479
|
+
const suiteCovers = payload.pid === "suite" && SUITE_PRODUCTS.includes(opts.product);
|
|
1480
|
+
if (payload.pid !== opts.product && !suiteCovers) {
|
|
1481
|
+
return { status: "wrong-product", licensed: false, payload };
|
|
1482
|
+
}
|
|
1483
|
+
const tenant = typeof opts.tenantId === "string" && opts.tenantId.trim() ? opts.tenantId : void 0;
|
|
1484
|
+
if (payload.v === 1 && payload.tid !== "*" && tenant !== void 0 && payload.tid.toLowerCase() !== tenant.toLowerCase()) {
|
|
1485
|
+
return { status: "wrong-tenant", licensed: false, payload };
|
|
1486
|
+
}
|
|
1487
|
+
const now = (opts.now ?? /* @__PURE__ */ new Date()).getTime();
|
|
1488
|
+
const expMs = isoToUtcMs(payload.exp) + DAY2;
|
|
1489
|
+
if (Number.isNaN(expMs)) return { status: "invalid", licensed: false };
|
|
1490
|
+
const daysLeft = Math.floor((expMs - now) / DAY2);
|
|
1491
|
+
if (now > expMs + GRACE_DAYS * DAY2) return { status: "expired", licensed: false, payload, daysLeft };
|
|
1492
|
+
if (now > expMs) return { status: "grace", licensed: true, payload, daysLeft };
|
|
1493
|
+
return { status: "valid", licensed: true, payload, daysLeft };
|
|
1494
|
+
}
|
|
1495
|
+
var FAILURE_RANK = {
|
|
1496
|
+
expired: 0,
|
|
1497
|
+
"wrong-tenant": 1,
|
|
1498
|
+
"wrong-host": 2,
|
|
1499
|
+
"wrong-product": 3,
|
|
1500
|
+
invalid: 4,
|
|
1501
|
+
missing: 5
|
|
1502
|
+
};
|
|
1503
|
+
async function verifyLicenses(keys, opts) {
|
|
1504
|
+
const list = (keys ?? "").split(/[\s,;]+/).filter(Boolean);
|
|
1505
|
+
if (list.length <= 1) return verifyLicense(list[0], opts);
|
|
1506
|
+
let best;
|
|
1507
|
+
for (const key of list) {
|
|
1508
|
+
const r = await verifyLicense(key, opts);
|
|
1509
|
+
if (r.licensed) return r;
|
|
1510
|
+
if (!best || (FAILURE_RANK[r.status] ?? 9) < (FAILURE_RANK[best.status] ?? 9)) best = r;
|
|
1511
|
+
}
|
|
1512
|
+
return best ?? { status: "missing", licensed: false };
|
|
1513
|
+
}
|
|
1514
|
+
var RENEW_URL = "https://ninthforge.com/licence?ref=renewal";
|
|
1515
|
+
function renewalNotice(result, now = /* @__PURE__ */ new Date()) {
|
|
1516
|
+
if (!result || result.status !== "grace" || !result.payload) return null;
|
|
1517
|
+
const expMs = isoToUtcMs(result.payload.exp) + DAY2;
|
|
1518
|
+
if (Number.isNaN(expMs)) return null;
|
|
1519
|
+
const daysLeft = Math.min(GRACE_DAYS, Math.max(1, Math.ceil((expMs + GRACE_DAYS * DAY2 - now.getTime()) / DAY2)));
|
|
1520
|
+
const days = daysLeft === 1 ? "1 day" : `${daysLeft} days`;
|
|
1521
|
+
return {
|
|
1522
|
+
text: `Licence expired \xB7 renew within ${days}`,
|
|
1523
|
+
detail: `Your Ninth Forge licence expired on ${result.payload.exp}. Renew within ${days} to keep the watermark off. Only makers see this.`,
|
|
1524
|
+
href: RENEW_URL,
|
|
1525
|
+
daysLeft
|
|
1526
|
+
};
|
|
1527
|
+
}
|
|
1528
|
+
function licenseMessage(result) {
|
|
1529
|
+
switch (result.status) {
|
|
1530
|
+
case "valid":
|
|
1531
|
+
case "unverifiable":
|
|
1532
|
+
return `Licensed to ${result.payload?.org ?? "your organisation"}`;
|
|
1533
|
+
case "grace":
|
|
1534
|
+
return "Licence expired \u2014 renew within 14 days to keep the watermark off";
|
|
1535
|
+
case "expired":
|
|
1536
|
+
return "Licence expired";
|
|
1537
|
+
case "missing":
|
|
1538
|
+
return "Unlicensed \u2014 add a licence key in the control properties";
|
|
1539
|
+
case "wrong-product":
|
|
1540
|
+
return "This key is for a different Ninth Forge product";
|
|
1541
|
+
case "wrong-tenant":
|
|
1542
|
+
return "This key belongs to a different Microsoft tenant";
|
|
1543
|
+
case "wrong-host":
|
|
1544
|
+
return `This demo key only works on ${result.payload?.hosts?.[0] ?? "the Ninth Forge website"}`;
|
|
1545
|
+
default:
|
|
1546
|
+
return "Licence key not recognised";
|
|
1547
|
+
}
|
|
1548
|
+
}
|
|
1549
|
+
var PAID_TIERS = ["team", "business", "enterprise"];
|
|
1550
|
+
function unlocksPro(r) {
|
|
1551
|
+
if (!r) return false;
|
|
1552
|
+
if (r.licensed) return true;
|
|
1553
|
+
return r.status === "expired" && !!r.payload && PAID_TIERS.includes(r.payload.tier);
|
|
1554
|
+
}
|
|
1555
|
+
|
|
1556
|
+
// src/useLicense.ts
|
|
1557
|
+
import { useEffect as useEffect2, useState as useState3 } from "react";
|
|
1558
|
+
function licenseOptions(opts) {
|
|
1559
|
+
return { product: opts.product, tenantId: opts.tenantId, host: browserHost() };
|
|
1560
|
+
}
|
|
1561
|
+
function useLicense(key, opts) {
|
|
1562
|
+
const [result, setResult] = useState3(null);
|
|
1563
|
+
const { product, tenantId } = opts;
|
|
1564
|
+
useEffect2(() => {
|
|
1565
|
+
let live = true;
|
|
1566
|
+
Promise.resolve(key).catch(() => void 0).then((k) => verifyLicenses(k, licenseOptions({ product, tenantId }))).then((r) => {
|
|
1567
|
+
if (live) setResult(r);
|
|
1568
|
+
});
|
|
1569
|
+
return () => {
|
|
1570
|
+
live = false;
|
|
1571
|
+
};
|
|
1572
|
+
}, [key, product, tenantId]);
|
|
1573
|
+
return result;
|
|
1574
|
+
}
|
|
1575
|
+
|
|
1576
|
+
// src/edit.ts
|
|
1577
|
+
var MINUTE4 = 6e4;
|
|
1578
|
+
var SCHED_FIELDS = {
|
|
1579
|
+
keys: ["start", "end", "resourceId"],
|
|
1580
|
+
equal: (key, a, b) => {
|
|
1581
|
+
if (key === "resourceId") return (a || null) === (b || null);
|
|
1582
|
+
const x = toMs(a);
|
|
1583
|
+
const y = toMs(b);
|
|
1584
|
+
return Number.isNaN(x) || Number.isNaN(y) ? defaultEqual(key, a, b) : x === y;
|
|
1585
|
+
},
|
|
1586
|
+
blank: (key) => key === "resourceId" ? null : void 0
|
|
1587
|
+
};
|
|
1588
|
+
var iso = (ms) => new Date(ms).toISOString();
|
|
1589
|
+
function canEdit(editable, hasHandler, action, booking) {
|
|
1590
|
+
if (!hasHandler || editable === false) return false;
|
|
1591
|
+
if (booking?.readOnly) return false;
|
|
1592
|
+
if (typeof editable === "object" && editable[action] === false) return false;
|
|
1593
|
+
return true;
|
|
1594
|
+
}
|
|
1595
|
+
function dragSpan(mode, span, delta, snapMinutes, zone) {
|
|
1596
|
+
const step = Math.max(1, snapMinutes) * MINUTE4;
|
|
1597
|
+
if (mode === "move") {
|
|
1598
|
+
const start = snap(span.start + delta, snapMinutes, zone);
|
|
1599
|
+
return { start, end: start + (span.end - span.start) };
|
|
1600
|
+
}
|
|
1601
|
+
if (mode === "start") return { start: Math.min(snap(span.start + delta, snapMinutes, zone), span.end - step), end: span.end };
|
|
1602
|
+
return { start: span.start, end: Math.max(snap(span.end + delta, snapMinutes, zone), span.start + step) };
|
|
1603
|
+
}
|
|
1604
|
+
function createSpan(a, b, snapMinutes, zone) {
|
|
1605
|
+
const step = Math.max(1, snapMinutes) * MINUTE4;
|
|
1606
|
+
const start = snap(Math.min(a, b), snapMinutes, zone, "floor");
|
|
1607
|
+
const end = Math.max(snap(Math.max(a, b), snapMinutes, zone, "ceil"), start + step);
|
|
1608
|
+
return { start, end };
|
|
1609
|
+
}
|
|
1610
|
+
function placeChange(booking, span, resourceId, source) {
|
|
1611
|
+
const next2 = {};
|
|
1612
|
+
const prev = {};
|
|
1613
|
+
if (toMs(booking.start) !== span.start) {
|
|
1614
|
+
next2.start = iso(span.start);
|
|
1615
|
+
prev.start = typeof booking.start === "number" ? iso(booking.start) : booking.start;
|
|
1616
|
+
}
|
|
1617
|
+
if (toMs(booking.end) !== span.end) {
|
|
1618
|
+
next2.end = iso(span.end);
|
|
1619
|
+
prev.end = typeof booking.end === "number" ? iso(booking.end) : booking.end;
|
|
1620
|
+
}
|
|
1621
|
+
if ((booking.resourceId || null) !== (resourceId || null)) {
|
|
1622
|
+
next2.resourceId = resourceId;
|
|
1623
|
+
prev.resourceId = booking.resourceId || null;
|
|
1624
|
+
}
|
|
1625
|
+
if (!Object.keys(next2).length) return null;
|
|
1626
|
+
return { id: booking.id, ...next2, previous: prev, source };
|
|
1627
|
+
}
|
|
1628
|
+
function keyEdit(e2) {
|
|
1629
|
+
if (!e2.altKey || e2.ctrlKey || e2.metaKey) return null;
|
|
1630
|
+
if (e2.key === "ArrowLeft" || e2.key === "ArrowRight") return { kind: "time", mode: e2.shiftKey ? "end" : "move", dir: e2.key === "ArrowRight" ? 1 : -1 };
|
|
1631
|
+
if ((e2.key === "ArrowUp" || e2.key === "ArrowDown") && !e2.shiftKey) return { kind: "resource", dir: e2.key === "ArrowDown" ? 1 : -1 };
|
|
1632
|
+
return null;
|
|
1633
|
+
}
|
|
1634
|
+
function overlapping(items, id, resourceId, span) {
|
|
1635
|
+
if (!resourceId) return [];
|
|
1636
|
+
return items.filter((i) => i.id !== id && !i.cancelled && i.resourceId === resourceId && i.start < span.end && i.end > span.start);
|
|
1637
|
+
}
|
|
1638
|
+
function checkPlacement(mode, booking, others, resource) {
|
|
1639
|
+
const notes = [];
|
|
1640
|
+
if (others.length && mode !== "allow") {
|
|
1641
|
+
const names = others.slice(0, 2).map((o) => `'${o.booking.name}'`).join(" and ");
|
|
1642
|
+
const more = others.length > 2 ? ` and ${others.length - 2} more` : "";
|
|
1643
|
+
if (mode === "block") return { ok: false, reason: `${resource?.name ?? "That"} is already booked then (${names}${more}).` };
|
|
1644
|
+
notes.push(`overlaps ${names}${more}`);
|
|
1645
|
+
}
|
|
1646
|
+
if (resource?.seats && booking.attendees && booking.attendees > resource.seats) notes.push(`${booking.attendees} people for ${resource.seats} seats`);
|
|
1647
|
+
return notes.length ? { ok: true, warning: notes.join("; ") } : { ok: true };
|
|
1648
|
+
}
|
|
1649
|
+
function whenLabel(start, end, zone) {
|
|
1650
|
+
const d1 = dayLabelIn(start, zone);
|
|
1651
|
+
const d2 = dayLabelIn(end - 1, zone);
|
|
1652
|
+
return d1 === d2 ? `${d1} ${timeLabel(start, zone)}\u2013${timeLabel(end, zone)}` : `${d1} ${timeLabel(start, zone)} \u2013 ${dayLabelIn(end, zone)} ${timeLabel(end, zone)}`;
|
|
1653
|
+
}
|
|
1654
|
+
function describeChange(change, ctx) {
|
|
1655
|
+
const who = `'${ctx.name(change.id)}'`;
|
|
1656
|
+
const prefix = change.source === "undo" ? "Undone: " : change.source === "redo" ? "Redone: " : "";
|
|
1657
|
+
const b = ctx.booking(change.id);
|
|
1658
|
+
const start = toMs(change.start ?? b?.start);
|
|
1659
|
+
const end = toMs(change.end ?? b?.end);
|
|
1660
|
+
const when = Number.isNaN(start) || Number.isNaN(end) ? "" : whenLabel(start, end, ctx.zone);
|
|
1661
|
+
let text2;
|
|
1662
|
+
if ("resourceId" in change) {
|
|
1663
|
+
text2 = change.resourceId ? `${who} ${change.previous.resourceId ? "moved" : "assigned"} to ${ctx.resource(change.resourceId)}${when ? `, ${when}` : ""}` : `${who} unassigned`;
|
|
1664
|
+
} else if ("start" in change && "end" in change) text2 = `${who} moved to ${when}`;
|
|
1665
|
+
else if ("end" in change) text2 = `${who} now ends ${timeLabel(end, ctx.zone)} (${when})`;
|
|
1666
|
+
else text2 = `${who} now starts ${timeLabel(start, ctx.zone)} (${when})`;
|
|
1667
|
+
return prefix + text2;
|
|
1668
|
+
}
|
|
1669
|
+
|
|
1670
|
+
// src/Agenda.tsx
|
|
1671
|
+
import * as React5 from "react";
|
|
1672
|
+
import { useEffect as useEffect3, useState as useState4 } from "react";
|
|
1673
|
+
var PROGRESSIVE_OVER = 400;
|
|
1674
|
+
function Agenda(props) {
|
|
1675
|
+
const { zone, startDay, days } = props;
|
|
1676
|
+
const groups = [];
|
|
1677
|
+
for (let day = startDay; day < startDay + days; day++) {
|
|
1678
|
+
const from = dayToInstant(day, zone);
|
|
1679
|
+
const to = dayToInstant(day + 1, zone);
|
|
1680
|
+
const rows = buildRows(props.resources, props.items, { from, to, groupBy: false, filter: props.filter }).filter(
|
|
1681
|
+
(r) => r.kind === "resource" && r.items.length > 0
|
|
1682
|
+
);
|
|
1683
|
+
const queue = props.filter ? [] : unassigned(props.items, from, to);
|
|
1684
|
+
if (rows.length || queue.length) groups.push({ day, rows, queue });
|
|
1685
|
+
}
|
|
1686
|
+
const total = groups.reduce((n, g) => n + g.queue.length + g.rows.reduce((m, r) => m + r.items.length, 0), 0);
|
|
1687
|
+
const [fullFor, setFullFor] = useState4(null);
|
|
1688
|
+
const progressive = total > PROGRESSIVE_OVER && fullFor !== startDay;
|
|
1689
|
+
useEffect3(() => {
|
|
1690
|
+
if (!progressive) return;
|
|
1691
|
+
let after;
|
|
1692
|
+
const done = () => setFullFor(startDay);
|
|
1693
|
+
const raf = typeof requestAnimationFrame === "function" ? requestAnimationFrame(() => after = setTimeout(done, 0)) : 0;
|
|
1694
|
+
const fallback = setTimeout(done, 150);
|
|
1695
|
+
return () => {
|
|
1696
|
+
if (raf && typeof cancelAnimationFrame === "function") cancelAnimationFrame(raf);
|
|
1697
|
+
if (after) clearTimeout(after);
|
|
1698
|
+
clearTimeout(fallback);
|
|
1699
|
+
};
|
|
1700
|
+
}, [progressive, startDay]);
|
|
1701
|
+
const shownGroups = progressive ? groups.slice(0, 1) : groups;
|
|
1702
|
+
const onKeyDown = (e2) => {
|
|
1703
|
+
const list = Array.from(e2.currentTarget.querySelectorAll(".nf-s-ag-item"));
|
|
1704
|
+
const i = list.indexOf(document.activeElement);
|
|
1705
|
+
const to = (n) => {
|
|
1706
|
+
const el = list[Math.max(0, Math.min(list.length - 1, n))];
|
|
1707
|
+
if (!el) return;
|
|
1708
|
+
el.focus();
|
|
1709
|
+
props.onSelect(el.dataset.id ?? "");
|
|
1710
|
+
};
|
|
1711
|
+
if (e2.key === "ArrowDown") to(i + 1);
|
|
1712
|
+
else if (e2.key === "ArrowUp") to(i < 0 ? 0 : i - 1);
|
|
1713
|
+
else if (e2.key === "Home") to(0);
|
|
1714
|
+
else if (e2.key === "End") to(list.length - 1);
|
|
1715
|
+
else return;
|
|
1716
|
+
e2.preventDefault();
|
|
1717
|
+
};
|
|
1718
|
+
const all = shownGroups.flatMap((g) => [...g.rows.flatMap((r) => r.items), ...g.queue]);
|
|
1719
|
+
const tabId = all.some((it) => it.id === props.selected) ? props.selected : all[0]?.id;
|
|
1720
|
+
let tabTaken = false;
|
|
1721
|
+
const item = (it, day, conflict) => {
|
|
1722
|
+
const look = props.appearance.get(it.id);
|
|
1723
|
+
const b = it.booking;
|
|
1724
|
+
const meta = [b.location, b.status, it.kind === "unavailable" ? "Unavailable" : ""].filter(Boolean).join(" \xB7 ");
|
|
1725
|
+
const tabbable = !tabTaken && it.id === tabId;
|
|
1726
|
+
if (tabbable) tabTaken = true;
|
|
1727
|
+
return /* @__PURE__ */ React5.createElement("li", { key: `${day}-${it.id}` }, /* @__PURE__ */ React5.createElement(
|
|
1728
|
+
"button",
|
|
1729
|
+
{
|
|
1730
|
+
type: "button",
|
|
1731
|
+
className: "nf-s-ag-item",
|
|
1732
|
+
"data-id": it.id,
|
|
1733
|
+
"data-selected": props.selected === it.id ? "" : void 0,
|
|
1734
|
+
"data-cancelled": it.cancelled ? "" : void 0,
|
|
1735
|
+
tabIndex: tabbable ? 0 : -1,
|
|
1736
|
+
onClick: () => props.onSelect(it.id),
|
|
1737
|
+
onDoubleClick: () => props.onOpen?.(b),
|
|
1738
|
+
onKeyDown: (e2) => {
|
|
1739
|
+
if (e2.key === "Enter") {
|
|
1740
|
+
props.onOpen?.(b);
|
|
1741
|
+
e2.preventDefault();
|
|
1742
|
+
}
|
|
1743
|
+
}
|
|
1744
|
+
},
|
|
1745
|
+
/* @__PURE__ */ React5.createElement("span", { className: "nf-s-ag-time", "aria-hidden": "true" }, agendaTime(it, day, zone)),
|
|
1746
|
+
/* @__PURE__ */ React5.createElement("span", { className: "nf-s-ag-sw", "aria-hidden": "true", "data-style": look?.style, style: { background: look?.style === "unavailable" ? void 0 : look?.color, borderColor: look?.color } }),
|
|
1747
|
+
/* @__PURE__ */ React5.createElement("span", { "aria-hidden": "true" }, /* @__PURE__ */ React5.createElement("span", { className: "nf-s-ag-name" }, b.name), meta || conflict ? /* @__PURE__ */ React5.createElement("span", { className: "nf-s-ag-meta" }, meta, conflict ? /* @__PURE__ */ React5.createElement("span", { className: "nf-s-ag-warn" }, meta ? " \xB7 " : "", "Overlaps") : null) : null),
|
|
1748
|
+
/* @__PURE__ */ React5.createElement("span", { className: "nf-s-sr" }, props.describe(it), conflict ? ", overlaps another booking" : "")
|
|
1749
|
+
));
|
|
1750
|
+
};
|
|
1751
|
+
return /* @__PURE__ */ React5.createElement("div", { className: "nf-s-agenda", role: "region", "aria-label": props.label, dir: props.dir, onKeyDown }, groups.length === 0 ? /* @__PURE__ */ React5.createElement("div", { className: "nf-s-ag-empty" }, props.filter ? `Nothing matches \u201C${props.filter}\u201D.` : `No ${props.labels.bookings.toLowerCase()} in these 7 days.`) : null, shownGroups.map((g) => {
|
|
1752
|
+
const today = g.day === props.todayDay;
|
|
1753
|
+
const head = `${props.uid}-ag-${g.day}`;
|
|
1754
|
+
return (
|
|
1755
|
+
// A labelled group, not a heading: the component can't know the host page's heading levels.
|
|
1756
|
+
/* @__PURE__ */ React5.createElement("div", { key: g.day, role: "group", "aria-labelledby": head }, /* @__PURE__ */ React5.createElement("div", { id: head, className: "nf-s-ag-day", "data-today": today ? "" : void 0 }, dayLabelIn(dayToInstant(g.day, zone), zone), today ? " \xB7 Today" : ""), g.rows.map((row) => {
|
|
1757
|
+
const cap = props.capacityOf(row, g.day);
|
|
1758
|
+
return /* @__PURE__ */ React5.createElement("div", { key: row.key, className: "nf-s-ag-group" }, /* @__PURE__ */ React5.createElement("div", { className: "nf-s-ag-res" }, /* @__PURE__ */ React5.createElement("b", null, row.resource.name), cap ? /* @__PURE__ */ React5.createElement("span", { className: "nf-s-cap", "data-level": cap.level === "over" ? "over" : "ok" }, cap.booked, " / ", cap.capacity, " h", cap.level === "over" ? /* @__PURE__ */ React5.createElement("span", { className: "nf-s-sr" }, ", over capacity") : null) : null), /* @__PURE__ */ React5.createElement("ul", { className: "nf-s-ag-list", "aria-label": `${row.resource.name}, ${dayLabelIn(dayToInstant(g.day, zone), zone)}` }, row.items.map((it) => item(it, g.day, row.conflicts.has(it.id)))));
|
|
1759
|
+
}), g.queue.length ? /* @__PURE__ */ React5.createElement("div", { className: "nf-s-ag-group" }, /* @__PURE__ */ React5.createElement("div", { className: "nf-s-ag-res" }, /* @__PURE__ */ React5.createElement("b", null, "Unassigned")), /* @__PURE__ */ React5.createElement("ul", { className: "nf-s-ag-list", "aria-label": `Unassigned, ${dayLabelIn(dayToInstant(g.day, zone), zone)}` }, g.queue.map((it) => item(it, g.day, false)))) : null)
|
|
1760
|
+
);
|
|
1761
|
+
}));
|
|
1762
|
+
}
|
|
1763
|
+
|
|
1764
|
+
// src/styles.ts
|
|
1765
|
+
var css = `
|
|
1766
|
+
.nf-sched{--nf-head:${HEADER_H}px;--nf-res:${RESOURCE_W}px;--nf-lane:${LANE_H}px;--nf-now:${brand.ember};position:relative;isolation:isolate;display:flex;flex-direction:column;width:100%;box-sizing:border-box;min-height:0;height:100%;box-sizing:border-box;background:var(--nf-surface);color:var(--nf-ink);font-family:var(--nf-font);font-size:var(--nf-text-body);line-height:1.3;border:1px solid var(--nf-line);border-radius:16px;overflow:hidden;-webkit-font-smoothing:antialiased}
|
|
1767
|
+
.nf-sched *,.nf-sched *::before,.nf-sched *::after{box-sizing:border-box}
|
|
1768
|
+
.nf-s-toolbar{display:flex;align-items:center;gap:10px;flex-wrap:wrap;min-height:60px;padding:10px 16px;border-bottom:1px solid var(--nf-line)}
|
|
1769
|
+
.nf-s-title{font-weight:600;font-size:var(--nf-text-title);letter-spacing:-0.01em;margin-inline-end:6px}
|
|
1770
|
+
.nf-s-range{font-weight:500;font-size:var(--nf-text-body);min-width:0}
|
|
1771
|
+
.nf-s-seg{display:inline-flex;flex-wrap:wrap;gap:2px;padding:3px;border-radius:9px;background:var(--nf-line-soft);max-width:100%}
|
|
1772
|
+
.nf-s-seg .nf-s-btn{white-space:nowrap;flex:none}
|
|
1773
|
+
.nf-s-btn{font:inherit;font-size:var(--nf-text-body);border:1px solid var(--nf-line);background:var(--nf-surface);color:var(--nf-ink);padding:7px 12px;border-radius:9px;cursor:pointer;min-height:32px;transition:background ${motion.fast} ${motion.ease}}
|
|
1774
|
+
.nf-s-btn:hover{background:var(--nf-line-soft)}
|
|
1775
|
+
.nf-s-btn[disabled]{opacity:.5;cursor:default}
|
|
1776
|
+
.nf-s-icon{padding:7px 10px;min-width:32px}
|
|
1777
|
+
.nf-s-seg .nf-s-btn{border:0;background:transparent;color:var(--nf-muted);padding:6px 12px;border-radius:7px}
|
|
1778
|
+
.nf-s-seg .nf-s-btn[aria-pressed="true"]{background:var(--nf-surface);color:var(--nf-ink);font-weight:500;box-shadow:0 1px 2px rgba(14,17,22,.12)}
|
|
1779
|
+
.nf-s-spacer{flex:1}
|
|
1780
|
+
.nf-s-filter{font:inherit;font-size:var(--nf-text-body);min-height:32px;width:calc(var(--nf-text-body) * 170 / 13);max-width:40vw;padding:6px 10px;border-radius:9px;border:1px solid var(--nf-line);background:var(--nf-surface);color:var(--nf-ink)}
|
|
1781
|
+
.nf-s-switch{display:inline-flex;align-items:center;gap:8px;font:inherit;font-size:var(--nf-text-body);color:var(--nf-ink);background:none;border:0;padding:6px 4px;cursor:pointer;border-radius:8px}
|
|
1782
|
+
.nf-s-switch-track{position:relative;width:30px;height:18px;border-radius:9px;background:var(--nf-line);transition:background ${motion.fast} ${motion.ease}}
|
|
1783
|
+
.nf-s-switch-track::after{content:"";position:absolute;top:2px;left:2px;width:14px;height:14px;border-radius:7px;background:#fff;transition:transform ${motion.base} ${motion.ease}}
|
|
1784
|
+
.nf-s-switch[aria-pressed="true"] .nf-s-switch-track{background:var(--nf-accent)}
|
|
1785
|
+
.nf-s-switch[aria-pressed="true"] .nf-s-switch-track::after{transform:translateX(12px)}
|
|
1786
|
+
.nf-s-count{display:inline-block;min-width:20px;padding:1px 6px;margin-inline-start:6px;border-radius:9px;background:var(--nf-accent-solid,var(--nf-accent));color:var(--nf-accent-text);font-size:var(--nf-text-caption);font-weight:600;text-align:center}
|
|
1787
|
+
.nf-s-btn:focus-visible,.nf-s-switch:focus-visible,.nf-s-filter:focus-visible,.nf-s-scroll:focus-visible,.nf-s-grouphead:focus-visible,.nf-s-un-item:focus-visible{outline:2px solid var(--nf-focus);outline-offset:2px}
|
|
1788
|
+
.nf-s-main{position:relative;flex:1;min-height:0;display:flex}
|
|
1789
|
+
.nf-s-scroll{position:relative;flex:1;min-width:0;overflow:auto;outline:none}
|
|
1790
|
+
.nf-s-agenda{position:relative;flex:1;min-width:0;overflow:auto;padding:0 16px 16px}
|
|
1791
|
+
.nf-s-agenda>*{max-width:720px}
|
|
1792
|
+
.nf-s-ag-day{position:sticky;top:0;z-index:1;margin:0;padding:14px 0 8px;background:var(--nf-surface);border-bottom:1px solid var(--nf-line);font-size:var(--nf-text-header);font-weight:600;color:var(--nf-ink)}
|
|
1793
|
+
.nf-s-ag-day[data-today]{box-shadow:inset 0 -2px 0 var(--nf-accent)}
|
|
1794
|
+
.nf-s-ag-res{display:flex;align-items:baseline;justify-content:space-between;gap:8px;padding:12px 0 6px;font-size:var(--nf-text-value);color:var(--nf-muted)}
|
|
1795
|
+
.nf-s-ag-res b{font-weight:600;color:var(--nf-ink)}
|
|
1796
|
+
.nf-s-ag-list{list-style:none;margin:0;padding:0;display:grid;gap:6px}
|
|
1797
|
+
.nf-s-ag-item{display:grid;grid-template-columns:minmax(76px,auto) 4px 1fr;align-items:start;gap:10px;width:100%;min-height:44px;text-align:start;font:inherit;font-size:var(--nf-text-body);line-height:1.35;color:var(--nf-ink);background:var(--nf-surface);border:1px solid var(--nf-line-soft);border-radius:8px;padding:8px 10px;cursor:pointer}
|
|
1798
|
+
.nf-s-ag-item:hover{border-color:var(--nf-line)}
|
|
1799
|
+
.nf-s-ag-item[data-selected]{border-color:var(--nf-accent);box-shadow:0 0 0 1px var(--nf-accent)}
|
|
1800
|
+
.nf-s-ag-item:focus-visible{outline:2px solid var(--nf-focus);outline-offset:2px}
|
|
1801
|
+
.nf-s-ag-time{font-family:var(--nf-mono-font);font-size:var(--nf-text-value);color:var(--nf-muted);padding-top:1px;white-space:nowrap}
|
|
1802
|
+
.nf-s-ag-sw{align-self:stretch;width:4px;border-radius:2px;border:0 solid}
|
|
1803
|
+
.nf-s-ag-sw[data-style="tentative"]{background:transparent!important;border-width:2px;border-style:dashed}
|
|
1804
|
+
.nf-s-ag-sw[data-style="unavailable"]{background:repeating-linear-gradient(135deg,var(--nf-muted) 0 2px,transparent 2px 5px)}
|
|
1805
|
+
.nf-s-ag-sw[data-style="done"]{opacity:.45}
|
|
1806
|
+
.nf-s-ag-name{font-weight:500}
|
|
1807
|
+
.nf-s-ag-item[data-cancelled] .nf-s-ag-name{text-decoration:line-through;color:var(--nf-muted)}
|
|
1808
|
+
.nf-s-ag-meta{display:block;margin-top:2px;font-size:var(--nf-text-callout);color:var(--nf-muted)}
|
|
1809
|
+
.nf-s-ag-warn{color:var(--nf-ink);text-decoration:underline 2px var(--nf-negative);text-underline-offset:3px}
|
|
1810
|
+
.nf-s-ag-empty{padding:32px 0;color:var(--nf-muted);font-size:var(--nf-text-body)}
|
|
1811
|
+
.nf-s-head{position:sticky;top:0;z-index:4;display:flex;height:var(--nf-head);background:var(--nf-surface);border-bottom:1px solid var(--nf-line)}
|
|
1812
|
+
.nf-s-corner{position:sticky;left:0;z-index:5;flex:none;width:var(--nf-res);display:flex;align-items:flex-end;padding:0 16px 10px;background:var(--nf-surface);border-right:1px solid var(--nf-line);font-family:var(--nf-mono-font);font-size:var(--nf-text-caption);letter-spacing:.06em;color:var(--nf-muted);text-transform:uppercase}
|
|
1813
|
+
.nf-s-axis{position:relative;flex:none}
|
|
1814
|
+
.nf-s-day{position:absolute;top:0;height:calc(var(--nf-text-label) * 28 / 12);padding:8px 10px 0;border-left:1px solid var(--nf-line);font-size:var(--nf-text-label);font-weight:600;white-space:nowrap;overflow:hidden}
|
|
1815
|
+
.nf-s-day[data-today]{color:var(--nf-ink);font-weight:600;box-shadow:inset 0 -2px 0 var(--nf-accent)}
|
|
1816
|
+
.nf-s-hour{position:absolute;top:calc(var(--nf-text-label) * 32 / 12);padding-left:4px;font-family:var(--nf-mono-font);font-size:var(--nf-text-caption);color:var(--nf-muted);white-space:nowrap}
|
|
1817
|
+
.nf-s-body{position:relative}
|
|
1818
|
+
.nf-s-layer{position:absolute;top:0;bottom:0;pointer-events:none}
|
|
1819
|
+
.nf-s-off{position:absolute;top:0;bottom:0;background:var(--nf-line-soft)}
|
|
1820
|
+
.nf-s-vline{position:absolute;top:0;bottom:0;width:1px;background:var(--nf-line-soft)}
|
|
1821
|
+
.nf-s-vline[data-day]{background:var(--nf-line)}
|
|
1822
|
+
.nf-s-nowline{position:absolute;top:0;bottom:0;width:2px;margin-left:-1px;background:var(--nf-now);z-index:2}
|
|
1823
|
+
.nf-s-row{position:absolute;left:0;display:flex}
|
|
1824
|
+
.nf-s-row[aria-selected="true"] .nf-s-res{background:var(--nf-selected)}
|
|
1825
|
+
.nf-s-res{position:sticky;left:0;z-index:3;flex:none;width:var(--nf-res);display:flex;align-items:center;gap:10px;padding:0 12px 0 16px;background:var(--nf-surface);border-right:1px solid var(--nf-line);border-bottom:1px solid var(--nf-line-soft)}
|
|
1826
|
+
.nf-s-avatar{flex:none;width:calc(var(--nf-text-caption) * 28 / 11);height:calc(var(--nf-text-caption) * 28 / 11);border-radius:50%;display:inline-flex;align-items:center;justify-content:center;font-size:var(--nf-text-caption);font-weight:600;background:var(--nf-accent-tint);color:var(--nf-ink);overflow:hidden}
|
|
1827
|
+
.nf-s-avatar img{width:100%;height:100%;object-fit:cover}
|
|
1828
|
+
.nf-s-who{min-width:0;flex:1;display:flex;flex-direction:column;gap:1px}
|
|
1829
|
+
.nf-s-name{font-weight:500;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
1830
|
+
.nf-s-sub{font-size:var(--nf-text-callout);color:var(--nf-muted);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
1831
|
+
.nf-s-cap{flex:none;font-family:var(--nf-mono-font);font-size:var(--nf-text-caption);color:var(--nf-muted);text-align:end}
|
|
1832
|
+
.nf-s-cap[data-level="over"]{color:var(--nf-ink);font-weight:600;text-decoration:underline 2px var(--nf-negative);text-underline-offset:3px}
|
|
1833
|
+
.nf-s-lanes{position:relative;flex:none;border-bottom:1px solid var(--nf-line-soft)}
|
|
1834
|
+
.nf-s-overday{position:absolute;top:0;bottom:0;pointer-events:none;background:repeating-linear-gradient(135deg,var(--nf-negative) 0 1px,transparent 1px 7px);opacity:.16}
|
|
1835
|
+
.nf-s-util{position:absolute;bottom:2px;height:3px;border-radius:2px;background:var(--nf-line-soft);overflow:hidden;pointer-events:none}
|
|
1836
|
+
.nf-s-util>i{display:block;height:100%;border-radius:inherit;background:var(--nf-accent);opacity:.45}
|
|
1837
|
+
.nf-s-util>i[data-level="near"]{opacity:1}
|
|
1838
|
+
.nf-s-util>i[data-level="over"]{background:var(--nf-negative);opacity:1}
|
|
1839
|
+
.nf-s-group{position:absolute;left:0;height:var(--nf-group,${GROUP_H}px);display:flex;align-items:center;background:var(--nf-surface-alt);border-bottom:1px solid var(--nf-line)}
|
|
1840
|
+
.nf-s-groupcell{position:sticky;left:0;height:100%;display:flex}
|
|
1841
|
+
.nf-s-grouphead{display:inline-flex;align-items:center;gap:8px;height:100%;padding:0 16px;font:inherit;font-size:var(--nf-text-label);font-weight:600;letter-spacing:.02em;color:var(--nf-ink);background:none;border:0;cursor:pointer}
|
|
1842
|
+
.nf-s-grouphead svg{transition:transform ${motion.fast} ${motion.ease}}
|
|
1843
|
+
.nf-s-grouphead[aria-expanded="false"] svg{transform:rotate(-90deg)}
|
|
1844
|
+
.nf-sched[data-rtl] .nf-s-grouphead[aria-expanded="false"] svg{transform:rotate(90deg)}
|
|
1845
|
+
.nf-s-groupcount{font-weight:400;color:var(--nf-muted)}
|
|
1846
|
+
.nf-s-bk{position:absolute;height:calc(var(--nf-lane) - 4px);border-radius:6px;padding:0 8px;display:flex;align-items:center;gap:6px;font-size:var(--nf-text-value);line-height:1.15;white-space:nowrap;overflow:hidden;cursor:pointer;background:var(--bk);color:var(--bk-text);border:1px solid var(--bk);transform-origin:left center;animation:nf-s-in ${motion.base} ${motion.ease} both}
|
|
1847
|
+
.nf-s-bk b{font-weight:600;overflow:hidden;text-overflow:ellipsis}
|
|
1848
|
+
.nf-s-bk span{overflow:hidden;text-overflow:ellipsis;font-weight:400}
|
|
1849
|
+
.nf-s-bk[data-style="tentative"]{background:color-mix(in srgb,var(--bk) 16%,var(--nf-surface));color:var(--nf-ink);border-style:dashed}
|
|
1850
|
+
.nf-s-bk[data-style="done"]{background:var(--nf-line-soft);color:var(--nf-ink);border-color:var(--nf-line);box-shadow:inset 3px 0 0 var(--bk)}
|
|
1851
|
+
.nf-s-bk[data-style="cancelled"]{background:var(--nf-surface);color:var(--nf-muted);border:1px dashed var(--nf-line);text-decoration:line-through}
|
|
1852
|
+
.nf-s-bk[data-style="unavailable"]{background:repeating-linear-gradient(135deg,var(--nf-line-soft) 0 6px,var(--nf-surface) 6px 12px);color:var(--nf-ink);border-color:var(--nf-line)}
|
|
1853
|
+
.nf-s-bk[data-conflict]::after{content:"";position:absolute;left:0;right:0;top:0;height:3px;background:var(--nf-negative)}
|
|
1854
|
+
.nf-s-bk[data-overseat]::before{content:"";position:absolute;left:0;right:0;bottom:0;height:3px;background:var(--nf-negative)}
|
|
1855
|
+
.nf-s-seats{flex:none;order:2;margin-inline-start:auto;padding:1px 5px;border-radius:4px;background:var(--nf-surface);color:var(--nf-ink);box-shadow:inset 0 0 0 1px var(--nf-negative);font-family:var(--nf-mono-font);font-size:var(--nf-text-caption);font-weight:600}
|
|
1856
|
+
.nf-s-bk[aria-selected="true"]{box-shadow:0 0 0 2px var(--nf-surface),0 0 0 4px var(--nf-accent);z-index:1}
|
|
1857
|
+
.nf-s-settled .nf-s-bk{animation:none}
|
|
1858
|
+
.nf-sched[data-narrow] .nf-s-unassigned{position:absolute;inset:0;z-index:6;width:auto;border-left:0}
|
|
1859
|
+
.nf-s-unassigned{flex:none;width:var(--nf-unassigned-w,280px);display:flex;flex-direction:column;border-left:1px solid var(--nf-line);background:var(--nf-surface)}
|
|
1860
|
+
.nf-s-un-head{display:flex;align-items:center;justify-content:space-between;padding:12px 16px;border-bottom:1px solid var(--nf-line);font-weight:600}
|
|
1861
|
+
/* RTL hosts: numbers, times and ranges stay in reading order ("0 / 8 h", "08:00\u201309:00"). */
|
|
1862
|
+
.nf-s-cap,.nf-s-count,.nf-s-ag-time,.nf-s-ltr,.nf-s-range{direction:ltr;unicode-bidi:isolate}
|
|
1863
|
+
.nf-s-menu [role="menuitem"]{unicode-bidi:plaintext}
|
|
1864
|
+
.nf-s-menuwrap{position:relative;display:inline-flex}
|
|
1865
|
+
.nf-s-menu{position:absolute;top:calc(100% + 6px);right:0;z-index:9;min-width:180px;display:flex;flex-direction:column;padding:4px;border-radius:10px;border:1px solid var(--nf-line);background:var(--nf-surface);box-shadow:0 8px 24px rgba(14,17,22,.14)}
|
|
1866
|
+
.nf-s-menu [role="menuitem"]{font:inherit;font-size:var(--nf-text-body);text-align:start;color:var(--nf-ink);background:none;border:0;border-radius:7px;padding:8px 10px;cursor:pointer}
|
|
1867
|
+
.nf-s-menu [role="menuitem"]:hover,.nf-s-menu [role="menuitem"]:focus-visible{background:var(--nf-line-soft);outline:none}
|
|
1868
|
+
.nf-s-menu [role="menuitem"]:focus-visible{box-shadow:inset 0 0 0 2px var(--nf-focus)}
|
|
1869
|
+
.nf-s-un-tools{display:flex;gap:6px;padding:8px 8px 0}
|
|
1870
|
+
.nf-s-un-tools .nf-s-filter{flex:1;min-width:0;width:auto;max-width:none}
|
|
1871
|
+
.nf-s-pick{display:inline-flex;align-items:center;gap:6px;font-size:var(--nf-text-body);color:var(--nf-muted)}
|
|
1872
|
+
.nf-s-pick select{font:inherit;font-size:var(--nf-text-body);min-height:32px;padding:5px 8px;border-radius:9px;border:1px solid var(--nf-line);background:var(--nf-surface);color:var(--nf-ink);cursor:pointer}
|
|
1873
|
+
.nf-s-pick select:focus-visible{outline:2px solid var(--nf-focus);outline-offset:2px}
|
|
1874
|
+
.nf-s-un-list{flex:1;overflow:auto;padding:8px;display:flex;flex-direction:column;gap:6px;margin:0;list-style:none}
|
|
1875
|
+
.nf-s-un-day{font-family:var(--nf-mono-font);font-size:var(--nf-text-caption);letter-spacing:.06em;text-transform:uppercase;color:var(--nf-muted);padding:8px 8px 2px}
|
|
1876
|
+
.nf-s-un-item{display:block;width:100%;text-align:start;font:inherit;font-size:var(--nf-text-body);padding:8px 10px;border-radius:9px;border:1px solid var(--nf-line);border-left:3px solid var(--nf-accent);background:var(--nf-surface);color:var(--nf-ink);cursor:pointer}
|
|
1877
|
+
.nf-s-un-item[aria-pressed="true"]{background:var(--nf-selected);border-color:var(--nf-accent)}
|
|
1878
|
+
.nf-s-un-item small{display:block;color:var(--nf-muted);font-size:var(--nf-text-callout);margin-top:2px}
|
|
1879
|
+
.nf-s-un-empty{padding:16px;color:var(--nf-muted);font-size:var(--nf-text-body)}
|
|
1880
|
+
.nf-s-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);clip-path:inset(50%);white-space:nowrap}
|
|
1881
|
+
.nf-s-empty{position:absolute;left:var(--nf-res);right:0;top:40px;text-align:center;color:var(--nf-muted);font-size:var(--nf-text-body)}.nf-s-emptyaction{display:flex;flex-direction:column;align-items:center;gap:8px;margin-top:12px;color:var(--nf-muted);font-size:var(--nf-text-body)}.nf-s-emptyaction small{font-size:var(--nf-text-callout);max-width:360px}.nf-s-emptyaction [role="alert"]{color:var(--nf-negative,#D93F2B)}
|
|
1882
|
+
.nf-s-bk{user-select:none;touch-action:none}
|
|
1883
|
+
.nf-s-bk[data-editable]{cursor:grab}
|
|
1884
|
+
.nf-s-dragging,.nf-s-dragging *{cursor:grabbing!important;user-select:none}
|
|
1885
|
+
.nf-s-bk[data-preview]{box-shadow:0 6px 18px rgba(14,17,22,.22);z-index:2}
|
|
1886
|
+
.nf-s-h{position:absolute;top:0;bottom:0;width:8px;cursor:ew-resize;z-index:1}
|
|
1887
|
+
.nf-s-h-l{left:0}
|
|
1888
|
+
.nf-s-h-r{right:0}
|
|
1889
|
+
.nf-s-bk:hover .nf-s-h::after,.nf-s-bk[aria-selected="true"] .nf-s-h::after{content:"";position:absolute;top:6px;bottom:6px;left:3px;width:2px;border-radius:1px;background:currentColor;opacity:.55}
|
|
1890
|
+
.nf-s-lanes[data-create]{cursor:crosshair}
|
|
1891
|
+
.nf-s-ghost{position:absolute;height:calc(var(--nf-lane) - 4px);border-radius:6px;border:1.5px dashed var(--nf-accent);background:var(--nf-accent-tint);color:var(--nf-ink);font-size:var(--nf-text-caption);font-family:var(--nf-mono-font);padding:0 8px;display:flex;align-items:center;white-space:nowrap;overflow:hidden;pointer-events:none}
|
|
1892
|
+
.nf-s-draglabel{position:absolute;z-index:5;padding:3px 8px;border-radius:6px;background:var(--nf-tooltip-bg);color:var(--nf-tooltip-ink);font-size:var(--nf-text-caption);font-family:var(--nf-mono-font);white-space:nowrap;pointer-events:none}
|
|
1893
|
+
.nf-s-un-item[data-editable]{cursor:grab;touch-action:none}
|
|
1894
|
+
.nf-s-toasts{position:absolute;left:12px;bottom:12px;z-index:9;display:flex;flex-direction:column;gap:8px;max-width:min(460px,calc(100% - 24px))}
|
|
1895
|
+
.nf-s-toast{display:flex;align-items:center;gap:10px;padding:8px 8px 8px 12px;border-radius:10px;background:var(--nf-tooltip-bg);color:var(--nf-tooltip-ink);box-shadow:0 8px 24px rgba(14,17,22,.24);font-size:var(--nf-text-body);animation:nf-s-in ${motion.fast} ${motion.ease} both}
|
|
1896
|
+
.nf-s-toast[data-tone="error"]{background:var(--nf-negative);color:#fff}
|
|
1897
|
+
.nf-s-toast-text{flex:1;min-width:0}
|
|
1898
|
+
.nf-s-toast-btn{font:inherit;font-size:var(--nf-text-body);font-weight:600;color:inherit;background:rgba(255,255,255,.14);border:0;padding:5px 10px;border-radius:7px;cursor:pointer}
|
|
1899
|
+
.nf-s-toast-x{font:inherit;font-size:var(--nf-text-title);line-height:1;color:inherit;opacity:.7;background:none;border:0;padding:4px 6px;border-radius:6px;cursor:pointer}
|
|
1900
|
+
.nf-s-toast button:focus-visible{outline:2px solid var(--nf-focus);outline-offset:2px}
|
|
1901
|
+
.nf-s-mark{position:absolute;right:12px;bottom:12px;z-index:7;display:inline-flex;align-items:center;gap:6px;padding:5px 9px 5px 6px;border-radius:8px;background:var(--nf-surface);border:1px solid var(--nf-line);box-shadow:0 2px 8px rgba(14,17,22,.08);font-size:var(--nf-text-caption);color:var(--nf-muted);text-decoration:none;font-family:inherit;cursor:pointer}
|
|
1902
|
+
.nf-s-mark:hover{color:var(--nf-ink)}
|
|
1903
|
+
.nf-s-locknote{position:absolute;left:12px;bottom:12px;z-index:7;margin:0;max-width:calc(100% - 220px);padding:5px 9px;border-radius:8px;background:var(--nf-surface);border:1px solid var(--nf-line);font-size:var(--nf-text-caption);color:var(--nf-muted)}
|
|
1904
|
+
.nf-s-renew{position:absolute;right:12px;bottom:12px;z-index:7;display:inline-flex;align-items:center;gap:6px;padding:5px 9px;border-radius:8px;background:var(--nf-surface);border:1px solid var(--nf-line);box-shadow:0 2px 8px rgba(14,17,22,.08);font-size:var(--nf-text-caption);color:var(--nf-muted);text-decoration:none}
|
|
1905
|
+
.nf-s-renew:hover{color:var(--nf-ink)}
|
|
1906
|
+
.nf-s-renew:focus-visible{outline:2px solid var(--nf-focus);outline-offset:2px}
|
|
1907
|
+
.nf-s-renew::before{content:"";width:6px;height:6px;border-radius:3px;background:var(--nf-milestone)}
|
|
1908
|
+
.nf-s-mark:focus-visible{outline:2px solid var(--nf-focus);outline-offset:2px}
|
|
1909
|
+
@keyframes nf-s-in{from{opacity:0;transform:scaleX(.96)}to{opacity:1;transform:none}}
|
|
1910
|
+
@media (prefers-reduced-motion: reduce){.nf-sched *{animation:none!important;transition:none!important}}
|
|
1911
|
+
@media (forced-colors: active){
|
|
1912
|
+
.nf-sched{border-color:CanvasText}
|
|
1913
|
+
.nf-s-bk,.nf-s-nowline,.nf-s-off,.nf-s-avatar,.nf-s-count,.nf-s-switch-track,.nf-s-switch-track::after{forced-color-adjust:none}
|
|
1914
|
+
.nf-s-bk{background:Canvas;color:CanvasText;border:1px solid CanvasText}
|
|
1915
|
+
.nf-s-bk[data-style="tentative"],.nf-s-bk[data-style="cancelled"]{border-style:dashed}
|
|
1916
|
+
.nf-s-bk[data-style="unavailable"]{background:repeating-linear-gradient(135deg,GrayText 0 2px,Canvas 2px 8px)}
|
|
1917
|
+
.nf-s-bk[data-style="unavailable"]>b,.nf-s-bk[data-style="unavailable"]>span[aria-hidden]{forced-color-adjust:none;background:Canvas;color:CanvasText;padding:0 3px;border-radius:2px}
|
|
1918
|
+
.nf-s-bk[data-conflict]::after{background:Highlight}
|
|
1919
|
+
.nf-s-bk[data-overseat]::before{background:Highlight}
|
|
1920
|
+
.nf-s-seats{forced-color-adjust:none;background:Highlight;color:HighlightText}
|
|
1921
|
+
.nf-s-ghost,.nf-s-draglabel,.nf-s-toast{forced-color-adjust:none;background:Canvas;color:CanvasText;border:1px solid CanvasText}
|
|
1922
|
+
.nf-s-bk[aria-selected="true"]{box-shadow:none;outline:2px solid Highlight;outline-offset:2px}
|
|
1923
|
+
.nf-s-nowline{background:Highlight}
|
|
1924
|
+
.nf-s-off{background:transparent}
|
|
1925
|
+
.nf-s-util,.nf-s-util>i,.nf-s-overday{forced-color-adjust:none}
|
|
1926
|
+
.nf-s-util{background:Canvas;outline:1px solid GrayText}
|
|
1927
|
+
.nf-s-util>i{background:GrayText;opacity:1}
|
|
1928
|
+
.nf-s-util>i[data-level="near"]{background:CanvasText}
|
|
1929
|
+
.nf-s-util>i[data-level="over"]{background:Highlight}
|
|
1930
|
+
.nf-s-overday{background:repeating-linear-gradient(135deg,Highlight 0 1px,transparent 1px 7px);opacity:.35}
|
|
1931
|
+
.nf-s-avatar,.nf-s-count{background:Canvas;color:CanvasText;border:1px solid CanvasText}
|
|
1932
|
+
.nf-sched :focus-visible{outline:2px solid Highlight}
|
|
1933
|
+
.nf-s-ag-item{border-color:CanvasText}
|
|
1934
|
+
.nf-s-menu{border-color:CanvasText}
|
|
1935
|
+
.nf-s-menu [role="menuitem"]:focus-visible{outline:2px solid Highlight;box-shadow:none}
|
|
1936
|
+
.nf-s-ag-item[data-selected]{outline:2px solid Highlight;box-shadow:none}
|
|
1937
|
+
.nf-s-ag-sw{forced-color-adjust:none;background:CanvasText!important}
|
|
1938
|
+
/* Forced colours drop box-shadows: today and the pressed view need real borders. */
|
|
1939
|
+
.nf-s-day[data-today],.nf-s-ag-day[data-today]{box-shadow:none;border-bottom:2px solid Highlight}
|
|
1940
|
+
.nf-s-seg .nf-s-btn[aria-pressed="true"]{outline:2px solid Highlight;outline-offset:-2px}
|
|
1941
|
+
.nf-s-switch[aria-pressed="true"] .nf-s-switch-track{background:Highlight}
|
|
1942
|
+
.nf-s-switch-track{background:Canvas;border:1px solid CanvasText}
|
|
1943
|
+
.nf-s-switch-track::after{background:CanvasText}
|
|
1944
|
+
.nf-s-switch[aria-pressed="true"] .nf-s-switch-track::after{background:HighlightText}
|
|
1945
|
+
}
|
|
1946
|
+
`;
|
|
1947
|
+
|
|
1948
|
+
// src/theme.ts
|
|
1949
|
+
function resolveTheme(theme, accent) {
|
|
1950
|
+
const base = theme === "dark" ? darkTheme : lightTheme;
|
|
1951
|
+
const overrides = typeof theme === "object" ? { ...theme } : {};
|
|
1952
|
+
const ownText = overrides.accentText;
|
|
1953
|
+
if (accent) {
|
|
1954
|
+
overrides.accent = accent;
|
|
1955
|
+
overrides.focus = overrides.focus ?? accent;
|
|
1956
|
+
const surface = overrides.surface ?? base.surface;
|
|
1957
|
+
const dark = theme === "dark";
|
|
1958
|
+
overrides.accentTint = overrides.accentTint ?? `color-mix(in srgb, ${accent} ${dark ? 32 : 20}%, ${surface})`;
|
|
1959
|
+
overrides.selected = overrides.selected ?? `color-mix(in srgb, ${accent} ${dark ? 14 : 6}%, ${surface})`;
|
|
1960
|
+
}
|
|
1961
|
+
const finalAccent = overrides.accent ?? base.accent;
|
|
1962
|
+
const pair = ownText ? { background: finalAccent, text: ownText } : accentForText(finalAccent);
|
|
1963
|
+
overrides.accentText = pair.text;
|
|
1964
|
+
return { ...themeVars(overrides, base), "--nf-accent-solid": pair.background };
|
|
1965
|
+
}
|
|
1966
|
+
function accentOf(theme, accent) {
|
|
1967
|
+
if (accent) return accent;
|
|
1968
|
+
if (typeof theme === "object" && theme.accent) return theme.accent;
|
|
1969
|
+
return (theme === "dark" ? darkTheme : lightTheme).accent;
|
|
1970
|
+
}
|
|
1971
|
+
function palette(theme, accent, forceLight) {
|
|
1972
|
+
const base = theme === "dark" && !forceLight ? darkTheme : lightTheme;
|
|
1973
|
+
const own = typeof theme === "object" && !forceLight ? theme : {};
|
|
1974
|
+
return { ...base, ...own, ...accent ? { accent, accentText: own.accentText ?? accentForText(accent).text } : {} };
|
|
1975
|
+
}
|
|
1976
|
+
|
|
1977
|
+
// src/export.ts
|
|
1978
|
+
var PRINT_TIMELINE_W = 820;
|
|
1979
|
+
var PRINT_RES_W = 190;
|
|
1980
|
+
var PRINT_LANE = 18;
|
|
1981
|
+
var e = escapeHtml;
|
|
1982
|
+
function fit(text2, px, perChar = 5.4) {
|
|
1983
|
+
const n = Math.floor((px - 6) / perChar);
|
|
1984
|
+
if (n <= 0) return "";
|
|
1985
|
+
return text2.length <= n ? text2 : `${text2.slice(0, Math.max(1, n - 1))}\u2026`;
|
|
1986
|
+
}
|
|
1987
|
+
function rowSvg(row, o, w) {
|
|
1988
|
+
const t = o.theme;
|
|
1989
|
+
const s = o.scale;
|
|
1990
|
+
const h = Math.max(24, row.lanes * PRINT_LANE + 6);
|
|
1991
|
+
const out = [];
|
|
1992
|
+
for (const b of s.nonWorking) out.push(`<rect x="${b.x}" y="0" width="${b.width}" height="${h}" fill="${t.lineSoft}"/>`);
|
|
1993
|
+
for (const d of s.days) out.push(`<line x1="${d.x}" x2="${d.x}" y1="0" y2="${h}" stroke="${t.line}"/>`);
|
|
1994
|
+
if (o.now !== void 0 && o.now >= s.start && o.now < s.end) {
|
|
1995
|
+
const nx = s.x(o.now);
|
|
1996
|
+
out.push(`<line x1="${nx}" x2="${nx}" y1="0" y2="${h}" stroke="${brand.ember}" stroke-width="1.5"/>`);
|
|
1997
|
+
}
|
|
1998
|
+
for (const it of row.items) {
|
|
1999
|
+
const x0 = s.x(Math.max(it.start, s.start));
|
|
2000
|
+
const x1 = s.x(Math.min(it.end, s.end));
|
|
2001
|
+
const bw = Math.max(2, x1 - x0);
|
|
2002
|
+
const y = 3 + (row.lane.get(it.id) ?? 0) * PRINT_LANE;
|
|
2003
|
+
const look = o.look.get(it.id) ?? { color: t.accent, text: t.accentText, style: "solid" };
|
|
2004
|
+
let fill = look.color;
|
|
2005
|
+
let ink = look.text;
|
|
2006
|
+
let extra = "";
|
|
2007
|
+
if (look.style === "tentative") {
|
|
2008
|
+
extra = ` fill-opacity=".16" stroke="${look.color}" stroke-dasharray="3 2"`;
|
|
2009
|
+
ink = t.ink;
|
|
2010
|
+
} else if (look.style === "done") {
|
|
2011
|
+
extra = ` fill-opacity=".4"`;
|
|
2012
|
+
ink = t.ink;
|
|
2013
|
+
} else if (look.style === "cancelled") {
|
|
2014
|
+
fill = t.lineSoft;
|
|
2015
|
+
extra = ` stroke="${t.line}"`;
|
|
2016
|
+
ink = t.muted;
|
|
2017
|
+
} else if (look.style === "unavailable") {
|
|
2018
|
+
fill = "url(#nf-hatch)";
|
|
2019
|
+
extra = ` stroke="${t.line}"`;
|
|
2020
|
+
ink = t.ink;
|
|
2021
|
+
}
|
|
2022
|
+
out.push(`<rect x="${x0}" y="${y}" width="${bw}" height="14" rx="3"${extra} fill="${fill}"/>`);
|
|
2023
|
+
if (row.conflicts.has(it.id)) out.push(`<rect x="${x0}" y="${y}" width="${bw}" height="2" fill="${t.negative}"/>`);
|
|
2024
|
+
const label = fit(it.booking.name, bw);
|
|
2025
|
+
if (label)
|
|
2026
|
+
out.push(
|
|
2027
|
+
`<text x="${x0 + 4}" y="${y + 10.5}" fill="${ink}"${look.style === "cancelled" ? ' text-decoration="line-through"' : ""}>${e(label)}</text>`
|
|
2028
|
+
);
|
|
2029
|
+
}
|
|
2030
|
+
return `<svg width="${w}" height="${h}" viewBox="0 0 ${w} ${h}" aria-hidden="true">${out.join("")}</svg>`;
|
|
2031
|
+
}
|
|
2032
|
+
function headerSvg(o, w) {
|
|
2033
|
+
const t = o.theme;
|
|
2034
|
+
const out = [];
|
|
2035
|
+
for (const d of o.scale.days) {
|
|
2036
|
+
out.push(`<line x1="${d.x}" x2="${d.x}" y1="0" y2="30" stroke="${t.line}"/>`);
|
|
2037
|
+
out.push(`<text x="${d.x + 4}" y="12" font-weight="600" fill="${t.ink}">${e(fit(d.label, d.width, 5.8))}</text>`);
|
|
2038
|
+
}
|
|
2039
|
+
if (o.showHours) {
|
|
2040
|
+
let lastX = -Infinity;
|
|
2041
|
+
for (const hr of o.scale.hours) {
|
|
2042
|
+
if (hr.x - lastX < 34) continue;
|
|
2043
|
+
lastX = hr.x;
|
|
2044
|
+
out.push(`<text x="${hr.x + 2}" y="26" fill="${t.muted}">${e(hr.label)}</text>`);
|
|
2045
|
+
}
|
|
2046
|
+
}
|
|
2047
|
+
return `<svg width="${w}" height="30" viewBox="0 0 ${w} 30" aria-hidden="true">${out.join("")}</svg>`;
|
|
2048
|
+
}
|
|
2049
|
+
function legend(t, withNow) {
|
|
2050
|
+
const sw = (style) => `<i style="${style}"></i>`;
|
|
2051
|
+
return [
|
|
2052
|
+
`<span>${sw(`background:${t.accent}`)}Booked</span>`,
|
|
2053
|
+
`<span>${sw(`border:1px dashed ${t.accent};background:transparent`)}Tentative</span>`,
|
|
2054
|
+
`<span>${sw(`background:${t.accent};opacity:.4`)}Done</span>`,
|
|
2055
|
+
`<span>${sw(`background:${t.lineSoft};border:1px solid ${t.line}`)}Cancelled</span>`,
|
|
2056
|
+
`<span>${sw(`background:repeating-linear-gradient(135deg,${t.muted} 0 1px,transparent 1px 4px);border:1px solid ${t.line}`)}Unavailable</span>`,
|
|
2057
|
+
`<span>${sw(`background:${t.negative};height:2px`)}Overlap</span>`,
|
|
2058
|
+
withNow ? `<span>${sw(`background:${brand.ember};width:2px`)}Now</span>` : ""
|
|
2059
|
+
].join("");
|
|
2060
|
+
}
|
|
2061
|
+
function shell(title, meta, legendHtml, t, body) {
|
|
2062
|
+
return `<!doctype html><html><head><meta charset="utf-8"><title>${e(title)}</title><style>
|
|
2063
|
+
@page{size:landscape;margin:10mm}
|
|
2064
|
+
*{box-sizing:border-box}
|
|
2065
|
+
html,body{margin:0;background:#fff;color:${t.ink};font-family:Geist,system-ui,-apple-system,"Segoe UI",sans-serif;font-size:10px;-webkit-print-color-adjust:exact;print-color-adjust:exact}
|
|
2066
|
+
header{display:flex;align-items:baseline;flex-wrap:wrap;gap:12px;margin-bottom:8px}
|
|
2067
|
+
h1{font-size:16px;margin:0;letter-spacing:-0.01em}
|
|
2068
|
+
h2{font-size:12px;margin:14px 0 6px}
|
|
2069
|
+
h3{font-size:10.5px;margin:8px 0 4px;color:${t.muted};font-weight:600}
|
|
2070
|
+
.meta{color:${t.muted}}
|
|
2071
|
+
.legend{display:flex;flex-wrap:wrap;gap:12px;margin-left:auto;color:${t.muted}}
|
|
2072
|
+
.legend span{display:inline-flex;align-items:center;gap:5px}
|
|
2073
|
+
.legend i{display:inline-block;width:18px;height:9px;border-radius:2px}
|
|
2074
|
+
table{width:100%;border-collapse:collapse;table-layout:fixed}
|
|
2075
|
+
thead{display:table-header-group}
|
|
2076
|
+
tr{page-break-inside:avoid;break-inside:avoid}
|
|
2077
|
+
th{font-family:"Geist Mono",ui-monospace,monospace;font-weight:500;font-size:8.5px;letter-spacing:.06em;text-transform:uppercase;color:${t.muted};text-align:left;padding:0 6px 4px;border-bottom:1px solid ${t.line};vertical-align:bottom}
|
|
2078
|
+
th.tl{padding:0}
|
|
2079
|
+
td{padding:2px 6px;border-bottom:1px solid ${t.lineSoft};vertical-align:middle;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
|
|
2080
|
+
td.tl{padding:0}
|
|
2081
|
+
td.tl svg,th.tl svg{display:block}
|
|
2082
|
+
svg text{font-family:inherit;font-size:9px}
|
|
2083
|
+
.res b{display:block;font-weight:600}
|
|
2084
|
+
.res small{color:${t.muted}}
|
|
2085
|
+
.cap{float:right;font-family:"Geist Mono",ui-monospace,monospace;color:${t.muted}}
|
|
2086
|
+
tr.group td{background:${t.surfaceAlt};font-weight:600;padding:4px 6px}
|
|
2087
|
+
.num{font-variant-numeric:tabular-nums;white-space:nowrap}
|
|
2088
|
+
.muted{color:${t.muted}}
|
|
2089
|
+
.strike{text-decoration:line-through;color:${t.muted}}
|
|
2090
|
+
</style></head><body>
|
|
2091
|
+
<svg width="0" height="0" style="position:absolute" aria-hidden="true"><defs><pattern id="nf-hatch" width="5" height="5" patternUnits="userSpaceOnUse" patternTransform="rotate(45)"><rect width="5" height="5" fill="#fff"/><line x1="0" y1="0" x2="0" y2="5" stroke="${t.line}" stroke-width="2"/></pattern></defs></svg>
|
|
2092
|
+
<header><h1>${e(title)}</h1><span class="meta">${meta}</span><div class="legend">${legendHtml}</div></header>
|
|
2093
|
+
${body}
|
|
2094
|
+
</body></html>`;
|
|
2095
|
+
}
|
|
2096
|
+
function queueTable(queue, zone, labels) {
|
|
2097
|
+
if (!queue.length) return "";
|
|
2098
|
+
const rows = queue.map(
|
|
2099
|
+
(it) => `<tr><td class="num">${e(dayLabelIn(it.start, zone))}, ${it.booking.allDay ? "all day" : `${timeLabel(it.start, zone)}\u2013${timeLabel(it.end, zone)}`}</td><td${it.cancelled ? ' class="strike"' : ""}>${e(it.booking.name)}</td><td>${e(it.booking.location ?? "")}</td><td class="muted">${e(it.booking.status ?? "")}</td></tr>`
|
|
2100
|
+
).join("");
|
|
2101
|
+
return `<h2>Unassigned ${e(labels.bookings.toLowerCase())} (${queue.length})</h2><table><colgroup><col style="width:190px"><col><col style="width:200px"><col style="width:110px"></colgroup><thead><tr><th>When</th><th>${e(
|
|
2102
|
+
labels.booking
|
|
2103
|
+
)}</th><th>Location</th><th>Status</th></tr></thead><tbody>${rows}</tbody></table>`;
|
|
2104
|
+
}
|
|
2105
|
+
function boardPrintHtml(o) {
|
|
2106
|
+
const t = o.theme;
|
|
2107
|
+
const w = o.scale.width;
|
|
2108
|
+
const resources = o.rows.filter((r) => r.kind === "resource");
|
|
2109
|
+
const body = o.rows.map((r) => {
|
|
2110
|
+
if (r.kind === "group") return `<tr class="group"><td colspan="2">${e(r.label)} <span class="muted">${r.count}</span></td></tr>`;
|
|
2111
|
+
const cap = o.capacity(r);
|
|
2112
|
+
return `<tr><td class="res">${cap ? `<span class="cap">${e(cap)}</span>` : ""}<b>${e(r.resource.name)}</b>${r.resource.subtitle ? `<small>${e(r.resource.subtitle)}</small>` : ""}</td><td class="tl">${rowSvg(r, o, w)}</td></tr>`;
|
|
2113
|
+
}).join("");
|
|
2114
|
+
const table = `<table><colgroup><col style="width:${PRINT_RES_W}px"><col style="width:${w}px"></colgroup><thead><tr><th>${e(
|
|
2115
|
+
o.labels.resources
|
|
2116
|
+
)}</th><th class="tl">${headerSvg(o, w)}</th></tr></thead><tbody>${body}</tbody></table>`;
|
|
2117
|
+
const showsNow = o.now !== void 0 && o.now >= o.scale.start && o.now < o.scale.end;
|
|
2118
|
+
const meta = `${e(o.range)} \xB7 ${resources.length} ${e((resources.length === 1 ? o.labels.resource : o.labels.resources).toLowerCase())} \xB7 Printed on ${e(o.printedOn)}`;
|
|
2119
|
+
return shell(o.title, meta, legend(t, showsNow), t, table + queueTable(o.queue, o.zone, o.labels));
|
|
2120
|
+
}
|
|
2121
|
+
function agendaPrintHtml(o) {
|
|
2122
|
+
const parts2 = [];
|
|
2123
|
+
let count = 0;
|
|
2124
|
+
for (let day = o.startDay; day < o.startDay + o.days; day++) {
|
|
2125
|
+
const from = dayToInstant(day, o.zone);
|
|
2126
|
+
const to = dayToInstant(day + 1, o.zone);
|
|
2127
|
+
const rows = buildRows(o.resources, o.items, { from, to, groupBy: false, filter: o.filter }).filter(
|
|
2128
|
+
(r) => r.kind === "resource" && r.items.length > 0
|
|
2129
|
+
);
|
|
2130
|
+
const queue = o.filter ? [] : unassigned(o.items, from, to);
|
|
2131
|
+
if (!rows.length && !queue.length) continue;
|
|
2132
|
+
const line = (it, conflict) => {
|
|
2133
|
+
count++;
|
|
2134
|
+
return `<tr><td class="num" style="width:110px">${e(agendaTime(it, day, o.zone))}</td><td${it.cancelled ? ' class="strike"' : ""}>${e(it.booking.name)}${conflict ? ' <span class="muted">\xB7 overlaps</span>' : ""}</td><td style="width:200px">${e(it.booking.location ?? "")}</td><td class="muted" style="width:110px">${e(
|
|
2135
|
+
it.kind === "unavailable" ? "Unavailable" : it.booking.status ?? ""
|
|
2136
|
+
)}</td></tr>`;
|
|
2137
|
+
};
|
|
2138
|
+
parts2.push(`<h2>${e(dayLabelIn(dayToInstant(day, o.zone), o.zone))}</h2>`);
|
|
2139
|
+
for (const r of rows) parts2.push(`<h3>${e(r.resource.name)}</h3><table><tbody>${r.items.map((it) => line(it, r.conflicts.has(it.id))).join("")}</tbody></table>`);
|
|
2140
|
+
if (queue.length) parts2.push(`<h3>Unassigned</h3><table><tbody>${queue.map((it) => line(it, false)).join("")}</tbody></table>`);
|
|
2141
|
+
}
|
|
2142
|
+
if (!parts2.length) parts2.push(`<p class="muted">No ${e(o.labels.bookings.toLowerCase())} in these days.</p>`);
|
|
2143
|
+
const meta = `${e(o.range)} \xB7 ${count} ${e((count === 1 ? o.labels.booking : o.labels.bookings).toLowerCase())} \xB7 Printed on ${e(o.printedOn)}`;
|
|
2144
|
+
return shell(o.title, meta, "", o.theme, parts2.join(""));
|
|
2145
|
+
}
|
|
2146
|
+
var TITLE_H = 48;
|
|
2147
|
+
var HEAD_H = 44;
|
|
2148
|
+
var FONT = 'Geist, system-ui, -apple-system, "Segoe UI", sans-serif';
|
|
2149
|
+
function boardPngSize(rows, timelineWidth) {
|
|
2150
|
+
const last = rows[rows.length - 1];
|
|
2151
|
+
const body = last ? last.top + last.height : 60;
|
|
2152
|
+
const width = Math.ceil(RESOURCE_W + timelineWidth);
|
|
2153
|
+
const height = Math.ceil(TITLE_H + HEAD_H + body);
|
|
2154
|
+
const ratio2 = exportRatio(width, height);
|
|
2155
|
+
return ratio2 === null ? null : { width, height, ratio: ratio2 };
|
|
2156
|
+
}
|
|
2157
|
+
function roundRect(ctx, x, y, w, h, r) {
|
|
2158
|
+
const rr = Math.min(r, w / 2, h / 2);
|
|
2159
|
+
ctx.beginPath();
|
|
2160
|
+
ctx.moveTo(x + rr, y);
|
|
2161
|
+
ctx.arcTo(x + w, y, x + w, y + h, rr);
|
|
2162
|
+
ctx.arcTo(x + w, y + h, x, y + h, rr);
|
|
2163
|
+
ctx.arcTo(x, y + h, x, y, rr);
|
|
2164
|
+
ctx.arcTo(x, y, x + w, y, rr);
|
|
2165
|
+
ctx.closePath();
|
|
2166
|
+
}
|
|
2167
|
+
function drawBoard(ctx, size, o) {
|
|
2168
|
+
const c = (v, fb) => canvasColor(ctx, v, fb);
|
|
2169
|
+
const T = {
|
|
2170
|
+
surface: c(o.theme.surface, "#fff"),
|
|
2171
|
+
alt: c(o.theme.surfaceAlt, "#f6f6f3"),
|
|
2172
|
+
ink: c(o.theme.ink, "#111"),
|
|
2173
|
+
muted: c(o.theme.muted, "#666"),
|
|
2174
|
+
line: c(o.theme.line, "#ddd"),
|
|
2175
|
+
soft: c(o.theme.lineSoft, "#eee"),
|
|
2176
|
+
negative: c(o.theme.negative, "#d93f2b")
|
|
2177
|
+
};
|
|
2178
|
+
const s = o.scale;
|
|
2179
|
+
const X0 = RESOURCE_W;
|
|
2180
|
+
const Y0 = TITLE_H + HEAD_H;
|
|
2181
|
+
ctx.save();
|
|
2182
|
+
ctx.scale(size.ratio, size.ratio);
|
|
2183
|
+
ctx.fillStyle = T.surface;
|
|
2184
|
+
ctx.fillRect(0, 0, size.width, size.height);
|
|
2185
|
+
ctx.textBaseline = "middle";
|
|
2186
|
+
ctx.fillStyle = T.ink;
|
|
2187
|
+
ctx.font = `600 16px ${FONT}`;
|
|
2188
|
+
ctx.fillText(o.title, 16, 24);
|
|
2189
|
+
const tw = ctx.measureText(o.title).width;
|
|
2190
|
+
ctx.fillStyle = T.muted;
|
|
2191
|
+
ctx.font = `400 12px ${FONT}`;
|
|
2192
|
+
ctx.fillText(o.range, 16 + tw + 12, 25);
|
|
2193
|
+
ctx.fillStyle = T.soft;
|
|
2194
|
+
ctx.fillRect(0, TITLE_H + HEAD_H - 1, size.width, 1);
|
|
2195
|
+
for (const d of s.days) {
|
|
2196
|
+
ctx.fillStyle = T.line;
|
|
2197
|
+
ctx.fillRect(X0 + d.x, TITLE_H, 1, size.height - TITLE_H);
|
|
2198
|
+
ctx.save();
|
|
2199
|
+
ctx.beginPath();
|
|
2200
|
+
ctx.rect(X0 + d.x, TITLE_H, d.width, HEAD_H);
|
|
2201
|
+
ctx.clip();
|
|
2202
|
+
ctx.fillStyle = T.ink;
|
|
2203
|
+
ctx.font = `600 12px ${FONT}`;
|
|
2204
|
+
ctx.fillText(d.label, X0 + d.x + 8, TITLE_H + 14);
|
|
2205
|
+
ctx.restore();
|
|
2206
|
+
}
|
|
2207
|
+
if (o.showHours) {
|
|
2208
|
+
ctx.fillStyle = T.muted;
|
|
2209
|
+
ctx.font = `400 11px ${FONT}`;
|
|
2210
|
+
let lastX = -Infinity;
|
|
2211
|
+
for (const hr of s.hours) {
|
|
2212
|
+
if (hr.x - lastX < 40) continue;
|
|
2213
|
+
lastX = hr.x;
|
|
2214
|
+
ctx.fillText(hr.label, X0 + hr.x + 4, TITLE_H + 32);
|
|
2215
|
+
}
|
|
2216
|
+
}
|
|
2217
|
+
ctx.fillStyle = T.muted;
|
|
2218
|
+
ctx.font = `500 11px ${FONT}`;
|
|
2219
|
+
ctx.fillText(o.labels.resources.toUpperCase(), 16, TITLE_H + HEAD_H - 14);
|
|
2220
|
+
ctx.fillStyle = T.soft;
|
|
2221
|
+
for (const b of s.nonWorking) ctx.fillRect(X0 + b.x, Y0, b.width, size.height - Y0);
|
|
2222
|
+
for (const r of o.rows) {
|
|
2223
|
+
const y = Y0 + r.top;
|
|
2224
|
+
if (r.kind === "group") {
|
|
2225
|
+
ctx.fillStyle = T.alt;
|
|
2226
|
+
ctx.fillRect(0, y, size.width, r.height);
|
|
2227
|
+
ctx.fillStyle = T.ink;
|
|
2228
|
+
ctx.font = `600 12px ${FONT}`;
|
|
2229
|
+
ctx.fillText(`${r.label} ${r.count}`, 16, y + r.height / 2);
|
|
2230
|
+
continue;
|
|
2231
|
+
}
|
|
2232
|
+
ctx.fillStyle = T.soft;
|
|
2233
|
+
ctx.fillRect(0, y + r.height - 1, size.width, 1);
|
|
2234
|
+
ctx.fillStyle = T.ink;
|
|
2235
|
+
ctx.font = `500 13px ${FONT}`;
|
|
2236
|
+
const sub = r.resource.subtitle;
|
|
2237
|
+
ctx.fillText(r.resource.name, 16, y + (sub ? r.height / 2 - 7 : r.height / 2), RESOURCE_W - 96);
|
|
2238
|
+
if (sub) {
|
|
2239
|
+
ctx.fillStyle = T.muted;
|
|
2240
|
+
ctx.font = `400 11px ${FONT}`;
|
|
2241
|
+
ctx.fillText(sub, 16, y + r.height / 2 + 8, RESOURCE_W - 96);
|
|
2242
|
+
}
|
|
2243
|
+
const cap = o.capacity(r);
|
|
2244
|
+
if (cap) {
|
|
2245
|
+
ctx.fillStyle = T.muted;
|
|
2246
|
+
ctx.font = `400 11px ${FONT}`;
|
|
2247
|
+
ctx.textAlign = "right";
|
|
2248
|
+
ctx.fillText(cap, RESOURCE_W - 12, y + r.height / 2);
|
|
2249
|
+
ctx.textAlign = "left";
|
|
2250
|
+
}
|
|
2251
|
+
for (const it of r.items) {
|
|
2252
|
+
const x0 = X0 + s.x(Math.max(it.start, s.start));
|
|
2253
|
+
const x1 = X0 + s.x(Math.min(it.end, s.end));
|
|
2254
|
+
const bw = Math.max(3, x1 - x0);
|
|
2255
|
+
const by = y + ROW_PAD + (r.lane.get(it.id) ?? 0) * LANE_H;
|
|
2256
|
+
const bh = LANE_H - 4;
|
|
2257
|
+
const look = o.look.get(it.id);
|
|
2258
|
+
const color = c(look?.color, "#2E54FF");
|
|
2259
|
+
let ink = c(look?.text, "#fff");
|
|
2260
|
+
roundRect(ctx, x0, by, bw, bh, 6);
|
|
2261
|
+
ctx.save();
|
|
2262
|
+
if (look?.style === "tentative" || look?.style === "done") {
|
|
2263
|
+
ctx.globalAlpha = look.style === "done" ? 0.4 : 0.16;
|
|
2264
|
+
ctx.fillStyle = color;
|
|
2265
|
+
ctx.fill();
|
|
2266
|
+
ctx.globalAlpha = 1;
|
|
2267
|
+
if (look.style === "tentative") {
|
|
2268
|
+
ctx.setLineDash([4, 3]);
|
|
2269
|
+
ctx.strokeStyle = color;
|
|
2270
|
+
ctx.stroke();
|
|
2271
|
+
}
|
|
2272
|
+
ink = T.ink;
|
|
2273
|
+
} else if (look?.style === "cancelled") {
|
|
2274
|
+
ctx.fillStyle = T.soft;
|
|
2275
|
+
ctx.fill();
|
|
2276
|
+
ctx.strokeStyle = T.line;
|
|
2277
|
+
ctx.stroke();
|
|
2278
|
+
ink = T.muted;
|
|
2279
|
+
} else if (look?.style === "unavailable") {
|
|
2280
|
+
ctx.fillStyle = T.surface;
|
|
2281
|
+
ctx.fill();
|
|
2282
|
+
ctx.clip();
|
|
2283
|
+
ctx.strokeStyle = T.line;
|
|
2284
|
+
ctx.lineWidth = 2;
|
|
2285
|
+
for (let hx = x0 - bh; hx < x0 + bw; hx += 8) {
|
|
2286
|
+
ctx.beginPath();
|
|
2287
|
+
ctx.moveTo(hx, by + bh);
|
|
2288
|
+
ctx.lineTo(hx + bh, by);
|
|
2289
|
+
ctx.stroke();
|
|
2290
|
+
}
|
|
2291
|
+
ink = T.ink;
|
|
2292
|
+
} else {
|
|
2293
|
+
ctx.fillStyle = color;
|
|
2294
|
+
ctx.fill();
|
|
2295
|
+
}
|
|
2296
|
+
ctx.restore();
|
|
2297
|
+
if (r.conflicts.has(it.id)) {
|
|
2298
|
+
ctx.fillStyle = T.negative;
|
|
2299
|
+
ctx.fillRect(x0 + 3, by, Math.max(0, bw - 6), 2);
|
|
2300
|
+
}
|
|
2301
|
+
if (bw > 24) {
|
|
2302
|
+
ctx.save();
|
|
2303
|
+
ctx.beginPath();
|
|
2304
|
+
ctx.rect(x0 + 6, by, bw - 10, bh);
|
|
2305
|
+
ctx.clip();
|
|
2306
|
+
ctx.fillStyle = ink;
|
|
2307
|
+
ctx.font = `500 12px ${FONT}`;
|
|
2308
|
+
ctx.fillText(it.booking.name, x0 + 8, by + bh / 2 + 0.5);
|
|
2309
|
+
if (look?.style === "cancelled") ctx.fillRect(x0 + 8, by + bh / 2, Math.min(bw - 12, ctx.measureText(it.booking.name).width), 1);
|
|
2310
|
+
ctx.restore();
|
|
2311
|
+
}
|
|
2312
|
+
}
|
|
2313
|
+
}
|
|
2314
|
+
ctx.fillStyle = T.line;
|
|
2315
|
+
ctx.fillRect(RESOURCE_W - 1, TITLE_H, 1, size.height - TITLE_H);
|
|
2316
|
+
if (o.now !== void 0 && o.now >= s.start && o.now < s.end) {
|
|
2317
|
+
ctx.fillStyle = brand.ember;
|
|
2318
|
+
ctx.fillRect(X0 + s.x(o.now) - 1, TITLE_H, 2, size.height - TITLE_H);
|
|
2319
|
+
}
|
|
2320
|
+
ctx.restore();
|
|
2321
|
+
}
|
|
2322
|
+
function exportBoardPng(o, fileName, doc = document) {
|
|
2323
|
+
const size = boardPngSize(o.rows, o.scale.width);
|
|
2324
|
+
if (!size) return false;
|
|
2325
|
+
const canvas = doc.createElement("canvas");
|
|
2326
|
+
canvas.width = Math.round(size.width * size.ratio);
|
|
2327
|
+
canvas.height = Math.round(size.height * size.ratio);
|
|
2328
|
+
const ctx = canvas.getContext("2d");
|
|
2329
|
+
if (!ctx) return false;
|
|
2330
|
+
drawBoard(ctx, size, o);
|
|
2331
|
+
downloadCanvas(canvas, fileName, doc);
|
|
2332
|
+
return true;
|
|
2333
|
+
}
|
|
2334
|
+
|
|
2335
|
+
// src/Scheduler.tsx
|
|
2336
|
+
var DRAG_THRESHOLD = 4;
|
|
2337
|
+
var VIEWS = ["day", "week", "twoWeeks", "month", "agenda"];
|
|
2338
|
+
var SETTLE_MS = 700;
|
|
2339
|
+
var Chevron = () => /* @__PURE__ */ React6.createElement("svg", { width: "12", height: "12", viewBox: "0 0 12 12", "aria-hidden": "true" }, /* @__PURE__ */ React6.createElement("path", { d: "M3 4.5 L6 7.5 L9 4.5", fill: "none", stroke: "currentColor", strokeWidth: "1.6", strokeLinecap: "round", strokeLinejoin: "round" }));
|
|
2340
|
+
var WEEKDAYS2 = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
2341
|
+
function fitsDay(text2, width, px) {
|
|
2342
|
+
return text2.length * px * 0.56 + 20 <= width;
|
|
2343
|
+
}
|
|
2344
|
+
function dayHeadLabel(full, compact, today, width, px) {
|
|
2345
|
+
if (today && fitsDay(`${full} \xB7 Today`, width, px)) return `${full} \xB7 Today`;
|
|
2346
|
+
if (fitsDay(full, width, px)) return full;
|
|
2347
|
+
return compact;
|
|
2348
|
+
}
|
|
2349
|
+
function compactDay(day) {
|
|
2350
|
+
return `${WEEKDAYS2[((day + 3) % 7 + 7) % 7]} ${parts(day).date}`;
|
|
2351
|
+
}
|
|
2352
|
+
function rangeLabel(startDay, days, view) {
|
|
2353
|
+
const a = parts(startDay);
|
|
2354
|
+
const b = parts(startDay + days - 1);
|
|
2355
|
+
if (view === "month") return `${MONTHS_LONG[a.month]} ${a.year}`;
|
|
2356
|
+
if (days === 1) return `${dayLabelIn(dayToInstant(startDay, () => 0), () => 0)} ${a.year}`;
|
|
2357
|
+
const left = a.year === b.year ? `${a.date} ${MONTHS[a.month]}` : `${a.date} ${MONTHS[a.month]} ${a.year}`;
|
|
2358
|
+
return `${left} \u2013 ${b.date} ${MONTHS[b.month]} ${b.year}`;
|
|
2359
|
+
}
|
|
2360
|
+
function Scheduler(props) {
|
|
2361
|
+
const preset = PRESETS[props.preset ?? "custom"];
|
|
2362
|
+
const labels = { ...preset.labels, ...props.labels };
|
|
2363
|
+
const zone = useMemo2(() => props.timeZone ?? browserZone(), [props.timeZone]);
|
|
2364
|
+
const accent = accentOf(props.theme, props.accent);
|
|
2365
|
+
const license = useLicense(props.licenseKey, { product: "plan", tenantId: props.tenantId });
|
|
2366
|
+
const renewal = props.renewalNotice !== false ? renewalNotice(license) : null;
|
|
2367
|
+
const [clock, setClock] = useState5(() => Date.now());
|
|
2368
|
+
useEffect4(() => {
|
|
2369
|
+
if (props.now !== void 0) return;
|
|
2370
|
+
const t = setInterval(() => setClock(Date.now()), 6e4);
|
|
2371
|
+
return () => clearInterval(t);
|
|
2372
|
+
}, [props.now]);
|
|
2373
|
+
const now = props.now ?? clock;
|
|
2374
|
+
const [rootWidth, setRootWidth] = useState5(0);
|
|
2375
|
+
useLayoutEffect(() => {
|
|
2376
|
+
const el = rootRef.current;
|
|
2377
|
+
if (!el) return;
|
|
2378
|
+
const measure = () => setRootWidth(el.clientWidth);
|
|
2379
|
+
measure();
|
|
2380
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
2381
|
+
const ro = new ResizeObserver(measure);
|
|
2382
|
+
ro.observe(el);
|
|
2383
|
+
return () => ro.disconnect();
|
|
2384
|
+
}, []);
|
|
2385
|
+
const narrow = rootWidth > 0 && rootWidth < 600;
|
|
2386
|
+
const [viewState, setViewState] = useState5(props.defaultView ?? preset.view);
|
|
2387
|
+
const [viewPicked, setViewPicked] = useState5(false);
|
|
2388
|
+
const view = props.view ?? (narrow && !viewPicked ? "agenda" : viewState);
|
|
2389
|
+
const setView = (v) => {
|
|
2390
|
+
if (props.view === void 0) {
|
|
2391
|
+
setViewState(v);
|
|
2392
|
+
setViewPicked(true);
|
|
2393
|
+
}
|
|
2394
|
+
props.onViewChange?.(v);
|
|
2395
|
+
};
|
|
2396
|
+
const [dateState, setDateState] = useState5(() => props.defaultDate ?? todayIn(zone, now));
|
|
2397
|
+
const date = props.date ?? dateState;
|
|
2398
|
+
const setDate = (d) => {
|
|
2399
|
+
if (props.date === void 0) setDateState(d);
|
|
2400
|
+
props.onDateChange?.(d);
|
|
2401
|
+
};
|
|
2402
|
+
const [hideOff, setHideOff] = useState5(props.hideNonWorking ?? true);
|
|
2403
|
+
const [filter, setFilter] = useState5("");
|
|
2404
|
+
const [collapsed, setCollapsed] = useState5(() => /* @__PURE__ */ new Set());
|
|
2405
|
+
const [panelOpenState, setPanelOpenState] = useState5(props.showUnassigned ?? preset.showUnassigned);
|
|
2406
|
+
const [panelTouched, setPanelTouched] = useState5(false);
|
|
2407
|
+
const panelOpen = narrow && !panelTouched ? false : panelOpenState;
|
|
2408
|
+
const setPanelOpen = (open) => {
|
|
2409
|
+
setPanelTouched(true);
|
|
2410
|
+
setPanelOpenState(open);
|
|
2411
|
+
};
|
|
2412
|
+
const [selected, setSelectedState] = useState5(null);
|
|
2413
|
+
const setSelected = (id) => {
|
|
2414
|
+
setSelectedState(id);
|
|
2415
|
+
props.onSelect?.(id);
|
|
2416
|
+
};
|
|
2417
|
+
const conflictMode = props.conflictMode ?? preset.conflictMode;
|
|
2418
|
+
const snapMinutes = props.snapMinutes ?? preset.snapMinutes;
|
|
2419
|
+
const can = (action, b) => canEdit(props.editable, action === "create" ? !!props.onCreate : !!props.onBookingChange, action, b);
|
|
2420
|
+
const resources = useMemo2(() => cleanResources(props.resources), [props.resources]);
|
|
2421
|
+
const resourceById = useMemo2(() => new Map(resources.map((r) => [r.id, r])), [resources]);
|
|
2422
|
+
const warnRef = useRef3(void 0);
|
|
2423
|
+
const spec = {
|
|
2424
|
+
...SCHED_FIELDS,
|
|
2425
|
+
// View-only without a key that unlocks Pro actions (Matt, 2026-11-08). Quiet while checking.
|
|
2426
|
+
...lockFor(license, unlocksPro(license), "Plan", "scheduler"),
|
|
2427
|
+
noun: labels.booking.toLowerCase(),
|
|
2428
|
+
describe: (change, nameOf) => {
|
|
2429
|
+
const text2 = describeChange(change, {
|
|
2430
|
+
name: nameOf,
|
|
2431
|
+
resource: (id) => id ? resourceById.get(id)?.name ?? labels.resource : "Unassigned",
|
|
2432
|
+
// What the user sees (pending edits included), so a follow-up edit describes the right times.
|
|
2433
|
+
booking: (id) => shownRef.current.find((b) => b.id === id),
|
|
2434
|
+
zone
|
|
2435
|
+
});
|
|
2436
|
+
return warnRef.current ? `${text2}. Note: ${warnRef.current}` : text2;
|
|
2437
|
+
}
|
|
2438
|
+
};
|
|
2439
|
+
const editing = useEditing(props.bookings, props.onBookingChange, spec);
|
|
2440
|
+
const shownRef = useRef3(editing.shown);
|
|
2441
|
+
shownRef.current = editing.shown;
|
|
2442
|
+
const announce = editing.announce;
|
|
2443
|
+
const dragRef = useRef3(null);
|
|
2444
|
+
const [preview, setPreview] = useState5(null);
|
|
2445
|
+
const [ghost, setGhost] = useState5(null);
|
|
2446
|
+
const [dragging, setDragging] = useState5(false);
|
|
2447
|
+
const uid = useState5(() => nextUid("nfs"))[0];
|
|
2448
|
+
const textScale = clampTextScale(props.textScale ?? 1);
|
|
2449
|
+
const headH = scaleWithText(HEADER_H, textScale);
|
|
2450
|
+
const laneH = scaleWithText(LANE_H, textScale);
|
|
2451
|
+
const resW = Math.round(RESOURCE_W * textScale);
|
|
2452
|
+
const rootRef = useRef3(null);
|
|
2453
|
+
const segRef = useRef3(null);
|
|
2454
|
+
useEffect4(() => {
|
|
2455
|
+
const seg = segRef.current;
|
|
2456
|
+
const on = seg?.querySelector('[aria-pressed="true"]');
|
|
2457
|
+
if (!seg || !on || seg.scrollWidth <= seg.clientWidth) return;
|
|
2458
|
+
const a = seg.getBoundingClientRect();
|
|
2459
|
+
const b = on.getBoundingClientRect();
|
|
2460
|
+
seg.scrollLeft += (b.left - a.left - (a.width - b.width) / 2) / cssScale(seg).x;
|
|
2461
|
+
}, [view, narrow]);
|
|
2462
|
+
const scrollRef = useRef3(null);
|
|
2463
|
+
const [inheritedRtl, setInheritedRtl] = useState5(false);
|
|
2464
|
+
useLayoutEffect(() => {
|
|
2465
|
+
const parent = rootRef.current?.parentElement;
|
|
2466
|
+
if (parent) setInheritedRtl(getComputedStyle(parent).direction === "rtl");
|
|
2467
|
+
}, []);
|
|
2468
|
+
const rtl = props.dir ? props.dir === "rtl" : inheritedRtl;
|
|
2469
|
+
const dirAttr = rtl ? "rtl" : void 0;
|
|
2470
|
+
const [size, setSize] = useState5({ width: 1e3, height: 600 });
|
|
2471
|
+
const [scroll, setScroll] = useState5({ top: 0, left: 0 });
|
|
2472
|
+
useLayoutEffect(() => {
|
|
2473
|
+
const el = scrollRef.current;
|
|
2474
|
+
if (!el) return;
|
|
2475
|
+
const measure = () => setSize({ width: el.clientWidth || 1e3, height: el.clientHeight || 600 });
|
|
2476
|
+
measure();
|
|
2477
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
2478
|
+
const ro = new ResizeObserver(measure);
|
|
2479
|
+
ro.observe(el);
|
|
2480
|
+
return () => ro.disconnect();
|
|
2481
|
+
}, [panelOpen, view === "agenda"]);
|
|
2482
|
+
const rafRef = useRef3(0);
|
|
2483
|
+
const onScroll = () => {
|
|
2484
|
+
if (rafRef.current) return;
|
|
2485
|
+
rafRef.current = requestAnimationFrame(() => {
|
|
2486
|
+
rafRef.current = 0;
|
|
2487
|
+
const el = scrollRef.current;
|
|
2488
|
+
if (el) setScroll({ top: el.scrollTop, left: el.scrollLeft });
|
|
2489
|
+
});
|
|
2490
|
+
};
|
|
2491
|
+
useEffect4(() => () => cancelAnimationFrame(rafRef.current), []);
|
|
2492
|
+
const [settled, setSettled] = useState5(false);
|
|
2493
|
+
useEffect4(() => {
|
|
2494
|
+
const t = setTimeout(() => setSettled(true), SETTLE_MS);
|
|
2495
|
+
return () => clearTimeout(t);
|
|
2496
|
+
}, []);
|
|
2497
|
+
const { startDay, days } = viewRange(date, view);
|
|
2498
|
+
const hours = boardHours(resources, preset.workStart, preset.workEnd);
|
|
2499
|
+
const dayHidden = hideOff;
|
|
2500
|
+
const workWeek = props.workingWeek ?? "mon-fri";
|
|
2501
|
+
const shownDays = view === "day" || !hideOff ? days : workingDaysBetween(startDay, startDay + days - 1, workWeek) || days;
|
|
2502
|
+
const hoursPerDay = hideOff ? Math.max(1, hours.end - hours.start) : 24;
|
|
2503
|
+
const avail = Math.max(320, size.width - resW - 2);
|
|
2504
|
+
const minPxPerHour = view === "day" ? 56 : view === "week" ? hideOff ? 16 : 6 : view === "month" ? hideOff ? 5.5 : 2.1 : hideOff ? 9 : 4;
|
|
2505
|
+
const pxPerHour = Math.max(minPxPerHour, avail / (shownDays * hoursPerDay));
|
|
2506
|
+
const scale = useMemo2(
|
|
2507
|
+
() => createInstantScale({
|
|
2508
|
+
from: dayToInstant(startDay, zone),
|
|
2509
|
+
to: dayToInstant(startDay + days, zone) - 1,
|
|
2510
|
+
zone,
|
|
2511
|
+
pxPerHour,
|
|
2512
|
+
workStart: hours.start,
|
|
2513
|
+
workEnd: hours.end,
|
|
2514
|
+
// Day view hides hours only; a Saturday is still shown when it's the chosen day.
|
|
2515
|
+
workingWeek: view === "day" ? "all" : workWeek,
|
|
2516
|
+
hideNonWorking: dayHidden
|
|
2517
|
+
}),
|
|
2518
|
+
[startDay, days, zone, pxPerHour, hours.start, hours.end, view, workWeek, dayHidden]
|
|
2519
|
+
);
|
|
2520
|
+
const rangeStart = dayToInstant(startDay, zone);
|
|
2521
|
+
const rangeEnd = dayToInstant(startDay + days, zone);
|
|
2522
|
+
const onRangeRef = useRef3(props.onRangeChange);
|
|
2523
|
+
onRangeRef.current = props.onRangeChange;
|
|
2524
|
+
useEffect4(() => {
|
|
2525
|
+
onRangeRef.current?.({ start: rangeStart, end: rangeEnd });
|
|
2526
|
+
}, [rangeStart, rangeEnd]);
|
|
2527
|
+
const baseItems = useMemo2(() => normalise(editing.shown), [editing.shown]);
|
|
2528
|
+
const items = useMemo2(() => {
|
|
2529
|
+
if (!preview) return baseItems;
|
|
2530
|
+
return baseItems.map((i) => i.id === preview.id ? { ...i, start: preview.start, end: preview.end, resourceId: preview.resourceId } : i);
|
|
2531
|
+
}, [baseItems, preview]);
|
|
2532
|
+
const hasGroups = resources.some((r) => !!r.group);
|
|
2533
|
+
const [groupState, setGroupState] = useState5(void 0);
|
|
2534
|
+
const groupBy = props.groupBy ?? groupState ?? hasGroups;
|
|
2535
|
+
const setGroupBy = (on) => {
|
|
2536
|
+
if (props.groupBy === void 0) setGroupState(on);
|
|
2537
|
+
props.onGroupByChange?.(on);
|
|
2538
|
+
announce(on ? `Grouped by ${labels.group.toLowerCase()}` : "Not grouped");
|
|
2539
|
+
};
|
|
2540
|
+
const rows = useMemo2(
|
|
2541
|
+
() => buildRows(resources, items, { from: scale.start, to: scale.end, groupBy, collapsed, filter, textScale }),
|
|
2542
|
+
[resources, items, scale.start, scale.end, groupBy, collapsed, filter]
|
|
2543
|
+
);
|
|
2544
|
+
const bodyHeight = totalHeight(rows);
|
|
2545
|
+
const shownRows = visibleRows(rows, Math.max(0, scroll.top - 56), size.height);
|
|
2546
|
+
const queue = useMemo2(() => unassigned(baseItems, -Infinity, Infinity), [baseItems]);
|
|
2547
|
+
const [queueFilter, setQueueFilter] = useState5("");
|
|
2548
|
+
const [queueSort, setQueueSort] = useState5("soonest");
|
|
2549
|
+
const queueShown = useMemo2(() => queueView(baseItems, queueFilter, queueSort), [baseItems, queueFilter, queueSort]);
|
|
2550
|
+
const byDay = queueSort === "soonest" || queueSort === "latest";
|
|
2551
|
+
const byId = useMemo2(() => new Map(items.map((i) => [i.id, i])), [items]);
|
|
2552
|
+
const resourceName = useMemo2(() => new Map(resources.map((r) => [r.id, r.name])), [resources]);
|
|
2553
|
+
const colours = useMemo2(() => {
|
|
2554
|
+
const seen = /* @__PURE__ */ new Map();
|
|
2555
|
+
return new Map(items.map((i) => [i.id, bookingAppearance(i.booking, accent, props.statusColors, seen)]));
|
|
2556
|
+
}, [items, accent, props.statusColors]);
|
|
2557
|
+
const workdays = workingDaysBetween(startDay, startDay + days - 1, workWeek) || days;
|
|
2558
|
+
const workWindow = (row) => {
|
|
2559
|
+
const ws = row.resource.workStart === void 0 ? hours.start : parseHour(row.resource.workStart);
|
|
2560
|
+
const we = row.resource.workEnd === void 0 ? hours.end : parseHour(row.resource.workEnd);
|
|
2561
|
+
return {
|
|
2562
|
+
ws: Number.isNaN(ws) ? hours.start : ws,
|
|
2563
|
+
we: Number.isNaN(we) ? hours.end : we,
|
|
2564
|
+
perDay: row.resource.capacityHours ?? preset.capacityHours
|
|
2565
|
+
};
|
|
2566
|
+
};
|
|
2567
|
+
const capacityText = (row) => {
|
|
2568
|
+
const w = workWindow(row);
|
|
2569
|
+
const cap = w.perDay * workdays;
|
|
2570
|
+
const booked = bookedWorkingHours(row.items, startDay, days, w.ws, w.we, workWeek, zone, w.perDay);
|
|
2571
|
+
return { text: `${booked} / ${cap} h`, level: booked > cap ? "over" : "ok" };
|
|
2572
|
+
};
|
|
2573
|
+
const showUtil = view !== "day" && view !== "agenda";
|
|
2574
|
+
const dayTicks = useMemo2(() => new Map(scale.days.map((d) => [d.day, d])), [scale.days]);
|
|
2575
|
+
const utilisation = (row) => {
|
|
2576
|
+
if (!showUtil) return [];
|
|
2577
|
+
const w = workWindow(row);
|
|
2578
|
+
return dailyUtilisation(row.items, scale.days.map((d) => d.day), w.ws, w.we, w.perDay, workWeek, zone);
|
|
2579
|
+
};
|
|
2580
|
+
const utilCache = /* @__PURE__ */ new Map();
|
|
2581
|
+
const utilOf = (row) => {
|
|
2582
|
+
let u = utilCache.get(row.key);
|
|
2583
|
+
if (!u) utilCache.set(row.key, u = utilisation(row));
|
|
2584
|
+
return u;
|
|
2585
|
+
};
|
|
2586
|
+
const todayDay = instantToDay(now, zone);
|
|
2587
|
+
useEffect4(() => {
|
|
2588
|
+
const el = scrollRef.current;
|
|
2589
|
+
if (!el) return;
|
|
2590
|
+
let left = 0;
|
|
2591
|
+
if (view !== "day" && todayDay >= startDay && todayDay < startDay + days) {
|
|
2592
|
+
const tx = scale.x(dayToInstant(todayDay, zone));
|
|
2593
|
+
const tEnd = scale.x(dayToInstant(todayDay + 1, zone));
|
|
2594
|
+
if (tEnd > tx && tEnd > el.clientWidth - resW) left = tx - 24;
|
|
2595
|
+
} else if (view === "day" && !dayHidden) left = scale.x(atHour(startDay, hours.start, zone)) - 24;
|
|
2596
|
+
el.scrollLeft = Math.max(0, left);
|
|
2597
|
+
}, [startDay, view, dayHidden]);
|
|
2598
|
+
const go = (d) => {
|
|
2599
|
+
setDate(isoOfDay(d));
|
|
2600
|
+
const r = viewRange(isoOfDay(d), view);
|
|
2601
|
+
announce(`${VIEW_LABEL[view]}: ${rangeLabel(r.startDay, r.days, view)}`);
|
|
2602
|
+
};
|
|
2603
|
+
const step = (dir) => go(stepDate(date, view, dir));
|
|
2604
|
+
const changeView = (v) => {
|
|
2605
|
+
setView(v);
|
|
2606
|
+
const r = viewRange(date, v);
|
|
2607
|
+
announce(`${VIEW_LABEL[v]}: ${rangeLabel(r.startDay, r.days, v)}`);
|
|
2608
|
+
};
|
|
2609
|
+
const bookingWords = (it) => {
|
|
2610
|
+
const b = it.booking;
|
|
2611
|
+
const who = it.resourceId ? resourceName.get(it.resourceId) ?? labels.resource : "Unassigned";
|
|
2612
|
+
const sameDay = instantToDay(it.start, zone) === instantToDay(it.end - 1, zone);
|
|
2613
|
+
const when = b.allDay ? `${dayLabelIn(it.start, zone)}${sameDay ? "" : ` to ${dayLabelIn(it.end - 1, zone)}`}, all day` : sameDay ? `${dayLabelIn(it.start, zone)}, ${timeLabel(it.start, zone)} to ${timeLabel(it.end, zone)}` : `${dayLabelIn(it.start, zone)} ${timeLabel(it.start, zone)} to ${dayLabelIn(it.end, zone)} ${timeLabel(it.end, zone)}`;
|
|
2614
|
+
const extras = [
|
|
2615
|
+
b.status,
|
|
2616
|
+
it.kind === "unavailable" ? "unavailable" : "",
|
|
2617
|
+
b.location,
|
|
2618
|
+
b.attendees ? `${b.attendees} people` : ""
|
|
2619
|
+
].filter(Boolean);
|
|
2620
|
+
return [b.name, who, when, ...extras].join(", ");
|
|
2621
|
+
};
|
|
2622
|
+
const select = (id, scrollIntoView = false) => {
|
|
2623
|
+
setSelected(id);
|
|
2624
|
+
if (!id || !scrollIntoView) return;
|
|
2625
|
+
const it = byId.get(id);
|
|
2626
|
+
const el = scrollRef.current;
|
|
2627
|
+
if (!it || !el) return;
|
|
2628
|
+
const row = rows.find((r) => r.kind === "resource" && r.items.some((x2) => x2.id === id));
|
|
2629
|
+
const x = scale.x(Math.max(it.start, scale.start));
|
|
2630
|
+
if (x < el.scrollLeft || x > el.scrollLeft + el.clientWidth - resW - 40) el.scrollLeft = Math.max(0, x - 40);
|
|
2631
|
+
if (row) {
|
|
2632
|
+
const top = row.top;
|
|
2633
|
+
if (top < el.scrollTop || top + row.height > el.scrollTop + el.clientHeight - 56) el.scrollTop = Math.max(0, top - 40);
|
|
2634
|
+
}
|
|
2635
|
+
};
|
|
2636
|
+
const baseById = useMemo2(() => new Map(baseItems.map((i) => [i.id, i])), [baseItems]);
|
|
2637
|
+
const tryCommit = (change) => {
|
|
2638
|
+
if (!change) return false;
|
|
2639
|
+
const base = baseById.get(change.id);
|
|
2640
|
+
if (!base) return false;
|
|
2641
|
+
const span = { start: toMs(change.start ?? base.start), end: toMs(change.end ?? base.end) };
|
|
2642
|
+
const rid = "resourceId" in change ? change.resourceId ?? null : base.resourceId;
|
|
2643
|
+
const others = base.cancelled || base.kind === "unavailable" ? [] : overlapping(baseItems, base.id, rid, span);
|
|
2644
|
+
const verdict = checkPlacement(conflictMode, base.booking, others, rid ? resourceById.get(rid) : void 0);
|
|
2645
|
+
if (!verdict.ok) {
|
|
2646
|
+
const text2 = `Not moved: ${verdict.reason}`;
|
|
2647
|
+
editing.toast({ text: text2, tone: "error" });
|
|
2648
|
+
announce(text2);
|
|
2649
|
+
return false;
|
|
2650
|
+
}
|
|
2651
|
+
warnRef.current = verdict.warning;
|
|
2652
|
+
editing.commit(change);
|
|
2653
|
+
warnRef.current = void 0;
|
|
2654
|
+
return true;
|
|
2655
|
+
};
|
|
2656
|
+
const pointAt = (clientX, clientY) => {
|
|
2657
|
+
const el = scrollRef.current;
|
|
2658
|
+
const p = localPoint(el, clientX, clientY);
|
|
2659
|
+
return { x: p.x + el.scrollLeft - resW, y: p.y + el.scrollTop - headH, inside: p.x >= resW && p.x <= el.offsetWidth && p.y >= headH && p.y <= el.offsetHeight };
|
|
2660
|
+
};
|
|
2661
|
+
const rowAtY = (y) => rows.find((r) => r.kind === "resource" && y >= r.top && y < r.top + r.height);
|
|
2662
|
+
const startDrag = (e2, d) => {
|
|
2663
|
+
if (e2.button !== 0) return;
|
|
2664
|
+
const p = pointAt(e2.clientX, e2.clientY);
|
|
2665
|
+
dragRef.current = { ...d, pointerId: e2.pointerId, x0: e2.clientX, y0: e2.clientY, t0: scale.msAt(p.x), started: false };
|
|
2666
|
+
};
|
|
2667
|
+
const onBookingPointerDown = (e2, it) => {
|
|
2668
|
+
if (e2.button !== 0) return;
|
|
2669
|
+
e2.stopPropagation();
|
|
2670
|
+
select(it.id);
|
|
2671
|
+
const edge = e2.target.getAttribute("data-edge");
|
|
2672
|
+
const mode = edge === "start" || edge === "end" ? edge : "move";
|
|
2673
|
+
if (mode === "move" ? !can("move", it.booking) && !can("assign", it.booking) : !can("resize", it.booking)) return;
|
|
2674
|
+
const base = baseById.get(it.id) ?? it;
|
|
2675
|
+
startDrag(e2, { kind: mode, id: it.id, span: { start: base.start, end: base.end }, resourceId: base.resourceId });
|
|
2676
|
+
};
|
|
2677
|
+
const onLanePointerDown = (e2, row) => {
|
|
2678
|
+
if (e2.button !== 0 || e2.target !== e2.currentTarget || !can("create")) return;
|
|
2679
|
+
startDrag(e2, { kind: "create", resourceId: row.resource.id });
|
|
2680
|
+
};
|
|
2681
|
+
const onQueuePointerDown = (e2, it) => {
|
|
2682
|
+
select(it.id);
|
|
2683
|
+
if (!can("assign", it.booking)) return;
|
|
2684
|
+
startDrag(e2, { kind: "assign", id: it.id, span: { start: it.start, end: it.end }, resourceId: null });
|
|
2685
|
+
};
|
|
2686
|
+
const endDrag = () => {
|
|
2687
|
+
dragRef.current = null;
|
|
2688
|
+
setPreview(null);
|
|
2689
|
+
setGhost(null);
|
|
2690
|
+
setDragging(false);
|
|
2691
|
+
};
|
|
2692
|
+
const gesture = useRef3({ move: (_e) => {
|
|
2693
|
+
}, up: (_e) => {
|
|
2694
|
+
} });
|
|
2695
|
+
gesture.current.move = (e2) => {
|
|
2696
|
+
const d = dragRef.current;
|
|
2697
|
+
if (!d || e2.pointerId !== d.pointerId) return;
|
|
2698
|
+
if (!d.started) {
|
|
2699
|
+
if (Math.hypot(e2.clientX - d.x0, e2.clientY - d.y0) < DRAG_THRESHOLD) return;
|
|
2700
|
+
d.started = true;
|
|
2701
|
+
setDragging(true);
|
|
2702
|
+
}
|
|
2703
|
+
const p = pointAt(e2.clientX, e2.clientY);
|
|
2704
|
+
const t = scale.msAt(p.x);
|
|
2705
|
+
if (d.kind === "create") {
|
|
2706
|
+
setGhost({ resourceId: d.resourceId, ...createSpan(d.t0, t, snapMinutes, zone) });
|
|
2707
|
+
return;
|
|
2708
|
+
}
|
|
2709
|
+
const booking = baseById.get(d.id).booking;
|
|
2710
|
+
if (d.kind === "assign") {
|
|
2711
|
+
const row2 = p.inside ? rowAtY(p.y) : void 0;
|
|
2712
|
+
if (!row2) return setPreview(null);
|
|
2713
|
+
const span2 = dragSpan("move", { start: t, end: t + (d.span.end - d.span.start) }, 0, snapMinutes, zone);
|
|
2714
|
+
setPreview({ id: d.id, ...span2, resourceId: row2.resource.id });
|
|
2715
|
+
return;
|
|
2716
|
+
}
|
|
2717
|
+
const span = dragSpan(d.kind, d.span, t - d.t0, snapMinutes, zone);
|
|
2718
|
+
const row = d.kind === "move" && can("assign", booking) ? rowAtY(p.y) : void 0;
|
|
2719
|
+
const timeOk = d.kind !== "move" || can("move", booking);
|
|
2720
|
+
setPreview({
|
|
2721
|
+
id: d.id,
|
|
2722
|
+
start: timeOk ? span.start : d.span.start,
|
|
2723
|
+
end: timeOk ? span.end : d.span.end,
|
|
2724
|
+
resourceId: row ? row.resource.id : d.resourceId
|
|
2725
|
+
});
|
|
2726
|
+
};
|
|
2727
|
+
gesture.current.up = (e2) => {
|
|
2728
|
+
const d = dragRef.current;
|
|
2729
|
+
if (!d || e2.pointerId !== d.pointerId) return;
|
|
2730
|
+
if (!d.started) return endDrag();
|
|
2731
|
+
if (d.kind === "create") {
|
|
2732
|
+
if (ghost && editing.locked) editing.refuse();
|
|
2733
|
+
else if (ghost) {
|
|
2734
|
+
props.onCreate?.({ resourceId: ghost.resourceId, start: new Date(ghost.start).toISOString(), end: new Date(ghost.end).toISOString() });
|
|
2735
|
+
announce(`New ${labels.booking.toLowerCase()} for ${resourceById.get(ghost.resourceId)?.name ?? labels.resource}, ${whenLabel(ghost.start, ghost.end, zone)}`);
|
|
2736
|
+
}
|
|
2737
|
+
return endDrag();
|
|
2738
|
+
}
|
|
2739
|
+
const base = baseById.get(d.id);
|
|
2740
|
+
if (base && preview) {
|
|
2741
|
+
tryCommit(placeChange(base.booking, { start: preview.start, end: preview.end }, preview.resourceId, d.kind === "assign" ? "assign" : "drag"));
|
|
2742
|
+
select(d.id);
|
|
2743
|
+
}
|
|
2744
|
+
endDrag();
|
|
2745
|
+
};
|
|
2746
|
+
useEffect4(() => {
|
|
2747
|
+
const move = (e2) => gesture.current.move(e2);
|
|
2748
|
+
const up = (e2) => gesture.current.up(e2);
|
|
2749
|
+
const cancel = (e2) => {
|
|
2750
|
+
if (dragRef.current && e2.pointerId === dragRef.current.pointerId) endDrag();
|
|
2751
|
+
};
|
|
2752
|
+
window.addEventListener("pointermove", move);
|
|
2753
|
+
window.addEventListener("pointerup", up);
|
|
2754
|
+
window.addEventListener("pointercancel", cancel);
|
|
2755
|
+
return () => {
|
|
2756
|
+
window.removeEventListener("pointermove", move);
|
|
2757
|
+
window.removeEventListener("pointerup", up);
|
|
2758
|
+
window.removeEventListener("pointercancel", cancel);
|
|
2759
|
+
};
|
|
2760
|
+
}, []);
|
|
2761
|
+
const keyboardEdit = (e2, id) => {
|
|
2762
|
+
const ke = keyEdit(e2);
|
|
2763
|
+
if (!ke || !id) return false;
|
|
2764
|
+
const base = baseById.get(id);
|
|
2765
|
+
if (!base) return false;
|
|
2766
|
+
const b = base.booking;
|
|
2767
|
+
const resourceRows = rows.filter((r) => r.kind === "resource");
|
|
2768
|
+
if (ke.kind === "resource") {
|
|
2769
|
+
if (!can("assign", b)) return true;
|
|
2770
|
+
const at = resourceRows.findIndex((r) => r.resource.id === base.resourceId);
|
|
2771
|
+
const target = at < 0 ? ke.dir === 1 ? resourceRows[0] : void 0 : resourceRows[at + ke.dir];
|
|
2772
|
+
if (!target) {
|
|
2773
|
+
announce(ke.dir === 1 ? `No ${labels.resource.toLowerCase()} below` : `No ${labels.resource.toLowerCase()} above`);
|
|
2774
|
+
return true;
|
|
2775
|
+
}
|
|
2776
|
+
tryCommit(placeChange(b, { start: base.start, end: base.end }, target.resource.id, base.resourceId ? "keyboard" : "assign"));
|
|
2777
|
+
return true;
|
|
2778
|
+
}
|
|
2779
|
+
if (ke.mode === "move" ? !can("move", b) : !can("resize", b)) return true;
|
|
2780
|
+
const stepMs = view === "day" ? snapMinutes * 6e4 : 24 * 36e5;
|
|
2781
|
+
const minMs = snapMinutes * 6e4;
|
|
2782
|
+
let span;
|
|
2783
|
+
if (ke.mode === "move") {
|
|
2784
|
+
span = view === "day" ? dragSpan("move", { start: base.start, end: base.end }, ke.dir * stepMs, snapMinutes, zone) : { start: addDaysIn(base.start, ke.dir, zone), end: addDaysIn(base.end, ke.dir, zone) };
|
|
2785
|
+
} else {
|
|
2786
|
+
const endStep = view === "day" ? stepMs : 36e5;
|
|
2787
|
+
span = { start: base.start, end: Math.max(base.start + minMs, base.end + ke.dir * endStep) };
|
|
2788
|
+
}
|
|
2789
|
+
tryCommit(placeChange(b, span, base.resourceId, "keyboard"));
|
|
2790
|
+
return true;
|
|
2791
|
+
};
|
|
2792
|
+
const createAfterSelected = () => {
|
|
2793
|
+
const it = selected ? baseById.get(selected) : void 0;
|
|
2794
|
+
if (!it?.resourceId) return announce(`Select a ${labels.booking.toLowerCase()} on a ${labels.resource.toLowerCase()} first`);
|
|
2795
|
+
const start = it.end;
|
|
2796
|
+
const end = start + 60 * 6e4;
|
|
2797
|
+
if (editing.locked) return editing.refuse();
|
|
2798
|
+
props.onCreate?.({ resourceId: it.resourceId, start: new Date(start).toISOString(), end: new Date(end).toISOString() });
|
|
2799
|
+
announce(`New ${labels.booking.toLowerCase()} for ${resourceById.get(it.resourceId)?.name ?? labels.resource}, ${whenLabel(start, end, zone)}`);
|
|
2800
|
+
};
|
|
2801
|
+
const onKeyDown = (e2) => {
|
|
2802
|
+
if ((e2.ctrlKey || e2.metaKey) && !e2.altKey) {
|
|
2803
|
+
const k = e2.key.toLowerCase();
|
|
2804
|
+
if (k === "z" || k === "y") {
|
|
2805
|
+
if (k === "y" || e2.shiftKey) editing.redo();
|
|
2806
|
+
else editing.undo();
|
|
2807
|
+
e2.preventDefault();
|
|
2808
|
+
}
|
|
2809
|
+
return;
|
|
2810
|
+
}
|
|
2811
|
+
if (e2.key === "Escape" && dragRef.current) {
|
|
2812
|
+
endDrag();
|
|
2813
|
+
announce("Drag cancelled");
|
|
2814
|
+
e2.preventDefault();
|
|
2815
|
+
return;
|
|
2816
|
+
}
|
|
2817
|
+
if (keyboardEdit(e2, selected)) {
|
|
2818
|
+
e2.preventDefault();
|
|
2819
|
+
return;
|
|
2820
|
+
}
|
|
2821
|
+
if (e2.key === "Insert" && can("create")) {
|
|
2822
|
+
createAfterSelected();
|
|
2823
|
+
e2.preventDefault();
|
|
2824
|
+
return;
|
|
2825
|
+
}
|
|
2826
|
+
if (e2.altKey || e2.ctrlKey || e2.metaKey) return;
|
|
2827
|
+
switch (e2.key) {
|
|
2828
|
+
case "ArrowLeft":
|
|
2829
|
+
case "ArrowRight":
|
|
2830
|
+
case "ArrowUp":
|
|
2831
|
+
case "ArrowDown": {
|
|
2832
|
+
select(neighbour(rows, selected, e2.key), true);
|
|
2833
|
+
break;
|
|
2834
|
+
}
|
|
2835
|
+
case "Home": {
|
|
2836
|
+
const first = rows.find((r) => r.kind === "resource" && r.items.length > 0);
|
|
2837
|
+
select(first?.items[0]?.id ?? null, true);
|
|
2838
|
+
break;
|
|
2839
|
+
}
|
|
2840
|
+
case "Enter": {
|
|
2841
|
+
const it = selected ? byId.get(selected) : void 0;
|
|
2842
|
+
if (it) props.onOpen?.(it.booking);
|
|
2843
|
+
break;
|
|
2844
|
+
}
|
|
2845
|
+
case "Escape":
|
|
2846
|
+
if (!selected) return;
|
|
2847
|
+
select(null);
|
|
2848
|
+
break;
|
|
2849
|
+
default:
|
|
2850
|
+
return;
|
|
2851
|
+
}
|
|
2852
|
+
e2.preventDefault();
|
|
2853
|
+
};
|
|
2854
|
+
const toggleGroup = (g) => setCollapsed((prev) => {
|
|
2855
|
+
const next2 = new Set(prev);
|
|
2856
|
+
if (next2.has(g)) next2.delete(g);
|
|
2857
|
+
else next2.add(g);
|
|
2858
|
+
return next2;
|
|
2859
|
+
});
|
|
2860
|
+
const style = {
|
|
2861
|
+
...resolveTheme(props.theme, props.accent),
|
|
2862
|
+
...textVars(textScale, props.textSizes),
|
|
2863
|
+
"--nf-head": `${headH}px`,
|
|
2864
|
+
"--nf-res": `${resW}px`,
|
|
2865
|
+
"--nf-lane": `${laneH}px`,
|
|
2866
|
+
"--nf-group": `${scaleWithText(GROUP_H, textScale)}px`,
|
|
2867
|
+
// The Unassigned panel grows with the text, so its filter box and cards don't clip.
|
|
2868
|
+
"--nf-unassigned-w": `${Math.round(280 * textScale)}px`,
|
|
2869
|
+
height: props.height ?? "100%"
|
|
2870
|
+
};
|
|
2871
|
+
const nowX = now >= scale.start && now < scale.end ? scale.x(now) : null;
|
|
2872
|
+
const left0 = scroll.left - 200;
|
|
2873
|
+
const right0 = scroll.left + size.width + 200;
|
|
2874
|
+
const showHours = view === "day";
|
|
2875
|
+
const captionPx = textSize("caption", textScale, props.textSizes);
|
|
2876
|
+
const hourEvery = hourLabelStep(pxPerHour, captionPx);
|
|
2877
|
+
const selectedRowKey = selected ? rows.find((r) => r.kind === "resource" && r.items.some((i) => i.id === selected))?.key : void 0;
|
|
2878
|
+
const title = props.title ?? labels.bookings;
|
|
2879
|
+
const showToolbar = props.showToolbar !== false;
|
|
2880
|
+
const renderBooking = (it, row) => {
|
|
2881
|
+
const x1 = scale.x(Math.max(it.start, scale.start));
|
|
2882
|
+
const x2 = scale.x(Math.min(it.end, scale.end));
|
|
2883
|
+
if (x2 < left0 - resW || x1 > right0) return null;
|
|
2884
|
+
const width = Math.max(6, x2 - x1 - 2);
|
|
2885
|
+
const look = colours.get(it.id);
|
|
2886
|
+
const conflict = row.conflicts.has(it.id);
|
|
2887
|
+
const seats = row.resource.seats;
|
|
2888
|
+
const people = it.booking.attendees;
|
|
2889
|
+
const overSeat = !!seats && !!people && people > seats;
|
|
2890
|
+
const seatWords = overSeat ? `, ${people} people for ${seats} seats` : "";
|
|
2891
|
+
const isSel = selected === it.id;
|
|
2892
|
+
const showTime = view === "day" || width > 140;
|
|
2893
|
+
const top = ROW_PAD + (row.lane.get(it.id) ?? 0) * laneH;
|
|
2894
|
+
const movable = can("move", it.booking) || can("assign", it.booking);
|
|
2895
|
+
const resizable = can("resize", it.booking) && width > 24;
|
|
2896
|
+
const isPreview = preview?.id === it.id;
|
|
2897
|
+
return /* @__PURE__ */ React6.createElement(React6.Fragment, { key: it.id }, /* @__PURE__ */ React6.createElement(
|
|
2898
|
+
"div",
|
|
2899
|
+
{
|
|
2900
|
+
id: `${uid}-b-${it.id}`,
|
|
2901
|
+
role: "gridcell",
|
|
2902
|
+
className: "nf-s-bk",
|
|
2903
|
+
"aria-selected": isSel,
|
|
2904
|
+
"data-style": look.style,
|
|
2905
|
+
"data-conflict": conflict ? "" : void 0,
|
|
2906
|
+
"data-overseat": overSeat ? "" : void 0,
|
|
2907
|
+
"data-editable": movable ? "" : void 0,
|
|
2908
|
+
"data-preview": isPreview ? "" : void 0,
|
|
2909
|
+
style: { left: x1 + 1, width, top, "--bk": look.color, "--bk-text": look.text },
|
|
2910
|
+
onPointerDown: (e2) => onBookingPointerDown(e2, it),
|
|
2911
|
+
onClick: () => scrollRef.current?.focus({ preventScroll: true }),
|
|
2912
|
+
onDoubleClick: () => props.onOpen?.(it.booking),
|
|
2913
|
+
title: isPreview ? void 0 : bookingWords(it) + seatWords
|
|
2914
|
+
},
|
|
2915
|
+
/* @__PURE__ */ React6.createElement("span", { className: "nf-s-sr" }, bookingWords(it) + seatWords + (conflict ? ", overlaps another booking" : "")),
|
|
2916
|
+
overSeat && width > 40 ? /* @__PURE__ */ React6.createElement("span", { className: "nf-s-seats", "aria-hidden": "true" }, people, "/", seats) : null,
|
|
2917
|
+
/* @__PURE__ */ React6.createElement("b", { "aria-hidden": "true" }, it.booking.name),
|
|
2918
|
+
width > 90 ? /* @__PURE__ */ React6.createElement("span", { "aria-hidden": "true" }, showTime && !it.booking.allDay ? barTimeLabel(it, zone) : "", showTime && !it.booking.allDay && it.booking.subtitle ? " \xB7 " : "", it.booking.subtitle ?? "") : null,
|
|
2919
|
+
resizable ? /* @__PURE__ */ React6.createElement(React6.Fragment, null, /* @__PURE__ */ React6.createElement("span", { className: "nf-s-h nf-s-h-l", "data-edge": "start", "aria-hidden": "true" }), /* @__PURE__ */ React6.createElement("span", { className: "nf-s-h nf-s-h-r", "data-edge": "end", "aria-hidden": "true" })) : null
|
|
2920
|
+
), isPreview && dragging ? /* @__PURE__ */ React6.createElement("div", { className: "nf-s-draglabel", "aria-hidden": "true", style: { left: x1, top: Math.max(0, top - 24) } }, whenLabel(it.start, it.end, zone), it.resourceId && it.resourceId !== baseById.get(it.id)?.resourceId ? ` \xB7 ${resourceById.get(it.resourceId)?.name ?? ""}` : "") : null);
|
|
2921
|
+
};
|
|
2922
|
+
const [exportOpen, setExportOpen] = useState5(false);
|
|
2923
|
+
const exportRef = useRef3(null);
|
|
2924
|
+
useEffect4(() => {
|
|
2925
|
+
if (!exportOpen) return;
|
|
2926
|
+
exportRef.current?.querySelector('[role="menuitem"]')?.focus();
|
|
2927
|
+
const away = (e2) => {
|
|
2928
|
+
if (!exportRef.current?.contains(e2.target)) setExportOpen(false);
|
|
2929
|
+
};
|
|
2930
|
+
document.addEventListener("pointerdown", away, true);
|
|
2931
|
+
return () => document.removeEventListener("pointerdown", away, true);
|
|
2932
|
+
}, [exportOpen]);
|
|
2933
|
+
const exportMenuKeys = (e2) => {
|
|
2934
|
+
const items2 = Array.from(exportRef.current?.querySelectorAll('[role="menuitem"]') ?? []);
|
|
2935
|
+
const i = items2.indexOf(document.activeElement);
|
|
2936
|
+
if (e2.key === "ArrowDown") items2[(i + 1) % items2.length]?.focus();
|
|
2937
|
+
else if (e2.key === "ArrowUp") items2[(i - 1 + items2.length) % items2.length]?.focus();
|
|
2938
|
+
else if (e2.key === "Escape") {
|
|
2939
|
+
setExportOpen(false);
|
|
2940
|
+
exportRef.current?.querySelector("[aria-haspopup]")?.focus();
|
|
2941
|
+
} else return;
|
|
2942
|
+
e2.preventDefault();
|
|
2943
|
+
};
|
|
2944
|
+
const ownerDoc = () => rootRef.current?.ownerDocument ?? document;
|
|
2945
|
+
const printedOn = `${dayLabelIn(now, zone)} ${parts(instantToDay(now, zone)).year}`;
|
|
2946
|
+
const boardExport = (forPrint) => ({
|
|
2947
|
+
title,
|
|
2948
|
+
range: rangeLabel(startDay, days, view),
|
|
2949
|
+
printedOn,
|
|
2950
|
+
// Print fits the range to the page; PNG keeps the screen's scale.
|
|
2951
|
+
scale: forPrint ? createInstantScale({
|
|
2952
|
+
from: dayToInstant(startDay, zone),
|
|
2953
|
+
to: dayToInstant(startDay + days, zone) - 1,
|
|
2954
|
+
zone,
|
|
2955
|
+
pxPerHour: PRINT_TIMELINE_W / (shownDays * hoursPerDay),
|
|
2956
|
+
workStart: hours.start,
|
|
2957
|
+
workEnd: hours.end,
|
|
2958
|
+
workingWeek: view === "day" ? "all" : workWeek,
|
|
2959
|
+
hideNonWorking: dayHidden
|
|
2960
|
+
}) : scale,
|
|
2961
|
+
rows,
|
|
2962
|
+
zone,
|
|
2963
|
+
labels,
|
|
2964
|
+
theme: palette(props.theme, props.accent, forPrint),
|
|
2965
|
+
look: colours,
|
|
2966
|
+
capacity: (row) => capacityText(row)?.text ?? null,
|
|
2967
|
+
queue: unassigned(items, scale.start, scale.end),
|
|
2968
|
+
now,
|
|
2969
|
+
showHours: view === "day"
|
|
2970
|
+
});
|
|
2971
|
+
const copySupport = () => {
|
|
2972
|
+
const host = (props.diagnostics || void 0)?.() ?? {};
|
|
2973
|
+
void copyDiagnostics({
|
|
2974
|
+
product: "Plan",
|
|
2975
|
+
control: "Scheduler",
|
|
2976
|
+
version: VERSION,
|
|
2977
|
+
licence: license,
|
|
2978
|
+
rows: props.bookings.length,
|
|
2979
|
+
...host,
|
|
2980
|
+
extra: { Resources: props.resources.length, ...host.extra }
|
|
2981
|
+
}).then((r) => editing.toast({ text: r.message, tone: r.ok ? "info" : "error" }));
|
|
2982
|
+
setExportOpen(false);
|
|
2983
|
+
};
|
|
2984
|
+
const printBoard = () => {
|
|
2985
|
+
setExportOpen(false);
|
|
2986
|
+
const html = view === "agenda" ? agendaPrintHtml({
|
|
2987
|
+
title,
|
|
2988
|
+
range: rangeLabel(startDay, days, view),
|
|
2989
|
+
printedOn,
|
|
2990
|
+
resources,
|
|
2991
|
+
items,
|
|
2992
|
+
startDay,
|
|
2993
|
+
days,
|
|
2994
|
+
zone,
|
|
2995
|
+
filter,
|
|
2996
|
+
labels,
|
|
2997
|
+
theme: palette(props.theme, props.accent, true)
|
|
2998
|
+
}) : boardPrintHtml(boardExport(true));
|
|
2999
|
+
printDocument(html, ownerDoc());
|
|
3000
|
+
announce("Print dialog opened");
|
|
3001
|
+
};
|
|
3002
|
+
const downloadPng = () => {
|
|
3003
|
+
setExportOpen(false);
|
|
3004
|
+
const ok = exportBoardPng(boardExport(false), exportFileName(title, todayIn(zone, now), "png", "schedule"), ownerDoc());
|
|
3005
|
+
if (ok) announce("Image downloaded");
|
|
3006
|
+
else editing.toast({ text: "Too big for one image. Filter or collapse groups first, or use Print.", tone: "info" });
|
|
3007
|
+
};
|
|
3008
|
+
return /* @__PURE__ */ React6.createElement(
|
|
3009
|
+
"div",
|
|
3010
|
+
{
|
|
3011
|
+
ref: rootRef,
|
|
3012
|
+
dir: "ltr",
|
|
3013
|
+
"data-rtl": rtl ? "" : void 0,
|
|
3014
|
+
"data-narrow": narrow ? "" : void 0,
|
|
3015
|
+
className: `nf-sched${settled ? " nf-s-settled" : ""}${dragging ? " nf-s-dragging" : ""}${props.className ? " " + props.className : ""}`,
|
|
3016
|
+
style
|
|
3017
|
+
},
|
|
3018
|
+
/* @__PURE__ */ React6.createElement("style", { dangerouslySetInnerHTML: { __html: css } }),
|
|
3019
|
+
showToolbar ? /* @__PURE__ */ React6.createElement("div", { className: "nf-s-toolbar", dir: dirAttr }, /* @__PURE__ */ React6.createElement("div", { className: "nf-s-title" }, title), /* @__PURE__ */ React6.createElement("div", { className: "nf-s-seg", role: "group", "aria-label": "View", ref: segRef }, VIEWS.map((v) => /* @__PURE__ */ React6.createElement("button", { key: v, type: "button", className: "nf-s-btn", "aria-pressed": v === view, onClick: () => changeView(v) }, VIEW_LABEL[v]))), /* @__PURE__ */ React6.createElement("button", { type: "button", className: "nf-s-btn", onClick: () => go(todayDay) }, "Today"), /* @__PURE__ */ React6.createElement("button", { type: "button", className: "nf-s-btn nf-s-icon", "aria-label": `Previous ${VIEW_STEP[view]}`, onClick: () => step(-1) }, rtl ? "\u203A" : "\u2039"), /* @__PURE__ */ React6.createElement("button", { type: "button", className: "nf-s-btn nf-s-icon", "aria-label": `Next ${VIEW_STEP[view]}`, onClick: () => step(1) }, rtl ? "\u2039" : "\u203A"), /* @__PURE__ */ React6.createElement("div", { className: "nf-s-range", "aria-hidden": "true" }, rangeLabel(startDay, days, view)), /* @__PURE__ */ React6.createElement(
|
|
3020
|
+
"input",
|
|
3021
|
+
{
|
|
3022
|
+
className: "nf-s-filter",
|
|
3023
|
+
type: "date",
|
|
3024
|
+
"aria-label": "Go to date",
|
|
3025
|
+
value: date,
|
|
3026
|
+
onChange: (e2) => {
|
|
3027
|
+
if (/^\d{4}-\d{2}-\d{2}$/.test(e2.target.value)) go(toDay(e2.target.value));
|
|
3028
|
+
},
|
|
3029
|
+
style: { width: Math.round(150 * textScale) }
|
|
3030
|
+
}
|
|
3031
|
+
), view !== "agenda" ? /* @__PURE__ */ React6.createElement("button", { type: "button", className: "nf-s-switch", "aria-pressed": hideOff, onClick: () => setHideOff(!hideOff) }, /* @__PURE__ */ React6.createElement("span", { className: "nf-s-switch-track", "aria-hidden": "true" }), "Working hours only") : null, /* @__PURE__ */ React6.createElement("div", { className: "nf-s-spacer" }), /* @__PURE__ */ React6.createElement(
|
|
3032
|
+
"input",
|
|
3033
|
+
{
|
|
3034
|
+
className: "nf-s-filter",
|
|
3035
|
+
type: "search",
|
|
3036
|
+
placeholder: `Filter ${labels.resources.toLowerCase()}`,
|
|
3037
|
+
"aria-label": `Filter ${labels.resources.toLowerCase()} by name, group or subtitle`,
|
|
3038
|
+
value: filter,
|
|
3039
|
+
onChange: (e2) => setFilter(e2.target.value),
|
|
3040
|
+
onKeyDown: (e2) => {
|
|
3041
|
+
if (e2.key === "Escape" && filter) {
|
|
3042
|
+
setFilter("");
|
|
3043
|
+
e2.preventDefault();
|
|
3044
|
+
}
|
|
3045
|
+
}
|
|
3046
|
+
}
|
|
3047
|
+
), hasGroups && view !== "agenda" ? /* @__PURE__ */ React6.createElement("label", { className: "nf-s-pick" }, "Group by", /* @__PURE__ */ React6.createElement("select", { value: groupBy ? "group" : "none", onChange: (e2) => setGroupBy(e2.target.value === "group") }, /* @__PURE__ */ React6.createElement("option", { value: "group" }, labels.group), /* @__PURE__ */ React6.createElement("option", { value: "none" }, "None"))) : null, /* @__PURE__ */ React6.createElement("button", { type: "button", className: "nf-s-btn", "aria-pressed": panelOpen, "aria-controls": panelOpen ? `${uid}-unassigned` : void 0, onClick: () => setPanelOpen(!panelOpen) }, "Unassigned", /* @__PURE__ */ React6.createElement("span", { className: "nf-s-count" }, queue.length)), props.exports !== false ? /* @__PURE__ */ React6.createElement("div", { className: "nf-s-menuwrap", ref: exportRef, onKeyDown: exportMenuKeys }, /* @__PURE__ */ React6.createElement("button", { type: "button", className: "nf-s-btn", "aria-haspopup": "menu", "aria-expanded": exportOpen, onClick: () => setExportOpen(!exportOpen) }, "Export"), exportOpen ? /* @__PURE__ */ React6.createElement("div", { className: "nf-s-menu", role: "menu", "aria-label": "Export" }, /* @__PURE__ */ React6.createElement("button", { type: "button", role: "menuitem", tabIndex: -1, onClick: printBoard }, "Print\u2026"), view !== "agenda" ? /* @__PURE__ */ React6.createElement("button", { type: "button", role: "menuitem", tabIndex: -1, onClick: downloadPng }, "Download PNG") : null, props.diagnostics !== false ? /* @__PURE__ */ React6.createElement("button", { type: "button", role: "menuitem", tabIndex: -1, onClick: copySupport }, "Copy diagnostics") : null) : null) : null) : null,
|
|
3048
|
+
/* @__PURE__ */ React6.createElement("div", { className: "nf-s-main" }, view === "agenda" ? /* @__PURE__ */ React6.createElement(
|
|
3049
|
+
Agenda,
|
|
3050
|
+
{
|
|
3051
|
+
uid,
|
|
3052
|
+
label: `${title}, ${rangeLabel(startDay, days, view)}`,
|
|
3053
|
+
resources,
|
|
3054
|
+
items,
|
|
3055
|
+
startDay,
|
|
3056
|
+
days,
|
|
3057
|
+
zone,
|
|
3058
|
+
todayDay,
|
|
3059
|
+
filter,
|
|
3060
|
+
labels,
|
|
3061
|
+
selected,
|
|
3062
|
+
appearance: colours,
|
|
3063
|
+
describe: bookingWords,
|
|
3064
|
+
capacityOf: (row, day) => {
|
|
3065
|
+
const w = workWindow(row);
|
|
3066
|
+
return dailyUtilisation(row.items, [day], w.ws, w.we, w.perDay, workWeek, zone)[0];
|
|
3067
|
+
},
|
|
3068
|
+
onSelect: (id) => select(id),
|
|
3069
|
+
onOpen: props.onOpen,
|
|
3070
|
+
dir: dirAttr
|
|
3071
|
+
}
|
|
3072
|
+
) : /* @__PURE__ */ React6.createElement(
|
|
3073
|
+
"div",
|
|
3074
|
+
{
|
|
3075
|
+
ref: scrollRef,
|
|
3076
|
+
className: "nf-s-scroll",
|
|
3077
|
+
role: "grid",
|
|
3078
|
+
"aria-busy": props.loading ? true : void 0,
|
|
3079
|
+
"aria-label": `${title}, ${rangeLabel(startDay, days, view)}`,
|
|
3080
|
+
"aria-rowcount": rows.filter((r) => r.kind === "resource").length + 1,
|
|
3081
|
+
"aria-activedescendant": selected && byId.get(selected)?.resourceId ? `${uid}-b-${selected}` : void 0,
|
|
3082
|
+
"aria-describedby": `${uid}-help`,
|
|
3083
|
+
tabIndex: 0,
|
|
3084
|
+
onKeyDown,
|
|
3085
|
+
onScroll
|
|
3086
|
+
},
|
|
3087
|
+
/* @__PURE__ */ React6.createElement("div", { className: "nf-s-head", role: "row", "aria-rowindex": 1, style: { width: resW + scale.width } }, /* @__PURE__ */ React6.createElement("div", { className: "nf-s-corner", role: "columnheader", dir: dirAttr }, labels.resources), /* @__PURE__ */ React6.createElement("div", { className: "nf-s-axis", role: "columnheader", "aria-label": rangeLabel(startDay, days, view), style: { width: scale.width } }, scale.days.map((d) => /* @__PURE__ */ React6.createElement("div", { key: d.day, className: "nf-s-day", "data-today": d.day === todayDay ? "" : void 0, style: { left: d.x, width: d.width } }, view === "month" ? /* @__PURE__ */ React6.createElement(React6.Fragment, null, compactDay(d.day), d.day === todayDay ? /* @__PURE__ */ React6.createElement("span", { className: "nf-s-sr" }, ", today") : null) : /* @__PURE__ */ React6.createElement(React6.Fragment, null, dayHeadLabel(d.label, compactDay(d.day), d.day === todayDay, d.width, textSize("label", textScale, props.textSizes)), d.day === todayDay && !fitsDay(`${d.label} \xB7 Today`, d.width, textSize("label", textScale, props.textSizes)) ? /* @__PURE__ */ React6.createElement("span", { className: "nf-s-sr" }, ", today") : null))), showHours ? scale.hours.filter((h, i) => i % hourEvery === 0 && hourLabelFits(h.x, scale.width, captionPx)).map((h) => /* @__PURE__ */ React6.createElement("div", { key: h.ms, className: "nf-s-hour", style: { left: h.x } }, h.label)) : null)),
|
|
3088
|
+
/* @__PURE__ */ React6.createElement("div", { className: "nf-s-body", style: { height: Math.max(bodyHeight, 120), width: resW + scale.width } }, /* @__PURE__ */ React6.createElement("div", { className: "nf-s-layer", "aria-hidden": "true", style: { left: resW, width: scale.width } }, scale.nonWorking.map((b, i) => /* @__PURE__ */ React6.createElement("div", { key: `o${i}`, className: "nf-s-off", style: { left: b.x, width: b.width } })), showHours ? scale.hours.map((h) => /* @__PURE__ */ React6.createElement("div", { key: `h${h.ms}`, className: "nf-s-vline", style: { left: h.x } })) : null, scale.days.map((d) => /* @__PURE__ */ React6.createElement("div", { key: `d${d.day}`, className: "nf-s-vline", "data-day": "", style: { left: d.x } })), nowX !== null ? /* @__PURE__ */ React6.createElement("div", { className: "nf-s-nowline", style: { left: nowX } }) : null), shownRows.map(
|
|
3089
|
+
(row) => row.kind === "group" ? /* @__PURE__ */ React6.createElement("div", { key: row.key, className: "nf-s-group", role: "row", style: { top: row.top, width: resW + scale.width } }, /* @__PURE__ */ React6.createElement("div", { role: "rowheader", className: "nf-s-groupcell" }, /* @__PURE__ */ React6.createElement(
|
|
3090
|
+
"button",
|
|
3091
|
+
{
|
|
3092
|
+
type: "button",
|
|
3093
|
+
className: "nf-s-grouphead",
|
|
3094
|
+
"aria-expanded": !row.collapsed,
|
|
3095
|
+
tabIndex: -1,
|
|
3096
|
+
onClick: () => toggleGroup(row.label === "Other" ? "" : row.label),
|
|
3097
|
+
dir: dirAttr
|
|
3098
|
+
},
|
|
3099
|
+
/* @__PURE__ */ React6.createElement(Chevron, null),
|
|
3100
|
+
row.label,
|
|
3101
|
+
/* @__PURE__ */ React6.createElement("span", { className: "nf-s-groupcount" }, row.count)
|
|
3102
|
+
))) : /* @__PURE__ */ React6.createElement(
|
|
3103
|
+
"div",
|
|
3104
|
+
{
|
|
3105
|
+
key: row.key,
|
|
3106
|
+
className: "nf-s-row",
|
|
3107
|
+
role: "row",
|
|
3108
|
+
"aria-rowindex": row.index + 2,
|
|
3109
|
+
"aria-selected": selectedRowKey === row.key,
|
|
3110
|
+
style: { top: row.top, height: row.height }
|
|
3111
|
+
},
|
|
3112
|
+
/* @__PURE__ */ React6.createElement("div", { className: "nf-s-res", role: "rowheader", dir: dirAttr, style: { height: row.height } }, /* @__PURE__ */ React6.createElement("span", { className: "nf-s-avatar", "aria-hidden": "true", style: row.resource.color ? { boxShadow: `inset 0 0 0 2px ${row.resource.color}` } : void 0 }, row.resource.image ? /* @__PURE__ */ React6.createElement("img", { src: row.resource.image, alt: "" }) : initials(row.resource.name)), /* @__PURE__ */ React6.createElement("span", { className: "nf-s-who" }, /* @__PURE__ */ React6.createElement("span", { className: "nf-s-name" }, row.resource.name), row.resource.subtitle ? /* @__PURE__ */ React6.createElement("span", { className: "nf-s-sub" }, row.resource.subtitle) : null), (() => {
|
|
3113
|
+
const cap = capacityText(row);
|
|
3114
|
+
const overDays = utilOf(row).filter((u) => u.level === "over");
|
|
3115
|
+
return cap ? /* @__PURE__ */ React6.createElement("span", { className: "nf-s-cap", "data-level": cap.level === "over" || overDays.length ? "over" : "ok" }, cap.text, cap.level === "over" ? /* @__PURE__ */ React6.createElement("span", { className: "nf-s-sr" }, ", over capacity") : null, overDays.length ? /* @__PURE__ */ React6.createElement("span", { className: "nf-s-sr" }, ", over on ", overDays.map((u) => `${dayLabelIn(dayToInstant(u.day, zone), zone)} (${u.booked} of ${u.capacity} h)`).join(", ")) : null) : null;
|
|
3116
|
+
})()),
|
|
3117
|
+
/* @__PURE__ */ React6.createElement(
|
|
3118
|
+
"div",
|
|
3119
|
+
{
|
|
3120
|
+
className: "nf-s-lanes",
|
|
3121
|
+
"data-create": can("create") ? "" : void 0,
|
|
3122
|
+
style: { width: scale.width, height: row.height },
|
|
3123
|
+
onPointerDown: (e2) => onLanePointerDown(e2, row)
|
|
3124
|
+
},
|
|
3125
|
+
utilOf(row).map((u) => {
|
|
3126
|
+
const t = dayTicks.get(u.day);
|
|
3127
|
+
if (!t) return null;
|
|
3128
|
+
return /* @__PURE__ */ React6.createElement(React6.Fragment, { key: `u${u.day}` }, u.level === "over" ? /* @__PURE__ */ React6.createElement("div", { className: "nf-s-overday", "aria-hidden": "true", style: { left: t.x, width: t.width } }) : null, /* @__PURE__ */ React6.createElement("div", { className: "nf-s-util", "aria-hidden": "true", style: { left: t.x + 4, width: Math.max(0, t.width - 8) } }, /* @__PURE__ */ React6.createElement("i", { "data-level": u.level, style: { width: `${Math.min(1, u.ratio) * 100}%` } })));
|
|
3129
|
+
}),
|
|
3130
|
+
row.items.map((it) => renderBooking(it, row)),
|
|
3131
|
+
ghost && ghost.resourceId === row.resource.id ? /* @__PURE__ */ React6.createElement(
|
|
3132
|
+
"div",
|
|
3133
|
+
{
|
|
3134
|
+
className: "nf-s-ghost",
|
|
3135
|
+
"aria-hidden": "true",
|
|
3136
|
+
style: { left: scale.x(ghost.start), width: Math.max(6, scale.x(ghost.end) - scale.x(ghost.start)), top: ROW_PAD }
|
|
3137
|
+
},
|
|
3138
|
+
whenLabel(ghost.start, ghost.end, zone)
|
|
3139
|
+
) : null
|
|
3140
|
+
)
|
|
3141
|
+
)
|
|
3142
|
+
))
|
|
3143
|
+
), view !== "agenda" && rows.length === 0 ? /* @__PURE__ */ React6.createElement("div", { className: "nf-s-empty", role: "status", "aria-busy": props.loading ? true : void 0, style: { top: headH + 40, right: panelOpen ? Math.round(280 * textScale) : 0 } }, props.loading ? `Loading ${labels.bookings.toLowerCase()}\u2026` : filter ? `No ${labels.resources.toLowerCase()} match \u201C${filter}\u201D.` : `No ${labels.resources.toLowerCase()} yet.`, props.emptyAction && !props.loading && !filter ? /* @__PURE__ */ React6.createElement(EmptyActionView, { action: props.emptyAction, prefix: "nf-s", buttonClass: "nf-s-btn" }) : null) : null, panelOpen ? /* @__PURE__ */ React6.createElement("aside", { className: "nf-s-unassigned", id: `${uid}-unassigned`, "aria-label": `Unassigned ${labels.bookings.toLowerCase()}`, dir: dirAttr }, /* @__PURE__ */ React6.createElement("div", { className: "nf-s-un-head" }, "Unassigned", /* @__PURE__ */ React6.createElement("span", { className: "nf-s-count" }, queueFilter ? `${queueShown.length} of ${queue.length}` : queue.length)), queue.length ? /* @__PURE__ */ React6.createElement("div", { className: "nf-s-un-tools" }, /* @__PURE__ */ React6.createElement(
|
|
3144
|
+
"input",
|
|
3145
|
+
{
|
|
3146
|
+
className: "nf-s-filter",
|
|
3147
|
+
type: "search",
|
|
3148
|
+
placeholder: `Filter ${labels.bookings.toLowerCase()}`,
|
|
3149
|
+
"aria-label": `Filter unassigned ${labels.bookings.toLowerCase()} by name, location or status`,
|
|
3150
|
+
value: queueFilter,
|
|
3151
|
+
onChange: (e2) => setQueueFilter(e2.target.value),
|
|
3152
|
+
onKeyDown: (e2) => {
|
|
3153
|
+
if (e2.key === "Escape" && queueFilter) {
|
|
3154
|
+
setQueueFilter("");
|
|
3155
|
+
e2.preventDefault();
|
|
3156
|
+
}
|
|
3157
|
+
}
|
|
3158
|
+
}
|
|
3159
|
+
), /* @__PURE__ */ React6.createElement("label", { className: "nf-s-pick" }, /* @__PURE__ */ React6.createElement("span", { className: "nf-s-sr" }, "Sort unassigned by"), /* @__PURE__ */ React6.createElement("select", { value: queueSort, onChange: (e2) => setQueueSort(e2.target.value) }, Object.keys(QUEUE_SORT_LABEL).map((k) => /* @__PURE__ */ React6.createElement("option", { key: k, value: k }, QUEUE_SORT_LABEL[k]))))) : null, queueShown.length ? /* @__PURE__ */ React6.createElement("ul", { className: "nf-s-un-list" }, queueShown.map((it, i) => {
|
|
3160
|
+
const d = instantToDay(it.start, zone);
|
|
3161
|
+
const newDay = byDay && (i === 0 || instantToDay(queueShown[i - 1].start, zone) !== d);
|
|
3162
|
+
return /* @__PURE__ */ React6.createElement("li", { key: it.id }, newDay ? /* @__PURE__ */ React6.createElement("div", { className: "nf-s-un-day" }, dayLabelIn(it.start, zone)) : null, /* @__PURE__ */ React6.createElement(
|
|
3163
|
+
"button",
|
|
3164
|
+
{
|
|
3165
|
+
type: "button",
|
|
3166
|
+
className: "nf-s-un-item",
|
|
3167
|
+
"aria-pressed": selected === it.id,
|
|
3168
|
+
"aria-describedby": can("assign", it.booking) ? `${uid}-queue-help` : void 0,
|
|
3169
|
+
"data-editable": can("assign", it.booking) ? "" : void 0,
|
|
3170
|
+
onPointerDown: (e2) => onQueuePointerDown(e2, it),
|
|
3171
|
+
onClick: () => select(it.id),
|
|
3172
|
+
onDoubleClick: () => props.onOpen?.(it.booking),
|
|
3173
|
+
onKeyDown: (e2) => {
|
|
3174
|
+
if (e2.key === "Enter") {
|
|
3175
|
+
e2.preventDefault();
|
|
3176
|
+
props.onOpen?.(it.booking);
|
|
3177
|
+
return;
|
|
3178
|
+
}
|
|
3179
|
+
if (keyboardEdit(e2, it.id)) {
|
|
3180
|
+
e2.preventDefault();
|
|
3181
|
+
select(it.id);
|
|
3182
|
+
}
|
|
3183
|
+
}
|
|
3184
|
+
},
|
|
3185
|
+
/* @__PURE__ */ React6.createElement("span", { className: "nf-s-sr" }, bookingWords(it)),
|
|
3186
|
+
/* @__PURE__ */ React6.createElement("span", { "aria-hidden": "true" }, it.booking.name),
|
|
3187
|
+
/* @__PURE__ */ React6.createElement("small", { "aria-hidden": "true" }, byDay ? "" : `${dayLabelIn(it.start, zone)} \xB7 `, /* @__PURE__ */ React6.createElement("span", { className: "nf-s-ltr" }, it.booking.allDay ? "All day" : `${timeLabel(it.start, zone)}\u2013${timeLabel(it.end, zone)}`), it.booking.location ? ` \xB7 ${it.booking.location}` : "")
|
|
3188
|
+
));
|
|
3189
|
+
})) : /* @__PURE__ */ React6.createElement("div", { className: "nf-s-un-empty" }, queue.length ? `Nothing matches \u201C${queueFilter}\u201D.` : "Nothing to schedule.")) : null),
|
|
3190
|
+
/* @__PURE__ */ React6.createElement("div", { id: `${uid}-help`, className: "nf-s-sr" }, "Arrow keys move between ", labels.bookings.toLowerCase(), ": left and right along a row, up and down to the next ", labels.resource.toLowerCase(), ". Enter opens.", props.onBookingChange ? ` Alt plus left or right moves the selected ${labels.booking.toLowerCase()} in time, Alt plus Shift plus left or right changes its end, Alt plus up or down moves it to another ${labels.resource.toLowerCase()}. Control plus Z undoes.` : "", props.onCreate ? ` Insert adds a ${labels.booking.toLowerCase()} after the selected one.` : ""),
|
|
3191
|
+
/* @__PURE__ */ React6.createElement("div", { id: `${uid}-queue-help`, className: "nf-s-sr" }, "Alt plus down arrow assigns it to the first ", labels.resource.toLowerCase(), ", keeping its time. Or drag it onto a row."),
|
|
3192
|
+
/* @__PURE__ */ React6.createElement(Toasts, { prefix: "nf-s", items: editing.toasts, announcement: editing.announcement, onDismiss: editing.dismiss }),
|
|
3193
|
+
license && !license.licensed && props.watermark !== false ? /* @__PURE__ */ React6.createElement(Watermark, { prefix: "nf-s", title: licenseMessage(license), markHtml: markSvg({ size: 16 }), variant: props.watermarkStyle, onClick: props.onWatermarkClick }) : null,
|
|
3194
|
+
editing.lockNote ? /* @__PURE__ */ React6.createElement("p", { className: "nf-s-locknote", role: "status" }, editing.lockNote) : null,
|
|
3195
|
+
renewal ? /* @__PURE__ */ React6.createElement("a", { className: "nf-s-renew", href: renewal.href, target: "_blank", rel: "noreferrer", title: renewal.detail }, renewal.text) : null
|
|
3196
|
+
);
|
|
3197
|
+
}
|
|
3198
|
+
|
|
3199
|
+
// src/sample.ts
|
|
3200
|
+
function rng(seed) {
|
|
3201
|
+
let s = seed | 0;
|
|
3202
|
+
return () => {
|
|
3203
|
+
s = s + 1831565813 | 0;
|
|
3204
|
+
let t = Math.imul(s ^ s >>> 15, 1 | s);
|
|
3205
|
+
t = t + Math.imul(t ^ t >>> 7, 61 | t) ^ t;
|
|
3206
|
+
return ((t ^ t >>> 14) >>> 0) / 4294967296;
|
|
3207
|
+
};
|
|
3208
|
+
}
|
|
3209
|
+
var iso2 = (ms) => new Date(ms).toISOString();
|
|
3210
|
+
function sampleScenario(preset, anchor, zone) {
|
|
3211
|
+
const rand = rng(preset.length * 7919 + toDay(anchor));
|
|
3212
|
+
const pick = (list) => list[Math.floor(rand() * list.length)];
|
|
3213
|
+
const anchorDay = toDay(anchor);
|
|
3214
|
+
const monday = anchorDay - weekday(anchorDay);
|
|
3215
|
+
const days = [];
|
|
3216
|
+
for (let d = monday - 21; d < monday + 21; d++) days.push(d);
|
|
3217
|
+
const weekdays = days.filter((d) => weekday(d) < 5);
|
|
3218
|
+
const at = (day, hour) => atHour(day, hour, zone);
|
|
3219
|
+
const resources = [];
|
|
3220
|
+
const bookings = [];
|
|
3221
|
+
let n = 0;
|
|
3222
|
+
const book = (b) => bookings.push({ ...b, id: `${preset}-${++n}`, start: iso2(b.start), end: iso2(b.end) });
|
|
3223
|
+
const status = (day) => day < anchorDay - 1 ? "Done" : rand() < 0.75 ? "Confirmed" : "Tentative";
|
|
3224
|
+
if (preset === "people" || preset === "custom") {
|
|
3225
|
+
const teams = {
|
|
3226
|
+
"Customer Care": ["Aisha Khan", "Ben Walker", "Chloe Nguyen", "Dev Patel", "Ella Brown"],
|
|
3227
|
+
Warehouse: ["Grace Lee", "Hamish Scott", "Isla Chen", "Jack Wilson"],
|
|
3228
|
+
Clinic: ["Maya Rossi", "Noah Kim", "Olivia Hart", "Priya Das"]
|
|
3229
|
+
};
|
|
3230
|
+
for (const [team, names] of Object.entries(teams))
|
|
3231
|
+
for (const name of names) {
|
|
3232
|
+
const early = team === "Warehouse";
|
|
3233
|
+
const r = { id: `p-${resources.length + 1}`, name, group: team, subtitle: team === "Clinic" ? "Nurse" : early ? "Picker" : "Agent", capacityHours: 8, workStart: early ? 7 : 9, workEnd: early ? 15.5 : 17 };
|
|
3234
|
+
resources.push(r);
|
|
3235
|
+
for (const day of weekdays) {
|
|
3236
|
+
if (rand() < 0.3) continue;
|
|
3237
|
+
const s = Number(r.workStart) + (rand() < 0.25 ? 1 : 0);
|
|
3238
|
+
const len = pick([4, 6, 8, 8]);
|
|
3239
|
+
book({ name: `${team} shift`, resourceId: r.id, start: at(day, s), end: at(day, s + len), status: status(day), subtitle: team });
|
|
3240
|
+
if (rand() < 0.07) book({ name: "Overtime cover", resourceId: r.id, start: at(day, s + len - 2), end: at(day, s + len + 3), status: "Tentative" });
|
|
3241
|
+
}
|
|
3242
|
+
if (rand() < 0.4) {
|
|
3243
|
+
const d = pick(weekdays);
|
|
3244
|
+
book({ name: "Annual leave", resourceId: r.id, start: at(d, 0), end: at(d + 3, 0), kind: "unavailable", allDay: true, status: "Confirmed" });
|
|
3245
|
+
}
|
|
3246
|
+
}
|
|
3247
|
+
}
|
|
3248
|
+
if (preset === "rooms") {
|
|
3249
|
+
const rooms = [
|
|
3250
|
+
["Harbour Room", "Building A", 12],
|
|
3251
|
+
["Ridge Room", "Building A", 6],
|
|
3252
|
+
["Studio 1", "Building A", 4],
|
|
3253
|
+
["Boardroom", "Building A", 20],
|
|
3254
|
+
["Kings Park", "Building B", 8],
|
|
3255
|
+
["Swan Room", "Building B", 10],
|
|
3256
|
+
["Focus Pod", "Building B", 2]
|
|
3257
|
+
];
|
|
3258
|
+
const meetings = ["Team stand-up", "Client workshop", "Interview", "Planning", "Training", "Board meeting", "1:1"];
|
|
3259
|
+
for (const [name, building, seats] of rooms) {
|
|
3260
|
+
const r = { id: `rm-${resources.length + 1}`, name, group: building, seats, subtitle: `${seats} seats`, capacityHours: 10, workStart: 8, workEnd: 18 };
|
|
3261
|
+
resources.push(r);
|
|
3262
|
+
for (const day of weekdays) {
|
|
3263
|
+
let h = 8.5;
|
|
3264
|
+
const count = 1 + Math.floor(rand() * 3);
|
|
3265
|
+
for (let i = 0; i < count && h < 16.5; i++) {
|
|
3266
|
+
h += pick([0, 0.5, 1]);
|
|
3267
|
+
const len = pick([0.5, 1, 1, 1.5, 2]);
|
|
3268
|
+
const over = rand() < 0.08;
|
|
3269
|
+
const attendees = Math.max(1, Math.round(seats * (over ? 1.3 : 0.4 + rand() * 0.6)));
|
|
3270
|
+
book({ name: pick(meetings), resourceId: r.id, start: at(day, h), end: at(day, h + len), attendees, status: status(day), subtitle: `${attendees} people` });
|
|
3271
|
+
h += rand() < 0.08 ? len - 0.5 : len;
|
|
3272
|
+
}
|
|
3273
|
+
}
|
|
3274
|
+
}
|
|
3275
|
+
}
|
|
3276
|
+
if (preset === "vehicles" || preset === "equipment") {
|
|
3277
|
+
const fleet = preset === "vehicles" ? [
|
|
3278
|
+
["Hilux 1", "North depot", "1ABC 234"],
|
|
3279
|
+
["Hilux 2", "North depot", "1ABD 771"],
|
|
3280
|
+
["Transit van", "South depot", "1XYZ 410"],
|
|
3281
|
+
["Prado", "South depot", "1PRA 090"],
|
|
3282
|
+
["Tipper truck", "South depot", "1TIP 655"],
|
|
3283
|
+
["Ute 3", "North depot", "1UTE 303"]
|
|
3284
|
+
] : [
|
|
3285
|
+
["Scissor lift", "Lifting", "EQ-1001"],
|
|
3286
|
+
["Forklift", "Lifting", "EQ-1002"],
|
|
3287
|
+
["Total station", "Survey", "EQ-2001"],
|
|
3288
|
+
["GPS rover", "Survey", "EQ-2002"],
|
|
3289
|
+
["Generator 20kVA", "Power", "EQ-3001"],
|
|
3290
|
+
["Light tower", "Power", "EQ-3002"]
|
|
3291
|
+
];
|
|
3292
|
+
const sites = ["Kwinana site", "Joondalup depot", "Fremantle port", "Midland yard", "Rockingham job", "CBD fit-out"];
|
|
3293
|
+
for (const [name, group, id] of fleet) {
|
|
3294
|
+
const r = { id: `${preset[0]}-${resources.length + 1}`, name, group, subtitle: id, capacityHours: 10, workStart: 7, workEnd: 17 };
|
|
3295
|
+
resources.push(r);
|
|
3296
|
+
let day = days[0] + Math.floor(rand() * 3);
|
|
3297
|
+
while (day < days[days.length - 1]) {
|
|
3298
|
+
const len = 1 + Math.floor(rand() * 4);
|
|
3299
|
+
const site = pick(sites);
|
|
3300
|
+
book({ name: `${site} hire`, resourceId: r.id, start: at(day, 7), end: at(day + len - 1, 17), location: site, status: status(day), subtitle: site });
|
|
3301
|
+
day += len + Math.floor(rand() * 3);
|
|
3302
|
+
}
|
|
3303
|
+
const m = pick(weekdays);
|
|
3304
|
+
book({ name: "Maintenance", resourceId: r.id, start: at(m, 7), end: at(m + 1, 17), kind: "unavailable", status: "Confirmed", subtitle: "Service" });
|
|
3305
|
+
}
|
|
3306
|
+
}
|
|
3307
|
+
if (preset === "jobs") {
|
|
3308
|
+
const areas = {
|
|
3309
|
+
"East area": ["Sam O'Brien", "Tara Mills", "Umar Ali", "Vera Novak"],
|
|
3310
|
+
"West area": ["Will Grant", "Xin Zhou", "Yusuf Kaya", "Zoe Clarke"]
|
|
3311
|
+
};
|
|
3312
|
+
const jobTypes = ["Air-con service", "Fault call-out", "Install", "Inspection", "Repair", "Commissioning"];
|
|
3313
|
+
const suburbs = ["Subiaco", "Midland", "Cannington", "Joondalup", "Fremantle", "Morley", "Armadale", "Scarborough"];
|
|
3314
|
+
for (const [area, names] of Object.entries(areas))
|
|
3315
|
+
for (const name of names) {
|
|
3316
|
+
const r = { id: `t-${resources.length + 1}`, name, group: area, subtitle: "Field technician", capacityHours: 8, workStart: 7.5, workEnd: 16 };
|
|
3317
|
+
resources.push(r);
|
|
3318
|
+
for (const day of weekdays) {
|
|
3319
|
+
if (rand() < 0.3) continue;
|
|
3320
|
+
let h = 7.5;
|
|
3321
|
+
const count = rand() < 0.1 ? 4 : 1 + Math.floor(rand() * 2);
|
|
3322
|
+
for (let i = 0; i < count; i++) {
|
|
3323
|
+
const len = pick([1.5, 2, 3]);
|
|
3324
|
+
const loc = pick(suburbs);
|
|
3325
|
+
book({ name: pick(jobTypes), resourceId: r.id, start: at(day, h), end: at(day, h + len), location: loc, subtitle: loc, status: status(day) });
|
|
3326
|
+
h += len + 0.5;
|
|
3327
|
+
}
|
|
3328
|
+
}
|
|
3329
|
+
}
|
|
3330
|
+
for (let i = 0; i < 25; i++) {
|
|
3331
|
+
const day = anchorDay + Math.floor(rand() * 10);
|
|
3332
|
+
const loc = pick(suburbs);
|
|
3333
|
+
const h = pick([8, 9, 10, 13, 14]);
|
|
3334
|
+
book({ name: `${pick(jobTypes)} #${1e3 + i}`, resourceId: null, start: at(day, h), end: at(day, h + pick([1, 2, 3])), location: loc, subtitle: loc, status: "Tentative" });
|
|
3335
|
+
}
|
|
3336
|
+
}
|
|
3337
|
+
return { resources, bookings };
|
|
3338
|
+
}
|
|
3339
|
+
function scaleScenario(anchor, zone, resources = 300, perResource = 10) {
|
|
3340
|
+
const rand = rng(toDay(anchor) * 31 + resources);
|
|
3341
|
+
const anchorDay = toDay(anchor);
|
|
3342
|
+
const monday = anchorDay - weekday(anchorDay);
|
|
3343
|
+
const workdays = [];
|
|
3344
|
+
for (let d = monday; d < monday + 14; d++) if (weekday(d) < 5) workdays.push(d);
|
|
3345
|
+
const out = { resources: [], bookings: [] };
|
|
3346
|
+
const statuses = ["Confirmed", "Confirmed", "Confirmed", "Tentative", "Done"];
|
|
3347
|
+
for (let r = 0; r < resources; r++) {
|
|
3348
|
+
const id = `s${r}`;
|
|
3349
|
+
out.resources.push({ id, name: `Person ${String(r + 1).padStart(3, "0")}`, group: `Team ${String.fromCharCode(65 + r % 10)}`, subtitle: r % 3 ? "Technician" : "Lead" });
|
|
3350
|
+
for (let k = 0; k < perResource; k++) {
|
|
3351
|
+
const day = workdays[k % workdays.length];
|
|
3352
|
+
const startH = 7 + Math.floor(rand() * 6);
|
|
3353
|
+
const len = 2 + Math.floor(rand() * 6);
|
|
3354
|
+
const leave = (r * perResource + k) % 25 === 0;
|
|
3355
|
+
out.bookings.push({
|
|
3356
|
+
id: `${id}-${k}`,
|
|
3357
|
+
name: leave ? "Leave" : `Job ${r * perResource + k + 1}`,
|
|
3358
|
+
start: iso2(atHour(day, leave ? 0 : startH, zone)),
|
|
3359
|
+
end: iso2(atHour(leave ? day + 1 : day, leave ? 0 : startH + len, zone)),
|
|
3360
|
+
resourceId: id,
|
|
3361
|
+
status: leave ? void 0 : statuses[Math.floor(rand() * statuses.length)],
|
|
3362
|
+
kind: leave ? "unavailable" : "booking",
|
|
3363
|
+
location: rand() < 0.3 ? "Site " + (1 + Math.floor(rand() * 40)) : void 0
|
|
3364
|
+
});
|
|
3365
|
+
}
|
|
3366
|
+
}
|
|
3367
|
+
for (let j = 0; j < 40; j++) {
|
|
3368
|
+
const day = workdays[j % workdays.length];
|
|
3369
|
+
out.bookings.push({ id: `u${j}`, name: `Unassigned job ${j + 1}`, start: iso2(atHour(day, 9, zone)), end: iso2(atHour(day, 11, zone)), resourceId: null, status: "Tentative" });
|
|
3370
|
+
}
|
|
3371
|
+
return out;
|
|
3372
|
+
}
|
|
3373
|
+
|
|
3374
|
+
// src/CapacityHeatmap.tsx
|
|
3375
|
+
import * as React7 from "react";
|
|
3376
|
+
import { useMemo as useMemo3, useRef as useRef4, useState as useState6 } from "react";
|
|
3377
|
+
|
|
3378
|
+
// src/capacity.ts
|
|
3379
|
+
var STEP_LABELS = ["under 60%", "60\u201379%", "80\u201394%", "95\u2013100%", "over 100%"];
|
|
3380
|
+
function utilisationPct(c) {
|
|
3381
|
+
if (!(c.capacity > 0)) return null;
|
|
3382
|
+
return Math.round(c.booked / c.capacity * 100);
|
|
3383
|
+
}
|
|
3384
|
+
function capacityStep(c) {
|
|
3385
|
+
const pct = utilisationPct(c);
|
|
3386
|
+
if (pct === null) return c.booked > 0 ? 4 : 0;
|
|
3387
|
+
if (pct > 100) return 4;
|
|
3388
|
+
if (pct >= 95) return 3;
|
|
3389
|
+
if (pct >= 80) return 2;
|
|
3390
|
+
if (pct >= 60) return 1;
|
|
3391
|
+
return 0;
|
|
3392
|
+
}
|
|
3393
|
+
function cellKey(rowId, week) {
|
|
3394
|
+
return rowId + KEY_SEPARATOR + week;
|
|
3395
|
+
}
|
|
3396
|
+
function weekStarts(fromIso, count) {
|
|
3397
|
+
const d = toDay(fromIso);
|
|
3398
|
+
if (Number.isNaN(d)) return [];
|
|
3399
|
+
const monday = d - weekday(d);
|
|
3400
|
+
const out = [];
|
|
3401
|
+
for (let i = 0; i < count; i++) out.push(isoOfDay(monday + i * 7));
|
|
3402
|
+
return out;
|
|
3403
|
+
}
|
|
3404
|
+
function weeklyUtilisation(resources, bookings, weeks, o) {
|
|
3405
|
+
const items = normalise(bookings);
|
|
3406
|
+
const byResource = /* @__PURE__ */ new Map();
|
|
3407
|
+
for (const it of items) {
|
|
3408
|
+
if (!it.resourceId) continue;
|
|
3409
|
+
const list = byResource.get(it.resourceId) ?? [];
|
|
3410
|
+
list.push(it);
|
|
3411
|
+
byResource.set(it.resourceId, list);
|
|
3412
|
+
}
|
|
3413
|
+
const week = o.week ?? "mon-fri";
|
|
3414
|
+
const days = week === "mon-fri" ? 5 : 7;
|
|
3415
|
+
const hour = (v, fallback) => {
|
|
3416
|
+
const h = v === void 0 ? NaN : parseHour(v);
|
|
3417
|
+
return Number.isNaN(h) ? fallback : h;
|
|
3418
|
+
};
|
|
3419
|
+
const ws0 = hour(o.workStart, 9);
|
|
3420
|
+
const we0 = hour(o.workEnd, 17);
|
|
3421
|
+
return resources.map((r) => {
|
|
3422
|
+
const ws = hour(r.workStart, ws0);
|
|
3423
|
+
const we = hour(r.workEnd, we0);
|
|
3424
|
+
const perDay = r.capacityHours ?? o.capacityHours ?? 8;
|
|
3425
|
+
const mine = byResource.get(r.id) ?? [];
|
|
3426
|
+
return {
|
|
3427
|
+
id: r.id,
|
|
3428
|
+
name: r.name,
|
|
3429
|
+
group: r.group,
|
|
3430
|
+
cells: weeks.map((w) => ({
|
|
3431
|
+
week: w,
|
|
3432
|
+
booked: bookedWorkingHours(mine, toDay(w), 7, ws, we, week, o.zone, perDay),
|
|
3433
|
+
capacity: perDay * days
|
|
3434
|
+
}))
|
|
3435
|
+
};
|
|
3436
|
+
});
|
|
3437
|
+
}
|
|
3438
|
+
function rollUp(rows) {
|
|
3439
|
+
const out = /* @__PURE__ */ new Map();
|
|
3440
|
+
for (const r of rows) {
|
|
3441
|
+
const key = r.group || r.name;
|
|
3442
|
+
let t = out.get(key);
|
|
3443
|
+
if (!t) out.set(key, t = { id: key, name: key, cells: r.cells.map((c) => ({ ...c })) });
|
|
3444
|
+
else {
|
|
3445
|
+
for (const c of r.cells) {
|
|
3446
|
+
const m = t.cells.find((x) => x.week === c.week);
|
|
3447
|
+
if (m) {
|
|
3448
|
+
m.booked = Math.round((m.booked + c.booked) * 10) / 10;
|
|
3449
|
+
m.capacity += c.capacity;
|
|
3450
|
+
} else t.cells.push({ ...c });
|
|
3451
|
+
}
|
|
3452
|
+
}
|
|
3453
|
+
}
|
|
3454
|
+
return [...out.values()];
|
|
3455
|
+
}
|
|
3456
|
+
function weeksOf(rows) {
|
|
3457
|
+
const s = /* @__PURE__ */ new Set();
|
|
3458
|
+
for (const r of rows) for (const c of r.cells) s.add(c.week);
|
|
3459
|
+
return [...s].sort();
|
|
3460
|
+
}
|
|
3461
|
+
function weekName(week) {
|
|
3462
|
+
const p = parts(toDay(week));
|
|
3463
|
+
return `${p.date} ${MONTHS[p.month] ?? ""} ${p.year}`;
|
|
3464
|
+
}
|
|
3465
|
+
function weekLabels(weeks) {
|
|
3466
|
+
let lastMonth = -1;
|
|
3467
|
+
return weeks.map((w) => {
|
|
3468
|
+
const p = parts(toDay(w));
|
|
3469
|
+
const label = p.month === lastMonth ? String(p.date) : `${p.date} ${MONTHS[p.month] ?? ""}`;
|
|
3470
|
+
lastMonth = p.month;
|
|
3471
|
+
return label;
|
|
3472
|
+
});
|
|
3473
|
+
}
|
|
3474
|
+
function capacityHeadline(rows, noun = "No one") {
|
|
3475
|
+
let worst;
|
|
3476
|
+
let count = 0;
|
|
3477
|
+
for (const r of rows) {
|
|
3478
|
+
let over = 0;
|
|
3479
|
+
let first = "";
|
|
3480
|
+
for (const c of r.cells) {
|
|
3481
|
+
if (capacityStep(c) !== 4) continue;
|
|
3482
|
+
over += c.booked - c.capacity;
|
|
3483
|
+
if (!first || c.week < first) first = c.week;
|
|
3484
|
+
}
|
|
3485
|
+
if (!first) continue;
|
|
3486
|
+
count++;
|
|
3487
|
+
if (!worst || over > worst.over) worst = { row: r, over, first };
|
|
3488
|
+
}
|
|
3489
|
+
if (!worst) return `${noun} over capacity`;
|
|
3490
|
+
const month = MONTHS[parts(toDay(worst.first)).month] ?? "";
|
|
3491
|
+
const others = count - 1;
|
|
3492
|
+
return `${worst.row.name}${others ? ` and ${others} other${others === 1 ? "" : "s"}` : ""} overbooked in ${month}`;
|
|
3493
|
+
}
|
|
3494
|
+
function hexParts(h) {
|
|
3495
|
+
const s = h.trim().replace("#", "");
|
|
3496
|
+
const f = s.length === 3 ? s.replace(/./g, (c) => c + c) : s;
|
|
3497
|
+
if (!/^[0-9a-f]{6}$/i.test(f)) return null;
|
|
3498
|
+
return [0, 2, 4].map((i) => parseInt(f.slice(i, i + 2), 16));
|
|
3499
|
+
}
|
|
3500
|
+
function mixColor(a, b, t) {
|
|
3501
|
+
const x = hexParts(a);
|
|
3502
|
+
const y = hexParts(b);
|
|
3503
|
+
if (!x || !y) return a;
|
|
3504
|
+
return "#" + x.map((v, i) => Math.round(v + (y[i] - v) * t).toString(16).padStart(2, "0")).join("").toUpperCase();
|
|
3505
|
+
}
|
|
3506
|
+
function stepColors(accent, surface, override) {
|
|
3507
|
+
const bgs = override && override.length === 5 ? override : [mixColor(accent, surface, 0.9), mixColor(accent, surface, 0.75), mixColor(accent, surface, 0.45), mixColor(accent, "#000000", 0.25), semantic.negative];
|
|
3508
|
+
return bgs.map((bg) => accentForText(bg));
|
|
3509
|
+
}
|
|
3510
|
+
|
|
3511
|
+
// src/CapacityHeatmap.tsx
|
|
3512
|
+
var css2 = `
|
|
3513
|
+
.nf-cap{position:relative;box-sizing:border-box;display:flex;flex-direction:column;gap:10px;padding:16px 18px;min-height:0;background:var(--nf-surface);color:var(--nf-ink);font-family:var(--nf-font);font-size:var(--nf-text-body);line-height:1.3;border:1px solid var(--nf-line);border-radius:16px;-webkit-font-smoothing:antialiased}
|
|
3514
|
+
.nf-cap *,.nf-cap *::before,.nf-cap *::after{box-sizing:border-box}
|
|
3515
|
+
.nf-cap-title{margin:0;font-size:var(--nf-text-title);font-weight:600;letter-spacing:-0.01em}
|
|
3516
|
+
.nf-cap-grid{display:grid;gap:3px;outline:none;overflow:auto;min-height:0;border-radius:8px}
|
|
3517
|
+
.nf-cap-grid:focus-visible{box-shadow:0 0 0 2px var(--nf-surface),0 0 0 4px var(--nf-focus)}
|
|
3518
|
+
.nf-cap-row{display:contents}
|
|
3519
|
+
.nf-cap-colhead{font-family:var(--nf-mono-font);font-size:var(--nf-text-caption);color:var(--nf-muted);text-align:center;white-space:nowrap;padding:0 0 2px}
|
|
3520
|
+
.nf-cap-name{font-size:var(--nf-text-label);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;align-self:center;padding-inline-end:8px}
|
|
3521
|
+
.nf-cap-cell{height:32px;border-radius:4px;display:flex;align-items:center;justify-content:center;font-family:var(--nf-mono-font);font-size:var(--nf-text-caption);cursor:pointer;user-select:none;transition:opacity ${motion.fast} ${motion.ease}}
|
|
3522
|
+
.nf-cap-cell[data-step="4"]{font-weight:600}
|
|
3523
|
+
.nf-cap-cell[aria-selected="true"],.nf-cap-cell[data-active]{box-shadow:0 0 0 2px var(--nf-surface),0 0 0 4px var(--nf-focus)}
|
|
3524
|
+
.nf-cap-cell[data-dim],.nf-cap-name[data-dim]{opacity:${HOST_DIM}}
|
|
3525
|
+
.nf-cap-legend{display:flex;flex-wrap:wrap;gap:6px 12px;align-items:center;font-size:var(--nf-text-caption);color:var(--nf-muted)}
|
|
3526
|
+
.nf-cap-legend i{display:inline-block;width:14px;height:10px;border-radius:2px;margin-inline-end:4px;vertical-align:-1px}
|
|
3527
|
+
.nf-cap-tip{position:fixed;z-index:20;pointer-events:none;padding:8px 10px;border-radius:8px;background:var(--nf-tooltip-bg);color:var(--nf-tooltip-ink);font-size:var(--nf-text-value);box-shadow:0 8px 24px rgba(14,17,22,.24);max-width:260px}
|
|
3528
|
+
.nf-cap-tip b{display:block;font-weight:600;margin-bottom:2px}
|
|
3529
|
+
.nf-cap-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
|
3530
|
+
.nf-cap-mark{position:absolute;right:12px;bottom:12px;display:inline-flex;align-items:center;gap:6px;padding:5px 9px 5px 6px;border-radius:8px;background:var(--nf-surface);border:1px solid var(--nf-line);font-size:var(--nf-text-caption);color:var(--nf-muted);text-decoration:none;font-family:inherit;cursor:pointer}
|
|
3531
|
+
.nf-cap-mark:focus-visible{outline:2px solid var(--nf-focus);outline-offset:2px}
|
|
3532
|
+
.nf-cap[data-static] *{animation:none!important;transition:none!important}
|
|
3533
|
+
@media (prefers-reduced-motion: reduce){.nf-cap *{animation:none!important;transition:none!important}}
|
|
3534
|
+
@media (forced-colors: active){.nf-cap-cell{forced-color-adjust:none;border:1px solid CanvasText}}
|
|
3535
|
+
`;
|
|
3536
|
+
function CapacityHeatmap(props) {
|
|
3537
|
+
const { rows } = props;
|
|
3538
|
+
const uid = useState6(() => nextUid("nfc"))[0];
|
|
3539
|
+
const weeks = useMemo3(() => props.weeks ?? weeksOf(rows), [props.weeks, rows]);
|
|
3540
|
+
const labels = useMemo3(() => weeks.map((w, i) => props.weekLabel ? props.weekLabel(w, i) : weekLabels(weeks)[i]), [weeks, props.weekLabel]);
|
|
3541
|
+
const license = useLicense(props.licenseKey, { product: "plan", tenantId: props.tenantId });
|
|
3542
|
+
const plain = palette(props.theme, props.accent, false);
|
|
3543
|
+
const surface = props.colors?.background ?? plain.surface;
|
|
3544
|
+
const steps = stepColors(plain.accent, surface, props.colors?.steps);
|
|
3545
|
+
const themeOverrides = {};
|
|
3546
|
+
if (props.colors?.background) themeOverrides.surface = themeOverrides.tooltipInk = props.colors.background;
|
|
3547
|
+
if (props.colors?.foreground) themeOverrides.ink = themeOverrides.muted = themeOverrides.line = themeOverrides.tooltipBg = themeOverrides.focus = props.colors.foreground;
|
|
3548
|
+
const vars = resolveTheme(Object.keys(themeOverrides).length ? { ...plain, ...themeOverrides } : props.theme, props.accent);
|
|
3549
|
+
const cellAt = (r, w) => r.cells.find((c) => c.week === w) ?? { week: w, booked: 0, capacity: 0 };
|
|
3550
|
+
const pctText = (c, use) => {
|
|
3551
|
+
const pct = utilisationPct(c);
|
|
3552
|
+
if (pct === null) return c.booked > 0 ? "Over" : "\u2013";
|
|
3553
|
+
return props.formatValue ? props.formatValue(pct, { field: "utilisation", use }) : `${pct}%`;
|
|
3554
|
+
};
|
|
3555
|
+
const hours = (v) => props.formatValue ? props.formatValue(v, { field: "hours", use: "tooltip" }) : `${Math.round(v * 10) / 10} h`;
|
|
3556
|
+
const target = (ri, wi) => {
|
|
3557
|
+
const r = rows[ri];
|
|
3558
|
+
const w = weeks[wi];
|
|
3559
|
+
if (!r || w === void 0) return null;
|
|
3560
|
+
return { row: r, cell: cellAt(r, w), key: cellKey(r.id, w) };
|
|
3561
|
+
};
|
|
3562
|
+
const hl = props.highlighted && props.highlighted.length ? new Set(props.highlighted) : void 0;
|
|
3563
|
+
const dimmed = (r, w) => !!hl && !isHighlighted(r.id, hl) && !isHighlighted(cellKey(r.id, w), hl);
|
|
3564
|
+
const [active, setActive] = useState6(null);
|
|
3565
|
+
const [selected, setSelected] = useState6(null);
|
|
3566
|
+
const [tip, setTip] = useState6(null);
|
|
3567
|
+
const hovering = useRef4(false);
|
|
3568
|
+
const hover = (t, at) => {
|
|
3569
|
+
if (!props.onHover) return;
|
|
3570
|
+
if (!t && !hovering.current) return;
|
|
3571
|
+
hovering.current = !!t;
|
|
3572
|
+
props.onHover(t, at);
|
|
3573
|
+
};
|
|
3574
|
+
const anchor = (ri, wi) => {
|
|
3575
|
+
const el = typeof document !== "undefined" ? document.getElementById(`${uid}-${ri}-${wi}`) : null;
|
|
3576
|
+
const b = el?.getBoundingClientRect();
|
|
3577
|
+
return b ? { clientX: b.left + b.width / 2, clientY: b.top + b.height / 2, multi: false } : null;
|
|
3578
|
+
};
|
|
3579
|
+
const choose = (ri, wi, multi) => {
|
|
3580
|
+
const t = target(ri, wi);
|
|
3581
|
+
if (!t) return;
|
|
3582
|
+
setSelected(t.key);
|
|
3583
|
+
setActive({ r: ri, w: wi });
|
|
3584
|
+
props.onSelect?.(t, { multi });
|
|
3585
|
+
};
|
|
3586
|
+
const onKeyDown = (e2) => {
|
|
3587
|
+
if (!rows.length || !weeks.length) return;
|
|
3588
|
+
const a = active ?? { r: 0, w: 0 };
|
|
3589
|
+
const rtl = props.dir === "rtl";
|
|
3590
|
+
let next2 = null;
|
|
3591
|
+
if (e2.key === "ArrowRight") next2 = { r: a.r, w: Math.min(weeks.length - 1, Math.max(0, a.w + (rtl ? -1 : 1))) };
|
|
3592
|
+
else if (e2.key === "ArrowLeft") next2 = { r: a.r, w: Math.min(weeks.length - 1, Math.max(0, a.w + (rtl ? 1 : -1))) };
|
|
3593
|
+
else if (e2.key === "ArrowDown") next2 = { r: Math.min(rows.length - 1, a.r + 1), w: a.w };
|
|
3594
|
+
else if (e2.key === "ArrowUp") next2 = { r: Math.max(0, a.r - 1), w: a.w };
|
|
3595
|
+
else if (e2.key === "Home") next2 = { r: a.r, w: 0 };
|
|
3596
|
+
else if (e2.key === "End") next2 = { r: a.r, w: weeks.length - 1 };
|
|
3597
|
+
else if (e2.key === "Enter" || e2.key === " ") {
|
|
3598
|
+
choose(a.r, a.w, isMultiSelect(e2));
|
|
3599
|
+
} else if (e2.key === "Escape") {
|
|
3600
|
+
setSelected(null);
|
|
3601
|
+
props.onSelect?.(null, { multi: false });
|
|
3602
|
+
} else if (isContextMenuKey(e2) && props.onContextMenu) {
|
|
3603
|
+
const t = target(a.r, a.w);
|
|
3604
|
+
const at = anchor(a.r, a.w);
|
|
3605
|
+
if (t && at) props.onContextMenu(t, at);
|
|
3606
|
+
} else return;
|
|
3607
|
+
e2.preventDefault();
|
|
3608
|
+
if (next2) {
|
|
3609
|
+
setActive(next2);
|
|
3610
|
+
const t = target(next2.r, next2.w);
|
|
3611
|
+
const n = next2;
|
|
3612
|
+
if (props.onHover && t) requestAnimationFrame(() => {
|
|
3613
|
+
const at = anchor(n.r, n.w);
|
|
3614
|
+
if (at) hover(t, at);
|
|
3615
|
+
});
|
|
3616
|
+
}
|
|
3617
|
+
};
|
|
3618
|
+
const title = props.title ?? capacityHeadline(rows);
|
|
3619
|
+
const columns = `minmax(96px, max-content) repeat(${weeks.length}, minmax(40px, 1fr))`;
|
|
3620
|
+
const tipT = tip && props.tooltip !== "none" ? target(tip.r, tip.w) : null;
|
|
3621
|
+
const style = { ...vars, height: props.height };
|
|
3622
|
+
const activeId = active ? `${uid}-${active.r}-${active.w}` : void 0;
|
|
3623
|
+
const describe = (t, label) => `${t.row.name}, week of ${label}: ${pctText(t.cell, "tooltip")}, ${hours(t.cell.booked)} of ${hours(t.cell.capacity)}${capacityStep(t.cell) === 4 ? ", over capacity" : ""}`;
|
|
3624
|
+
return /* @__PURE__ */ React7.createElement(
|
|
3625
|
+
"div",
|
|
3626
|
+
{
|
|
3627
|
+
className: `nf-cap${props.className ? " " + props.className : ""}`,
|
|
3628
|
+
style,
|
|
3629
|
+
dir: props.dir,
|
|
3630
|
+
"data-static": props.animate === false ? "" : void 0,
|
|
3631
|
+
"data-hl": hl ? "" : void 0
|
|
3632
|
+
},
|
|
3633
|
+
/* @__PURE__ */ React7.createElement("style", { dangerouslySetInnerHTML: { __html: css2 } }),
|
|
3634
|
+
/* @__PURE__ */ React7.createElement("p", { className: "nf-cap-title", id: `${uid}-t` }, title),
|
|
3635
|
+
/* @__PURE__ */ React7.createElement(
|
|
3636
|
+
"div",
|
|
3637
|
+
{
|
|
3638
|
+
className: "nf-cap-grid",
|
|
3639
|
+
role: "grid",
|
|
3640
|
+
"aria-labelledby": `${uid}-t`,
|
|
3641
|
+
"aria-rowcount": rows.length + 1,
|
|
3642
|
+
"aria-colcount": weeks.length + 1,
|
|
3643
|
+
tabIndex: 0,
|
|
3644
|
+
"aria-activedescendant": activeId,
|
|
3645
|
+
style: { gridTemplateColumns: columns },
|
|
3646
|
+
onKeyDown,
|
|
3647
|
+
onFocus: () => setActive((a) => a ?? (rows.length && weeks.length ? { r: 0, w: 0 } : null)),
|
|
3648
|
+
onMouseLeave: (e2) => {
|
|
3649
|
+
setTip(null);
|
|
3650
|
+
hover(null, { clientX: e2.clientX, clientY: e2.clientY, multi: false });
|
|
3651
|
+
}
|
|
3652
|
+
},
|
|
3653
|
+
/* @__PURE__ */ React7.createElement("div", { className: "nf-cap-row", role: "row", "aria-rowindex": 1 }, /* @__PURE__ */ React7.createElement("div", { className: "nf-cap-colhead", role: "columnheader", style: { textAlign: "start" } }, props.rowHeader ?? /* @__PURE__ */ React7.createElement("span", { className: "nf-cap-sr" }, "Resource")), weeks.map((w, wi) => /* @__PURE__ */ React7.createElement("div", { key: w, className: "nf-cap-colhead", role: "columnheader" }, labels[wi]))),
|
|
3654
|
+
rows.map((r, ri) => /* @__PURE__ */ React7.createElement("div", { key: r.id, className: "nf-cap-row", role: "row", "aria-rowindex": ri + 2 }, /* @__PURE__ */ React7.createElement("div", { className: "nf-cap-name", role: "rowheader", title: r.name, "data-dim": hl && !isHighlighted(r.id, hl) && !weeks.some((w) => isHighlighted(cellKey(r.id, w), hl)) ? "" : void 0 }, r.name), weeks.map((w, wi) => {
|
|
3655
|
+
const c = cellAt(r, w);
|
|
3656
|
+
const step = capacityStep(c);
|
|
3657
|
+
const col = steps[step];
|
|
3658
|
+
const key = cellKey(r.id, w);
|
|
3659
|
+
const t = { row: r, cell: c, key };
|
|
3660
|
+
return /* @__PURE__ */ React7.createElement(
|
|
3661
|
+
"div",
|
|
3662
|
+
{
|
|
3663
|
+
key: w,
|
|
3664
|
+
id: `${uid}-${ri}-${wi}`,
|
|
3665
|
+
className: "nf-cap-cell",
|
|
3666
|
+
role: "gridcell",
|
|
3667
|
+
"aria-selected": selected === key,
|
|
3668
|
+
"aria-label": describe(t, weekName(w)),
|
|
3669
|
+
"data-step": step,
|
|
3670
|
+
"data-active": active && active.r === ri && active.w === wi ? "" : void 0,
|
|
3671
|
+
"data-dim": dimmed(r, w) ? "" : void 0,
|
|
3672
|
+
style: { background: col.background, color: col.text },
|
|
3673
|
+
onClick: (e2) => choose(ri, wi, isMultiSelect(e2)),
|
|
3674
|
+
onMouseMove: (e2) => {
|
|
3675
|
+
if (!tip || tip.r !== ri || tip.w !== wi) setTip({ r: ri, w: wi, x: e2.clientX, y: e2.clientY });
|
|
3676
|
+
hover(t, { clientX: e2.clientX, clientY: e2.clientY, multi: isMultiSelect(e2) });
|
|
3677
|
+
},
|
|
3678
|
+
onContextMenu: props.onContextMenu ? (e2) => {
|
|
3679
|
+
e2.preventDefault();
|
|
3680
|
+
props.onContextMenu(t, { clientX: e2.clientX, clientY: e2.clientY, multi: isMultiSelect(e2) });
|
|
3681
|
+
} : void 0
|
|
3682
|
+
},
|
|
3683
|
+
/* @__PURE__ */ React7.createElement("span", { "aria-hidden": "true" }, pctText(c, "label"))
|
|
3684
|
+
);
|
|
3685
|
+
})))
|
|
3686
|
+
),
|
|
3687
|
+
props.showLegend === false ? null : /* @__PURE__ */ React7.createElement("div", { className: "nf-cap-legend", "aria-hidden": "true" }, /* @__PURE__ */ React7.createElement("span", null, "Booked vs capacity"), STEP_LABELS.map((l, i) => /* @__PURE__ */ React7.createElement("span", { key: l }, /* @__PURE__ */ React7.createElement("i", { style: { background: steps[i].background } }), l))),
|
|
3688
|
+
tipT && tip ? /* @__PURE__ */ React7.createElement("div", { className: "nf-cap-tip", role: "tooltip", style: { left: tip.x + 12, top: tip.y + 12 } }, /* @__PURE__ */ React7.createElement("b", null, tipT.row.name), "Week of ", weekName(tipT.cell.week), ": ", pctText(tipT.cell, "tooltip"), /* @__PURE__ */ React7.createElement("br", null), hours(tipT.cell.booked), " booked of ", hours(tipT.cell.capacity)) : null,
|
|
3689
|
+
license && !license.licensed && props.watermark !== false ? /* @__PURE__ */ React7.createElement(Watermark, { prefix: "nf-cap", title: licenseMessage(license), markHtml: markSvg({ size: 16 }), variant: props.watermarkStyle, onClick: props.onWatermarkClick }) : null
|
|
3690
|
+
);
|
|
3691
|
+
}
|
|
3692
|
+
export {
|
|
3693
|
+
CapacityHeatmap,
|
|
3694
|
+
PRESETS,
|
|
3695
|
+
STEP_LABELS,
|
|
3696
|
+
Scheduler,
|
|
3697
|
+
UTC,
|
|
3698
|
+
VERSION,
|
|
3699
|
+
VIEW_LABEL,
|
|
3700
|
+
assignLanes,
|
|
3701
|
+
browserZone,
|
|
3702
|
+
buildRows,
|
|
3703
|
+
capacityHeadline,
|
|
3704
|
+
capacityStep,
|
|
3705
|
+
cellKey,
|
|
3706
|
+
conflicts,
|
|
3707
|
+
fixedZone,
|
|
3708
|
+
ianaZone,
|
|
3709
|
+
normalise,
|
|
3710
|
+
pcfZone,
|
|
3711
|
+
rollUp,
|
|
3712
|
+
sampleScenario,
|
|
3713
|
+
scaleScenario,
|
|
3714
|
+
stepColors,
|
|
3715
|
+
todayIn,
|
|
3716
|
+
utilisationPct,
|
|
3717
|
+
viewRange,
|
|
3718
|
+
weekLabels,
|
|
3719
|
+
weekName,
|
|
3720
|
+
weekStarts,
|
|
3721
|
+
weeklyUtilisation,
|
|
3722
|
+
weeksOf
|
|
3723
|
+
};
|
|
3724
|
+
//# sourceMappingURL=index.js.map
|