Merge lane/berichte in feature/craftvia-mvp
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -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" };
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
Reference in New Issue
Block a user