L2 Aufträge & Backoffice: Service-Schicht Aufträge + Tests
Statusmaschine (transitionWorkOrder inkl. eventData), Zuweisung, Completion-Guards, Materialvorgabe, Checklisten/Pflichtfotos, Liste/Dashboard-Presets, Suche, Sync-Konflikte, Einstellungen (Auftragsarten, Vorlagen, Nummernkreise). Tests: Übergangsmatrix je Rolle, Kernlogik, Scope/Mandantentrennung, Nummernkreis-Parallelität. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Timezone helpers (client-safe, Intl only). Planned dates are entered as wall-clock time in the
|
||||
* tenant timezone (TenantSettings.timezone) and stored as UTC.
|
||||
*/
|
||||
|
||||
export const DEFAULT_TIMEZONE = "Europe/Berlin";
|
||||
|
||||
export function safeTimeZone(tz: string | null | undefined): string {
|
||||
if (!tz) return DEFAULT_TIMEZONE;
|
||||
try {
|
||||
new Intl.DateTimeFormat("en-US", { timeZone: tz });
|
||||
return tz;
|
||||
} catch {
|
||||
return DEFAULT_TIMEZONE;
|
||||
}
|
||||
}
|
||||
|
||||
/** Offset (ms) of `timeZone` relative to UTC at instant `at`. */
|
||||
export function tzOffsetMs(at: Date, timeZone: string): number {
|
||||
const parts = new Intl.DateTimeFormat("en-US", {
|
||||
timeZone,
|
||||
hourCycle: "h23",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
second: "2-digit",
|
||||
}).formatToParts(at);
|
||||
const n = (t: string) => Number(parts.find((p) => p.type === t)?.value);
|
||||
const asUtc = Date.UTC(n("year"), n("month") - 1, n("day"), n("hour"), n("minute"), n("second"));
|
||||
return asUtc - Math.floor(at.getTime() / 1000) * 1000;
|
||||
}
|
||||
|
||||
/** "2026-09-14T08:30" (wall time in tz) → UTC Date. Returns undefined for invalid input. */
|
||||
export function wallTimeToUtc(value: string, timeZone: string): Date | undefined {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})(?:T(\d{2}):(\d{2}))?$/.exec(value.trim());
|
||||
if (!m) return undefined;
|
||||
const tz = safeTimeZone(timeZone);
|
||||
const guess = Date.UTC(+m[1], +m[2] - 1, +m[3], m[4] ? +m[4] : 0, m[5] ? +m[5] : 0);
|
||||
const first = guess - tzOffsetMs(new Date(guess), tz);
|
||||
// second pass corrects DST boundaries
|
||||
const result = guess - tzOffsetMs(new Date(first), tz);
|
||||
const d = new Date(result);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
/** UTC Date → "YYYY-MM-DDTHH:mm" wall time in tz (for <input type="datetime-local">). */
|
||||
export function toWallTimeInput(date: Date | null | undefined, timeZone: string): string {
|
||||
if (!date) return "";
|
||||
const parts = new Intl.DateTimeFormat("en-CA", {
|
||||
timeZone: safeTimeZone(timeZone),
|
||||
hourCycle: "h23",
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
}).formatToParts(date);
|
||||
const p = (t: string) => parts.find((x) => x.type === t)?.value ?? "00";
|
||||
return `${p("year")}-${p("month")}-${p("day")}T${p("hour")}:${p("minute")}`;
|
||||
}
|
||||
|
||||
/** Start/end of the calendar day containing `now` in the given timezone. */
|
||||
export function zonedDayBounds(now: Date, timeZone: string): { start: Date; end: Date } {
|
||||
const tz = safeTimeZone(timeZone);
|
||||
const [y, m, d] = new Intl.DateTimeFormat("en-CA", { timeZone: tz, year: "numeric", month: "2-digit", day: "2-digit" })
|
||||
.format(now)
|
||||
.split("-")
|
||||
.map(Number);
|
||||
const guessStart = Date.UTC(y, m - 1, d, 0, 0, 0);
|
||||
const start = new Date(guessStart - tzOffsetMs(new Date(guessStart), tz));
|
||||
const guessEnd = Date.UTC(y, m - 1, d, 23, 59, 59, 999);
|
||||
const end = new Date(guessEnd - tzOffsetMs(new Date(guessEnd), tz));
|
||||
return { start, end };
|
||||
}
|
||||
|
||||
export function formatDateTime(date: Date | null | undefined, locale: string, timeZone: string): string {
|
||||
if (!date) return "";
|
||||
return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", { timeZone: safeTimeZone(timeZone), dateStyle: "medium", timeStyle: "short" }).format(date);
|
||||
}
|
||||
|
||||
export function formatDate(date: Date | null | undefined, locale: string, timeZone: string): string {
|
||||
if (!date) return "";
|
||||
return new Intl.DateTimeFormat(locale === "en" ? "en-GB" : "de-DE", { timeZone: safeTimeZone(timeZone), dateStyle: "medium" }).format(date);
|
||||
}
|
||||
Reference in New Issue
Block a user