Merge lane/berichte in feature/craftvia-mvp

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:23:34 +02:00
co-authored by Claude Opus 5
57 changed files with 4781 additions and 6 deletions
+11
View File
@@ -0,0 +1,11 @@
import type { CompletionBlocker } from "@/lib/work-orders/status";
/** Result of report server actions (client-safe; "use server" files may only export async functions). */
export type ReportActionErrorCode = "generic" | "not_found" | "forbidden" | "conflict" | "invalid" | "blocked";
export type ReportActionState =
| { status: "idle" }
| { status: "ok"; reportId?: string; at: number }
| { status: "error"; code: ReportActionErrorCode; field?: string; blockers?: CompletionBlocker[]; at: number };
export const IDLE: ReportActionState = { status: "idle" };
+194
View File
@@ -0,0 +1,194 @@
import { z } from "zod";
/**
* Report content snapshot (ARCHITEKTUR §4.7, Spec §16.2/§17.2). Client-safe.
*
* Built from the database by src/server/services/reports/build-content.ts when a report is
* created, refreshed on submit/approve and frozen once the report is approved. The editable
* free-text block (`texts`) is owned by the technician and survives every refresh.
*/
export const REPORT_TYPES = ["daily", "completion"] as const;
export type ReportType = (typeof REPORT_TYPES)[number];
export const REPORT_STATUSES = ["draft", "submitted", "team_approved", "approved", "rejected", "superseded"] as const;
export type ReportStatus = (typeof REPORT_STATUSES)[number];
/** Statuses in which the technician may still edit texts / capture a signature. */
export const REPORT_EDITABLE: readonly ReportStatus[] = ["draft", "rejected"];
/** Statuses waiting for a reviewer ("Zur Prüfung"). */
export const REPORT_IN_REVIEW: readonly ReportStatus[] = ["submitted", "team_approved"];
/** Badge tone per report status — always rendered together with the status text. */
export const REPORT_STATUS_TONE: Record<ReportStatus, "mut" | "info" | "warn" | "ok" | "risk"> = {
draft: "mut",
submitted: "info",
team_approved: "info",
approved: "ok",
rejected: "risk",
superseded: "mut",
};
export const SIGNATURE_OUTCOMES = ["signed", "customer_absent", "refused", "later", "not_required"] as const;
export type SignatureOutcome = (typeof SIGNATURE_OUTCOMES)[number];
/** Outcomes that require a written reason (Spec §18.2). */
export const SIGNATURE_REASON_REQUIRED: readonly SignatureOutcome[] = ["customer_absent", "refused", "later"];
export const TIME_ENTRY_TYPES = ["travel", "work", "break", "material_procurement", "return_travel", "interruption"] as const;
export const PHOTO_PHASES = ["before", "during", "after"] as const;
export const MATERIAL_USAGE_STATUSES = ["fully_used", "partially_used", "not_used", "additional"] as const;
/** Editable free-text fields (technician / Lotse draft). */
export const REPORT_TEXT_FIELDS = ["workPerformed", "deviations", "additionalWork", "problems", "openItems", "nextSteps", "hints"] as const;
export type ReportTextField = (typeof REPORT_TEXT_FIELDS)[number];
/** Text fields that must not be empty before submit ("Pflichtangaben"). */
export const REPORT_REQUIRED_TEXTS: Record<ReportType, readonly ReportTextField[]> = {
daily: ["workPerformed"],
completion: ["workPerformed"],
};
export const TEXT_MAX = 10_000;
const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
const isoDateTime = z.string().datetime({ offset: true });
const nullableText = z.string().max(2_000).nullable();
export const addressSchema = z.object({
line1: nullableText,
line2: nullableText,
});
export const reportTextsSchema = z.object({
workPerformed: z.string().max(TEXT_MAX),
deviations: z.string().max(TEXT_MAX),
additionalWork: z.string().max(TEXT_MAX),
problems: z.string().max(TEXT_MAX),
openItems: z.string().max(TEXT_MAX),
nextSteps: z.string().max(TEXT_MAX),
hints: z.string().max(TEXT_MAX),
});
export type ReportTexts = z.infer<typeof reportTextsSchema>;
export const materialLineSchema = z.object({
usageId: z.string().nullable(),
planId: z.string().nullable(),
name: z.string(),
articleNumber: nullableText,
plannedQuantity: z.string().nullable(),
actualQuantity: z.string().nullable(),
unit: z.string(),
status: z.enum(MATERIAL_USAGE_STATUSES).nullable(),
/** planned vs. actual differs (quantity or status) */
deviation: z.boolean(),
deviationReason: nullableText,
notes: nullableText,
/** false = planned material without any documented usage */
documented: z.boolean(),
});
export type MaterialLine = z.infer<typeof materialLineSchema>;
export const photoLineSchema = z.object({
photoId: z.string(),
documentId: z.string(),
phase: z.enum(PHOTO_PHASES).nullable(),
comment: nullableText,
requirement: nullableText,
takenAt: isoDateTime,
});
export type PhotoLine = z.infer<typeof photoLineSchema>;
export const timeLineSchema = z.object({
userId: z.string(),
name: z.string(),
type: z.enum(TIME_ENTRY_TYPES),
minutes: z.number().int().nonnegative(),
});
export const signatureBlockSchema = z.object({
outcome: z.enum(SIGNATURE_OUTCOMES),
signerName: nullableText,
signerRole: nullableText,
signedAt: isoDateTime,
reason: nullableText,
confirmationText: nullableText,
imageDocumentId: z.string().nullable(),
capturedByName: nullableText,
});
export type SignatureBlock = z.infer<typeof signatureBlockSchema>;
export const reportContentSchema = z.object({
schemaVersion: z.literal(1),
type: z.enum(REPORT_TYPES),
reportNumber: z.string(),
version: z.number().int().positive(),
reportDate: isoDate,
generatedAt: isoDateTime,
tenant: z.object({
name: z.string(),
address: nullableText,
phone: nullableText,
email: nullableText,
logoDocumentId: z.string().nullable(),
}),
customer: z.object({
id: z.string(),
number: nullableText,
name: z.string(),
address: addressSchema,
}),
site: z.object({ id: z.string(), name: z.string(), address: addressSchema }).nullable(),
contact: z.object({ name: z.string(), role: nullableText, phone: nullableText, email: nullableText }).nullable(),
workOrder: z.object({
id: z.string(),
number: z.string(),
externalOrderNumber: nullableText,
title: z.string(),
description: z.string().nullable(),
scope: z.string().nullable(),
orderType: nullableText,
signatureRequired: z.boolean(),
}),
/** Dates (YYYY-MM-DD, tenant time zone) with documented work; daily report = [reportDate]. */
workDates: z.array(isoDate),
staff: z.array(z.object({ userId: z.string(), name: z.string() })),
time: z.object({
entries: z.array(timeLineSchema),
totalsByType: z.record(z.string(), z.number().int().nonnegative()),
totalsByPerson: z.array(z.object({ userId: z.string(), name: z.string(), minutes: z.number().int().nonnegative() })),
/** billable total = all types except break */
totalMinutes: z.number().int().nonnegative(),
/** true if an entry was still running while the snapshot was built */
hasRunningEntries: z.boolean(),
}),
texts: reportTextsSchema,
materials: z.object({
used: z.array(materialLineSchema),
notUsed: z.array(materialLineSchema),
additional: z.array(materialLineSchema),
}),
photos: z.array(photoLineSchema),
checklist: z.array(z.object({ label: z.string(), required: z.boolean(), checked: z.boolean(), comment: nullableText })),
signature: signatureBlockSchema.nullable(),
technician: z.object({ userId: z.string(), name: z.string() }).nullable(),
});
export type ReportContent = z.infer<typeof reportContentSchema>;
export function emptyTexts(): ReportTexts {
return { workPerformed: "", deviations: "", additionalWork: "", problems: "", openItems: "", nextSteps: "", hints: "" };
}
/** Parse stored JSON; throws on schema drift so broken snapshots never render silently. */
export function parseReportContent(json: unknown): ReportContent {
return reportContentSchema.parse(json);
}
/** Missing required text fields for submit. */
export function missingRequiredTexts(content: Pick<ReportContent, "type" | "texts">): ReportTextField[] {
return REPORT_REQUIRED_TEXTS[content.type].filter((f) => !content.texts[f].trim());
}
/** "7 h 05 min" style duration without locale dependency (labels come from messages). */
export function splitMinutes(minutes: number): { hours: number; minutes: number } {
return { hours: Math.floor(minutes / 60), minutes: minutes % 60 };
}
+46
View File
@@ -0,0 +1,46 @@
/**
* Calendar-day helpers in the tenant time zone (client-safe, no dependencies).
* A daily report covers [start of reportDate, start of next day) in `timeZone`.
*/
/** Offset (ms) of `timeZone` relative to UTC at the given instant. */
function tzOffsetMs(instant: 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(instant);
const get = (t: string) => Number(parts.find((p) => p.type === t)?.value);
const asUtc = Date.UTC(get("year"), get("month") - 1, get("day"), get("hour"), get("minute"), get("second"));
return asUtc - Math.floor(instant.getTime() / 1000) * 1000;
}
/** YYYY-MM-DD of an instant in `timeZone`. */
export function localDateKey(instant: Date, timeZone: string): string {
return new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).format(instant);
}
/** UTC instants [start, end) of a local calendar day. */
export function dayWindow(dateKey: string, timeZone: string): { start: Date; end: Date } {
const [y, m, d] = dateKey.split("-").map(Number);
const startGuess = Date.UTC(y, m - 1, d);
const endGuess = Date.UTC(y, m - 1, d + 1);
const start = new Date(startGuess - tzOffsetMs(new Date(startGuess), timeZone));
const end = new Date(endGuess - tzOffsetMs(new Date(endGuess), timeZone));
return { start, end };
}
/** Date-only column value (Prisma @db.Date) for a YYYY-MM-DD key. */
export function dateKeyToDbDate(dateKey: string): Date {
return new Date(`${dateKey}T00:00:00.000Z`);
}
/** YYYY-MM-DD from a Prisma @db.Date value. */
export function dbDateToKey(d: Date): string {
return d.toISOString().slice(0, 10);
}