Merge lane/auftraege in feature/craftvia-mvp
Konflikte gelöst: Header mit Suche (L2) und Glocke (L6), Audit-Labels vereinigt (ohne doppeltes sync_operation), Navigation mit Benachrichtigungen und Auftragsvorlagen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -11,6 +11,7 @@ import {
|
||||
Bell,
|
||||
History,
|
||||
Mail,
|
||||
ListChecks,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { ModuleKey } from "@/lib/modules";
|
||||
@@ -53,6 +54,7 @@ export const NAV_ITEMS: readonly NavItem[] = [
|
||||
{ href: "/reports", label: "reports", icon: FileText, module: "reports", permissions: ["report:read"], section: "main" },
|
||||
{ href: "/documents", label: "documents", icon: FolderOpen, module: "documents", permissions: ["document:read"], section: "main" },
|
||||
{ href: "/notifications", label: "notifications", icon: Bell, module: "notifications", permissions: ["notification:read"], section: "main" },
|
||||
{ href: "/settings/order-types", label: "templates", icon: ListChecks, permissions: ["settings:templates"], section: "admin" },
|
||||
{ href: "/settings", label: "settings", icon: Settings, permissions: ["tenant:manage"], section: "admin" },
|
||||
{ href: "/settings/email", label: "email", icon: Mail, module: "notifications", permissions: ["tenant:manage"], section: "admin" },
|
||||
{ href: "/settings/audit", label: "audit", icon: History, permissions: ["audit:read"], section: "admin" },
|
||||
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { CompletionBlocker } from "@/lib/work-orders/status";
|
||||
|
||||
/** Result of a work order server action (client-safe; rendered by components/work-orders/action-form.tsx). */
|
||||
export type ActionState =
|
||||
| { status: "idle" }
|
||||
| { status: "ok"; at: number }
|
||||
| {
|
||||
status: "error";
|
||||
/** ServiceError code: not_found | forbidden | invalid | conflict | blocked | internal */
|
||||
code: string;
|
||||
/** message key below `errors.` (falls back to the code) */
|
||||
message: string;
|
||||
blockers?: CompletionBlocker[];
|
||||
at: number;
|
||||
};
|
||||
|
||||
export const IDLE_STATE: ActionState = { status: "idle" };
|
||||
@@ -0,0 +1,48 @@
|
||||
import type { TemplateItem, TemplatePhoto } from "@/lib/work-orders/schemas";
|
||||
|
||||
/**
|
||||
* Tenant defaults (client-safe data). Labels are German master data (stored per tenant and
|
||||
* editable afterwards), not UI chrome — therefore not part of the message catalogue.
|
||||
*/
|
||||
|
||||
/** Mirrors the defaults of src/server/services/numbering.ts (used before a sequence row exists). */
|
||||
export const DEFAULT_NUMBER_PREFIX: Record<"customer" | "work_order" | "emergency" | "report", string> = {
|
||||
customer: "K-",
|
||||
work_order: "A-",
|
||||
emergency: "N-",
|
||||
report: "B-",
|
||||
};
|
||||
|
||||
/** Spec §10.2 — created by ensureDefaultOrderTypes on first use per tenant. */
|
||||
export const DEFAULT_ORDER_TYPES: ReadonlyArray<{ key: string; name: string; signatureRequired: boolean; sortOrder: number }> = [
|
||||
{ key: "montage", name: "Montage", signatureRequired: true, sortOrder: 10 },
|
||||
{ key: "reparatur", name: "Reparatur", signatureRequired: true, sortOrder: 20 },
|
||||
{ key: "wartung", name: "Wartung", signatureRequired: true, sortOrder: 30 },
|
||||
{ key: "stoerung", name: "Störung", signatureRequired: true, sortOrder: 40 },
|
||||
{ key: "notdienst", name: "Notdienst", signatureRequired: true, sortOrder: 50 },
|
||||
{ key: "besichtigung", name: "Besichtigung", signatureRequired: false, sortOrder: 60 },
|
||||
{ key: "abnahme", name: "Abnahme", signatureRequired: true, sortOrder: 70 },
|
||||
{ key: "nacharbeit", name: "Nacharbeit", signatureRequired: true, sortOrder: 80 },
|
||||
];
|
||||
|
||||
/** Spec §12.4 — suggested checklist items. */
|
||||
export const DEFAULT_CHECKLIST_ITEMS: readonly TemplateItem[] = [
|
||||
{ key: "spannungsfrei", label: "Anlage spannungsfrei geschaltet", required: true, requiresPhoto: false },
|
||||
{ key: "arbeitsbereich_abgesichert", label: "Arbeitsbereich abgesichert", required: true, requiresPhoto: false },
|
||||
{ key: "material_geprueft", label: "Material geprüft", required: false, requiresPhoto: false },
|
||||
{ key: "funktionspruefung", label: "Funktionsprüfung durchgeführt", required: true, requiresPhoto: true },
|
||||
{ key: "arbeitsbereich_gereinigt", label: "Arbeitsbereich gereinigt", required: true, requiresPhoto: false },
|
||||
{ key: "kunde_eingewiesen", label: "Kunde eingewiesen", required: false, requiresPhoto: false },
|
||||
{ key: "pflichtfotos_erstellt", label: "Pflichtfotos erstellt", required: true, requiresPhoto: false },
|
||||
];
|
||||
|
||||
/** Spec §14.2 — suggested required photos (keys match PhotoRequirement.key catalogue). */
|
||||
export const DEFAULT_REQUIRED_PHOTOS: readonly TemplatePhoto[] = [
|
||||
{ key: "ausgangszustand", label: "Ausgangszustand" },
|
||||
{ key: "typenschild", label: "Typenschild" },
|
||||
{ key: "leitungsverlauf", label: "Leitungsverlauf" },
|
||||
{ key: "zwischenschritt", label: "Zwischenschritt" },
|
||||
{ key: "fertige_montage", label: "Fertige Montage" },
|
||||
{ key: "funktionspruefung", label: "Funktionsprüfung" },
|
||||
{ key: "arbeitsbereich_abschluss", label: "Arbeitsbereich nach Abschluss" },
|
||||
];
|
||||
@@ -0,0 +1,126 @@
|
||||
import { WORK_ORDER_STATUSES, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
import { WORK_ORDER_PRIORITIES, type WorkOrderPriority } from "@/lib/work-orders/schemas";
|
||||
|
||||
/**
|
||||
* List/dashboard filter model (spec §21) and its URL representation (client-safe).
|
||||
* The same parser feeds /work-orders, /dashboard and GET /api/v1/work-orders.
|
||||
*/
|
||||
|
||||
export const STATUS_GROUPS: readonly StatusGroup[] = [
|
||||
"new",
|
||||
"planned",
|
||||
"en_route",
|
||||
"in_progress",
|
||||
"documentation_incomplete",
|
||||
"in_review",
|
||||
"ready_for_billing",
|
||||
"billed",
|
||||
"cancelled",
|
||||
];
|
||||
|
||||
/** Dashboard tiles (§21) — each tile links to the list with `preset=<key>`. */
|
||||
export const PRESETS = [
|
||||
"open",
|
||||
"today",
|
||||
"running",
|
||||
"not_accepted",
|
||||
"overdue",
|
||||
"reports_in_review",
|
||||
"completed",
|
||||
"billing",
|
||||
"emergency_new",
|
||||
"missing_signatures",
|
||||
] as const;
|
||||
export type Preset = (typeof PRESETS)[number];
|
||||
|
||||
export const SORT_FIELDS = ["plannedStart", "createdAt", "updatedAt", "number", "priority", "status"] as const;
|
||||
export type SortField = (typeof SORT_FIELDS)[number];
|
||||
|
||||
export type WorkOrderFilter = {
|
||||
from?: Date;
|
||||
to?: Date;
|
||||
customerId?: string;
|
||||
siteId?: string;
|
||||
teamId?: string;
|
||||
userId?: string;
|
||||
statuses?: WorkOrderStatus[];
|
||||
group?: StatusGroup;
|
||||
orderTypeId?: string;
|
||||
priority?: WorkOrderPriority;
|
||||
preset?: Preset;
|
||||
q?: string;
|
||||
};
|
||||
|
||||
export type ListParams = WorkOrderFilter & {
|
||||
sort: SortField;
|
||||
dir: "asc" | "desc";
|
||||
page: number;
|
||||
pageSize: number;
|
||||
};
|
||||
|
||||
type Raw = Record<string, string | string[] | undefined> | URLSearchParams;
|
||||
|
||||
function get(raw: Raw, key: string): string | undefined {
|
||||
const v = raw instanceof URLSearchParams ? raw.get(key) ?? undefined : raw[key];
|
||||
const s = Array.isArray(v) ? v[0] : v;
|
||||
return s && s.trim() ? s.trim() : undefined;
|
||||
}
|
||||
|
||||
function date(v: string | undefined, endOfDay = false): Date | undefined {
|
||||
if (!v || !/^\d{4}-\d{2}-\d{2}$/.test(v)) return undefined;
|
||||
const d = new Date(`${v}T${endOfDay ? "23:59:59.999" : "00:00:00.000"}`);
|
||||
return Number.isNaN(d.getTime()) ? undefined : d;
|
||||
}
|
||||
|
||||
const cuidLike = (v: string | undefined) => (v && /^[A-Za-z0-9_-]{1,64}$/.test(v) ? v : undefined);
|
||||
|
||||
export function parseListParams(raw: Raw): ListParams {
|
||||
const statusRaw = get(raw, "status");
|
||||
const statuses = statusRaw
|
||||
?.split(",")
|
||||
.filter((s): s is WorkOrderStatus => (WORK_ORDER_STATUSES as readonly string[]).includes(s));
|
||||
const group = get(raw, "group");
|
||||
const priority = get(raw, "priority");
|
||||
const preset = get(raw, "preset");
|
||||
const sort = get(raw, "sort");
|
||||
const q = get(raw, "q");
|
||||
return {
|
||||
from: date(get(raw, "from")),
|
||||
to: date(get(raw, "to"), true),
|
||||
customerId: cuidLike(get(raw, "customerId")),
|
||||
siteId: cuidLike(get(raw, "siteId")),
|
||||
teamId: cuidLike(get(raw, "teamId")),
|
||||
userId: cuidLike(get(raw, "userId")),
|
||||
statuses: statuses?.length ? statuses : undefined,
|
||||
group: group && (STATUS_GROUPS as readonly string[]).includes(group) ? (group as StatusGroup) : undefined,
|
||||
orderTypeId: cuidLike(get(raw, "orderTypeId")),
|
||||
priority: priority && (WORK_ORDER_PRIORITIES as readonly string[]).includes(priority) ? (priority as WorkOrderPriority) : undefined,
|
||||
preset: preset && (PRESETS as readonly string[]).includes(preset) ? (preset as Preset) : undefined,
|
||||
q: q ? q.slice(0, 100) : undefined,
|
||||
sort: sort && (SORT_FIELDS as readonly string[]).includes(sort) ? (sort as SortField) : "plannedStart",
|
||||
dir: get(raw, "dir") === "desc" ? "desc" : "asc",
|
||||
page: Math.max(1, Math.min(10_000, Number.parseInt(get(raw, "page") ?? "1", 10) || 1)),
|
||||
pageSize: Math.max(1, Math.min(100, Number.parseInt(get(raw, "pageSize") ?? "25", 10) || 25)),
|
||||
};
|
||||
}
|
||||
|
||||
/** Serialise filter params back into a query string (drops empty values). */
|
||||
export function toQuery(params: Partial<Record<keyof ListParams | "view" | "new" | "status", unknown>>): string {
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) {
|
||||
if (v === undefined || v === null || v === "") continue;
|
||||
if (v instanceof Date) sp.set(k, isoDay(v));
|
||||
else if (Array.isArray(v)) {
|
||||
if (v.length) sp.set(k, v.join(","));
|
||||
} else sp.set(k, String(v));
|
||||
}
|
||||
const s = sp.toString();
|
||||
return s ? `?${s}` : "";
|
||||
}
|
||||
|
||||
export function isoDay(d: Date): string {
|
||||
const y = d.getFullYear();
|
||||
const m = String(d.getMonth() + 1).padStart(2, "0");
|
||||
const day = String(d.getDate()).padStart(2, "0");
|
||||
return `${y}-${m}-${day}`;
|
||||
}
|
||||
@@ -0,0 +1,219 @@
|
||||
import { z } from "zod";
|
||||
import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status";
|
||||
|
||||
/**
|
||||
* Zod input schemas of the work order module (client-safe). Services parse with these;
|
||||
* server actions and /api/v1 handlers only map FormData/JSON onto them.
|
||||
*/
|
||||
|
||||
export const WORK_ORDER_PRIORITIES = ["low", "normal", "high", "urgent"] as const;
|
||||
export type WorkOrderPriority = (typeof WORK_ORDER_PRIORITIES)[number];
|
||||
|
||||
export const BILLING_TYPES = ["fixed", "time_material", "maintenance_contract", "warranty"] as const;
|
||||
export type BillingType = (typeof BILLING_TYPES)[number];
|
||||
|
||||
const id = z.string().min(1).max(64);
|
||||
const optId = id.nullish();
|
||||
const text = (max: number) => z.string().trim().max(max);
|
||||
const optText = (max: number) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.max(max)
|
||||
.nullish()
|
||||
.transform((v) => (v ? v : null));
|
||||
const optDate = z.coerce.date().nullish();
|
||||
|
||||
export const materialPlanInputSchema = z.object({
|
||||
name: text(200).min(1),
|
||||
articleNumber: optText(80),
|
||||
plannedQuantity: z.coerce.number().positive().max(1_000_000),
|
||||
unit: text(20).min(1),
|
||||
notes: optText(1000),
|
||||
sortOrder: z.coerce.number().int().min(0).max(10_000).optional(),
|
||||
});
|
||||
export type MaterialPlanInput = z.input<typeof materialPlanInputSchema>;
|
||||
|
||||
export const checklistItemInputSchema = z.object({
|
||||
key: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(60)
|
||||
.regex(/^[a-z0-9_]+$/)
|
||||
.optional(),
|
||||
label: text(200).min(1),
|
||||
required: z.boolean().default(false),
|
||||
requiresPhoto: z.boolean().default(false),
|
||||
sortOrder: z.coerce.number().int().min(0).max(10_000).optional(),
|
||||
});
|
||||
export type ChecklistItemInput = z.input<typeof checklistItemInputSchema>;
|
||||
|
||||
export const photoRequirementInputSchema = z.object({
|
||||
key: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(60)
|
||||
.regex(/^[a-z0-9_]+$/)
|
||||
.optional(),
|
||||
label: text(200).min(1),
|
||||
sortOrder: z.coerce.number().int().min(0).max(10_000).optional(),
|
||||
});
|
||||
export type PhotoRequirementInput = z.input<typeof photoRequirementInputSchema>;
|
||||
|
||||
/** Statuses a work order may be created in (import → review_required, emergency → in_progress via L8). */
|
||||
export const INITIAL_STATUSES = ["draft", "review_required", "planned", "in_progress"] as const;
|
||||
|
||||
export const createWorkOrderSchema = z
|
||||
.object({
|
||||
title: text(200).min(1),
|
||||
customerId: id,
|
||||
siteId: optId,
|
||||
contactId: optId,
|
||||
orderTypeId: optId,
|
||||
priority: z.enum(WORK_ORDER_PRIORITIES).default("normal"),
|
||||
status: z.enum(INITIAL_STATUSES).default("draft"),
|
||||
description: optText(10_000),
|
||||
scope: optText(10_000),
|
||||
plannedStart: optDate,
|
||||
plannedEnd: optDate,
|
||||
/** undefined → taken from the order type (default true) */
|
||||
signatureRequired: z.boolean().optional(),
|
||||
billingType: z.enum(BILLING_TYPES).nullish(),
|
||||
internalNotes: optText(5000),
|
||||
technicianNotes: optText(5000),
|
||||
externalOrderNumber: optText(80),
|
||||
offerNumber: optText(80),
|
||||
isEmergency: z.boolean().default(false),
|
||||
emergencyReason: optText(2000),
|
||||
sourceImportId: optId,
|
||||
/** "emergency" allocates from the N- sequence (lane emergency). */
|
||||
numberKey: z.enum(["work_order", "emergency"]).default("work_order"),
|
||||
/** Copy checklist / photo requirements from the order type's active template (default true). */
|
||||
applyTemplate: z.boolean().default(true),
|
||||
materials: z.array(materialPlanInputSchema).max(200).optional(),
|
||||
checklistItems: z.array(checklistItemInputSchema).max(200).optional(),
|
||||
photoRequirements: z.array(photoRequirementInputSchema).max(50).optional(),
|
||||
})
|
||||
.refine((v) => !v.plannedStart || !v.plannedEnd || v.plannedEnd >= v.plannedStart, {
|
||||
path: ["plannedEnd"],
|
||||
message: "plannedEnd_before_start",
|
||||
});
|
||||
|
||||
/** Input type of createWorkOrder — used by lanes imports (L3) and emergency (L8). */
|
||||
export type CreateWorkOrderInput = z.input<typeof createWorkOrderSchema>;
|
||||
|
||||
export const updateWorkOrderSchema = z
|
||||
.object({
|
||||
title: text(200).min(1).optional(),
|
||||
customerId: id.optional(),
|
||||
siteId: optId,
|
||||
contactId: optId,
|
||||
orderTypeId: optId,
|
||||
priority: z.enum(WORK_ORDER_PRIORITIES).optional(),
|
||||
description: optText(10_000).optional(),
|
||||
scope: optText(10_000).optional(),
|
||||
plannedStart: optDate,
|
||||
plannedEnd: optDate,
|
||||
signatureRequired: z.boolean().optional(),
|
||||
billingType: z.enum(BILLING_TYPES).nullish(),
|
||||
internalNotes: optText(5000).optional(),
|
||||
technicianNotes: optText(5000).optional(),
|
||||
externalOrderNumber: optText(80).optional(),
|
||||
offerNumber: optText(80).optional(),
|
||||
emergencyReason: optText(2000).optional(),
|
||||
})
|
||||
.strict();
|
||||
export type UpdateWorkOrderInput = z.input<typeof updateWorkOrderSchema>;
|
||||
|
||||
export const transitionSchema = z.object({
|
||||
workOrderId: id,
|
||||
to: z.enum(WORK_ORDER_STATUSES),
|
||||
reason: optText(2000),
|
||||
baseVersion: z.coerce.number().int().positive().optional(),
|
||||
});
|
||||
export type TransitionInput = z.input<typeof transitionSchema> & {
|
||||
/** Extra facts merged into the emitted event's `data` (e.g. reportId from lane reports). Never overrides number/from/to. */
|
||||
eventData?: Record<string, string | number | boolean | null>;
|
||||
};
|
||||
|
||||
export const assignSchema = z.object({
|
||||
workOrderId: id,
|
||||
teamId: id,
|
||||
userIds: z.array(id).max(50).default([]),
|
||||
teamLeadUserId: optId,
|
||||
baseVersion: z.coerce.number().int().positive().optional(),
|
||||
});
|
||||
export type AssignInput = z.input<typeof assignSchema>;
|
||||
|
||||
// ---------- Settings ----------
|
||||
|
||||
export const orderTypeSchema = z.object({
|
||||
key: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(2)
|
||||
.max(40)
|
||||
.regex(/^[a-z0-9_]+$/),
|
||||
name: text(80).min(1),
|
||||
signatureRequired: z.boolean().default(true),
|
||||
active: z.boolean().default(true),
|
||||
sortOrder: z.coerce.number().int().min(0).max(10_000).default(100),
|
||||
});
|
||||
export type OrderTypeInput = z.input<typeof orderTypeSchema>;
|
||||
|
||||
export const templateItemSchema = z.object({
|
||||
key: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(60)
|
||||
.regex(/^[a-z0-9_]+$/),
|
||||
label: text(200).min(1),
|
||||
required: z.boolean().default(false),
|
||||
requiresPhoto: z.boolean().default(false),
|
||||
});
|
||||
export const templatePhotoSchema = z.object({
|
||||
key: z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1)
|
||||
.max(60)
|
||||
.regex(/^[a-z0-9_]+$/),
|
||||
label: text(200).min(1),
|
||||
});
|
||||
export type TemplateItem = z.output<typeof templateItemSchema>;
|
||||
export type TemplatePhoto = z.output<typeof templatePhotoSchema>;
|
||||
|
||||
export const checklistTemplateSchema = z.object({
|
||||
name: text(120).min(1),
|
||||
orderTypeId: optId,
|
||||
active: z.boolean().default(true),
|
||||
items: z.array(templateItemSchema).max(100),
|
||||
requiredPhotos: z.array(templatePhotoSchema).max(30),
|
||||
});
|
||||
export type ChecklistTemplateInput = z.input<typeof checklistTemplateSchema>;
|
||||
|
||||
export const NUMBER_KEYS = ["work_order", "emergency", "customer", "report"] as const;
|
||||
export const numberingSchema = z.object({
|
||||
key: z.enum(NUMBER_KEYS),
|
||||
prefix: z
|
||||
.string()
|
||||
.trim()
|
||||
.max(12)
|
||||
.regex(/^[A-Za-z0-9\-_/]*$/),
|
||||
padding: z.coerce.number().int().min(1).max(10),
|
||||
});
|
||||
|
||||
/** Stable key from a free label (umlauts transliterated). */
|
||||
export function slugKey(label: string): string {
|
||||
const s = label
|
||||
.toLowerCase()
|
||||
.replace(/ä/g, "ae")
|
||||
.replace(/ö/g, "oe")
|
||||
.replace(/ü/g, "ue")
|
||||
.replace(/ß/g, "ss")
|
||||
.replace(/[^a-z0-9]+/g, "_")
|
||||
.replace(/^_+|_+$/g, "")
|
||||
.slice(0, 60);
|
||||
return s || "punkt";
|
||||
}
|
||||
@@ -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