From 1f8e6413febe0f504fff31e3151ebd9af8df2e86 Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 12:26:07 +0200 Subject: [PATCH 1/5] =?UTF-8?q?L1=20Stammdaten:=20Services,=20Dublettenpr?= =?UTF-8?q?=C3=BCfung,=20Server=20Actions=20und=20API=20v1?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Kunden (Nummernkreis, Ansprechpartner, vorläufig bestätigen, Zusammenführen mit Bestätigung), Objekte inkl. Historie, Teams mit Mitgliedschaften, Dublettenlogik (lib + Service), API-Kontext/Antwortformat unter src/server/api und die Endpunkte /api/v1/customers, /api/v1/sites, /api/v1/sites/[id]/history. Co-Authored-By: Claude Opus 5 --- src/app/(app)/files/[...key]/route.ts | 61 ----- src/app/api/v1/customers/[id]/route.ts | 22 ++ src/app/api/v1/customers/route.ts | 31 +++ src/app/api/v1/sites/[id]/history/route.ts | 21 ++ src/app/api/v1/sites/route.ts | 29 +++ src/lib/customers/duplicates.ts | 212 +++++++++++++++++ src/server/actions/customers/contacts.ts | 48 ++++ src/server/actions/customers/customers.ts | 123 ++++++++++ src/server/actions/sites/sites.ts | 71 ++++++ src/server/actions/teams/teams.ts | 46 ++++ src/server/api/action-state.ts | 53 +++++ src/server/api/context.ts | 97 ++++++++ src/server/api/respond.ts | 104 +++++++++ src/server/services/customers/contacts.ts | 47 ++++ src/server/services/customers/customers.ts | 239 ++++++++++++++++++++ src/server/services/customers/duplicates.ts | 108 +++++++++ src/server/services/customers/format.ts | 29 +++ src/server/services/customers/merge.ts | 57 +++++ src/server/services/customers/schemas.ts | 72 ++++++ src/server/services/sites/history.ts | 127 +++++++++++ src/server/services/sites/map-link.ts | 22 ++ src/server/services/sites/sites.ts | 149 ++++++++++++ src/server/services/teams/teams.ts | 129 +++++++++++ 23 files changed, 1836 insertions(+), 61 deletions(-) delete mode 100644 src/app/(app)/files/[...key]/route.ts create mode 100644 src/app/api/v1/customers/[id]/route.ts create mode 100644 src/app/api/v1/customers/route.ts create mode 100644 src/app/api/v1/sites/[id]/history/route.ts create mode 100644 src/app/api/v1/sites/route.ts create mode 100644 src/lib/customers/duplicates.ts create mode 100644 src/server/actions/customers/contacts.ts create mode 100644 src/server/actions/customers/customers.ts create mode 100644 src/server/actions/sites/sites.ts create mode 100644 src/server/actions/teams/teams.ts create mode 100644 src/server/api/action-state.ts create mode 100644 src/server/api/context.ts create mode 100644 src/server/api/respond.ts create mode 100644 src/server/services/customers/contacts.ts create mode 100644 src/server/services/customers/customers.ts create mode 100644 src/server/services/customers/duplicates.ts create mode 100644 src/server/services/customers/format.ts create mode 100644 src/server/services/customers/merge.ts create mode 100644 src/server/services/customers/schemas.ts create mode 100644 src/server/services/sites/history.ts create mode 100644 src/server/services/sites/map-link.ts create mode 100644 src/server/services/sites/sites.ts create mode 100644 src/server/services/teams/teams.ts diff --git a/src/app/(app)/files/[...key]/route.ts b/src/app/(app)/files/[...key]/route.ts deleted file mode 100644 index 5632cb3..0000000 --- a/src/app/(app)/files/[...key]/route.ts +++ /dev/null @@ -1,61 +0,0 @@ -import { requireSession } from "@/server/auth"; -import { storage } from "@/server/storage/adapter"; - -/** - * Download-Route für Dateien im Objektspeicher (Garage/S3) anhand ihres Storage-Keys. - * - * Mandanten-Isolation: - * Der Key ist mandantenpräfixiert (`/…`). Er MUSS mit dem Tenant der - * aktuellen Session beginnen — ein Fremd-Tenant-Key wird mit 404 abgewiesen - * (keine Existenz-Preisgabe). - * - * TODO(documents): Defense in Depth wiederherstellen — sobald das Craftvia-Document-Modell - * existiert, zusätzlich prüfen, dass der Key in einer mandantengebundenen Referenz - * (Document.storageKey) vorkommt und der Nutzer das Dokument sehen darf - * (document:read bzw. document:read_internal für interne Dokumente). - * - * Auslieferung mit `Content-Disposition: attachment` und `X-Content-Type-Options: - * nosniff` (F-07) — kein Inline-Rendering, kein MIME-Sniffing. - * - * Route-Handler laufen NICHT durch das Layout-Gate; die Auth wird hier eigenständig - * über `requireSession` erzwungen. - */ -export async function GET( - _req: Request, - { params }: { params: Promise<{ key: string[] }> }, -) { - const session = await requireSession(); - const tenantId = session.user.tenantId; - - const { key: segments } = await params; - // Catch-all-Segmente sind bereits URL-dekodiert; zum Objekt-Key zusammenfügen. - const key = (segments ?? []).join("/"); - - // Pfad-Traversal ausschließen und Mandantenpräfix erzwingen. - if ( - !tenantId || - !key || - key.includes("..") || - key.includes("\0") || - !key.startsWith(`${tenantId}/`) - ) { - return new Response("Nicht gefunden.", { status: 404 }); - } - - const content = await storage.get(key); - if (!content) { - // Kein Byte-Backend (Stub) oder Objekt fehlt → 404. - return new Response("Datei nicht verfügbar.", { status: 404 }); - } - - const filename = content.filename.replace(/["\\]/g, "_"); - const headers = new Headers({ - "Content-Type": content.contentType ?? "application/octet-stream", - "Content-Disposition": `attachment; filename="${filename}"`, - "X-Content-Type-Options": "nosniff", - "Cache-Control": "private, no-store", - }); - if (content.size != null) headers.set("Content-Length", String(content.size)); - - return new Response(content.stream, { headers }); -} diff --git a/src/app/api/v1/customers/[id]/route.ts b/src/app/api/v1/customers/[id]/route.ts new file mode 100644 index 0000000..3f13787 --- /dev/null +++ b/src/app/api/v1/customers/[id]/route.ts @@ -0,0 +1,22 @@ +import { assertSameOrigin, requireApiContext } from "@/server/api/context"; +import { json, readJson, withApi } from "@/server/api/respond"; +import { getCustomer, updateCustomer } from "@/server/services/customers/customers"; +import type { CustomerPatchInput } from "@/server/services/customers/schemas"; + +type Ctx = { params: Promise<{ id: string }> }; + +/** GET /api/v1/customers/:id — customer incl. contacts (scope applies, otherwise 404). */ +export const GET = withApi(async (_req: Request, { params }: Ctx) => { + const ctx = await requireApiContext("customers", "customer:read"); + const { id } = await params; + return json({ data: await getCustomer(ctx, id) }); +}); + +/** PATCH /api/v1/customers/:id — absent fields unchanged, null clears. */ +export const PATCH = withApi(async (req: Request, { params }: Ctx) => { + assertSameOrigin(req); + const ctx = await requireApiContext("customers", "customer:write"); + const { id } = await params; + const body = (await readJson(req)) as CustomerPatchInput | null; + return json({ data: await updateCustomer(ctx, id, body ?? {}) }); +}); diff --git a/src/app/api/v1/customers/route.ts b/src/app/api/v1/customers/route.ts new file mode 100644 index 0000000..ccf5f00 --- /dev/null +++ b/src/app/api/v1/customers/route.ts @@ -0,0 +1,31 @@ +import { z } from "zod"; +import { assertSameOrigin, requireApiContext } from "@/server/api/context"; +import { json, paginated, parsePagination, readJson, withApi } from "@/server/api/respond"; +import { createCustomer, CUSTOMER_LIST_STATUSES, listCustomers } from "@/server/services/customers/customers"; +import type { CustomerCreateInput } from "@/server/services/customers/schemas"; + +const statusParam = z.enum([...CUSTOMER_LIST_STATUSES, "all"]).optional(); + +/** GET /api/v1/customers?q&status&page&pageSize */ +export const GET = withApi(async (req: Request) => { + const ctx = await requireApiContext("customers", "customer:read"); + const url = new URL(req.url); + const { page, pageSize } = parsePagination(url); + const status = statusParam.parse(url.searchParams.get("status") ?? undefined); + const result = await listCustomers(ctx, { q: url.searchParams.get("q") ?? undefined, status, page, pageSize }); + return paginated(result.items, result.total, result.page, result.pageSize); +}); + +/** + * POST /api/v1/customers — body: customer fields + optional `acknowledgeDuplicates: true`. + * Possible duplicates without acknowledgement → 409 `{ error: { code: "conflict", details: { reason: "possible_duplicates", candidates } } }`. + */ +export const POST = withApi(async (req: Request) => { + assertSameOrigin(req); + const ctx = await requireApiContext("customers", "customer:write"); + const body = (await readJson(req)) as Record | null; + const customer = await createCustomer(ctx, (body ?? {}) as CustomerCreateInput, { + acknowledgeDuplicates: body?.acknowledgeDuplicates === true, + }); + return json({ data: customer }, { status: 201 }); +}); diff --git a/src/app/api/v1/sites/[id]/history/route.ts b/src/app/api/v1/sites/[id]/history/route.ts new file mode 100644 index 0000000..445a982 --- /dev/null +++ b/src/app/api/v1/sites/[id]/history/route.ts @@ -0,0 +1,21 @@ +import { requireApiContext } from "@/server/api/context"; +import { json, parsePagination, withApi } from "@/server/api/respond"; +import { getSiteHistory } from "@/server/services/sites/history"; + +/** + * GET /api/v1/sites/:id/history?onlyApproved=true&page&pageSize + * Field roles always receive released deployments only (see getSiteHistory). + */ +export const GET = withApi(async (req: Request, { params }: { params: Promise<{ id: string }> }) => { + const ctx = await requireApiContext("sites", "site:read"); + const { id } = await params; + const url = new URL(req.url); + const { page, pageSize } = parsePagination(url, { pageSize: 50 }); + const flag = url.searchParams.get("onlyApproved"); + const result = await getSiteHistory(ctx, id, { onlyApproved: flag === "true" || flag === "1", page, pageSize }); + return json({ + data: result.items, + pagination: { page: result.page, pageSize: result.pageSize, total: result.total }, + meta: { onlyApproved: result.onlyApproved }, + }); +}); diff --git a/src/app/api/v1/sites/route.ts b/src/app/api/v1/sites/route.ts new file mode 100644 index 0000000..354b5ce --- /dev/null +++ b/src/app/api/v1/sites/route.ts @@ -0,0 +1,29 @@ +import { z } from "zod"; +import { assertSameOrigin, requireApiContext } from "@/server/api/context"; +import { json, paginated, parsePagination, readJson, withApi } from "@/server/api/respond"; +import { createSite, listSites, SITE_STATUSES, type SiteCreateInput } from "@/server/services/sites/sites"; + +const statusParam = z.enum([...SITE_STATUSES, "all"]).optional(); + +/** GET /api/v1/sites?q&customerId&status&page&pageSize */ +export const GET = withApi(async (req: Request) => { + const ctx = await requireApiContext("sites", "site:read"); + const url = new URL(req.url); + const { page, pageSize } = parsePagination(url); + const result = await listSites(ctx, { + q: url.searchParams.get("q") ?? undefined, + customerId: url.searchParams.get("customerId") ?? undefined, + status: statusParam.parse(url.searchParams.get("status") ?? undefined), + page, + pageSize, + }); + return paginated(result.items, result.total, result.page, result.pageSize); +}); + +/** POST /api/v1/sites */ +export const POST = withApi(async (req: Request) => { + assertSameOrigin(req); + const ctx = await requireApiContext("sites", "site:write"); + const body = (await readJson(req)) as SiteCreateInput | null; + return json({ data: await createSite(ctx, body ?? ({} as SiteCreateInput)) }, { status: 201 }); +}); diff --git a/src/lib/customers/duplicates.ts b/src/lib/customers/duplicates.ts new file mode 100644 index 0000000..78dacbc --- /dev/null +++ b/src/lib/customers/duplicates.ts @@ -0,0 +1,212 @@ +// Customer duplicate detection — pure, client-safe normalization and scoring (spec §7.3, US-003). +// The DB lookup lives in src/server/services/customers/duplicates.ts#findDuplicateCustomers. +// Used by lane "imports" (review mask) and the manual create flow. NEVER merges automatically. + +export type DuplicateReason = "customer_number" | "company_name" | "address" | "email" | "phone"; + +/** Fields of a new/imported customer that are compared against existing customers. */ +export type DuplicateCandidateInput = { + customerNumber?: string | null; + companyName?: string | null; + firstName?: string | null; + lastName?: string | null; + street?: string | null; + houseNumber?: string | null; + postalCode?: string | null; + city?: string | null; + email?: string | null; + phone?: string | null; + mobile?: string | null; +}; + +export type DuplicateMatch = { score: number; reasons: DuplicateReason[] }; + +/** Minimum score for a record to be reported as "possible duplicate". */ +export const DUPLICATE_THRESHOLD = 0.4; + +/** Signal weights; combined as probabilistic OR: 1 - Π(1 - w). */ +export const DUPLICATE_WEIGHTS = { + customerNumber: 1, + email: 0.6, + phone: 0.5, + companyExact: 0.6, + companySimilar: 0.45, + addressExact: 0.4, + addressStreetOnly: 0.25, +} as const; + +// Legal forms, longest first so "gmbh & co kg" is removed before "gmbh"/"kg". +const LEGAL_FORMS = [ + "gmbh & co. kgaa", + "gmbh & co. kg", + "gmbh & co kg", + "gmbh und co kg", + "ug (haftungsbeschraenkt)", + "ug haftungsbeschraenkt", + "e. k.", + "e.k.", + "e. v.", + "e.v.", + "kgaa", + "gmbh", + "mbh", + "ohg", + "gbr", + "partg", + "ltd.", + "ltd", + "inc.", + "inc", + "ag", + "kg", + "ug", + "se", + "ek", + "ev", +]; + +/** Lowercase, transliterate German umlauts, strip remaining diacritics, collapse whitespace. */ +export function normalizeText(value: string | null | undefined): string { + if (!value) return ""; + return value + .toLowerCase() + .replace(/ä/g, "ae") + .replace(/ö/g, "oe") + .replace(/ü/g, "ue") + .replace(/ß/g, "ss") + .normalize("NFKD") + .replace(/\p{M}+/gu, "") + .replace(/\s+/g, " ") + .trim(); +} + +function escapeRegExp(s: string): string { + return s.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); +} + +/** Company name without legal form and punctuation, e.g. "Müller GmbH & Co. KG" → "mueller". */ +export function normalizeCompanyName(value: string | null | undefined): string { + let s = normalizeText(value); + if (!s) return ""; + for (const form of LEGAL_FORMS) { + s = s.replace(new RegExp(`(^|[\\s,])${escapeRegExp(form)}(?=$|[\\s,])`, "g"), " "); + } + return s + .replace(/&/g, " ") + .replace(/\bund\b/g, " ") + .replace(/[^a-z0-9]+/g, " ") + .replace(/\s+/g, " ") + .trim(); +} + +/** Street with unified abbreviation: "Hafenstraße" / "Hafen-Str." / "Hafen Strasse" → "hafenstr". */ +export function normalizeStreet(value: string | null | undefined): string { + return normalizeText(value) + .replace(/strasse\b|str\.?(?=\s|$)/g, "str") + .replace(/[^a-z0-9]+/g, ""); +} + +export function normalizeHouseNumber(value: string | null | undefined): string { + return normalizeText(value).replace(/[^a-z0-9]+/g, ""); +} + +export function normalizePostalCode(value: string | null | undefined): string { + return (value ?? "").replace(/\s+/g, "").toUpperCase(); +} + +export function normalizeEmail(value: string | null | undefined): string { + return (value ?? "").trim().toLowerCase(); +} + +/** + * Phone digits only; international German prefix unified to national form + * (+49 40 … / 0049 40 … / 040 … → "040…"). Numbers with < 6 digits are ignored. + */ +export function normalizePhone(value: string | null | undefined): string { + let digits = (value ?? "").replace(/\D+/g, ""); + if (digits.startsWith("0049")) digits = "0" + digits.slice(4); + else if (digits.startsWith("49") && (value ?? "").trim().startsWith("+")) digits = "0" + digits.slice(2); + return digits.length >= 6 ? digits : ""; +} + +/** Display/compare name: company without legal form, otherwise "first last". */ +export function normalizedPartyName(c: Pick): string { + const company = normalizeCompanyName(c.companyName); + if (company) return company; + return normalizeText([c.firstName, c.lastName].filter(Boolean).join(" ")).replace(/[^a-z0-9 ]+/g, ""); +} + +function bigrams(s: string): Map { + const compact = s.replace(/\s+/g, " "); + const map = new Map(); + for (let i = 0; i < compact.length - 1; i++) { + const g = compact.slice(i, i + 2); + map.set(g, (map.get(g) ?? 0) + 1); + } + return map; +} + +/** Sørensen–Dice coefficient over character bigrams (0..1). */ +export function nameSimilarity(a: string, b: string): number { + if (!a || !b) return 0; + if (a === b) return 1; + if (a.length < 2 || b.length < 2) return 0; + const ba = bigrams(a); + const bb = bigrams(b); + let overlap = 0; + for (const [g, n] of ba) overlap += Math.min(n, bb.get(g) ?? 0); + const total = a.length - 1 + (b.length - 1); + return (2 * overlap) / total; +} + +/** Score one existing customer against a candidate. Pure — safe for client and tests. */ +export function scoreDuplicate(candidate: DuplicateCandidateInput, existing: DuplicateCandidateInput): DuplicateMatch { + const reasons: DuplicateReason[] = []; + const weights: number[] = []; + + const numA = normalizeText(candidate.customerNumber).replace(/\s+/g, ""); + const numB = normalizeText(existing.customerNumber).replace(/\s+/g, ""); + if (numA && numA === numB) { + reasons.push("customer_number"); + weights.push(DUPLICATE_WEIGHTS.customerNumber); + } + + const nameA = normalizedPartyName(candidate); + const nameB = normalizedPartyName(existing); + if (nameA && nameB) { + if (nameA === nameB) { + reasons.push("company_name"); + weights.push(DUPLICATE_WEIGHTS.companyExact); + } else if (nameSimilarity(nameA, nameB) >= 0.8) { + reasons.push("company_name"); + weights.push(DUPLICATE_WEIGHTS.companySimilar); + } + } + + const streetA = normalizeStreet(candidate.street); + const streetB = normalizeStreet(existing.street); + const plzA = normalizePostalCode(candidate.postalCode); + const plzB = normalizePostalCode(existing.postalCode); + if (streetA && streetA === streetB && plzA && plzA === plzB) { + const hnA = normalizeHouseNumber(candidate.houseNumber); + const hnB = normalizeHouseNumber(existing.houseNumber); + reasons.push("address"); + weights.push(hnA && hnA === hnB ? DUPLICATE_WEIGHTS.addressExact : DUPLICATE_WEIGHTS.addressStreetOnly); + } + + const mailA = normalizeEmail(candidate.email); + if (mailA && mailA === normalizeEmail(existing.email)) { + reasons.push("email"); + weights.push(DUPLICATE_WEIGHTS.email); + } + + const phonesA = [normalizePhone(candidate.phone), normalizePhone(candidate.mobile)].filter(Boolean); + const phonesB = new Set([normalizePhone(existing.phone), normalizePhone(existing.mobile)].filter(Boolean)); + if (phonesA.some((p) => phonesB.has(p))) { + reasons.push("phone"); + weights.push(DUPLICATE_WEIGHTS.phone); + } + + const score = 1 - weights.reduce((acc, w) => acc * (1 - w), 1); + return { score: Math.round(Math.min(1, score) * 1000) / 1000, reasons }; +} diff --git a/src/server/actions/customers/contacts.ts b/src/server/actions/customers/contacts.ts new file mode 100644 index 0000000..30b2b47 --- /dev/null +++ b/src/server/actions/customers/contacts.ts @@ -0,0 +1,48 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { moduleGuard } from "@/server/action-guard"; +import { ctxFromGuard } from "@/server/services/context"; +import { formObject, toActionError, type ActionState } from "@/server/api/action-state"; +import { createContact, deleteContact, updateContact } from "@/server/services/customers/contacts"; + +const guard = moduleGuard("customers"); +const CONTACT_KEYS = ["name", "role", "phone", "mobile", "email", "preferredChannel", "notes"] as const; + +function contactValues(fd: FormData) { + const v = formObject(fd, CONTACT_KEYS); + return { ...v, name: v.name ?? "" }; +} + +export async function createContactAction(customerId: string, _prev: ActionState, fd: FormData): Promise { + try { + const ctx = ctxFromGuard(await guard("customer:write")); + await createContact(ctx, customerId, contactValues(fd)); + } catch (err) { + return toActionError(err); + } + revalidatePath(`/customers/${customerId}`); + return { status: "ok" }; +} + +export async function updateContactAction(contactId: string, customerId: string, _prev: ActionState, fd: FormData): Promise { + try { + const ctx = ctxFromGuard(await guard("customer:write")); + await updateContact(ctx, contactId, contactValues(fd)); + } catch (err) { + return toActionError(err); + } + revalidatePath(`/customers/${customerId}`); + return { status: "ok" }; +} + +export async function deleteContactAction(contactId: string, customerId: string): Promise { + try { + const ctx = ctxFromGuard(await guard("customer:write")); + await deleteContact(ctx, contactId); + } catch (err) { + return toActionError(err); + } + revalidatePath(`/customers/${customerId}`); + return { status: "ok" }; +} diff --git a/src/server/actions/customers/customers.ts b/src/server/actions/customers/customers.ts new file mode 100644 index 0000000..6702933 --- /dev/null +++ b/src/server/actions/customers/customers.ts @@ -0,0 +1,123 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { moduleGuard } from "@/server/action-guard"; +import { ctxFromGuard, ServiceError } from "@/server/services/context"; +import { formObject, toActionError, type ActionState } from "@/server/api/action-state"; +import { + confirmProvisionalCustomer, + createCustomer, + deleteCustomer, + updateCustomer, +} from "@/server/services/customers/customers"; +import { mergeCustomers } from "@/server/services/customers/merge"; +import type { DuplicateCandidate } from "@/server/services/customers/duplicates"; + +const guard = moduleGuard("customers"); + +const CUSTOMER_KEYS = [ + "customerNumber", + "companyName", + "salutation", + "firstName", + "lastName", + "street", + "houseNumber", + "postalCode", + "city", + "country", + "phone", + "mobile", + "email", + "notes", + "billingNotes", + "status", +] as const; + +export type CustomerFormState = + | ActionState + | { status: "duplicates"; candidates: DuplicateCandidate[]; values: Record }; + +/** Create; on possible duplicates the form shows the candidates and may resubmit with acknowledgeDuplicates=1. */ +export async function createCustomerAction(_prev: CustomerFormState, fd: FormData): Promise { + const values = formObject(fd, CUSTOMER_KEYS); + let id: string; + try { + const ctx = ctxFromGuard(await guard("customer:write")); + const customer = await createCustomer(ctx, values, { acknowledgeDuplicates: fd.get("acknowledgeDuplicates") === "1" }); + id = customer.id; + } catch (err) { + if (err instanceof ServiceError && (err.details as { reason?: string } | undefined)?.reason === "possible_duplicates") { + return { status: "duplicates", candidates: (err.details as { candidates: DuplicateCandidate[] }).candidates, values }; + } + return toActionError(err); + } + revalidatePath("/customers"); + redirect(`/customers/${id}`); +} + +export async function updateCustomerAction(id: string, _prev: ActionState, fd: FormData): Promise { + try { + const ctx = ctxFromGuard(await guard("customer:write")); + const values = formObject(fd, CUSTOMER_KEYS); + // full form submit: empty inputs clear the field + const patch = Object.fromEntries(CUSTOMER_KEYS.map((k) => [k, values[k] ?? (k === "status" || k === "country" ? undefined : null)])); + await updateCustomer(ctx, id, patch); + } catch (err) { + return toActionError(err); + } + revalidatePath("/customers"); + revalidatePath(`/customers/${id}`); + return { status: "ok" }; +} + +export async function deleteCustomerAction(id: string): Promise { + try { + const ctx = ctxFromGuard(await guard("customer:write")); + await deleteCustomer(ctx, id); + } catch (err) { + return toActionError(err); + } + revalidatePath("/customers"); + redirect("/customers"); +} + +export async function confirmCustomerAction(id: string): Promise { + try { + const ctx = ctxFromGuard(await guard("customer:write")); + await confirmProvisionalCustomer(ctx, id); + } catch (err) { + return toActionError(err); + } + revalidatePath("/customers"); + revalidatePath(`/customers/${id}`); + return { status: "ok" }; +} + +/** Merge `sourceId` into the selected target. Requires customer:merge and the confirmation checkbox. */ +export async function mergeCustomerAction(sourceId: string, _prev: ActionState, fd: FormData): Promise { + let targetId: string; + try { + const g = await guard("customer:read", "customer:merge"); + const ctx = ctxFromGuard(g); + const values = formObject(fd, ["targetId", "targetNumber"]); + let target = values.targetId; + if (!target && values.targetNumber) { + const byNumber = await ctx.db.customer.findFirst({ + where: { customerNumber: values.targetNumber, deletedAt: null }, + select: { id: true }, + }); + if (!byNumber) throw new ServiceError("invalid", "target not found", { field: "targetNumber", reason: "target_not_found" }); + target = byNumber.id; + } + if (!target) throw new ServiceError("invalid", "target required", { field: "targetId", reason: "target_required" }); + const confirmed = fd.get("confirm") === "on" || fd.get("confirm") === "1"; + await mergeCustomers(ctx, { sourceId, targetId: target, confirm: confirmed as true }); + targetId = target; + } catch (err) { + return toActionError(err); + } + revalidatePath("/customers"); + redirect(`/customers/${targetId}?merged=1`); +} diff --git a/src/server/actions/sites/sites.ts b/src/server/actions/sites/sites.ts new file mode 100644 index 0000000..7922b7e --- /dev/null +++ b/src/server/actions/sites/sites.ts @@ -0,0 +1,71 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { redirect } from "next/navigation"; +import { moduleGuard } from "@/server/action-guard"; +import { ctxFromGuard } from "@/server/services/context"; +import { formObject, toActionError, type ActionState } from "@/server/api/action-state"; +import { createSite, deleteSite, updateSite } from "@/server/services/sites/sites"; + +const guard = moduleGuard("sites"); + +const SITE_KEYS = [ + "customerId", + "name", + "street", + "houseNumber", + "postalCode", + "city", + "country", + "contactId", + "onSiteContact", + "phone", + "accessNotes", + "parkingNotes", + "safetyNotes", + "technicalNotes", + "status", + "latitude", + "longitude", +] as const; + +export async function createSiteAction(_prev: ActionState, fd: FormData): Promise { + let id: string; + try { + const ctx = ctxFromGuard(await guard("site:write")); + const v = formObject(fd, SITE_KEYS); + const site = await createSite(ctx, { ...v, customerId: v.customerId ?? "", name: v.name ?? "" }); + id = site.id; + } catch (err) { + return toActionError(err); + } + revalidatePath("/sites"); + redirect(`/sites/${id}`); +} + +export async function updateSiteAction(id: string, _prev: ActionState, fd: FormData): Promise { + try { + const ctx = ctxFromGuard(await guard("site:write")); + const v = formObject(fd, SITE_KEYS); + const patch = Object.fromEntries( + SITE_KEYS.map((k) => [k, v[k] ?? (k === "status" || k === "country" || k === "customerId" || k === "name" ? undefined : null)]), + ); + await updateSite(ctx, id, patch); + } catch (err) { + return toActionError(err); + } + revalidatePath("/sites"); + revalidatePath(`/sites/${id}`); + return { status: "ok" }; +} + +export async function deleteSiteAction(id: string): Promise { + try { + const ctx = ctxFromGuard(await guard("site:write")); + await deleteSite(ctx, id); + } catch (err) { + return toActionError(err); + } + revalidatePath("/sites"); + redirect("/sites"); +} diff --git a/src/server/actions/teams/teams.ts b/src/server/actions/teams/teams.ts new file mode 100644 index 0000000..e162fa4 --- /dev/null +++ b/src/server/actions/teams/teams.ts @@ -0,0 +1,46 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { moduleGuard } from "@/server/action-guard"; +import { ctxFromGuard } from "@/server/services/context"; +import { formObject, toActionError, type ActionState } from "@/server/api/action-state"; +import { createTeam, deleteTeam, updateTeam, type TeamInput } from "@/server/services/teams/teams"; + +const guard = moduleGuard("teams"); +const TEAM_KEYS = ["name", "leaderUserId", "status", "phone", "vehicle", "area", "notes"] as const; + +function teamValues(fd: FormData): TeamInput { + const v = formObject(fd, TEAM_KEYS); + const userIds = fd.getAll("memberUserId").map(String); + const froms = fd.getAll("memberValidFrom").map(String); + const tos = fd.getAll("memberValidTo").map(String); + const members = userIds + .map((userId, i) => ({ userId: userId.trim(), validFrom: froms[i] ?? "", validTo: tos[i] ?? "" })) + .filter((m) => m.userId); + return { ...v, name: v.name ?? "", leaderUserId: v.leaderUserId ?? null, members }; +} + +/** Create (id = null) or fully update a team incl. its member list. */ +export async function saveTeamAction(id: string | null, _prev: ActionState, fd: FormData): Promise { + try { + const ctx = ctxFromGuard(await guard("team:manage")); + const input = teamValues(fd); + if (id) await updateTeam(ctx, id, input); + else await createTeam(ctx, input); + } catch (err) { + return toActionError(err); + } + revalidatePath("/teams"); + return { status: "ok" }; +} + +export async function deleteTeamAction(id: string): Promise { + try { + const ctx = ctxFromGuard(await guard("team:manage")); + await deleteTeam(ctx, id); + } catch (err) { + return toActionError(err); + } + revalidatePath("/teams"); + return { status: "ok" }; +} diff --git a/src/server/api/action-state.ts b/src/server/api/action-state.ts new file mode 100644 index 0000000..373ff20 --- /dev/null +++ b/src/server/api/action-state.ts @@ -0,0 +1,53 @@ +import { ZodError } from "zod"; +import { ServiceError } from "@/server/services/context"; +import { ForbiddenError } from "@/server/rbac"; +import { ModuleDisabledError } from "@/server/modules"; + +/** + * Uniform result shape for form-based server actions of the master-data lanes. + * Errors are CODES (translated in the client via messages/.json → errors.), + * never raw server messages (CWE-209). Field errors map field name → issue code. + */ +export type ActionErrorCode = "invalid" | "not_found" | "forbidden" | "conflict" | "blocked" | "generic"; + +export type ActionState = + | { status: "idle" } + | { status: "ok"; data?: T } + | { status: "error"; code: ActionErrorCode; reason?: string; fieldErrors?: Record }; + +export const IDLE: ActionState = { status: "idle" }; + +export function toActionError(err: unknown): Extract { + if (err instanceof ZodError) { + const fieldErrors: Record = {}; + for (const issue of err.issues) { + const key = issue.path.join(".") || "_"; + if (!fieldErrors[key]) fieldErrors[key] = issue.code === "too_small" && issue.minimum === 1 ? "required" : "invalid"; + } + return { status: "error", code: "invalid", fieldErrors }; + } + if (err instanceof ServiceError) { + const details = err.details as { field?: string; reason?: string } | undefined; + return { + status: "error", + code: err.code, + reason: details?.reason ?? err.message, + ...(details?.field ? { fieldErrors: { [details.field]: details.reason ?? err.code } } : {}), + }; + } + if (err instanceof ForbiddenError || err instanceof ModuleDisabledError) return { status: "error", code: "forbidden" }; + if (err instanceof Error && /Tenant isolation violation/.test(err.message)) return { status: "error", code: "not_found" }; + console.error("[action] unexpected error", err); + return { status: "error", code: "generic" }; +} + +/** FormData → plain object of trimmed strings; empty strings become undefined. */ +export function formObject(fd: FormData, keys: readonly string[]): Record { + const out: Record = {}; + for (const k of keys) { + const v = fd.get(k); + const s = typeof v === "string" ? v.trim() : ""; + out[k] = s === "" ? undefined : s; + } + return out; +} diff --git a/src/server/api/context.ts b/src/server/api/context.ts new file mode 100644 index 0000000..4fbcce9 --- /dev/null +++ b/src/server/api/context.ts @@ -0,0 +1,97 @@ +import { requireSession } from "@/server/auth"; +import { dbForTenant, prisma } from "@/server/db"; +import { writeAuditLog } from "@/server/audit"; +import { isTokenStillValid } from "@/server/sessions"; +import { assertModuleEnabled, requireModule } from "@/server/modules"; +import type { Permission } from "@/server/rbac"; +import type { ModuleKey } from "@/lib/modules"; +import type { ServiceCtx } from "@/server/services/context"; +import { ApiError } from "@/server/api/respond"; + +/** + * Service context for /api/v1 route handlers and other route handlers (e.g. /files/). + * + * Same authority model as `moduleGuard` (src/server/action-guard.ts, F-06): session cookie + * (Auth.js), then membership status, identity status, session kill switch, forced password + * change and the EFFECTIVE permissions are read from the database — never from the JWT. + * Differences: failures are thrown as `ApiError` (401/403) so handlers can answer with JSON, + * and `moduleKey` may be `null` for cross-module endpoints (document downloads are needed + * by field, reports and documents alike). + */ +export async function requireApiContext(moduleKey: ModuleKey | null, ...permissions: Permission[]): Promise { + let session; + try { + session = await requireSession(); + } catch { + throw new ApiError("unauthorized", "authentication required"); + } + const tenantId = session.user.tenantId; + const db = dbForTenant(tenantId); + + const account = await db.user.findFirst({ + where: { id: session.user.id, status: "ACTIVE" }, + select: { + userRoles: { select: { role: { select: { rolePermissions: { select: { permission: { select: { key: true } } } } } } } }, + }, + }); + const identity = session.user.identityId + ? await prisma.identity.findUnique({ + where: { id: session.user.identityId }, + select: { status: true, mustChangePassword: true, sessionsValidAfter: true }, + }) + : null; + if (!account || !identity || identity.status !== "ACTIVE") { + await writeAuditLog({ tenantId, actorId: session.user.id, action: "denied", entity: "account_inactive", entityId: session.user.id }); + throw new ApiError("unauthorized", "account inactive"); + } + if (!isTokenStillValid(session.user.tokenIssuedAt, identity.sessionsValidAfter)) { + throw new ApiError("unauthorized", "session invalidated"); + } + if (identity.mustChangePassword) throw new ApiError("forbidden", "password change required"); + + const effective = new Set(account.userRoles.flatMap((ur) => ur.role.rolePermissions.map((rp) => rp.permission.key))); + for (const p of permissions) { + if (!effective.has(p)) { + await writeAuditLog({ tenantId, actorId: session.user.id, action: "denied", entity: "api", entityId: p }); + throw new ApiError("forbidden", "forbidden"); + } + } + if (moduleKey) await assertModuleEnabled(session, moduleKey); // throws ModuleDisabledError → 403 + + return { db, tenantId, userId: session.user.id, permissions: effective }; +} + +/** + * CSRF defense for cookie-authenticated, state-changing route handlers: reject requests whose + * Origin (or Sec-Fetch-Site) shows a foreign site. Server actions have this built in. + */ +export function assertSameOrigin(req: Request): void { + const site = req.headers.get("sec-fetch-site"); + if (site && site !== "same-origin" && site !== "none") throw new ApiError("forbidden", "cross-site request"); + const origin = req.headers.get("origin"); + if (origin) { + const host = req.headers.get("x-forwarded-host") ?? req.headers.get("host"); + let originHost: string | null = null; + try { + originHost = new URL(origin).host; + } catch { + originHost = null; + } + if (!host || originHost !== host) throw new ApiError("forbidden", "cross-site request"); + } +} + +/** + * Read context for server components (pages). Uses the session's permission set (JWT), which + * is the documented behaviour for read paths (AGENTS.md "Rollen"); mutations go through + * moduleGuard / requireApiContext with DB-authoritative permissions. Also enforces the module gate. + */ +export async function requirePageContext(moduleKey: ModuleKey): Promise { + const session = await requireModule(moduleKey); + return { + db: dbForTenant(session.user.tenantId), + tenantId: session.user.tenantId, + userId: session.user.id, + permissions: new Set(session.user.permissions ?? []), + }; +} diff --git a/src/server/api/respond.ts b/src/server/api/respond.ts new file mode 100644 index 0000000..a4ae630 --- /dev/null +++ b/src/server/api/respond.ts @@ -0,0 +1,104 @@ +import { ZodError } from "zod"; +import { ServiceError } from "@/server/services/context"; +import { ForbiddenError } from "@/server/rbac"; +import { ModuleDisabledError } from "@/server/modules"; + +/** + * JSON response helpers for /api/v1 route handlers (spec §29.2). + * Error format: `{ error: { code, message, details? } }`; list format: + * `{ data: [...], pagination: { page, pageSize, total } }`. + * Internal error details never leave the server (CWE-209). + */ + +export type ApiErrorCode = + | "unauthorized" + | "forbidden" + | "not_found" + | "invalid" + | "conflict" + | "blocked" + | "payload_too_large" + | "internal"; + +const STATUS: Record = { + unauthorized: 401, + forbidden: 403, + not_found: 404, + invalid: 422, + conflict: 409, + blocked: 409, + payload_too_large: 413, + internal: 500, +}; + +export class ApiError extends Error { + constructor( + public code: ApiErrorCode, + message: string, + public details?: unknown, + ) { + super(message); + this.name = "ApiError"; + } +} + +export function errorResponse(code: ApiErrorCode, message: string, details?: unknown): Response { + return Response.json( + { error: { code, message, ...(details !== undefined ? { details } : {}) } }, + { status: STATUS[code], headers: { "Cache-Control": "no-store" } }, + ); +} + +/** Map any thrown error to a JSON error response. */ +export function toErrorResponse(err: unknown): Response { + if (err instanceof ApiError) return errorResponse(err.code, err.message, err.details); + if (err instanceof ServiceError) return errorResponse(err.code, err.message, err.details); + if (err instanceof ZodError) { + return errorResponse( + "invalid", + "validation failed", + err.issues.map((i) => ({ path: i.path.join("."), code: i.code })), + ); + } + if (err instanceof ForbiddenError) return errorResponse("forbidden", "forbidden"); + if (err instanceof ModuleDisabledError) return errorResponse("forbidden", "module disabled"); + if (err instanceof Error && /Tenant isolation violation/.test(err.message)) return errorResponse("not_found", "not found"); + console.error("[api] unhandled error", err); + return errorResponse("internal", "internal error"); +} + +export function json(data: unknown, init?: { status?: number }): Response { + return Response.json(data, { status: init?.status ?? 200, headers: { "Cache-Control": "no-store" } }); +} + +export function paginated(items: T[], total: number, page: number, pageSize: number): Response { + return json({ data: items, pagination: { page, pageSize, total } }); +} + +/** `?page&pageSize` with sane bounds (pageSize 1..100, default 25). */ +export function parsePagination(url: URL | string, defaults = { pageSize: 25 }): { page: number; pageSize: number } { + const u = typeof url === "string" ? new URL(url) : url; + const page = Math.max(1, Math.floor(Number(u.searchParams.get("page")) || 1)); + const pageSize = Math.min(100, Math.max(1, Math.floor(Number(u.searchParams.get("pageSize")) || defaults.pageSize))); + return { page, pageSize }; +} + +/** Wrap a handler so every thrown error becomes a JSON error response. */ +export function withApi(handler: (...args: A) => Promise) { + return async (...args: A): Promise => { + try { + return await handler(...args); + } catch (err) { + return toErrorResponse(err); + } + }; +} + +/** Read a JSON body; malformed JSON → 422. */ +export async function readJson(req: Request): Promise { + try { + return await req.json(); + } catch { + throw new ApiError("invalid", "malformed JSON body"); + } +} diff --git a/src/server/services/customers/contacts.ts b/src/server/services/customers/contacts.ts new file mode 100644 index 0000000..41bbe33 --- /dev/null +++ b/src/server/services/customers/contacts.ts @@ -0,0 +1,47 @@ +import { writeAuditLog } from "@/server/audit"; +import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { customerScope } from "@/server/services/work-orders/visibility"; +import { contactSchema, type ContactInput } from "@/server/services/customers/schemas"; + +async function requireWritableCustomer(ctx: ServiceCtx, customerId: string) { + const customer = await ctx.db.customer.findFirst({ + where: { AND: [{ id: customerId }, await customerScope(ctx), { status: { not: "merged" } }] }, + select: { id: true }, + }); + if (!customer) throw new ServiceError("not_found", "customer not found"); + return customer; +} + +async function requireContact(ctx: ServiceCtx, contactId: string) { + const contact = await ctx.db.contact.findFirst({ + where: { id: contactId, deletedAt: null, customer: { AND: [await customerScope(ctx), { status: { not: "merged" } }] } }, + }); + if (!contact) throw new ServiceError("not_found", "contact not found"); + return contact; +} + +export async function createContact(ctx: ServiceCtx, customerId: string, input: ContactInput) { + assertCan(ctx, "customer:write"); + const data = contactSchema.parse(input); + await requireWritableCustomer(ctx, customerId); + const contact = await ctx.db.contact.create({ data: { ...data, tenantId: ctx.tenantId, customerId } }); + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "create", entity: "contact", entityId: contact.id, after: contact }); + return contact; +} + +export async function updateContact(ctx: ServiceCtx, contactId: string, input: ContactInput) { + assertCan(ctx, "customer:write"); + const data = contactSchema.parse(input); + const before = await requireContact(ctx, contactId); + const after = await ctx.db.contact.update({ where: { id: contactId }, data }); + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "contact", entityId: contactId, before, after }); + return after; +} + +export async function deleteContact(ctx: ServiceCtx, contactId: string) { + assertCan(ctx, "customer:write"); + const before = await requireContact(ctx, contactId); + const after = await ctx.db.contact.update({ where: { id: contactId }, data: { deletedAt: new Date() } }); + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "delete", entity: "contact", entityId: contactId, before, after }); + return after; +} diff --git a/src/server/services/customers/customers.ts b/src/server/services/customers/customers.ts new file mode 100644 index 0000000..bbb0208 --- /dev/null +++ b/src/server/services/customers/customers.ts @@ -0,0 +1,239 @@ +import type { Prisma } from "@prisma/client"; +import { writeAuditLog } from "@/server/audit"; +import { nextNumber } from "@/server/services/numbering"; +import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { customerScope, workOrderScope } from "@/server/services/work-orders/visibility"; +import { findDuplicateCustomers } from "@/server/services/customers/duplicates"; +import { + customerCreateSchema, + customerPatchSchema, + type CustomerCreateInput, + type CustomerPatchInput, +} from "@/server/services/customers/schemas"; + +export const CUSTOMER_LIST_STATUSES = ["active", "inactive", "provisional", "merged"] as const; +export type CustomerListStatus = (typeof CUSTOMER_LIST_STATUSES)[number]; + +const CLOSED_ORDER_STATUSES = ["billed", "cancelled"] as const; + +function isUniqueViolation(err: unknown): boolean { + return (err as { code?: string })?.code === "P2002"; +} + +/** Customer ids are only visible within the user's scope; everything else is "not found". */ +async function findVisibleCustomer(ctx: ServiceCtx, id: string, extra: Prisma.CustomerWhereInput = {}) { + return ctx.db.customer.findFirst({ where: { AND: [{ id }, await customerScope(ctx), extra] } }); +} + +export async function listCustomers( + ctx: ServiceCtx, + opts: { q?: string; status?: CustomerListStatus | "all"; page?: number; pageSize?: number } = {}, +) { + assertCan(ctx, "customer:read"); + const page = Math.max(1, opts.page ?? 1); + const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 25)); + const q = opts.q?.trim(); + const statusFilter: Prisma.CustomerWhereInput = + !opts.status || opts.status === "all" ? { status: { not: "merged" } } : { status: opts.status }; + const where: Prisma.CustomerWhereInput = { + AND: [ + await customerScope(ctx), + statusFilter, + q + ? { + OR: [ + { customerNumber: { contains: q, mode: "insensitive" } }, + { companyName: { contains: q, mode: "insensitive" } }, + { firstName: { contains: q, mode: "insensitive" } }, + { lastName: { contains: q, mode: "insensitive" } }, + { city: { contains: q, mode: "insensitive" } }, + { email: { contains: q, mode: "insensitive" } }, + ], + } + : {}, + ], + }; + const [total, items] = await Promise.all([ + ctx.db.customer.count({ where }), + ctx.db.customer.findMany({ + where, + orderBy: [{ companyName: "asc" }, { lastName: "asc" }, { createdAt: "asc" }], + skip: (page - 1) * pageSize, + take: pageSize, + select: { + id: true, + customerNumber: true, + companyName: true, + salutation: true, + firstName: true, + lastName: true, + postalCode: true, + city: true, + phone: true, + email: true, + status: true, + updatedAt: true, + _count: { select: { sites: { where: { deletedAt: null } } } }, + }, + }), + ]); + return { items, total, page, pageSize }; +} + +export async function getCustomer(ctx: ServiceCtx, id: string) { + assertCan(ctx, "customer:read"); + const customer = await ctx.db.customer.findFirst({ + where: { AND: [{ id }, await customerScope(ctx)] }, + include: { contacts: { where: { deletedAt: null }, orderBy: { name: "asc" } } }, + }); + if (!customer) throw new ServiceError("not_found", "customer not found"); + return customer; +} + +/** Lightweight options for selects (sites form, merge target). */ +export async function customerOptions(ctx: ServiceCtx, opts: { take?: number } = {}) { + assertCan(ctx, "customer:read"); + return ctx.db.customer.findMany({ + where: { AND: [await customerScope(ctx), { status: { in: ["active", "provisional"] } }] }, + select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, city: true }, + orderBy: [{ companyName: "asc" }, { lastName: "asc" }], + take: opts.take ?? 500, + }); +} + +async function assertNumberFree(ctx: ServiceCtx, customerNumber: string, exceptId?: string) { + const clash = await ctx.db.customer.findFirst({ + where: { customerNumber, ...(exceptId ? { id: { not: exceptId } } : {}) }, + select: { id: true }, + }); + if (clash) throw new ServiceError("conflict", "customer number taken", { field: "customerNumber", reason: "number_taken" }); +} + +/** Next free sequence number; skips values already taken by manually entered numbers. */ +async function allocateCustomerNumber(ctx: ServiceCtx): Promise { + for (let i = 0; i < 20; i++) { + const candidate = await nextNumber(ctx.db, ctx.tenantId, "customer"); + const taken = await ctx.db.customer.findFirst({ where: { customerNumber: candidate }, select: { id: true } }); + if (!taken) return candidate; + } + throw new ServiceError("conflict", "could not allocate customer number", { reason: "number_allocation" }); +} + +/** + * Create a customer. Runs the duplicate check first; if possible duplicates exist and the caller + * has not acknowledged them, throws `conflict` with `details.reason = "possible_duplicates"` and + * `details.candidates` — the UI shows "Mögliche Dublette" and lets the user decide. + */ +export async function createCustomer(ctx: ServiceCtx, input: CustomerCreateInput, opts: { acknowledgeDuplicates?: boolean } = {}) { + assertCan(ctx, "customer:write"); + const data = customerCreateSchema.parse(input); + + // a taken number is a hard conflict — acknowledging a duplicate hint could not resolve it + if (data.customerNumber) await assertNumberFree(ctx, data.customerNumber); + + if (!opts.acknowledgeDuplicates) { + const candidates = await findDuplicateCustomers(ctx, data); + if (candidates.length > 0) { + throw new ServiceError("conflict", "possible duplicates", { reason: "possible_duplicates", candidates }); + } + } + + const customerNumber = data.customerNumber ?? (await allocateCustomerNumber(ctx)); + const status = data.status ?? "active"; + + let customer; + try { + customer = await ctx.db.customer.create({ + data: { + ...data, + tenantId: ctx.tenantId, + customerNumber, + country: data.country ?? "DE", + status, + isProvisional: status === "provisional", + createdById: ctx.userId, + }, + }); + } catch (err) { + if (isUniqueViolation(err)) throw new ServiceError("conflict", "customer number taken", { field: "customerNumber", reason: "number_taken" }); + throw err; + } + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "create", entity: "customer", entityId: customer.id, after: customer }); + return customer; +} + +export async function updateCustomer(ctx: ServiceCtx, id: string, patch: CustomerPatchInput) { + assertCan(ctx, "customer:write"); + const data = customerPatchSchema.parse(patch); + const before = await findVisibleCustomer(ctx, id, { status: { not: "merged" } }); + if (!before) throw new ServiceError("not_found", "customer not found"); + + const merged = { companyName: before.companyName, lastName: before.lastName, ...data }; + if (!merged.companyName && !merged.lastName) { + throw new ServiceError("invalid", "name required", { field: "companyName", reason: "name_required" }); + } + if (data.customerNumber === null) delete data.customerNumber; // the number can be changed, not removed + if (data.customerNumber && data.customerNumber !== before.customerNumber) await assertNumberFree(ctx, data.customerNumber, id); + + let after; + try { + after = await ctx.db.customer.update({ + where: { id }, + data: { + ...data, + ...(data.status ? { isProvisional: data.status === "provisional" } : {}), + }, + }); + } catch (err) { + if (isUniqueViolation(err)) throw new ServiceError("conflict", "customer number taken", { field: "customerNumber", reason: "number_taken" }); + throw err; + } + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "customer", entityId: id, before, after }); + return after; +} + +/** Soft delete (spec §27.5). Blocked while open work orders reference the customer. */ +export async function deleteCustomer(ctx: ServiceCtx, id: string) { + assertCan(ctx, "customer:write"); + const before = await findVisibleCustomer(ctx, id); + if (!before) throw new ServiceError("not_found", "customer not found"); + const open = await ctx.db.workOrder.count({ + where: { customerId: id, deletedAt: null, status: { notIn: [...CLOSED_ORDER_STATUSES] } }, + }); + if (open > 0) throw new ServiceError("blocked", "customer has open work orders", { reason: "open_work_orders", count: open }); + const after = await ctx.db.customer.update({ where: { id }, data: { deletedAt: new Date(), status: "inactive" } }); + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "delete", entity: "customer", entityId: id, before, after }); + return after; +} + +/** provisional → active (used by the emergency lane's backoffice review). */ +export async function confirmProvisionalCustomer(ctx: ServiceCtx, id: string) { + assertCan(ctx, "customer:write"); + const before = await findVisibleCustomer(ctx, id); + if (!before) throw new ServiceError("not_found", "customer not found"); + if (before.status !== "provisional") throw new ServiceError("conflict", "customer is not provisional", { reason: "not_provisional" }); + const after = await ctx.db.customer.update({ where: { id }, data: { status: "active", isProvisional: false } }); + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "customer", entityId: id, before, after }); + return after; +} + +/** Read-only order list for the customer detail tab (work order scope applies). */ +export async function listCustomerWorkOrders(ctx: ServiceCtx, customerId: string, opts: { take?: number } = {}) { + await getCustomer(ctx, customerId); + return ctx.db.workOrder.findMany({ + where: { AND: [{ customerId }, await workOrderScope(ctx)] }, + orderBy: [{ plannedStart: "desc" }, { createdAt: "desc" }], + take: opts.take ?? 100, + select: { + id: true, + number: true, + title: true, + status: true, + plannedStart: true, + createdAt: true, + site: { select: { id: true, name: true } }, + team: { select: { name: true } }, + orderType: { select: { name: true } }, + }, + }); +} diff --git a/src/server/services/customers/duplicates.ts b/src/server/services/customers/duplicates.ts new file mode 100644 index 0000000..53bb5b1 --- /dev/null +++ b/src/server/services/customers/duplicates.ts @@ -0,0 +1,108 @@ +import type { Prisma } from "@prisma/client"; +import { assertCan, type ServiceCtx } from "@/server/services/context"; +import { customerScope } from "@/server/services/work-orders/visibility"; +import { + DUPLICATE_THRESHOLD, + normalizeCompanyName, + normalizePhone, + normalizeText, + scoreDuplicate, + type DuplicateCandidateInput, + type DuplicateReason, +} from "@/lib/customers/duplicates"; +import { customerDisplayName } from "@/server/services/customers/format"; + +export type DuplicateCandidate = { + customerId: string; + score: number; + reasons: DuplicateReason[]; + customerNumber: string | null; + displayName: string; + city: string | null; + status: string; +}; + +const PREFILTER_LIMIT = 200; + +/** A raw (non-transliterated) significant word of the company name for a DB `contains` prefilter. */ +function rawNameTokens(companyName: string | null | undefined): string[] { + const normalized = normalizeCompanyName(companyName); + const raw = (companyName ?? "").toLowerCase().split(/[^\p{L}\p{N}]+/u).filter((w) => w.length >= 3); + const norm = normalized.split(" ").filter((w) => w.length >= 3); + const legal = new Set(["gmbh", "mbh", "kgaa", "ohg", "gbr", "und", "co"]); + return [...new Set([...raw, ...norm])].filter((w) => !legal.has(normalizeText(w))).slice(0, 4); +} + +/** + * Possible duplicates of `candidate` among the tenant's customers (spec §7.3, US-003). + * Returns candidates with score ≥ DUPLICATE_THRESHOLD, best first. Never merges. + * Contract (ARCHITEKTUR §6, used by lane imports): `findDuplicateCustomers(ctx, candidate) → Candidate[]`. + */ +export async function findDuplicateCustomers( + ctx: ServiceCtx, + candidate: DuplicateCandidateInput, + opts: { excludeId?: string; limit?: number } = {}, +): Promise { + assertCan(ctx, "customer:read"); + + const or: Prisma.CustomerWhereInput[] = []; + const insensitive = "insensitive" as const; + if (candidate.customerNumber?.trim()) or.push({ customerNumber: { equals: candidate.customerNumber.trim(), mode: insensitive } }); + if (candidate.email?.trim()) or.push({ email: { equals: candidate.email.trim(), mode: insensitive } }); + if (candidate.postalCode?.trim()) or.push({ postalCode: candidate.postalCode.replace(/\s+/g, "") }); + for (const token of rawNameTokens(candidate.companyName)) or.push({ companyName: { contains: token, mode: insensitive } }); + if (candidate.lastName?.trim()) or.push({ lastName: { equals: candidate.lastName.trim(), mode: insensitive } }); + const scope = await customerScope(ctx); + const baseFilter: Prisma.CustomerWhereInput[] = [scope, { status: { not: "merged" } }, opts.excludeId ? { id: { not: opts.excludeId } } : {}]; + + // Stored phone numbers carry arbitrary formatting ("+49 40 123456-0"), so a SQL `contains` is + // unreliable: compare normalized digits over the (narrow) phone columns in memory instead. + const wantedPhones = new Set([normalizePhone(candidate.phone), normalizePhone(candidate.mobile)].filter(Boolean)); + if (wantedPhones.size) { + const phoneRows = await ctx.db.customer.findMany({ + where: { AND: [...baseFilter, { OR: [{ phone: { not: null } }, { mobile: { not: null } }] }] }, + select: { id: true, phone: true, mobile: true }, + take: 10_000, + }); + const ids = phoneRows.filter((r) => wantedPhones.has(normalizePhone(r.phone)) || wantedPhones.has(normalizePhone(r.mobile))).map((r) => r.id); + if (ids.length) or.push({ id: { in: ids } }); + } + if (or.length === 0) return []; + + const rows = await ctx.db.customer.findMany({ + where: { AND: [...baseFilter, { OR: or }] }, + select: { + id: true, + customerNumber: true, + companyName: true, + firstName: true, + lastName: true, + street: true, + houseNumber: true, + postalCode: true, + city: true, + email: true, + phone: true, + mobile: true, + status: true, + }, + take: PREFILTER_LIMIT, + }); + + return rows + .map((r) => { + const m = scoreDuplicate(candidate, r); + return { + customerId: r.id, + score: m.score, + reasons: m.reasons, + customerNumber: r.customerNumber, + displayName: customerDisplayName(r), + city: r.city, + status: r.status, + }; + }) + .filter((c) => c.score >= DUPLICATE_THRESHOLD) + .sort((a, b) => b.score - a.score) + .slice(0, opts.limit ?? 10); +} diff --git a/src/server/services/customers/format.ts b/src/server/services/customers/format.ts new file mode 100644 index 0000000..9c2f552 --- /dev/null +++ b/src/server/services/customers/format.ts @@ -0,0 +1,29 @@ +// Pure display helpers (no server imports) — usable from server and client components. + +export type CustomerNameFields = { + companyName?: string | null; + salutation?: string | null; + firstName?: string | null; + lastName?: string | null; +}; + +export function customerDisplayName(c: CustomerNameFields): string { + if (c.companyName?.trim()) return c.companyName.trim(); + return [c.firstName, c.lastName].filter((s) => s && s.trim()).join(" ").trim(); +} + +export type AddressFields = { + street?: string | null; + houseNumber?: string | null; + postalCode?: string | null; + city?: string | null; + country?: string | null; +}; + +export function formatAddress(a: AddressFields, opts: { withCountry?: boolean } = {}): string { + const line1 = [a.street, a.houseNumber].filter(Boolean).join(" "); + const line2 = [a.postalCode, a.city].filter(Boolean).join(" "); + const parts = [line1, line2]; + if (opts.withCountry && a.country && a.country !== "DE") parts.push(a.country); + return parts.filter(Boolean).join(", "); +} diff --git a/src/server/services/customers/merge.ts b/src/server/services/customers/merge.ts new file mode 100644 index 0000000..206018e --- /dev/null +++ b/src/server/services/customers/merge.ts @@ -0,0 +1,57 @@ +import { writeAuditLog } from "@/server/audit"; +import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { mergeSchema, type MergeInput } from "@/server/services/customers/schemas"; + +/** + * Merge two customers (spec §7.3). Only with `customer:merge` and an explicit `confirm: true`. + * Contacts, sites, work orders and documents of the source are moved to the target; the source + * becomes status `merged` with `mergedIntoId`. Both records must belong to the caller's tenant + * (dbForTenant) — ids of another tenant are "not found". Never triggered automatically. + */ +export async function mergeCustomers(ctx: ServiceCtx, input: MergeInput) { + assertCan(ctx, "customer:merge"); + const { sourceId, targetId } = mergeSchema.parse(input); + + const [source, target] = await Promise.all([ + ctx.db.customer.findFirst({ where: { id: sourceId, deletedAt: null } }), + ctx.db.customer.findFirst({ where: { id: targetId, deletedAt: null } }), + ]); + if (!source) throw new ServiceError("not_found", "source customer not found", { field: "sourceId", reason: "not_found" }); + if (!target) throw new ServiceError("not_found", "target customer not found", { field: "targetId", reason: "not_found" }); + if (source.status === "merged" || target.status === "merged") { + throw new ServiceError("conflict", "customer already merged", { reason: "already_merged" }); + } + + const [contacts, sites, workOrders, documents, mergedSource] = await ctx.db.$transaction([ + ctx.db.contact.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }), + ctx.db.site.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }), + // version bump: offline clients must not overwrite the re-parented order with stale data + ctx.db.workOrder.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId, version: { increment: 1 } } }), + ctx.db.document.updateMany({ where: { customerId: sourceId }, data: { customerId: targetId } }), + ctx.db.customer.update({ + where: { id: sourceId }, + data: { status: "merged", mergedIntoId: targetId, isProvisional: false }, + }), + ]); + + const moved = { contacts: contacts.count, sites: sites.count, workOrders: workOrders.count, documents: documents.count }; + await writeAuditLog({ + tenantId: ctx.tenantId, + actorId: ctx.userId, + action: "update", + entity: "customer", + entityId: sourceId, + before: source, + after: { ...mergedSource, merge: { role: "source", targetId, moved } }, + }); + await writeAuditLog({ + tenantId: ctx.tenantId, + actorId: ctx.userId, + action: "update", + entity: "customer", + entityId: targetId, + before: target, + after: { merge: { role: "target", sourceId, moved } }, + }); + return { sourceId, targetId, moved }; +} diff --git a/src/server/services/customers/schemas.ts b/src/server/services/customers/schemas.ts new file mode 100644 index 0000000..2ac2a72 --- /dev/null +++ b/src/server/services/customers/schemas.ts @@ -0,0 +1,72 @@ +import { z } from "zod"; + +/** Empty strings become null; strings are trimmed and length-limited. */ +export const optStr = (max: number) => + z.preprocess((v) => (typeof v === "string" && v.trim() === "" ? null : v), z.string().trim().max(max).nullable().optional()); + +export const optEmail = () => + z.preprocess( + (v) => (typeof v === "string" && v.trim() === "" ? null : typeof v === "string" ? v.trim().toLowerCase() : v), + z.string().max(200).email().nullable().optional(), + ); + +export const CUSTOMER_EDITABLE_STATUSES = ["active", "inactive", "provisional"] as const; + +const customerFields = { + customerNumber: optStr(40), + companyName: optStr(200), + salutation: optStr(40), + firstName: optStr(100), + lastName: optStr(100), + street: optStr(200), + houseNumber: optStr(20), + postalCode: optStr(12), + city: optStr(100), + country: z.preprocess( + (v) => (typeof v === "string" && v.trim() === "" ? undefined : typeof v === "string" ? v.trim().toUpperCase() : v), + z.string().regex(/^[A-Z]{2}$/).optional(), + ), + phone: optStr(50), + mobile: optStr(50), + email: optEmail(), + notes: optStr(5000), + billingNotes: optStr(5000), + status: z.enum(CUSTOMER_EDITABLE_STATUSES).optional(), +}; + +const nameRequired = (v: { companyName?: string | null; lastName?: string | null }) => Boolean(v.companyName || v.lastName); + +export const customerCreateSchema = z + .object(customerFields) + .refine(nameRequired, { message: "name_required", path: ["companyName"] }); + +/** PATCH semantics: absent = unchanged, null = cleared. */ +export const customerPatchSchema = z.object(customerFields).partial(); + +export type CustomerCreateInput = z.input; +export type CustomerPatchInput = z.input; + +export const CONTACT_CHANNELS = ["phone", "mobile", "email"] as const; + +export const contactSchema = z.object({ + name: z.string().trim().min(1).max(200), + role: optStr(100), + phone: optStr(50), + mobile: optStr(50), + email: optEmail(), + preferredChannel: z.preprocess((v) => (v === "" ? null : v), z.enum(CONTACT_CHANNELS).nullable().optional()), + notes: optStr(2000), +}); + +export type ContactInput = z.input; + +export const mergeSchema = z + .object({ + sourceId: z.string().min(1), + targetId: z.string().min(1), + // explicit confirmation is mandatory (spec §7.3: never merge without confirmation) + confirm: z.literal(true), + }) + .refine((v) => v.sourceId !== v.targetId, { message: "same_customer", path: ["targetId"] }); + +export type MergeInput = z.input; diff --git a/src/server/services/sites/history.ts b/src/server/services/sites/history.ts new file mode 100644 index 0000000..1aaed76 --- /dev/null +++ b/src/server/services/sites/history.ts @@ -0,0 +1,127 @@ +import type { Prisma, WorkOrderStatus } from "@prisma/client"; +import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { siteScope } from "@/server/services/work-orders/visibility"; + +export type SiteHistoryEntry = { + workOrderId: string; + number: string; + title: string; + date: Date; + status: WorkOrderStatus; + isEmergency: boolean; + orderType: string | null; + team: string | null; + /** Texts of ActivityNote kind work_done, oldest first. */ + workDone: string[]; + /** Short summary for list views (≤ 280 chars). */ + summary: string; + materials: { name: string; unit: string; quantity: number }[]; + photoCount: number; + approvedReports: { id: string; type: "daily" | "completion"; reportDate: Date; version: number }[]; + signed: boolean; + followUps: string[]; + hasOpenFollowUp: boolean; +}; + +const SUMMARY_MAX = 280; + +/** + * Chronological deployment history of a site (spec §8.3, US-005, US-011) — newest first. + * + * Access: the site itself must be visible (`siteScope`: backoffice all, field roles only via a + * visible work order at the site — otherwise `not_found`). + * Field roles (no `work_order:read_all`) ALWAYS get only released deployments — work orders with an + * approved report — regardless of `onlyApproved` (US-011 "Liste aller freigegebenen Einsätze"); + * this is the history of the site, so released deployments of other teams are included (US-005). + * Internal notes are never part of the result. Backoffice may pass `onlyApproved=false`. + */ +export async function getSiteHistory( + ctx: ServiceCtx, + siteId: string, + opts: { onlyApproved?: boolean; page?: number; pageSize?: number } = {}, +): Promise<{ items: SiteHistoryEntry[]; total: number; page: number; pageSize: number; onlyApproved: boolean }> { + assertCan(ctx, "site:read"); + const site = await ctx.db.site.findFirst({ where: { AND: [{ id: siteId }, await siteScope(ctx)] }, select: { id: true } }); + if (!site) throw new ServiceError("not_found", "site not found"); + + const onlyApproved = !can(ctx, "work_order:read_all") || opts.onlyApproved === true; + const page = Math.max(1, opts.page ?? 1); + const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 50)); + + const where: Prisma.WorkOrderWhereInput = { + siteId, + deletedAt: null, + ...(onlyApproved ? { reports: { some: { status: "approved" } } } : {}), + }; + + const orders = await ctx.db.workOrder.findMany({ + where, + take: 1000, + select: { + id: true, + number: true, + title: true, + status: true, + isEmergency: true, + plannedStart: true, + createdAt: true, + followUpWork: true, + orderType: { select: { name: true } }, + team: { select: { name: true } }, + workSessions: { select: { startedAt: true }, orderBy: { startedAt: "asc" }, take: 1 }, + notes: { + where: { deletedAt: null, kind: { in: ["work_done", "follow_up"] } }, + select: { kind: true, text: true }, + orderBy: { createdAt: "asc" }, + }, + materialUsages: { where: { usageStatus: { not: "not_used" } }, select: { name: true, unit: true, actualQuantity: true } }, + _count: { select: { photos: true } }, + reports: { + where: { status: { not: "superseded" } }, + select: { id: true, type: true, reportDate: true, version: true, status: true, signature: { select: { outcome: true } } }, + orderBy: { reportDate: "asc" }, + }, + }, + }); + + const entries: SiteHistoryEntry[] = orders.map((o) => { + const workDone = o.notes.filter((n) => n.kind === "work_done").map((n) => n.text); + const followUps = [ + ...(o.followUpWork?.trim() ? [o.followUpWork.trim()] : []), + ...o.notes.filter((n) => n.kind === "follow_up").map((n) => n.text), + ]; + const materialMap = new Map(); + for (const m of o.materialUsages) { + const key = `${m.name.trim().toLowerCase()}|${m.unit.trim().toLowerCase()}`; + const entry = materialMap.get(key) ?? { name: m.name.trim(), unit: m.unit.trim(), quantity: 0 }; + entry.quantity = Math.round((entry.quantity + Number(m.actualQuantity.toString())) * 1000) / 1000; + materialMap.set(key, entry); + } + const joined = workDone.join(" · "); + const reports = onlyApproved ? o.reports.filter((r) => r.status === "approved") : o.reports; + return { + workOrderId: o.id, + number: o.number, + title: o.title, + date: o.workSessions[0]?.startedAt ?? o.plannedStart ?? o.createdAt, + status: o.status, + isEmergency: o.isEmergency, + orderType: o.orderType?.name ?? null, + team: o.team?.name ?? null, + workDone, + summary: joined.length > SUMMARY_MAX ? `${joined.slice(0, SUMMARY_MAX - 1)}…` : joined, + materials: [...materialMap.values()].sort((a, b) => a.name.localeCompare(b.name, "de")), + photoCount: o._count.photos, + approvedReports: o.reports + .filter((r) => r.status === "approved") + .map((r) => ({ id: r.id, type: r.type, reportDate: r.reportDate, version: r.version })), + signed: reports.some((r) => r.signature?.outcome === "signed"), + followUps, + hasOpenFollowUp: followUps.length > 0, + }; + }); + + entries.sort((a, b) => b.date.getTime() - a.date.getTime()); + const total = entries.length; + return { items: entries.slice((page - 1) * pageSize, page * pageSize), total, page, pageSize, onlyApproved }; +} diff --git a/src/server/services/sites/map-link.ts b/src/server/services/sites/map-link.ts new file mode 100644 index 0000000..502a7e9 --- /dev/null +++ b/src/server/services/sites/map-link.ts @@ -0,0 +1,22 @@ +// OpenStreetMap link for a site (spec §8.1) — plain URL, no embed (no third-party requests from the app). + +export function siteMapUrl(site: { + street?: string | null; + houseNumber?: string | null; + postalCode?: string | null; + city?: string | null; + country?: string | null; + latitude?: number | null; + longitude?: number | null; +}): string | null { + if (typeof site.latitude === "number" && typeof site.longitude === "number") { + const lat = site.latitude.toFixed(6); + const lon = site.longitude.toFixed(6); + return `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}#map=18/${lat}/${lon}`; + } + const line = [[site.street, site.houseNumber].filter(Boolean).join(" "), [site.postalCode, site.city].filter(Boolean).join(" "), site.country] + .filter((s) => s && String(s).trim()) + .join(", "); + if (!site.city && !site.postalCode) return null; + return `https://www.openstreetmap.org/search?query=${encodeURIComponent(line)}`; +} diff --git a/src/server/services/sites/sites.ts b/src/server/services/sites/sites.ts new file mode 100644 index 0000000..c0380d4 --- /dev/null +++ b/src/server/services/sites/sites.ts @@ -0,0 +1,149 @@ +import { z } from "zod"; +import type { Prisma } from "@prisma/client"; +import { writeAuditLog } from "@/server/audit"; +import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { siteScope } from "@/server/services/work-orders/visibility"; +import { optStr } from "@/server/services/customers/schemas"; + +export const SITE_STATUSES = ["active", "inactive", "provisional"] as const; + +const optCoord = (min: number, max: number) => + z.preprocess((v) => (v === "" || v === undefined ? undefined : v === null ? null : Number(String(v).replace(",", "."))), z.number().min(min).max(max).nullable().optional()); + +const siteFields = { + customerId: z.string().min(1), + name: z.string().trim().min(1).max(200), + street: optStr(200), + houseNumber: optStr(20), + postalCode: optStr(12), + city: optStr(100), + country: z.preprocess( + (v) => (typeof v === "string" && v.trim() === "" ? undefined : typeof v === "string" ? v.trim().toUpperCase() : v), + z.string().regex(/^[A-Z]{2}$/).optional(), + ), + contactId: optStr(64), + onSiteContact: optStr(200), + phone: optStr(50), + accessNotes: optStr(5000), + parkingNotes: optStr(5000), + safetyNotes: optStr(5000), + technicalNotes: optStr(5000), + status: z.enum(SITE_STATUSES).optional(), + latitude: optCoord(-90, 90), + longitude: optCoord(-180, 180), +}; + +export const siteCreateSchema = z.object(siteFields); +export const sitePatchSchema = z.object(siteFields).partial(); +export type SiteCreateInput = z.input; +export type SitePatchInput = z.input; + +const CLOSED_ORDER_STATUSES = ["billed", "cancelled"] as const; + +async function assertCustomerAndContact(ctx: ServiceCtx, customerId: string, contactId: string | null | undefined) { + const customer = await ctx.db.customer.findFirst({ + where: { id: customerId, deletedAt: null, status: { not: "merged" } }, + select: { id: true }, + }); + if (!customer) throw new ServiceError("invalid", "customer not found", { field: "customerId", reason: "customer_not_found" }); + if (contactId) { + const contact = await ctx.db.contact.findFirst({ where: { id: contactId, customerId, deletedAt: null }, select: { id: true } }); + if (!contact) throw new ServiceError("invalid", "contact does not belong to customer", { field: "contactId", reason: "contact_mismatch" }); + } +} + +export async function listSites( + ctx: ServiceCtx, + opts: { q?: string; customerId?: string; status?: (typeof SITE_STATUSES)[number] | "all"; page?: number; pageSize?: number } = {}, +) { + assertCan(ctx, "site:read"); + const page = Math.max(1, opts.page ?? 1); + const pageSize = Math.min(100, Math.max(1, opts.pageSize ?? 25)); + const q = opts.q?.trim(); + const where: Prisma.SiteWhereInput = { + AND: [ + await siteScope(ctx), + opts.status && opts.status !== "all" ? { status: opts.status } : {}, + opts.customerId ? { customerId: opts.customerId } : {}, + q + ? { + OR: [ + { name: { contains: q, mode: "insensitive" } }, + { street: { contains: q, mode: "insensitive" } }, + { city: { contains: q, mode: "insensitive" } }, + { postalCode: { contains: q } }, + { customer: { companyName: { contains: q, mode: "insensitive" } } }, + { customer: { lastName: { contains: q, mode: "insensitive" } } }, + ], + } + : {}, + ], + }; + const [total, items] = await Promise.all([ + ctx.db.site.count({ where }), + ctx.db.site.findMany({ + where, + orderBy: [{ name: "asc" }, { createdAt: "asc" }], + skip: (page - 1) * pageSize, + take: pageSize, + select: { + id: true, + name: true, + street: true, + houseNumber: true, + postalCode: true, + city: true, + status: true, + customer: { select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true } }, + _count: { select: { workOrders: { where: { deletedAt: null } } } }, + }, + }), + ]); + return { items, total, page, pageSize }; +} + +export async function getSite(ctx: ServiceCtx, id: string) { + assertCan(ctx, "site:read"); + const site = await ctx.db.site.findFirst({ + where: { AND: [{ id }, await siteScope(ctx)] }, + include: { + customer: { select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true, status: true } }, + contact: { select: { id: true, name: true, phone: true, mobile: true, email: true, preferredChannel: true } }, + }, + }); + if (!site) throw new ServiceError("not_found", "site not found"); + return site; +} + +export async function createSite(ctx: ServiceCtx, input: SiteCreateInput) { + assertCan(ctx, "site:write"); + const data = siteCreateSchema.parse(input); + await assertCustomerAndContact(ctx, data.customerId, data.contactId); + const site = await ctx.db.site.create({ data: { ...data, tenantId: ctx.tenantId, country: data.country ?? "DE" } }); + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "create", entity: "site", entityId: site.id, after: site }); + return site; +} + +export async function updateSite(ctx: ServiceCtx, id: string, patch: SitePatchInput) { + assertCan(ctx, "site:write"); + const data = sitePatchSchema.parse(patch); + const before = await ctx.db.site.findFirst({ where: { AND: [{ id }, await siteScope(ctx)] } }); + if (!before) throw new ServiceError("not_found", "site not found"); + const customerId = data.customerId ?? before.customerId; + const contactId = data.contactId === undefined ? (data.customerId ? null : before.contactId) : data.contactId; + await assertCustomerAndContact(ctx, customerId, contactId); + const after = await ctx.db.site.update({ where: { id }, data: { ...data, contactId } }); + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "site", entityId: id, before, after }); + return after; +} + +export async function deleteSite(ctx: ServiceCtx, id: string) { + assertCan(ctx, "site:write"); + const before = await ctx.db.site.findFirst({ where: { AND: [{ id }, await siteScope(ctx)] } }); + if (!before) throw new ServiceError("not_found", "site not found"); + const open = await ctx.db.workOrder.count({ where: { siteId: id, deletedAt: null, status: { notIn: [...CLOSED_ORDER_STATUSES] } } }); + if (open > 0) throw new ServiceError("blocked", "site has open work orders", { reason: "open_work_orders", count: open }); + const after = await ctx.db.site.update({ where: { id }, data: { deletedAt: new Date(), status: "inactive" } }); + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "delete", entity: "site", entityId: id, before, after }); + return after; +} diff --git a/src/server/services/teams/teams.ts b/src/server/services/teams/teams.ts new file mode 100644 index 0000000..2bf89b3 --- /dev/null +++ b/src/server/services/teams/teams.ts @@ -0,0 +1,129 @@ +import { z } from "zod"; +import { writeAuditLog } from "@/server/audit"; +import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { optStr } from "@/server/services/customers/schemas"; + +export const TEAM_STATUSES = ["active", "inactive"] as const; + +const dateInput = z.preprocess((v) => (v === "" || v === null || v === undefined ? undefined : v), z.coerce.date().optional()); +const optDate = z.preprocess((v) => (v === "" || v === undefined ? undefined : v), z.coerce.date().nullable().optional()); + +export const teamMemberSchema = z + .object({ + userId: z.string().min(1), + validFrom: dateInput, + validTo: optDate, + }) + .refine((m) => !m.validTo || !m.validFrom || m.validTo >= m.validFrom, { message: "valid_to_before_from", path: ["validTo"] }); + +export const teamSchema = z.object({ + name: z.string().trim().min(1).max(120), + leaderUserId: optStr(64), + status: z.enum(TEAM_STATUSES).optional(), + phone: optStr(50), + vehicle: optStr(120), + area: optStr(200), + notes: optStr(5000), + members: z.array(teamMemberSchema).max(100).default([]), +}); + +export type TeamInput = z.input; + +const teamInclude = { + leader: { select: { id: true, name: true } }, + members: { include: { user: { select: { id: true, name: true, email: true, status: true } } }, orderBy: { validFrom: "asc" as const } }, +}; + +export async function listTeams(ctx: ServiceCtx, opts: { includeInactive?: boolean } = {}) { + assertCan(ctx, "team:read"); + return ctx.db.team.findMany({ + where: { deletedAt: null, ...(opts.includeInactive ? {} : { status: "active" }) }, + include: teamInclude, + orderBy: { name: "asc" }, + }); +} + +export async function getTeam(ctx: ServiceCtx, id: string) { + assertCan(ctx, "team:read"); + const team = await ctx.db.team.findFirst({ where: { id, deletedAt: null }, include: teamInclude }); + if (!team) throw new ServiceError("not_found", "team not found"); + return team; +} + +/** Active members of the tenant for leader/member selects. */ +export async function teamUserOptions(ctx: ServiceCtx) { + assertCan(ctx, "team:manage"); + return ctx.db.user.findMany({ where: { status: "ACTIVE" }, select: { id: true, name: true, email: true }, orderBy: { name: "asc" } }); +} + +type ParsedTeam = z.output; + +async function validateTeam(ctx: ServiceCtx, data: ParsedTeam, exceptId?: string) { + const memberIds = data.members.map((m) => m.userId); + if (new Set(memberIds).size !== memberIds.length) { + throw new ServiceError("invalid", "duplicate member", { field: "members", reason: "duplicate_member" }); + } + const ids = [...new Set([...memberIds, ...(data.leaderUserId ? [data.leaderUserId] : [])])]; + if (ids.length) { + // dbForTenant restricts to the tenant: users of other tenants are simply not found. + const found = await ctx.db.user.count({ where: { id: { in: ids }, status: "ACTIVE" } }); + if (found !== ids.length) throw new ServiceError("invalid", "unknown or inactive user", { field: "members", reason: "inactive_user" }); + } + const clash = await ctx.db.team.findFirst({ where: { name: data.name, ...(exceptId ? { id: { not: exceptId } } : {}) }, select: { id: true } }); + if (clash) throw new ServiceError("conflict", "team name taken", { field: "name", reason: "name_taken" }); +} + +function memberRows(ctx: ServiceCtx, teamId: string, data: ParsedTeam) { + const now = new Date(); + return data.members.map((m) => ({ tenantId: ctx.tenantId, teamId, userId: m.userId, validFrom: m.validFrom ?? now, validTo: m.validTo ?? null })); +} + +function snapshot(team: { members: { userId: string; validFrom: Date; validTo: Date | null }[] } & Record) { + const { members, ...rest } = team; + return { ...rest, members: members.map((m) => ({ userId: m.userId, validFrom: m.validFrom, validTo: m.validTo })) }; +} + +export async function createTeam(ctx: ServiceCtx, input: TeamInput) { + assertCan(ctx, "team:manage"); + const data = teamSchema.parse(input); + await validateTeam(ctx, data); + const { members, ...fields } = data; + void members; + const team = await ctx.db.team.create({ data: { ...fields, tenantId: ctx.tenantId } }); + if (data.members.length) await ctx.db.teamMember.createMany({ data: memberRows(ctx, team.id, data) }); + const after = await getTeam(ctx, team.id); + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "create", entity: "team", entityId: team.id, after: snapshot(after) }); + return after; +} + +/** Full replace of team data and membership list (validity periods included). */ +export async function updateTeam(ctx: ServiceCtx, id: string, input: TeamInput) { + assertCan(ctx, "team:manage"); + const data = teamSchema.parse(input); + const before = await getTeam(ctx, id); + await validateTeam(ctx, data, id); + const { members, ...fields } = data; + void members; + await ctx.db.$transaction([ + ctx.db.team.update({ where: { id }, data: fields }), + ctx.db.teamMember.deleteMany({ where: { teamId: id } }), + ctx.db.teamMember.createMany({ data: memberRows(ctx, id, data) }), + ]); + const after = await getTeam(ctx, id); + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "team", entityId: id, before: snapshot(before), after: snapshot(after) }); + return after; +} + +/** Soft delete; the unique name is released by suffixing it. Blocked while open orders are assigned. */ +export async function deleteTeam(ctx: ServiceCtx, id: string) { + assertCan(ctx, "team:manage"); + const before = await getTeam(ctx, id); + const open = await ctx.db.workOrder.count({ where: { assignedTeamId: id, deletedAt: null, status: { notIn: ["billed", "cancelled"] } } }); + if (open > 0) throw new ServiceError("blocked", "team has open work orders", { reason: "open_work_orders", count: open }); + const after = await ctx.db.team.update({ + where: { id }, + data: { deletedAt: new Date(), status: "inactive", name: `${before.name} · ${id.slice(-6)}` }, + }); + await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "delete", entity: "team", entityId: id, before: snapshot(before), after }); + return after; +} From 49c5ad0e33e0f7fdf9de36e801854ef7154131fb Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 12:26:27 +0200 Subject: [PATCH 2/5] L1 Stammdaten: Dokumentenablage-Service und Download per documentId MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit storeFile (Allowlist, Magic Bytes, Größenlimits, Dateinamen-Normalisierung, SHA-256, Versionierung über lineageId), FileScanner mit optionalem ClamAV-Hook, Sichtbarkeits-/Scope-Autorisierung, Upload-Route und Umbau der Download-Route von files/[...key] auf files/[documentId]. Co-Authored-By: Claude Opus 5 --- src/app/(app)/documents/upload/route.ts | 84 +++++++++ src/app/(app)/files/[documentId]/route.ts | 49 ++++++ src/server/actions/documents/documents.ts | 44 +++++ src/server/services/documents/access.ts | 183 ++++++++++++++++++++ src/server/services/documents/scanner.ts | 158 +++++++++++++++++ src/server/services/documents/store.ts | 201 ++++++++++++++++++++++ 6 files changed, 719 insertions(+) create mode 100644 src/app/(app)/documents/upload/route.ts create mode 100644 src/app/(app)/files/[documentId]/route.ts create mode 100644 src/server/actions/documents/documents.ts create mode 100644 src/server/services/documents/access.ts create mode 100644 src/server/services/documents/scanner.ts create mode 100644 src/server/services/documents/store.ts diff --git a/src/app/(app)/documents/upload/route.ts b/src/app/(app)/documents/upload/route.ts new file mode 100644 index 0000000..341eee7 --- /dev/null +++ b/src/app/(app)/documents/upload/route.ts @@ -0,0 +1,84 @@ +import { DocumentCategory, DocumentVisibility } from "@prisma/client"; +import { assertSameOrigin, requireApiContext } from "@/server/api/context"; +import { ApiError, toErrorResponse } from "@/server/api/respond"; +import { ServiceError } from "@/server/services/context"; +import { storeFile } from "@/server/services/documents/store"; + +/** + * Multipart upload for the backoffice document tabs (customer, site, /documents). + * A route handler instead of a server action because server action bodies are limited to 1 MB. + * Note: with the proxy active, Next.js buffers at most `proxyClientMaxBodySize` (default 10 MB); + * larger bodies fail to parse and are answered with `too_large`. + * + * Browser forms get a 303 redirect back to `returnTo` with `?docOk=1` or `?docError=`; + * clients sending `Accept: application/json` get JSON (`{ data: { id } }` or the error format). + */ +export async function POST(req: Request) { + const wantsJson = (req.headers.get("accept") ?? "").includes("application/json"); + let returnTo = "/documents"; + try { + assertSameOrigin(req); + const ctx = await requireApiContext("documents"); + + let form: FormData; + try { + form = await req.formData(); + } catch { + throw new ServiceError("invalid", "unreadable upload", { reason: "too_large" }); + } + returnTo = safeReturnTo(form.get("returnTo")); + + const file = form.get("file"); + if (!(file instanceof File)) throw new ServiceError("invalid", "file missing", { reason: "empty_file" }); + const str = (k: string) => { + const v = form.get(k); + return typeof v === "string" && v.trim() !== "" ? v.trim() : null; + }; + const category = str("category"); + const visibility = str("visibility"); + if (!category || !(category in DocumentCategory)) throw new ServiceError("invalid", "category", { reason: "invalid_category" }); + if (!visibility || !(visibility in DocumentVisibility)) throw new ServiceError("invalid", "visibility", { reason: "visibility_not_allowed" }); + + const document = await storeFile(ctx, { + bytes: new Uint8Array(await file.arrayBuffer()), + fileName: file.name, + declaredMime: file.type || "application/octet-stream", + category: category as DocumentCategory, + visibility: visibility as DocumentVisibility, + title: str("title"), + lineageId: str("lineageId"), + links: { customerId: str("customerId"), siteId: str("siteId"), workOrderId: str("workOrderId") }, + }); + + if (wantsJson) return Response.json({ data: { id: document.id, version: document.version, lineageId: document.lineageId } }, { status: 201 }); + return redirectTo(returnTo, { docOk: "1" }); + } catch (err) { + if (wantsJson) return toErrorResponse(err); + if (err instanceof ApiError && err.code === "unauthorized") return redirectTo("/login", {}); + const reason = + err instanceof ServiceError + ? String((err.details as { reason?: string } | undefined)?.reason ?? err.code) + : err instanceof ApiError + ? err.code + : "generic"; + if (!(err instanceof ServiceError) && !(err instanceof ApiError)) console.error("[documents/upload] failed", err); + return redirectTo(returnTo, { docError: reason }); + } +} + +/** Only same-app relative paths; everything else falls back to /documents (no open redirect). */ +function safeReturnTo(value: FormDataEntryValue | null): string { + if (typeof value !== "string") return "/documents"; + if (!value.startsWith("/") || value.startsWith("//") || value.includes("\\")) return "/documents"; + return value.slice(0, 500); +} + +function redirectTo(path: string, params: Record): Response { + const [pathname, query = ""] = path.split("?"); + const sp = new URLSearchParams(query); + sp.delete("docOk"); + sp.delete("docError"); + for (const [k, v] of Object.entries(params)) sp.set(k, v); + const qs = sp.toString(); + return new Response(null, { status: 303, headers: { Location: `${pathname}${qs ? `?${qs}` : ""}` } }); +} diff --git a/src/app/(app)/files/[documentId]/route.ts b/src/app/(app)/files/[documentId]/route.ts new file mode 100644 index 0000000..db582de --- /dev/null +++ b/src/app/(app)/files/[documentId]/route.ts @@ -0,0 +1,49 @@ +import { requireApiContext } from "@/server/api/context"; +import { ApiError } from "@/server/api/respond"; +import { ServiceError, type ServiceCtx } from "@/server/services/context"; +import { openDocumentContent } from "@/server/services/documents/access"; + +/** + * Document download by id (ARCHITEKTUR §4.3). Replaces the former storage-key route. + * + * Authorization on EVERY request (no public or long-lived links): + * session + DB-authoritative `document:read` → document in the tenant (dbForTenant) → + * visibility allowed for the user → work order in scope (`requireVisibleWorkOrder` semantics via + * `workOrderScope`) or site/customer in `siteScope`/`customerScope`. Everything else → 404 + * without revealing existence. + * + * Delivered as attachment with nosniff (F-07); images still render in tags. + */ +export async function GET(_req: Request, { params }: { params: Promise<{ documentId: string }> }) { + let ctx: ServiceCtx; + try { + ctx = await requireApiContext(null, "document:read"); + } catch (err) { + const status = err instanceof ApiError && err.code === "unauthorized" ? 401 : 403; + return new Response(status === 401 ? "Nicht angemeldet." : "Kein Zugriff.", { status, headers: { "Cache-Control": "no-store" } }); + } + + const { documentId } = await params; + if (!documentId || documentId.length > 64) return notFound(); + + try { + const { document, content } = await openDocumentContent(ctx, documentId); + const asciiName = document.fileName.replace(/[^\x20-\x7e]/g, "_").replace(/["\\]/g, "_"); + const headers = new Headers({ + "Content-Type": document.mimeType || content.contentType || "application/octet-stream", + "Content-Disposition": `attachment; filename="${asciiName}"; filename*=UTF-8''${encodeURIComponent(document.fileName)}`, + "X-Content-Type-Options": "nosniff", + "Cache-Control": "private, no-store", + }); + if (content.size != null) headers.set("Content-Length", String(content.size)); + return new Response(content.stream, { headers }); + } catch (err) { + if (err instanceof ServiceError || (err instanceof Error && /Tenant isolation violation/.test(err.message))) return notFound(); + console.error("[files] download failed", err); + return new Response("Datei nicht verfügbar.", { status: 500, headers: { "Cache-Control": "no-store" } }); + } +} + +function notFound() { + return new Response("Nicht gefunden.", { status: 404, headers: { "Cache-Control": "no-store" } }); +} diff --git a/src/server/actions/documents/documents.ts b/src/server/actions/documents/documents.ts new file mode 100644 index 0000000..348164a --- /dev/null +++ b/src/server/actions/documents/documents.ts @@ -0,0 +1,44 @@ +"use server"; + +import { revalidatePath } from "next/cache"; +import { moduleGuard } from "@/server/action-guard"; +import { ctxFromGuard } from "@/server/services/context"; +import { formObject, toActionError, type ActionState } from "@/server/api/action-state"; +import { deleteDocument, updateDocumentMeta } from "@/server/services/documents/access"; +import type { DocumentCategory, DocumentVisibility } from "@prisma/client"; + +const guard = moduleGuard("documents"); + +/** Only revalidate same-app paths passed by our own pages. */ +function safePath(path: string): string { + return path.startsWith("/") && !path.startsWith("//") ? path.split("?")[0] : "/documents"; +} + +export async function updateDocumentAction(documentId: string, returnPath: string, _prev: ActionState, fd: FormData): Promise { + try { + const ctx = ctxFromGuard(await guard("document:write")); + const v = formObject(fd, ["title", "category", "visibility"]); + await updateDocumentMeta(ctx, documentId, { + title: v.title ?? null, + category: v.category as DocumentCategory | undefined, + visibility: v.visibility as DocumentVisibility | undefined, + }); + } catch (err) { + return toActionError(err); + } + revalidatePath(safePath(returnPath)); + revalidatePath("/documents"); + return { status: "ok" }; +} + +export async function deleteDocumentAction(documentId: string, returnPath: string): Promise { + try { + const ctx = ctxFromGuard(await guard("document:write")); + await deleteDocument(ctx, documentId); + } catch (err) { + return toActionError(err); + } + revalidatePath(safePath(returnPath)); + revalidatePath("/documents"); + return { status: "ok" }; +} diff --git a/src/server/services/documents/access.ts b/src/server/services/documents/access.ts new file mode 100644 index 0000000..3e73858 --- /dev/null +++ b/src/server/services/documents/access.ts @@ -0,0 +1,183 @@ +import { z } from "zod"; +import { DocumentCategory, DocumentVisibility, type Prisma } from "@prisma/client"; +import { writeAuditLog } from "@/server/audit"; +import { storage, type StoredContent } from "@/server/storage/adapter"; +import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { allowedDocumentVisibility, customerScope, siteScope, workOrderScope } from "@/server/services/work-orders/visibility"; + +/** + * Read side of the document service: visibility filter, download authorization, listing, + * metadata changes and soft delete (spec §24.3, ARCHITEKTUR §2 "Dokument-Sichtbarkeit"). + */ + +/** + * Documents the user may read: + * - `document:read` required; + * - visibility ∈ allowedDocumentVisibility(ctx) (backoffice_only needs document:read_internal); + * - users without `work_order:read_all`: linked work order in `workOrderScope`, or — for documents + * without an order — site in `siteScope` / customer in `customerScope`. Unlinked documents + * (e.g. import originals) are backoffice-only. + */ +export async function documentReadWhere(ctx: ServiceCtx): Promise { + if (!can(ctx, "document:read")) return { id: "__none__" }; + const base: Prisma.DocumentWhereInput = { + deletedAt: null, + uploadStatus: "uploaded", + visibility: { in: allowedDocumentVisibility(ctx) }, + }; + if (can(ctx, "work_order:read_all")) return base; + const [wo, site, customer] = await Promise.all([workOrderScope(ctx), siteScope(ctx), customerScope(ctx)]); + return { + AND: [ + base, + { + OR: [ + { workOrderId: { not: null }, workOrder: { is: wo } }, + { workOrderId: null, siteId: { not: null }, site: { is: site } }, + { workOrderId: null, siteId: null, customerId: { not: null }, customer: { is: customer } }, + ], + }, + ], + }; +} + +/** Load a document the user may read, or throw `not_found` (never reveals existence). */ +export async function authorizeDocumentAccess(ctx: ServiceCtx, documentId: string) { + const document = await ctx.db.document.findFirst({ where: { AND: [{ id: documentId }, await documentReadWhere(ctx)] } }); + if (!document) throw new ServiceError("not_found", "document not found"); + return document; +} + +/** Internal download link — authorization happens again on every request to the route. */ +export async function getDownloadUrl(ctx: ServiceCtx, documentId: string): Promise { + const document = await authorizeDocumentAccess(ctx, documentId); + return documentHref(document.id); +} + +export function documentHref(documentId: string): string { + return `/files/${encodeURIComponent(documentId)}`; +} + +/** Authorize and open the stored bytes (used by /files/[documentId]). */ +export async function openDocumentContent(ctx: ServiceCtx, documentId: string): Promise<{ document: Awaited>; content: StoredContent }> { + const document = await authorizeDocumentAccess(ctx, documentId); + // defense in depth: the key must carry the tenant prefix + if (!document.storageKey.startsWith(`${ctx.tenantId}/`)) throw new ServiceError("not_found", "document content not available"); + const content = await storage.get(document.storageKey); + if (!content) throw new ServiceError("not_found", "document content not available"); + return { document, content }; +} + +export type DocumentListFilter = { + category?: DocumentCategory; + customerId?: string; + siteId?: string; + workOrderId?: string; + q?: string; + /** Only the newest version of each lineage. */ + latestOnly?: boolean; + page?: number; + pageSize?: number; +}; + +export async function listDocuments(ctx: ServiceCtx, filter: DocumentListFilter = {}) { + const page = Math.max(1, filter.page ?? 1); + const pageSize = Math.min(500, Math.max(1, filter.pageSize ?? 25)); + const and: Prisma.DocumentWhereInput[] = [await documentReadWhere(ctx)]; + if (filter.category) and.push({ category: filter.category }); + if (filter.workOrderId) and.push({ workOrderId: filter.workOrderId }); + if (filter.siteId) and.push({ OR: [{ siteId: filter.siteId }, { workOrder: { is: { siteId: filter.siteId } } }] }); + if (filter.customerId) { + and.push({ + OR: [ + { customerId: filter.customerId }, + { site: { is: { customerId: filter.customerId } } }, + { workOrder: { is: { customerId: filter.customerId } } }, + ], + }); + } + if (filter.q?.trim()) { + const q = filter.q.trim(); + and.push({ OR: [{ fileName: { contains: q, mode: "insensitive" } }, { title: { contains: q, mode: "insensitive" } }] }); + } + + let where: Prisma.DocumentWhereInput = { AND: and }; + if (filter.latestOnly) { + const groups = await ctx.db.document.groupBy({ by: ["lineageId"], where, _max: { version: true } }); + where = { AND: [where, { OR: groups.map((g) => ({ lineageId: g.lineageId, version: g._max.version ?? 1 })) }] }; + if (groups.length === 0) return { items: [], total: 0, page, pageSize }; + } + + const [total, items] = await Promise.all([ + ctx.db.document.count({ where }), + ctx.db.document.findMany({ + where, + orderBy: [{ createdAt: "desc" }, { version: "desc" }], + skip: (page - 1) * pageSize, + take: pageSize, + select: { + id: true, + title: true, + fileName: true, + category: true, + visibility: true, + mimeType: true, + fileSize: true, + checksum: true, + version: true, + lineageId: true, + approvalStatus: true, + uploadedById: true, + createdAt: true, + updatedAt: true, + customer: { select: { id: true, customerNumber: true, companyName: true, firstName: true, lastName: true } }, + site: { select: { id: true, name: true } }, + workOrder: { select: { id: true, number: true, title: true } }, + }, + }), + ]); + return { items, total, page, pageSize }; +} + +const metaPatchSchema = z.object({ + title: z.preprocess((v) => (typeof v === "string" && v.trim() === "" ? null : v), z.string().trim().max(300).nullable().optional()), + category: z.enum(DocumentCategory).optional(), + visibility: z.enum(DocumentVisibility).optional(), +}); + +export async function updateDocumentMeta(ctx: ServiceCtx, documentId: string, input: z.input) { + assertCan(ctx, "document:write"); + const data = metaPatchSchema.parse(input); + const before = await authorizeDocumentAccess(ctx, documentId); + if (data.visibility && !allowedDocumentVisibility(ctx).includes(data.visibility)) { + throw new ServiceError("invalid", "visibility not allowed", { field: "visibility", reason: "visibility_not_allowed" }); + } + const after = await ctx.db.document.update({ where: { id: documentId }, data }); + await writeAuditLog({ + tenantId: ctx.tenantId, + actorId: ctx.userId, + action: "update", + entity: "document", + entityId: documentId, + before: { title: before.title, category: before.category, visibility: before.visibility }, + after: { title: after.title, category: after.category, visibility: after.visibility }, + }); + return after; +} + +/** Soft delete of one document version. */ +export async function deleteDocument(ctx: ServiceCtx, documentId: string) { + assertCan(ctx, "document:write"); + const before = await authorizeDocumentAccess(ctx, documentId); + const after = await ctx.db.document.update({ where: { id: documentId }, data: { deletedAt: new Date() } }); + await writeAuditLog({ + tenantId: ctx.tenantId, + actorId: ctx.userId, + action: "delete", + entity: "document", + entityId: documentId, + before: { fileName: before.fileName, version: before.version, lineageId: before.lineageId }, + after: { deletedAt: after.deletedAt }, + }); + return after; +} diff --git a/src/server/services/documents/scanner.ts b/src/server/services/documents/scanner.ts new file mode 100644 index 0000000..50b3419 --- /dev/null +++ b/src/server/services/documents/scanner.ts @@ -0,0 +1,158 @@ +import { connect } from "node:net"; + +/** + * File scanning (ARCHITEKTUR §4.3, spec §27.4). MVP: magic-byte/type verification against an + * allowlist. If CLAMAV_HOST is set, the bytes are additionally streamed to clamd (INSTREAM). + * Scanners never throw for bad content — they return a structured verdict. + */ + +export type DetectedKind = "pdf" | "image" | "audio"; + +export type ScanVerdict = + | { ok: true; detectedMime: string; kind: DetectedKind } + | { ok: false; reason: "unsupported_type" | "type_mismatch" | "malware" | "scanner_unavailable"; detail?: string }; + +export interface FileScanner { + name: string; + scan(input: { bytes: Uint8Array; declaredMime: string; fileName: string }): Promise; +} + +/** Allowlisted MIME types → kind. */ +export const ALLOWED_MIME: Record = { + "application/pdf": "pdf", + "image/jpeg": "image", + "image/png": "image", + "image/webp": "image", + "image/heic": "image", + "audio/webm": "audio", + "audio/ogg": "audio", + "audio/mp4": "audio", + "audio/mpeg": "audio", + "audio/wav": "audio", +}; + +const MIME_ALIASES: Record = { + "image/jpg": "image/jpeg", + "image/pjpeg": "image/jpeg", + "image/heif": "image/heic", + "audio/x-wav": "audio/wav", + "audio/wave": "audio/wav", + "audio/x-m4a": "audio/mp4", + "audio/m4a": "audio/mp4", + "audio/mp3": "audio/mpeg", + "video/webm": "audio/webm", // MediaRecorder often labels audio-only recordings as video/webm +}; + +export function canonicalMime(mime: string): string { + const base = mime.split(";")[0].trim().toLowerCase(); + return MIME_ALIASES[base] ?? base; +} + +function startsWith(bytes: Uint8Array, sig: number[], offset = 0): boolean { + if (bytes.length < offset + sig.length) return false; + return sig.every((b, i) => bytes[offset + i] === b); +} + +function ascii(bytes: Uint8Array, start: number, end: number): string { + return String.fromCharCode(...bytes.slice(start, end)); +} + +/** Detect the real MIME type from the leading bytes; null if not on the allowlist. */ +export function detectMime(bytes: Uint8Array): string | null { + if (startsWith(bytes, [0x25, 0x50, 0x44, 0x46, 0x2d])) return "application/pdf"; // %PDF- + if (startsWith(bytes, [0xff, 0xd8, 0xff])) return "image/jpeg"; + if (startsWith(bytes, [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) return "image/png"; + if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WEBP") return "image/webp"; + if (ascii(bytes, 0, 4) === "RIFF" && ascii(bytes, 8, 12) === "WAVE") return "audio/wav"; + if (startsWith(bytes, [0x1a, 0x45, 0xdf, 0xa3])) return "audio/webm"; // EBML (WebM/Matroska) + if (ascii(bytes, 0, 4) === "OggS") return "audio/ogg"; + if (ascii(bytes, 0, 3) === "ID3" || startsWith(bytes, [0xff, 0xfb]) || startsWith(bytes, [0xff, 0xf3])) return "audio/mpeg"; + if (ascii(bytes, 4, 8) === "ftyp") { + const brand = ascii(bytes, 8, 12); + if (["heic", "heix", "mif1", "msf1", "heim", "heis"].includes(brand)) return "image/heic"; + if (["M4A ", "mp42", "isom", "dash", "iso5", "iso6"].includes(brand)) return "audio/mp4"; + } + return null; +} + +export class MagicByteScanner implements FileScanner { + name = "magic-bytes"; + + async scan({ bytes, declaredMime }: { bytes: Uint8Array; declaredMime: string; fileName: string }): Promise { + const declared = canonicalMime(declaredMime); + if (!ALLOWED_MIME[declared]) return { ok: false, reason: "unsupported_type", detail: declared }; + const detected = detectMime(bytes); + if (!detected) return { ok: false, reason: "type_mismatch", detail: "unknown signature" }; + if (detected !== declared) return { ok: false, reason: "type_mismatch", detail: `${declared} ≠ ${detected}` }; + return { ok: true, detectedMime: detected, kind: ALLOWED_MIME[detected] }; + } +} + +/** clamd INSTREAM client (only active if CLAMAV_HOST is configured). */ +export class ClamAvScanner implements FileScanner { + name = "clamav"; + constructor( + private host: string, + private port: number, + private timeoutMs = 15_000, + ) {} + + scan({ bytes }: { bytes: Uint8Array; declaredMime: string; fileName: string }): Promise { + return new Promise((resolve) => { + const socket = connect({ host: this.host, port: this.port }); + let response = ""; + const done = (v: ScanVerdict) => { + socket.destroy(); + resolve(v); + }; + socket.setTimeout(this.timeoutMs, () => done({ ok: false, reason: "scanner_unavailable", detail: "timeout" })); + socket.on("error", (err) => done({ ok: false, reason: "scanner_unavailable", detail: err.message })); + socket.on("data", (chunk) => (response += chunk.toString("utf8"))); + socket.on("end", () => { + if (/OK\s*\0?$/.test(response.trim())) done({ ok: true, detectedMime: "", kind: "pdf" }); + else if (/FOUND/.test(response)) done({ ok: false, reason: "malware", detail: response.trim() }); + else done({ ok: false, reason: "scanner_unavailable", detail: response.trim() }); + }); + socket.on("connect", () => { + socket.write("zINSTREAM\0"); + const chunkSize = 64 * 1024; + for (let i = 0; i < bytes.length; i += chunkSize) { + const chunk = bytes.subarray(i, i + chunkSize); + const len = Buffer.alloc(4); + len.writeUInt32BE(chunk.length, 0); + socket.write(len); + socket.write(chunk); + } + socket.write(Buffer.alloc(4)); // zero-length chunk terminates the stream + }); + }); + } +} + +/** Magic bytes first (cheap, authoritative for the stored MIME), then optional ClamAV. */ +export class CompositeScanner implements FileScanner { + name: string; + constructor(private primary: FileScanner, private extra: FileScanner[]) { + this.name = [primary.name, ...extra.map((s) => s.name)].join("+"); + } + + async scan(input: { bytes: Uint8Array; declaredMime: string; fileName: string }): Promise { + const first = await this.primary.scan(input); + if (!first.ok) return first; + for (const s of this.extra) { + const v = await s.scan(input); + if (!v.ok) return v; + } + return first; + } +} + +let scanner: FileScanner | null = null; + +export function getFileScanner(): FileScanner { + if (scanner) return scanner; + const host = process.env.CLAMAV_HOST?.trim(); + const magic = new MagicByteScanner(); + scanner = host ? new CompositeScanner(magic, [new ClamAvScanner(host, Number(process.env.CLAMAV_PORT ?? 3310))]) : magic; + return scanner; +} diff --git a/src/server/services/documents/store.ts b/src/server/services/documents/store.ts new file mode 100644 index 0000000..d5c6640 --- /dev/null +++ b/src/server/services/documents/store.ts @@ -0,0 +1,201 @@ +import { createHash, randomUUID } from "node:crypto"; +import { z } from "zod"; +import { DocumentCategory, DocumentVisibility, type Document } from "@prisma/client"; +import { writeAuditLog } from "@/server/audit"; +import { storage } from "@/server/storage/adapter"; +import { assertCan, can, ServiceError, type ServiceCtx } from "@/server/services/context"; +import { allowedDocumentVisibility, requireVisibleWorkOrder } from "@/server/services/work-orders/visibility"; +import { getFileScanner, type DetectedKind, type FileScanner } from "@/server/services/documents/scanner"; +import { documentReadWhere } from "@/server/services/documents/access"; + +/** + * Document storage service (ARCHITEKTUR §4.3, spec §24, §27.4). Owned by lane "stammdaten"; + * every lane stores files ONLY through `storeFile` and links downloads via `getDownloadUrl` + * (see ./access.ts). + */ + +const MB = 1024 * 1024; + +/** Size limits per detected kind (ARCHITEKTUR §4.3). */ +export const SIZE_LIMITS: Record = { image: 15 * MB, pdf: 25 * MB, audio: 20 * MB }; +export const MAX_UPLOAD_BYTES = Math.max(...Object.values(SIZE_LIMITS)); + +export const DOCUMENT_CATEGORIES = Object.values(DocumentCategory); +export const DOCUMENT_VISIBILITIES = Object.values(DocumentVisibility); + +export type StoreFileInput = { + bytes: Uint8Array; + fileName: string; + declaredMime: string; + category: DocumentCategory; + visibility: DocumentVisibility; + title?: string | null; + links?: { customerId?: string | null; siteId?: string | null; workOrderId?: string | null }; + /** Existing lineage → stored as the next version of that document. */ + lineageId?: string | null; + approvalStatus?: "draft" | "approved" | null; +}; + +const metaSchema = z.object({ + fileName: z.string().min(1).max(500), + declaredMime: z.string().min(1).max(200), + category: z.enum(DocumentCategory), + visibility: z.enum(DocumentVisibility), + title: z.string().trim().max(300).nullable().optional(), + lineageId: z.string().min(1).max(64).nullable().optional(), + approvalStatus: z.enum(["draft", "approved"]).nullable().optional(), + links: z + .object({ + customerId: z.string().min(1).nullable().optional(), + siteId: z.string().min(1).nullable().optional(), + workOrderId: z.string().min(1).nullable().optional(), + }) + .optional(), +}); + +/** + * Normalize a user-supplied file name: strip any path, control and reserved characters, unify + * Unicode (NFC), collapse whitespace, keep the extension, limit length. Never empty. + */ +export function normalizeFileName(name: string): string { + const base = name.split(/[\\/]/).pop() ?? ""; + const cleaned = base + .normalize("NFC") + .replace(/[\x00-\x1f\x7f]/g, "") + .replace(/[<>:"|?*]/g, "_") + .replace(/\s+/g, " ") + .replace(/^[.\s]+/, "") + .trim(); + if (!cleaned) return "datei"; + const MAX = 180; + if (cleaned.length <= MAX) return cleaned; + const dot = cleaned.lastIndexOf("."); + const ext = dot > 0 && cleaned.length - dot <= 10 ? cleaned.slice(dot) : ""; + return cleaned.slice(0, MAX - ext.length) + ext; +} + +export function sha256Hex(bytes: Uint8Array): string { + return createHash("sha256").update(bytes).digest("hex"); +} + +/** Who may attach a file where (the caller's own action guard stays in place in addition). */ +async function assertMayAttach(ctx: ServiceCtx, links: NonNullable) { + if (links.workOrderId) { + // field roles attach photos/voice notes/signatures to orders in their scope + if (!["document:write", "field:execute", "report:write", "emergency:create"].some((p) => can(ctx, p))) { + throw new ServiceError("forbidden", "missing permission to attach documents"); + } + await requireVisibleWorkOrder(ctx, links.workOrderId, { id: true }); + } else if (links.siteId || links.customerId) { + assertCan(ctx, "document:write"); + } else if (!can(ctx, "document:write") && !can(ctx, "import:write")) { + // unlinked originals (e.g. PDF imports) are backoffice material + throw new ServiceError("forbidden", "missing permission document:write"); + } + if (links.siteId) { + const site = await ctx.db.site.findFirst({ where: { id: links.siteId, deletedAt: null }, select: { id: true } }); + if (!site) throw new ServiceError("invalid", "site not found", { field: "siteId", reason: "site_not_found" }); + } + if (links.customerId) { + const customer = await ctx.db.customer.findFirst({ where: { id: links.customerId, deletedAt: null }, select: { id: true } }); + if (!customer) throw new ServiceError("invalid", "customer not found", { field: "customerId", reason: "customer_not_found" }); + } +} + +/** + * Validate and store a file, creating a `Document` row. + * Order: metadata → size → magic bytes/scanner → permission/links → visibility → storage → DB → audit. + * Rejections are `ServiceError("invalid", …, { reason })` with reason + * `empty_file | too_large | unsupported_type | type_mismatch | malware | scanner_unavailable | + * visibility_not_allowed | lineage_not_found`. + */ +export async function storeFile(ctx: ServiceCtx, input: StoreFileInput, deps: { scanner?: FileScanner } = {}): Promise { + const meta = metaSchema.parse({ ...input, bytes: undefined }); + const bytes = input.bytes; + if (!bytes || bytes.byteLength === 0) throw new ServiceError("invalid", "empty file", { field: "file", reason: "empty_file" }); + if (bytes.byteLength > MAX_UPLOAD_BYTES) throw new ServiceError("invalid", "file too large", { field: "file", reason: "too_large" }); + + const fileName = normalizeFileName(meta.fileName); + const verdict = await (deps.scanner ?? getFileScanner()).scan({ bytes, declaredMime: meta.declaredMime, fileName }); + if (!verdict.ok) throw new ServiceError("invalid", `file rejected: ${verdict.reason}`, { field: "file", reason: verdict.reason }); + if (bytes.byteLength > SIZE_LIMITS[verdict.kind]) { + throw new ServiceError("invalid", "file too large", { field: "file", reason: "too_large", limit: SIZE_LIMITS[verdict.kind] }); + } + + let links = { customerId: meta.links?.customerId ?? null, siteId: meta.links?.siteId ?? null, workOrderId: meta.links?.workOrderId ?? null }; + let lineageId: string = randomUUID(); + let version = 1; + if (meta.lineageId) { + // a new version is only possible for a document the user may read + const previous = await ctx.db.document.findFirst({ + where: { AND: [{ lineageId: meta.lineageId }, await documentReadWhere(ctx)] }, + orderBy: { version: "desc" }, + }); + if (!previous) throw new ServiceError("invalid", "lineage not found", { field: "lineageId", reason: "lineage_not_found" }); + lineageId = previous.lineageId; + version = previous.version + 1; + if (!links.customerId && !links.siteId && !links.workOrderId) { + links = { customerId: previous.customerId, siteId: previous.siteId, workOrderId: previous.workOrderId }; + } + } + + await assertMayAttach(ctx, links); + if (!allowedDocumentVisibility(ctx).includes(meta.visibility)) { + throw new ServiceError("invalid", "visibility not allowed", { field: "visibility", reason: "visibility_not_allowed" }); + } + + const checksum = sha256Hex(bytes); + const stored = await storage.put({ tenantId: ctx.tenantId, filename: fileName, contentType: verdict.detectedMime, bytes }); + + let document: Document | null = null; + for (let attempt = 0; attempt < 2 && !document; attempt++) { + try { + document = await ctx.db.document.create({ + data: { + tenantId: ctx.tenantId, + ...links, + category: meta.category, + title: meta.title ?? null, + fileName, + storageKey: stored.storageKey, + mimeType: verdict.detectedMime, + fileSize: bytes.byteLength, + checksum, + version, + lineageId, + visibility: meta.visibility, + approvalStatus: meta.approvalStatus ?? null, + uploadStatus: "uploaded", + uploadedById: ctx.userId, + }, + }); + } catch (err) { + // concurrent new version of the same lineage → take the next number once + if ((err as { code?: string }).code !== "P2002" || attempt > 0 || !meta.lineageId) throw err; + const latest = await ctx.db.document.findFirst({ where: { lineageId }, orderBy: { version: "desc" }, select: { version: true } }); + version = (latest?.version ?? version) + 1; + } + } + if (!document) throw new ServiceError("conflict", "could not store document version"); + + await writeAuditLog({ + tenantId: ctx.tenantId, + actorId: ctx.userId, + action: "create", + entity: "document", + entityId: document.id, + after: { + fileName, + category: document.category, + visibility: document.visibility, + mimeType: document.mimeType, + fileSize: document.fileSize, + checksum, + version, + lineageId, + links, + scanner: (deps.scanner ?? getFileScanner()).name, + }, + }); + return document; +} From d18f4fe4313f172c49acc6835e7b0d3214e334df Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 12:26:27 +0200 Subject: [PATCH 3/5] L1 Stammdaten: Backoffice-Seiten Kunden, Objekte, Teams und Dokumente MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Listen mit Suche/Filter/Paginierung, Popups für Anlage und Bearbeitung, Kundendetail mit Tabs, Dublettenhinweis und Zusammenführen, Objektdetail mit Kartenlink, Dokumenten-Tab und Historie, Teamverwaltung mit Mitgliedern, Dokumentenübersicht. Texte in messages de/en, Audit-Label Ansprechpartner. Co-Authored-By: Claude Opus 5 --- messages/de/customers.json | 180 +++++++++ messages/de/documents.json | 120 ++++++ messages/de/sites.json | 137 +++++++ messages/de/teams.json | 75 ++++ messages/en/customers.json | 180 +++++++++ messages/en/documents.json | 120 ++++++ messages/en/sites.json | 137 +++++++ messages/en/teams.json | 75 ++++ src/app/(app)/customers/[id]/page.tsx | 353 ++++++++++++++++++ src/app/(app)/customers/page.tsx | 149 +++++++- src/app/(app)/documents/page.tsx | 123 +++++- src/app/(app)/sites/[id]/page.tsx | 231 ++++++++++++ src/app/(app)/sites/page.tsx | 153 +++++++- src/app/(app)/teams/page.tsx | 169 ++++++++- src/components/audit-trail.tsx | 1 + src/components/customers/action-form.tsx | 100 +++++ src/components/customers/contact-form.tsx | 74 ++++ src/components/customers/customer-form.tsx | 142 +++++++ src/components/customers/form-ui.tsx | 172 +++++++++ src/components/customers/merge-form.tsx | 94 +++++ src/components/customers/status.tsx | 31 ++ .../documents/document-edit-form.tsx | 67 ++++ src/components/documents/document-panel.tsx | 86 +++++ src/components/documents/document-table.tsx | 168 +++++++++ .../documents/document-upload-form.tsx | 94 +++++ src/components/sites/site-form.tsx | 144 +++++++ src/components/sites/site-history.tsx | 140 +++++++ src/components/teams/team-form.tsx | 157 ++++++++ 28 files changed, 3660 insertions(+), 12 deletions(-) create mode 100644 messages/de/customers.json create mode 100644 messages/de/documents.json create mode 100644 messages/de/sites.json create mode 100644 messages/de/teams.json create mode 100644 messages/en/customers.json create mode 100644 messages/en/documents.json create mode 100644 messages/en/sites.json create mode 100644 messages/en/teams.json create mode 100644 src/app/(app)/customers/[id]/page.tsx create mode 100644 src/app/(app)/sites/[id]/page.tsx create mode 100644 src/components/customers/action-form.tsx create mode 100644 src/components/customers/contact-form.tsx create mode 100644 src/components/customers/customer-form.tsx create mode 100644 src/components/customers/form-ui.tsx create mode 100644 src/components/customers/merge-form.tsx create mode 100644 src/components/customers/status.tsx create mode 100644 src/components/documents/document-edit-form.tsx create mode 100644 src/components/documents/document-panel.tsx create mode 100644 src/components/documents/document-table.tsx create mode 100644 src/components/documents/document-upload-form.tsx create mode 100644 src/components/sites/site-form.tsx create mode 100644 src/components/sites/site-history.tsx create mode 100644 src/components/teams/team-form.tsx diff --git a/messages/de/customers.json b/messages/de/customers.json new file mode 100644 index 0000000..b4457a4 --- /dev/null +++ b/messages/de/customers.json @@ -0,0 +1,180 @@ +{ + "title": "Kunden", + "crumb": "Stammdaten", + "sub": "Kunden, Ansprechpartner und Objekte an einem Ort.", + "new": "Neuer Kunde", + "searchLabel": "Suche", + "searchPlaceholder": "Name, Kundennummer, Ort oder E-Mail", + "filter": { + "status": "Status", + "all": "Alle (ohne zusammengeführte)", + "apply": "Filtern", + "reset": "Zurücksetzen" + }, + "status": { + "active": "Aktiv", + "inactive": "Inaktiv", + "provisional": "Vorläufig", + "merged": "Zusammengeführt" + }, + "provisionalHint": "Vorläufig – Prüfung durch Backoffice erforderlich", + "columns": { + "number": "Kundennr.", + "name": "Name", + "city": "Ort", + "contact": "Kontakt", + "sites": "Objekte", + "status": "Status" + }, + "empty": "Keine Kunden gefunden.", + "pagination": { + "prev": "Zurück", + "next": "Weiter", + "summary": "{from}–{to} von {total}" + }, + "fields": { + "customerNumber": "Kundennummer", + "customerNumberHint": "Leer lassen: nächste freie Nummer wird vergeben.", + "companyName": "Firmenname", + "salutation": "Anrede", + "firstName": "Vorname", + "lastName": "Nachname", + "street": "Straße", + "houseNumber": "Hausnr.", + "postalCode": "PLZ", + "city": "Ort", + "country": "Land", + "phone": "Telefon", + "mobile": "Mobil", + "email": "E-Mail", + "notes": "Allgemeine Hinweise", + "billingNotes": "Abrechnungshinweise", + "status": "Status", + "createdAt": "Angelegt", + "updatedAt": "Geändert" + }, + "sections": { + "customer": "Kunde", + "address": "Adresse", + "contact": "Erreichbarkeit", + "notes": "Hinweise" + }, + "form": { + "createTitle": "Kunde anlegen", + "createSub": "Firmen- oder Privatkunde. Firmenname oder Nachname ist Pflicht.", + "editTitle": "Kunde bearbeiten", + "create": "Kunde anlegen", + "save": "Speichern", + "saving": "Speichert…", + "cancel": "Abbrechen", + "close": "Schließen", + "saved": "Gespeichert.", + "required": "Pflichtfeld" + }, + "duplicates": { + "title": "Mögliche Dublette", + "hint": "Diese Kunden sind ähnlich. Bestehenden Kunden verwenden oder trotzdem neu anlegen.", + "open": "Bestehenden Kunden öffnen", + "createAnyway": "Trotzdem neu anlegen", + "score": "Übereinstimmung {percent} %", + "reasons": { + "customer_number": "Kundennummer", + "company_name": "Name", + "address": "Adresse", + "email": "E-Mail", + "phone": "Telefon" + } + }, + "tabs": { + "master": "Stammdaten", + "contacts": "Ansprechpartner", + "sites": "Objekte", + "orders": "Aufträge", + "documents": "Dokumente" + }, + "detail": { + "edit": "Bearbeiten", + "confirm": "Kunden bestätigen", + "confirmHint": "Vorläufigen Kunden nach Prüfung als aktiv übernehmen.", + "merge": "Zusammenführen", + "delete": "Löschen", + "deleteTitle": "Kunden löschen", + "deleteHint": "Der Kunde wird ausgeblendet. Aufträge, Berichte und Nachweise bleiben erhalten. Nur ohne offene Aufträge möglich.", + "deleteConfirm": "Kunden löschen", + "mergedInto": "Dieser Kunde wurde zusammengeführt in:", + "mergedOk": "Kunden zusammengeführt. Kontakte, Objekte, Aufträge und Dokumente liegen jetzt hier.", + "back": "Zur Kundenliste", + "readOnly": "Nur Lesezugriff." + }, + "contacts": { + "new": "Ansprechpartner hinzufügen", + "createTitle": "Ansprechpartner hinzufügen", + "editTitle": "Ansprechpartner bearbeiten", + "empty": "Noch keine Ansprechpartner.", + "delete": "Entfernen", + "deleteConfirm": "Ansprechpartner entfernen?", + "preferred": "bevorzugt", + "fields": { + "name": "Name", + "role": "Funktion", + "phone": "Telefon", + "mobile": "Mobil", + "email": "E-Mail", + "preferredChannel": "Bevorzugter Kontaktweg", + "notes": "Bemerkungen" + }, + "channel": { + "phone": "Telefon", + "mobile": "Mobil", + "email": "E-Mail", + "none": "Keine Angabe" + } + }, + "sites": { + "empty": "Noch keine Objekte.", + "new": "Objekt anlegen" + }, + "orders": { + "empty": "Keine Aufträge sichtbar.", + "hint": "Lesesicht. Aufträge werden im Bereich Aufträge bearbeitet.", + "columns": { + "number": "Auftrag", + "title": "Bezeichnung", + "site": "Objekt", + "team": "Team", + "date": "Termin", + "status": "Status" + } + }, + "merge": { + "title": "Kunden zusammenführen", + "sub": "Kontakte, Objekte, Aufträge und Dokumente werden auf den Zielkunden umgehängt. Dieser Kunde wird als zusammengeführt markiert.", + "source": "Wird aufgelöst", + "target": "Zielkunde", + "candidates": "Mögliche Dubletten", + "noCandidates": "Keine ähnlichen Kunden gefunden. Zielkunde über die Kundennummer wählen.", + "targetNumber": "Oder Kundennummer des Zielkunden", + "confirm": "Datensätze geprüft. Zusammenführen bestätigen – das wird nicht automatisch rückgängig gemacht.", + "submit": "Zusammenführen" + }, + "errors": { + "invalid": "Bitte die markierten Felder prüfen.", + "not_found": "Datensatz nicht gefunden.", + "forbidden": "Dafür fehlt die Berechtigung.", + "conflict": "Das geht so nicht: Der Datensatz wurde geändert oder ist bereits vergeben.", + "blocked": "Aktion aktuell nicht möglich.", + "generic": "Das hat nicht geklappt. Bitte erneut versuchen.", + "required": "Pflichtfeld", + "invalidField": "Eingabe prüfen", + "name_required": "Firmenname oder Nachname angeben.", + "number_taken": "Kundennummer ist bereits vergeben.", + "number_allocation": "Keine freie Kundennummer gefunden.", + "open_work_orders": "Es gibt noch offene Aufträge.", + "not_provisional": "Der Kunde ist nicht vorläufig.", + "already_merged": "Einer der Kunden ist bereits zusammengeführt.", + "same_customer": "Quelle und Ziel sind derselbe Kunde.", + "target_required": "Zielkunden auswählen.", + "target_not_found": "Kundennummer nicht gefunden.", + "confirm_required": "Bitte die Zusammenführung bestätigen." + } +} diff --git a/messages/de/documents.json b/messages/de/documents.json new file mode 100644 index 0000000..e2206b1 --- /dev/null +++ b/messages/de/documents.json @@ -0,0 +1,120 @@ +{ + "title": "Dokumente", + "crumb": "Ablage", + "sub": "Alle Unterlagen zu Kunden, Objekten und Aufträgen – mit Version, Prüfsumme und Sichtbarkeit.", + "empty": "Keine Dokumente gefunden.", + "columns": { + "document": "Dokument", + "category": "Kategorie", + "link": "Zuordnung", + "visibility": "Sichtbarkeit", + "version": "Version", + "size": "Größe", + "uploaded": "Hochgeladen" + }, + "filter": { + "q": "Dateiname oder Titel", + "category": "Kategorie", + "allCategories": "Alle Kategorien", + "customer": "Kunde", + "allCustomers": "Alle Kunden", + "site": "Objekt", + "allSites": "Alle Objekte", + "workOrder": "Auftragsnummer", + "latestOnly": "Nur aktuelle Versionen", + "apply": "Filtern", + "reset": "Zurücksetzen" + }, + "pagination": { + "prev": "Zurück", + "next": "Weiter", + "summary": "{from}–{to} von {total}" + }, + "category": { + "order_confirmation": "Auftragsbestätigung", + "technical_drawing": "Technische Zeichnung", + "floor_plan": "Grundriss", + "wiring_diagram": "Schaltplan", + "assembly_instructions": "Montageanleitung", + "safety_document": "Sicherheitsunterlage", + "product_document": "Produktunterlage", + "customer_note": "Kundenhinweis", + "work_record": "Arbeitsnachweis", + "daily_report": "Tagesbericht", + "completion_report": "Abschlussbericht", + "customer_approval": "Kundenfreigabe", + "photo": "Foto", + "voice_note": "Sprachnotiz", + "signature": "Unterschrift", + "other": "Sonstiges Dokument" + }, + "visibility": { + "backoffice_only": "Nur Backoffice", + "team_lead": "Für Teamleiter", + "team": "Für Montageteam", + "customer_report": "Für Kundenbericht freigegeben" + }, + "link": { + "customer": "Kunde", + "site": "Objekt", + "workOrder": "Auftrag", + "none": "Ohne Zuordnung" + }, + "upload": { + "title": "Dokument hochladen", + "file": "Datei", + "fileHint": "PDF bis 25 MB, Bilder (JPEG, PNG, WebP, HEIC) bis 15 MB, Audio bis 20 MB.", + "titleField": "Titel (optional)", + "category": "Kategorie", + "visibility": "Sichtbarkeit", + "submit": "Hochladen", + "newVersion": "Neue Version", + "newVersionTitle": "Neue Version hochladen", + "newVersionOf": "Neue Version von „{name}“", + "ok": "Dokument gespeichert." + }, + "versions": { + "label": "v{version}", + "older": "{count, plural, one {# ältere Version} other {# ältere Versionen}}", + "latest": "aktuell" + }, + "actions": { + "download": "Herunterladen", + "edit": "Bearbeiten", + "delete": "Entfernen", + "deleteConfirm": "Diese Version entfernen?" + }, + "edit": { + "title": "Dokument bearbeiten", + "titleField": "Titel", + "save": "Speichern", + "saving": "Speichert…", + "cancel": "Abbrechen" + }, + "uploadErrors": { + "empty_file": "Bitte eine Datei auswählen.", + "too_large": "Die Datei ist zu groß.", + "unsupported_type": "Dateityp nicht erlaubt. Erlaubt sind PDF, JPEG, PNG, WebP, HEIC und Audio.", + "type_mismatch": "Dateiinhalt passt nicht zum Dateityp.", + "malware": "Die Datei wurde vom Virenscan abgelehnt.", + "scanner_unavailable": "Virenscan nicht erreichbar. Bitte später erneut versuchen.", + "visibility_not_allowed": "Diese Sichtbarkeit ist nicht erlaubt.", + "lineage_not_found": "Ursprungsdokument nicht gefunden.", + "invalid_category": "Bitte eine Kategorie wählen.", + "site_not_found": "Objekt nicht gefunden.", + "customer_not_found": "Kunde nicht gefunden.", + "forbidden": "Dafür fehlt die Berechtigung.", + "not_found": "Auftrag nicht gefunden.", + "invalid": "Upload ungültig.", + "generic": "Upload fehlgeschlagen. Bitte erneut versuchen." + }, + "errors": { + "invalid": "Bitte die Eingaben prüfen.", + "not_found": "Dokument nicht gefunden.", + "forbidden": "Dafür fehlt die Berechtigung.", + "conflict": "Das geht so nicht.", + "blocked": "Aktion aktuell nicht möglich.", + "generic": "Das hat nicht geklappt. Bitte erneut versuchen.", + "visibility_not_allowed": "Diese Sichtbarkeit ist nicht erlaubt." + } +} diff --git a/messages/de/sites.json b/messages/de/sites.json new file mode 100644 index 0000000..4a25792 --- /dev/null +++ b/messages/de/sites.json @@ -0,0 +1,137 @@ +{ + "title": "Objekte", + "crumb": "Stammdaten", + "sub": "Baustellen, Filialen und Anlagen mit Hinweisen, Dokumenten und Einsatzhistorie.", + "new": "Neues Objekt", + "searchLabel": "Suche", + "searchPlaceholder": "Objekt, Adresse oder Kunde", + "filter": { + "status": "Status", + "all": "Alle", + "apply": "Filtern", + "reset": "Zurücksetzen" + }, + "status": { + "active": "Aktiv", + "inactive": "Inaktiv", + "provisional": "Vorläufig" + }, + "columns": { + "name": "Objekt", + "customer": "Kunde", + "address": "Adresse", + "orders": "Aufträge", + "status": "Status" + }, + "empty": "Keine Objekte gefunden.", + "pagination": { + "prev": "Zurück", + "next": "Weiter", + "summary": "{from}–{to} von {total}" + }, + "fields": { + "customerId": "Kunde", + "selectCustomer": "Kunden auswählen", + "name": "Objektbezeichnung", + "street": "Straße", + "houseNumber": "Hausnr.", + "postalCode": "PLZ", + "city": "Ort", + "country": "Land", + "contactId": "Ansprechpartner vor Ort", + "noContact": "Kein hinterlegter Kontakt", + "onSiteContact": "Ansprechpartner vor Ort (Freitext)", + "phone": "Telefon vor Ort", + "accessNotes": "Zugangshinweise", + "parkingNotes": "Parkhinweise", + "safetyNotes": "Sicherheitsinformationen", + "technicalNotes": "Technische Hinweise", + "status": "Status", + "latitude": "Breitengrad", + "longitude": "Längengrad" + }, + "sections": { + "base": "Objekt", + "address": "Adresse", + "onSite": "Vor Ort", + "notes": "Hinweise für den Einsatz", + "geo": "Koordinaten (optional)" + }, + "form": { + "createTitle": "Objekt anlegen", + "createSub": "Jedes Objekt gehört zu einem Kunden.", + "editTitle": "Objekt bearbeiten", + "create": "Objekt anlegen", + "save": "Speichern", + "saving": "Speichert…", + "cancel": "Abbrechen", + "saved": "Gespeichert.", + "contactHint": "Kontakte des Kunden stehen nach dem Anlegen zur Auswahl." + }, + "tabs": { + "master": "Stammdaten", + "documents": "Dokumente", + "history": "Historie" + }, + "detail": { + "edit": "Bearbeiten", + "delete": "Löschen", + "deleteTitle": "Objekt löschen", + "deleteHint": "Das Objekt wird ausgeblendet. Historie und Nachweise bleiben erhalten. Nur ohne offene Aufträge möglich.", + "deleteConfirm": "Objekt löschen", + "map": "Karte öffnen (OpenStreetMap)", + "noMap": "Keine Adresse für die Karte", + "customer": "Kunde", + "back": "Zur Objektliste", + "noNotes": "Keine Hinweise hinterlegt." + }, + "history": { + "title": "Einsatzhistorie", + "onlyApproved": "Nur freigegebene", + "showAll": "Alle Einsätze", + "approvedOnlyHint": "Angezeigt werden freigegebene Einsätze.", + "empty": "Noch keine Einsätze an diesem Objekt.", + "workDone": "Durchgeführte Arbeiten", + "noWorkDone": "Keine Tätigkeiten erfasst.", + "materials": "Material", + "noMaterials": "Kein Material erfasst.", + "photos": "{count, plural, =0 {Keine Fotos} one {# Foto} other {# Fotos}}", + "reports": "Berichte", + "noReports": "Kein freigegebener Bericht.", + "reportType": { + "daily": "Tagesbericht", + "completion": "Abschlussbericht" + }, + "reportLink": "{type} v{version} vom {date}", + "signed": "Kundenunterschrift liegt vor", + "notSigned": "Keine Kundenunterschrift", + "followUp": "Offene Folgearbeiten", + "emergency": "Notdienst", + "noTeam": "Kein Team", + "openOrder": "Auftrag öffnen" + }, + "statusGroup": { + "new": "Neu", + "planned": "Geplant", + "en_route": "Unterwegs", + "in_progress": "In Arbeit", + "documentation_incomplete": "Dokumentation unvollständig", + "in_review": "Zur Prüfung", + "ready_for_billing": "Bereit zur Abrechnung", + "billed": "Abgerechnet", + "cancelled": "Storniert" + }, + "errors": { + "invalid": "Bitte die markierten Felder prüfen.", + "not_found": "Datensatz nicht gefunden.", + "forbidden": "Dafür fehlt die Berechtigung.", + "conflict": "Das geht so nicht: Der Datensatz wurde geändert.", + "blocked": "Aktion aktuell nicht möglich.", + "generic": "Das hat nicht geklappt. Bitte erneut versuchen.", + "required": "Pflichtfeld", + "invalidField": "Eingabe prüfen", + "customer_not_found": "Kunde nicht gefunden.", + "contact_mismatch": "Der Kontakt gehört nicht zu diesem Kunden.", + "open_work_orders": "Es gibt noch offene Aufträge." + } +} diff --git a/messages/de/teams.json b/messages/de/teams.json new file mode 100644 index 0000000..4f9fdb1 --- /dev/null +++ b/messages/de/teams.json @@ -0,0 +1,75 @@ +{ + "title": "Teams", + "crumb": "Stammdaten", + "sub": "Montageteams mit Teamleitung, Mitgliedern und Einsatzgebiet.", + "new": "Neues Team", + "showInactive": "Inaktive anzeigen", + "hideInactive": "Inaktive ausblenden", + "empty": "Noch keine Teams.", + "columns": { + "name": "Team", + "leader": "Teamleiter", + "members": "Mitglieder", + "phone": "Telefon", + "vehicle": "Fahrzeug", + "area": "Einsatzgebiet", + "status": "Status" + }, + "status": { + "active": "Aktiv", + "inactive": "Inaktiv" + }, + "fields": { + "name": "Teamname", + "leaderUserId": "Teamleiter", + "noLeader": "Kein Teamleiter", + "status": "Status", + "phone": "Telefon", + "vehicle": "Fahrzeug", + "area": "Einsatzgebiet", + "notes": "Interne Hinweise" + }, + "members": { + "title": "Mitglieder", + "add": "Mitglied hinzufügen", + "remove": "Entfernen", + "user": "Person", + "selectUser": "Person auswählen", + "validFrom": "Gültig ab", + "validTo": "Gültig bis", + "validToHint": "Leer = unbefristet", + "empty": "Noch keine Mitglieder.", + "current": "aktuell", + "ended": "beendet", + "upcoming": "ab {date}", + "count": "{count, plural, =0 {Keine Mitglieder} one {# Mitglied} other {# Mitglieder}}" + }, + "form": { + "createTitle": "Team anlegen", + "editTitle": "Team bearbeiten", + "sub": "Mitglieder kommen aus den aktiven Nutzern des Mandanten.", + "save": "Speichern", + "create": "Team anlegen", + "saving": "Speichert…", + "cancel": "Abbrechen", + "delete": "Team löschen", + "deleteHint": "Das Team wird ausgeblendet. Nur ohne offene Aufträge möglich.", + "deleteConfirm": "Team wirklich löschen?" + }, + "readOnly": "Nur Lesezugriff.", + "errors": { + "invalid": "Bitte die markierten Felder prüfen.", + "not_found": "Team nicht gefunden.", + "forbidden": "Dafür fehlt die Berechtigung.", + "conflict": "Das geht so nicht: Der Name ist bereits vergeben.", + "blocked": "Aktion aktuell nicht möglich.", + "generic": "Das hat nicht geklappt. Bitte erneut versuchen.", + "required": "Pflichtfeld", + "invalidField": "Eingabe prüfen", + "duplicate_member": "Eine Person ist mehrfach eingetragen.", + "inactive_user": "Nur aktive Nutzer des Mandanten sind möglich.", + "name_taken": "Teamname ist bereits vergeben.", + "valid_to_before_from": "„Gültig bis“ liegt vor „Gültig ab“.", + "open_work_orders": "Dem Team sind noch offene Aufträge zugewiesen." + } +} diff --git a/messages/en/customers.json b/messages/en/customers.json new file mode 100644 index 0000000..14250eb --- /dev/null +++ b/messages/en/customers.json @@ -0,0 +1,180 @@ +{ + "title": "Customers", + "crumb": "Master data", + "sub": "Customers, contacts and sites in one place.", + "new": "New customer", + "searchLabel": "Search", + "searchPlaceholder": "Name, customer no., city or e-mail", + "filter": { + "status": "Status", + "all": "All (without merged)", + "apply": "Filter", + "reset": "Reset" + }, + "status": { + "active": "Active", + "inactive": "Inactive", + "provisional": "Provisional", + "merged": "Merged" + }, + "provisionalHint": "Provisional – back office review required", + "columns": { + "number": "Customer no.", + "name": "Name", + "city": "City", + "contact": "Contact", + "sites": "Sites", + "status": "Status" + }, + "empty": "No customers found.", + "pagination": { + "prev": "Previous", + "next": "Next", + "summary": "{from}–{to} of {total}" + }, + "fields": { + "customerNumber": "Customer number", + "customerNumberHint": "Leave empty to use the next free number.", + "companyName": "Company name", + "salutation": "Salutation", + "firstName": "First name", + "lastName": "Last name", + "street": "Street", + "houseNumber": "No.", + "postalCode": "Postal code", + "city": "City", + "country": "Country", + "phone": "Phone", + "mobile": "Mobile", + "email": "E-mail", + "notes": "General notes", + "billingNotes": "Billing notes", + "status": "Status", + "createdAt": "Created", + "updatedAt": "Updated" + }, + "sections": { + "customer": "Customer", + "address": "Address", + "contact": "Reachability", + "notes": "Notes" + }, + "form": { + "createTitle": "Create customer", + "createSub": "Business or private customer. Company name or last name is required.", + "editTitle": "Edit customer", + "create": "Create customer", + "save": "Save", + "saving": "Saving…", + "cancel": "Cancel", + "close": "Close", + "saved": "Saved.", + "required": "Required" + }, + "duplicates": { + "title": "Possible duplicate", + "hint": "These customers look similar. Use an existing customer or create a new one anyway.", + "open": "Open existing customer", + "createAnyway": "Create anyway", + "score": "Match {percent} %", + "reasons": { + "customer_number": "Customer number", + "company_name": "Name", + "address": "Address", + "email": "E-mail", + "phone": "Phone" + } + }, + "tabs": { + "master": "Master data", + "contacts": "Contacts", + "sites": "Sites", + "orders": "Work orders", + "documents": "Documents" + }, + "detail": { + "edit": "Edit", + "confirm": "Confirm customer", + "confirmHint": "Take over the provisional customer as active after review.", + "merge": "Merge", + "delete": "Delete", + "deleteTitle": "Delete customer", + "deleteHint": "The customer is hidden. Work orders, reports and records are kept. Only possible without open work orders.", + "deleteConfirm": "Delete customer", + "mergedInto": "This customer was merged into:", + "mergedOk": "Customers merged. Contacts, sites, work orders and documents are now here.", + "back": "Back to customers", + "readOnly": "Read-only access." + }, + "contacts": { + "new": "Add contact", + "createTitle": "Add contact", + "editTitle": "Edit contact", + "empty": "No contacts yet.", + "delete": "Remove", + "deleteConfirm": "Remove contact?", + "preferred": "preferred", + "fields": { + "name": "Name", + "role": "Role", + "phone": "Phone", + "mobile": "Mobile", + "email": "E-mail", + "preferredChannel": "Preferred channel", + "notes": "Remarks" + }, + "channel": { + "phone": "Phone", + "mobile": "Mobile", + "email": "E-mail", + "none": "Not specified" + } + }, + "sites": { + "empty": "No sites yet.", + "new": "Create site" + }, + "orders": { + "empty": "No visible work orders.", + "hint": "Read-only. Work orders are edited in the work orders area.", + "columns": { + "number": "Order", + "title": "Title", + "site": "Site", + "team": "Team", + "date": "Scheduled", + "status": "Status" + } + }, + "merge": { + "title": "Merge customers", + "sub": "Contacts, sites, work orders and documents are moved to the target customer. This customer is marked as merged.", + "source": "Will be merged", + "target": "Target customer", + "candidates": "Possible duplicates", + "noCandidates": "No similar customers found. Choose the target by customer number.", + "targetNumber": "Or customer number of the target", + "confirm": "Records checked. Confirm merge – this is not undone automatically.", + "submit": "Merge" + }, + "errors": { + "invalid": "Please check the highlighted fields.", + "not_found": "Record not found.", + "forbidden": "You are not allowed to do this.", + "conflict": "Not possible: the record was changed or is already taken.", + "blocked": "Action currently not possible.", + "generic": "That did not work. Please try again.", + "required": "Required", + "invalidField": "Check input", + "name_required": "Enter a company name or last name.", + "number_taken": "Customer number is already taken.", + "number_allocation": "No free customer number found.", + "open_work_orders": "There are still open work orders.", + "not_provisional": "The customer is not provisional.", + "already_merged": "One of the customers is already merged.", + "same_customer": "Source and target are the same customer.", + "target_required": "Select a target customer.", + "target_not_found": "Customer number not found.", + "confirm_required": "Please confirm the merge." + } +} diff --git a/messages/en/documents.json b/messages/en/documents.json new file mode 100644 index 0000000..3bb2537 --- /dev/null +++ b/messages/en/documents.json @@ -0,0 +1,120 @@ +{ + "title": "Documents", + "crumb": "Storage", + "sub": "All files for customers, sites and work orders – with version, checksum and visibility.", + "empty": "No documents found.", + "columns": { + "document": "Document", + "category": "Category", + "link": "Linked to", + "visibility": "Visibility", + "version": "Version", + "size": "Size", + "uploaded": "Uploaded" + }, + "filter": { + "q": "File name or title", + "category": "Category", + "allCategories": "All categories", + "customer": "Customer", + "allCustomers": "All customers", + "site": "Site", + "allSites": "All sites", + "workOrder": "Work order number", + "latestOnly": "Latest versions only", + "apply": "Filter", + "reset": "Reset" + }, + "pagination": { + "prev": "Previous", + "next": "Next", + "summary": "{from}–{to} of {total}" + }, + "category": { + "order_confirmation": "Order confirmation", + "technical_drawing": "Technical drawing", + "floor_plan": "Floor plan", + "wiring_diagram": "Wiring diagram", + "assembly_instructions": "Assembly instructions", + "safety_document": "Safety document", + "product_document": "Product document", + "customer_note": "Customer note", + "work_record": "Work record", + "daily_report": "Daily report", + "completion_report": "Completion report", + "customer_approval": "Customer approval", + "photo": "Photo", + "voice_note": "Voice note", + "signature": "Signature", + "other": "Other document" + }, + "visibility": { + "backoffice_only": "Back office only", + "team_lead": "Team leads", + "team": "Installation team", + "customer_report": "Released for customer report" + }, + "link": { + "customer": "Customer", + "site": "Site", + "workOrder": "Work order", + "none": "Not linked" + }, + "upload": { + "title": "Upload document", + "file": "File", + "fileHint": "PDF up to 25 MB, images (JPEG, PNG, WebP, HEIC) up to 15 MB, audio up to 20 MB.", + "titleField": "Title (optional)", + "category": "Category", + "visibility": "Visibility", + "submit": "Upload", + "newVersion": "New version", + "newVersionTitle": "Upload new version", + "newVersionOf": "New version of \"{name}\"", + "ok": "Document saved." + }, + "versions": { + "label": "v{version}", + "older": "{count, plural, one {# older version} other {# older versions}}", + "latest": "latest" + }, + "actions": { + "download": "Download", + "edit": "Edit", + "delete": "Remove", + "deleteConfirm": "Remove this version?" + }, + "edit": { + "title": "Edit document", + "titleField": "Title", + "save": "Save", + "saving": "Saving…", + "cancel": "Cancel" + }, + "uploadErrors": { + "empty_file": "Please select a file.", + "too_large": "The file is too large.", + "unsupported_type": "File type not allowed. Allowed: PDF, JPEG, PNG, WebP, HEIC and audio.", + "type_mismatch": "File content does not match the file type.", + "malware": "The file was rejected by the virus scan.", + "scanner_unavailable": "Virus scan unavailable. Please try again later.", + "visibility_not_allowed": "This visibility is not allowed.", + "lineage_not_found": "Original document not found.", + "invalid_category": "Please choose a category.", + "site_not_found": "Site not found.", + "customer_not_found": "Customer not found.", + "forbidden": "You are not allowed to do this.", + "not_found": "Work order not found.", + "invalid": "Invalid upload.", + "generic": "Upload failed. Please try again." + }, + "errors": { + "invalid": "Please check your input.", + "not_found": "Document not found.", + "forbidden": "You are not allowed to do this.", + "conflict": "Not possible.", + "blocked": "Action currently not possible.", + "generic": "That did not work. Please try again.", + "visibility_not_allowed": "This visibility is not allowed." + } +} diff --git a/messages/en/sites.json b/messages/en/sites.json new file mode 100644 index 0000000..cf893e4 --- /dev/null +++ b/messages/en/sites.json @@ -0,0 +1,137 @@ +{ + "title": "Sites", + "crumb": "Master data", + "sub": "Construction sites, branches and installations with notes, documents and job history.", + "new": "New site", + "searchLabel": "Search", + "searchPlaceholder": "Site, address or customer", + "filter": { + "status": "Status", + "all": "All", + "apply": "Filter", + "reset": "Reset" + }, + "status": { + "active": "Active", + "inactive": "Inactive", + "provisional": "Provisional" + }, + "columns": { + "name": "Site", + "customer": "Customer", + "address": "Address", + "orders": "Work orders", + "status": "Status" + }, + "empty": "No sites found.", + "pagination": { + "prev": "Previous", + "next": "Next", + "summary": "{from}–{to} of {total}" + }, + "fields": { + "customerId": "Customer", + "selectCustomer": "Select customer", + "name": "Site name", + "street": "Street", + "houseNumber": "No.", + "postalCode": "Postal code", + "city": "City", + "country": "Country", + "contactId": "On-site contact", + "noContact": "No stored contact", + "onSiteContact": "On-site contact (free text)", + "phone": "On-site phone", + "accessNotes": "Access notes", + "parkingNotes": "Parking notes", + "safetyNotes": "Safety information", + "technicalNotes": "Technical notes", + "status": "Status", + "latitude": "Latitude", + "longitude": "Longitude" + }, + "sections": { + "base": "Site", + "address": "Address", + "onSite": "On site", + "notes": "Notes for the job", + "geo": "Coordinates (optional)" + }, + "form": { + "createTitle": "Create site", + "createSub": "Every site belongs to a customer.", + "editTitle": "Edit site", + "create": "Create site", + "save": "Save", + "saving": "Saving…", + "cancel": "Cancel", + "saved": "Saved.", + "contactHint": "The customer's contacts can be selected after creating the site." + }, + "tabs": { + "master": "Master data", + "documents": "Documents", + "history": "History" + }, + "detail": { + "edit": "Edit", + "delete": "Delete", + "deleteTitle": "Delete site", + "deleteHint": "The site is hidden. History and records are kept. Only possible without open work orders.", + "deleteConfirm": "Delete site", + "map": "Open map (OpenStreetMap)", + "noMap": "No address for the map", + "customer": "Customer", + "back": "Back to sites", + "noNotes": "No notes stored." + }, + "history": { + "title": "Job history", + "onlyApproved": "Released only", + "showAll": "All jobs", + "approvedOnlyHint": "Showing released jobs.", + "empty": "No jobs at this site yet.", + "workDone": "Work carried out", + "noWorkDone": "No activities recorded.", + "materials": "Material", + "noMaterials": "No material recorded.", + "photos": "{count, plural, =0 {No photos} one {# photo} other {# photos}}", + "reports": "Reports", + "noReports": "No released report.", + "reportType": { + "daily": "Daily report", + "completion": "Completion report" + }, + "reportLink": "{type} v{version} of {date}", + "signed": "Customer signature available", + "notSigned": "No customer signature", + "followUp": "Open follow-up work", + "emergency": "Emergency", + "noTeam": "No team", + "openOrder": "Open work order" + }, + "statusGroup": { + "new": "New", + "planned": "Planned", + "en_route": "On the way", + "in_progress": "In progress", + "documentation_incomplete": "Documentation incomplete", + "in_review": "In review", + "ready_for_billing": "Ready for billing", + "billed": "Billed", + "cancelled": "Cancelled" + }, + "errors": { + "invalid": "Please check the highlighted fields.", + "not_found": "Record not found.", + "forbidden": "You are not allowed to do this.", + "conflict": "Not possible: the record was changed.", + "blocked": "Action currently not possible.", + "generic": "That did not work. Please try again.", + "required": "Required", + "invalidField": "Check input", + "customer_not_found": "Customer not found.", + "contact_mismatch": "The contact does not belong to this customer.", + "open_work_orders": "There are still open work orders." + } +} diff --git a/messages/en/teams.json b/messages/en/teams.json new file mode 100644 index 0000000..41462f9 --- /dev/null +++ b/messages/en/teams.json @@ -0,0 +1,75 @@ +{ + "title": "Teams", + "crumb": "Master data", + "sub": "Installation teams with team lead, members and service area.", + "new": "New team", + "showInactive": "Show inactive", + "hideInactive": "Hide inactive", + "empty": "No teams yet.", + "columns": { + "name": "Team", + "leader": "Team lead", + "members": "Members", + "phone": "Phone", + "vehicle": "Vehicle", + "area": "Service area", + "status": "Status" + }, + "status": { + "active": "Active", + "inactive": "Inactive" + }, + "fields": { + "name": "Team name", + "leaderUserId": "Team lead", + "noLeader": "No team lead", + "status": "Status", + "phone": "Phone", + "vehicle": "Vehicle", + "area": "Service area", + "notes": "Internal notes" + }, + "members": { + "title": "Members", + "add": "Add member", + "remove": "Remove", + "user": "Person", + "selectUser": "Select person", + "validFrom": "Valid from", + "validTo": "Valid until", + "validToHint": "Empty = unlimited", + "empty": "No members yet.", + "current": "current", + "ended": "ended", + "upcoming": "from {date}", + "count": "{count, plural, =0 {No members} one {# member} other {# members}}" + }, + "form": { + "createTitle": "Create team", + "editTitle": "Edit team", + "sub": "Members are active users of the tenant.", + "save": "Save", + "create": "Create team", + "saving": "Saving…", + "cancel": "Cancel", + "delete": "Delete team", + "deleteHint": "The team is hidden. Only possible without open work orders.", + "deleteConfirm": "Really delete the team?" + }, + "readOnly": "Read-only access.", + "errors": { + "invalid": "Please check the highlighted fields.", + "not_found": "Team not found.", + "forbidden": "You are not allowed to do this.", + "conflict": "Not possible: the name is already taken.", + "blocked": "Action currently not possible.", + "generic": "That did not work. Please try again.", + "required": "Required", + "invalidField": "Check input", + "duplicate_member": "A person is listed more than once.", + "inactive_user": "Only active users of the tenant can be added.", + "name_taken": "Team name is already taken.", + "valid_to_before_from": "\"Valid until\" is before \"valid from\".", + "open_work_orders": "Open work orders are still assigned to the team." + } +} diff --git a/src/app/(app)/customers/[id]/page.tsx b/src/app/(app)/customers/[id]/page.tsx new file mode 100644 index 0000000..bd04785 --- /dev/null +++ b/src/app/(app)/customers/[id]/page.tsx @@ -0,0 +1,353 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { ArrowLeft, Mail, Phone, Plus, Smartphone } from "lucide-react"; +import { getFormatter, getTranslations } from "next-intl/server"; +import { PageHead, Pill } from "@/components/mockup-ui"; +import { Modal } from "@/components/modal"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { ActionButtonForm } from "@/components/customers/action-form"; +import { ContactForm } from "@/components/customers/contact-form"; +import { CustomerForm } from "@/components/customers/customer-form"; +import { MergeForm } from "@/components/customers/merge-form"; +import { CustomerStatusPill, OrderStatusPill, orderStatusGroup, SiteStatusPill } from "@/components/customers/status"; +import { Banner, buttonLinkClass, Card, DefinitionList, primaryButtonClass, TabNav } from "@/components/customers/form-ui"; +import { DocumentPanel } from "@/components/documents/document-panel"; +import { confirmCustomerAction, deleteCustomerAction, mergeCustomerAction, updateCustomerAction } from "@/server/actions/customers/customers"; +import { createContactAction, deleteContactAction, updateContactAction } from "@/server/actions/customers/contacts"; +import { requirePageContext } from "@/server/api/context"; +import { can, ServiceError } from "@/server/services/context"; +import { getCustomer, listCustomerWorkOrders } from "@/server/services/customers/customers"; +import { findDuplicateCustomers } from "@/server/services/customers/duplicates"; +import { customerDisplayName, formatAddress } from "@/server/services/customers/format"; +import { listDocuments } from "@/server/services/documents/access"; +import { listSites } from "@/server/services/sites/sites"; + +const TABS = ["master", "contacts", "sites", "orders", "documents"] as const; +type Tab = (typeof TABS)[number]; + +type SearchParams = Promise<{ + tab?: string; + edit?: string; + contact?: string; + merge?: string; + delete?: string; + merged?: string; + docOk?: string; + docError?: string; + docEdit?: string; + docVersion?: string; +}>; + +function toFormValues(obj: Record): Record { + return Object.fromEntries(Object.entries(obj).map(([k, v]) => [k, v === null || v === undefined ? "" : String(v)])); +} + +export default async function CustomerDetailPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: SearchParams }) { + const ctx = await requirePageContext("customers"); + const [{ id }, sp] = await Promise.all([params, searchParams]); + const [t, tc, ts, format] = await Promise.all([getTranslations("customers"), getTranslations("common"), getTranslations("sites"), getFormatter()]); + + let customer; + try { + customer = await getCustomer(ctx, id); + } catch (err) { + if (err instanceof ServiceError) notFound(); + throw err; + } + + const tab: Tab = (TABS as readonly string[]).includes(sp.tab ?? "") ? (sp.tab as Tab) : "master"; + const base = `/customers/${id}`; + const tabHref = (k: Tab) => (k === "master" ? base : `${base}?tab=${k}`); + const here = tabHref(tab); + const withParam = (k: string, v: string) => `${here}${here.includes("?") ? "&" : "?"}${k}=${encodeURIComponent(v)}`; + const isMerged = customer.status === "merged"; + const canWrite = can(ctx, "customer:write") && !isMerged; + const canMerge = can(ctx, "customer:merge") && !isMerged; + const name = customerDisplayName(customer) || "—"; + + const mergedTarget = isMerged && customer.mergedIntoId + ? await ctx.db.customer.findFirst({ where: { id: customer.mergedIntoId }, select: { id: true, companyName: true, firstName: true, lastName: true, customerNumber: true } }) + : null; + + return ( +
+ + {t("detail.back")} + + + + {canWrite && customer.status === "provisional" && ( + + )} + {canWrite && ( + + {t("detail.edit")} + + )} + {canMerge && ( + + {t("detail.merge")} + + )} + {canWrite && ( + + {t("detail.delete")} + + )} + + } + /> + + {sp.merged && {t("detail.mergedOk")}} + {customer.status === "provisional" && {t("provisionalHint")}} + {mergedTarget && ( + + {t("detail.mergedInto")}{" "} + + {customerDisplayName(mergedTarget)} ({mergedTarget.customerNumber}) + + + )} + + ({ key: k, label: t(`tabs.${k}`), href: tabHref(k), count: k === "contacts" ? customer.contacts.length : undefined }))} + /> + + {tab === "master" && ( + + + + )} + + {tab === "contacts" && ( + + )} + + {tab === "sites" && } + + {tab === "orders" && ( + + )} + + {tab === "documents" && ( + + )} + + {canWrite && sp.edit && ( + + + + )} + + {canWrite && sp.contact && (sp.contact === "new" || customer.contacts.some((c) => c.id === sp.contact)) && ( + + c.id === sp.contact)!)} + closeHref={here} + /> + + )} + + {canMerge && sp.merge && ( + + + + )} + + {canWrite && sp.delete && ( + +
+

{t("detail.deleteHint")}

+
+ + + {t("form.cancel")} + +
+
+
+ )} + +
+ ); +} + +async function SitesTab({ customerId, ctx, canCreate }: { customerId: string; ctx: Awaited>; canCreate: boolean }) { + const [t, ts] = await Promise.all([getTranslations("customers"), getTranslations("sites")]); + if (!can(ctx, "site:read")) return

{t("errors.forbidden")}

; + const sites = await listSites(ctx, { customerId, status: "all", pageSize: 100 }); + return ( +
+ {canCreate && ( + + {t("sites.new")} + + )} + {sites.items.length === 0 ? ( +

{t("sites.empty")}

+ ) : ( +
+ + + + {ts("columns.name")} + {ts("columns.address")} + {ts("columns.status")} + + + + {sites.items.map((s) => ( + + + {s.name} + + {formatAddress(s) || "—"} + + + + + ))} + +
+
+ )} +
+ ); +} + +async function OrdersTab({ customerId, ctx }: { customerId: string; ctx: Awaited> }) { + const [t, ts, format] = await Promise.all([getTranslations("customers"), getTranslations("sites"), getFormatter()]); + const orders = await listCustomerWorkOrders(ctx, customerId); + return ( +
+

{t("orders.hint")}

+ {orders.length === 0 ? ( +

{t("orders.empty")}

+ ) : ( +
+ + + + {t("orders.columns.number")} + {t("orders.columns.title")} + {t("orders.columns.site")} + {t("orders.columns.team")} + {t("orders.columns.date")} + {t("orders.columns.status")} + + + + {orders.map((o) => ( + + + {o.number} + + + {o.title} + + {o.site?.name ?? "—"} + {o.team?.name ?? "—"} + {o.plannedStart ? format.dateTime(o.plannedStart, { dateStyle: "medium" }) : "—"} + + + + + ))} + +
+
+ )} +
+ ); +} diff --git a/src/app/(app)/customers/page.tsx b/src/app/(app)/customers/page.tsx index 8c2dac3..0f499a2 100644 --- a/src/app/(app)/customers/page.tsx +++ b/src/app/(app)/customers/page.tsx @@ -1,5 +1,148 @@ -import { ModulePlaceholder } from "@/components/module-placeholder"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { Plus } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import { PageHead } from "@/components/mockup-ui"; +import { Modal } from "@/components/modal"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { CustomerForm } from "@/components/customers/customer-form"; +import { CustomerStatusPill } from "@/components/customers/status"; +import { buttonLinkClass, controlClass, hrefWith, Pagination, paginationSummary, primaryButtonClass } from "@/components/customers/form-ui"; +import { createCustomerAction } from "@/server/actions/customers/customers"; +import { requirePageContext } from "@/server/api/context"; +import { can } from "@/server/services/context"; +import { CUSTOMER_LIST_STATUSES, listCustomers, type CustomerListStatus } from "@/server/services/customers/customers"; +import { customerDisplayName } from "@/server/services/customers/format"; -export default function Page() { - return ; +type SearchParams = Promise<{ q?: string; status?: string; page?: string; new?: string }>; + +/** Customer list (spec §7): search, status filter incl. "provisional", 25 per page, create popup. */ +export default async function CustomersPage({ searchParams }: { searchParams: SearchParams }) { + const ctx = await requirePageContext("customers"); + if (!can(ctx, "customer:read")) notFound(); + const [t, tc] = await Promise.all([getTranslations("customers"), getTranslations("common")]); + const sp = await searchParams; + + const status: CustomerListStatus | "all" = (CUSTOMER_LIST_STATUSES as readonly string[]).includes(sp.status ?? "") + ? (sp.status as CustomerListStatus) + : "all"; + const q = sp.q?.trim() || undefined; + const page = Math.max(1, Math.floor(Number(sp.page)) || 1); + const result = await listCustomers(ctx, { q, status, page, pageSize: 25 }); + const canWrite = can(ctx, "customer:write"); + + const listHref = (p: number) => hrefWith("/customers", { q, status: status === "all" ? undefined : status, page: p > 1 ? p : undefined }); + const closeHref = listHref(page); + + return ( +
+ 1 ? page : undefined, new: 1 })} className={primaryButtonClass}> + {t("new")} + + ) : undefined + } + /> + +
+ + + + {(q || status !== "all") && ( + + {t("filter.reset")} + + )} +
+ +
+ + + + {t("columns.number")} + {t("columns.name")} + {t("columns.city")} + {t("columns.contact")} + {t("columns.sites")} + {t("columns.status")} + + + + {result.items.map((c) => { + const href = `/customers/${c.id}`; + return ( + + + + {c.customerNumber ?? "—"} + + + + + {customerDisplayName(c) || "—"} + + + + + {[c.postalCode, c.city].filter(Boolean).join(" ") || "—"} + + + + + {c.phone || c.email || "—"} + + + {c._count.sites} + + + + + ); + })} + {result.items.length === 0 && ( + + + {t("empty")} + + + )} + +
+
+ + + + {canWrite && sp.new && ( + + + + )} +
+ ); } diff --git a/src/app/(app)/documents/page.tsx b/src/app/(app)/documents/page.tsx index 5efb9ae..1bb7f28 100644 --- a/src/app/(app)/documents/page.tsx +++ b/src/app/(app)/documents/page.tsx @@ -1,5 +1,122 @@ -import { ModulePlaceholder } from "@/components/module-placeholder"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { getTranslations } from "next-intl/server"; +import { DocumentCategory } from "@prisma/client"; +import { PageHead } from "@/components/mockup-ui"; +import { buttonLinkClass, controlClass, hrefWith, Pagination, paginationSummary } from "@/components/customers/form-ui"; +import { DocumentPanel } from "@/components/documents/document-panel"; +import { requirePageContext } from "@/server/api/context"; +import { can } from "@/server/services/context"; +import { customerOptions } from "@/server/services/customers/customers"; +import { customerDisplayName } from "@/server/services/customers/format"; +import { listDocuments } from "@/server/services/documents/access"; +import { DOCUMENT_CATEGORIES } from "@/server/services/documents/store"; +import { workOrderScope } from "@/server/services/work-orders/visibility"; -export default function Page() { - return ; +type SearchParams = Promise<{ + q?: string; + category?: string; + customerId?: string; + siteId?: string; + workOrder?: string; + all?: string; + page?: string; + docOk?: string; + docError?: string; + docEdit?: string; + docVersion?: string; +}>; + +/** Backoffice overview of all documents (spec §24) with filters category / customer / site / work order. */ +export default async function DocumentsPage({ searchParams }: { searchParams: SearchParams }) { + const ctx = await requirePageContext("documents"); + if (!can(ctx, "document:read")) notFound(); + const t = await getTranslations("documents"); + const sp = await searchParams; + + const q = sp.q?.trim() || undefined; + const category = (DOCUMENT_CATEGORIES as string[]).includes(sp.category ?? "") ? (sp.category as DocumentCategory) : undefined; + const customerId = sp.customerId?.trim() || undefined; + const siteId = sp.siteId?.trim() || undefined; + const workOrderNumber = sp.workOrder?.trim() || undefined; + const latestOnly = sp.all !== "1"; + const page = Math.max(1, Math.floor(Number(sp.page)) || 1); + + let workOrderId: string | undefined; + if (workOrderNumber) { + const wo = await ctx.db.workOrder.findFirst({ where: { AND: [{ number: workOrderNumber }, await workOrderScope(ctx)] }, select: { id: true } }); + workOrderId = wo?.id ?? "__none__"; + } + + const [result, customers] = await Promise.all([ + listDocuments(ctx, { q, category, customerId, siteId, workOrderId, latestOnly, page, pageSize: 25 }), + can(ctx, "customer:read") ? customerOptions(ctx) : Promise.resolve([]), + ]); + + const filters = { q, category, customerId, siteId, workOrder: workOrderNumber, all: latestOnly ? undefined : "1" }; + const listHref = (p: number) => hrefWith("/documents", { ...filters, page: p > 1 ? p : undefined }); + + return ( +
+ + +
+ {siteId && } + + + + +
+ + + {Object.values(filters).some(Boolean) && ( + + {t("filter.reset")} + + )} +
+
+ + + + +
+ ); } diff --git a/src/app/(app)/sites/[id]/page.tsx b/src/app/(app)/sites/[id]/page.tsx new file mode 100644 index 0000000..3d0affc --- /dev/null +++ b/src/app/(app)/sites/[id]/page.tsx @@ -0,0 +1,231 @@ +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { AlertTriangle, ArrowLeft, Car, KeyRound, MapPin, Wrench } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import { PageHead } from "@/components/mockup-ui"; +import { Modal } from "@/components/modal"; +import { ActionButtonForm } from "@/components/customers/action-form"; +import { SiteStatusPill } from "@/components/customers/status"; +import { Banner, buttonLinkClass, Card, DefinitionList, TabNav } from "@/components/customers/form-ui"; +import { DocumentPanel } from "@/components/documents/document-panel"; +import { SiteForm } from "@/components/sites/site-form"; +import { SiteHistory } from "@/components/sites/site-history"; +import { deleteSiteAction, updateSiteAction } from "@/server/actions/sites/sites"; +import { requirePageContext } from "@/server/api/context"; +import { can, ServiceError } from "@/server/services/context"; +import { customerOptions, getCustomer } from "@/server/services/customers/customers"; +import { customerDisplayName, formatAddress } from "@/server/services/customers/format"; +import { listDocuments } from "@/server/services/documents/access"; +import { getSiteHistory } from "@/server/services/sites/history"; +import { siteMapUrl } from "@/server/services/sites/map-link"; +import { getSite } from "@/server/services/sites/sites"; + +const TABS = ["master", "documents", "history"] as const; +type Tab = (typeof TABS)[number]; + +type SearchParams = Promise<{ + tab?: string; + edit?: string; + delete?: string; + approved?: string; + docOk?: string; + docError?: string; + docEdit?: string; + docVersion?: string; +}>; + +export default async function SiteDetailPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: SearchParams }) { + const ctx = await requirePageContext("sites"); + const [{ id }, sp] = await Promise.all([params, searchParams]); + const [t, tc] = await Promise.all([getTranslations("sites"), getTranslations("common")]); + + let site; + try { + site = await getSite(ctx, id); + } catch (err) { + if (err instanceof ServiceError) notFound(); + throw err; + } + + const tab: Tab = (TABS as readonly string[]).includes(sp.tab ?? "") ? (sp.tab as Tab) : "master"; + const base = `/sites/${id}`; + const tabHref = (k: Tab) => (k === "master" ? base : `${base}?tab=${k}`); + const here = tabHref(tab); + const canWrite = can(ctx, "site:write") && can(ctx, "customer:read"); + const mapUrl = siteMapUrl(site); + const fullHistoryAccess = can(ctx, "work_order:read_all"); + + const notes = [ + { key: "accessNotes", icon: KeyRound, value: site.accessNotes, tone: "info" as const }, + { key: "parkingNotes", icon: Car, value: site.parkingNotes, tone: "info" as const }, + { key: "safetyNotes", icon: AlertTriangle, value: site.safetyNotes, tone: "warn" as const }, + { key: "technicalNotes", icon: Wrench, value: site.technicalNotes, tone: "info" as const }, + ].filter((n) => n.value?.trim()); + + return ( +
+ + {t("detail.back")} + + + + {mapUrl ? ( + + {t("detail.map")} + + ) : ( + {t("detail.noMap")} + )} + {canWrite && ( + + {t("detail.edit")} + + )} + {canWrite && ( + + {t("detail.delete")} + + )} + + } + /> + + ({ key: k, label: t(`tabs.${k}`), href: tabHref(k) }))} /> + + {tab === "master" && ( +
+ + + {customerDisplayName(site.customer)} · {site.customer.customerNumber} + + ) : ( + customerDisplayName(site.customer) + ), + }, + { label: t("sections.address"), value: formatAddress(site, { withCountry: true }) }, + { label: t("fields.contactId"), value: site.contact ? [site.contact.name, site.contact.phone ?? site.contact.mobile, site.contact.email].filter(Boolean).join(" · ") : null }, + { label: t("fields.onSiteContact"), value: site.onSiteContact }, + { label: t("fields.phone"), value: site.phone }, + { + label: `${t("fields.latitude")} / ${t("fields.longitude")}`, + value: site.latitude != null && site.longitude != null ? `${site.latitude}, ${site.longitude}` : null, + }, + ]} + /> + +
+ {notes.length === 0 &&

{t("detail.noNotes")}

} + {notes.map((n) => ( +
+

+ {t(`fields.${n.key}`)} +

+

{n.value}

+
+ ))} +
+
+ )} + + {tab === "documents" && ( + + )} + + {tab === "history" && ( + + )} + + {canWrite && sp.edit && ( + + [k, v === null || typeof v === "object" ? "" : String(v)]))} + closeHref={here} + /> + + )} + + {canWrite && sp.delete && ( + +
+

{t("detail.deleteHint")}

+
+ + + {t("form.cancel")} + +
+
+
+ )} +
+ ); +} + +async function editCustomerOptions(ctx: Awaited>, current: { id: string; customerNumber: string | null; companyName: string | null; firstName: string | null; lastName: string | null }) { + const options = (await customerOptions(ctx)).map((c) => ({ id: c.id, label: `${customerDisplayName(c)} · ${c.customerNumber ?? ""}` })); + if (!options.some((o) => o.id === current.id)) options.unshift({ id: current.id, label: `${customerDisplayName(current)} · ${current.customerNumber ?? ""}` }); + return options; +} + +async function siteContacts(ctx: Awaited>, customerId: string) { + try { + const customer = await getCustomer(ctx, customerId); + return customer.contacts.map((c) => ({ id: c.id, label: c.role ? `${c.name} (${c.role})` : c.name })); + } catch (err) { + if (err instanceof ServiceError) return []; + throw err; + } +} + +async function HistoryTab({ siteId, ctx, onlyApproved, canToggle }: { siteId: string; ctx: Awaited>; onlyApproved: boolean; canToggle: boolean }) { + const t = await getTranslations("sites"); + const history = await getSiteHistory(ctx, siteId, { onlyApproved, pageSize: 100 }); + const base = `/sites/${siteId}?tab=history`; + return ( +
+
+

{t("history.title")}

+ {canToggle && ( +
+ + {t("history.showAll")} + + + {t("history.onlyApproved")} + +
+ )} +
+ {history.onlyApproved && {t("history.approvedOnlyHint")}} + +
+ ); +} diff --git a/src/app/(app)/sites/page.tsx b/src/app/(app)/sites/page.tsx index bfcdf3f..04e19a3 100644 --- a/src/app/(app)/sites/page.tsx +++ b/src/app/(app)/sites/page.tsx @@ -1,5 +1,152 @@ -import { ModulePlaceholder } from "@/components/module-placeholder"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { Plus } from "lucide-react"; +import { getTranslations } from "next-intl/server"; +import { PageHead } from "@/components/mockup-ui"; +import { Modal } from "@/components/modal"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { SiteForm } from "@/components/sites/site-form"; +import { SiteStatusPill } from "@/components/customers/status"; +import { buttonLinkClass, controlClass, hrefWith, Pagination, paginationSummary, primaryButtonClass } from "@/components/customers/form-ui"; +import { createSiteAction } from "@/server/actions/sites/sites"; +import { requirePageContext } from "@/server/api/context"; +import { can, ServiceError } from "@/server/services/context"; +import { customerOptions, getCustomer } from "@/server/services/customers/customers"; +import { customerDisplayName, formatAddress } from "@/server/services/customers/format"; +import { listSites, SITE_STATUSES } from "@/server/services/sites/sites"; -export default function Page() { - return ; +type SearchParams = Promise<{ q?: string; status?: string; customerId?: string; page?: string; new?: string }>; + +/** Site list (spec §8): search, status/customer filter, pagination, create popup. */ +export default async function SitesPage({ searchParams }: { searchParams: SearchParams }) { + const ctx = await requirePageContext("sites"); + if (!can(ctx, "site:read")) notFound(); + const [t, tc] = await Promise.all([getTranslations("sites"), getTranslations("common")]); + const sp = await searchParams; + + const status = (SITE_STATUSES as readonly string[]).includes(sp.status ?? "") ? (sp.status as (typeof SITE_STATUSES)[number]) : "all"; + const q = sp.q?.trim() || undefined; + const customerId = sp.customerId?.trim() || undefined; + const page = Math.max(1, Math.floor(Number(sp.page)) || 1); + const canWrite = can(ctx, "site:write") && can(ctx, "customer:read"); + + // with ?new=1 the customerId only preselects the form, it does not filter the list + const filterCustomer = sp.new ? undefined : customerId; + const result = await listSites(ctx, { q, status, customerId: filterCustomer, page, pageSize: 25 }); + const listHref = (p: number) => hrefWith("/sites", { q, status: status === "all" ? undefined : status, customerId: filterCustomer, page: p > 1 ? p : undefined }); + const closeHref = sp.new && customerId ? `/customers/${customerId}?tab=sites` : listHref(page); + + let formCustomers: { id: string; label: string }[] = []; + let contacts: { id: string; label: string }[] | null = null; + if (canWrite && sp.new) { + formCustomers = (await customerOptions(ctx)).map((c) => ({ id: c.id, label: `${customerDisplayName(c)} · ${c.customerNumber ?? ""}${c.city ? ` · ${c.city}` : ""}` })); + if (customerId) { + try { + const customer = await getCustomer(ctx, customerId); + contacts = customer.contacts.map((c) => ({ id: c.id, label: c.role ? `${c.name} (${c.role})` : c.name })); + } catch (err) { + if (!(err instanceof ServiceError)) throw err; + } + } + } + + return ( +
+ + {t("new")} + + ) : undefined + } + /> + +
+ {filterCustomer && } + + + + {(q || status !== "all" || filterCustomer) && ( + + {t("filter.reset")} + + )} +
+ +
+ + + + {t("columns.name")} + {t("columns.customer")} + {t("columns.address")} + {t("columns.orders")} + {t("columns.status")} + + + + {result.items.map((s) => ( + + + + {s.name} + + + + + {customerDisplayName(s.customer)} + + + {formatAddress(s) || "—"} + {s._count.workOrders} + + + + + ))} + {result.items.length === 0 && ( + + + {t("empty")} + + + )} + +
+
+ + + + {canWrite && sp.new && ( + + + + )} +
+ ); } diff --git a/src/app/(app)/teams/page.tsx b/src/app/(app)/teams/page.tsx index c1b698c..340da9e 100644 --- a/src/app/(app)/teams/page.tsx +++ b/src/app/(app)/teams/page.tsx @@ -1,5 +1,168 @@ -import { ModulePlaceholder } from "@/components/module-placeholder"; +import Link from "next/link"; +import { notFound } from "next/navigation"; +import { Plus } from "lucide-react"; +import { getFormatter, getTranslations } from "next-intl/server"; +import { PageHead } from "@/components/mockup-ui"; +import { Modal } from "@/components/modal"; +import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table"; +import { ActionButtonForm } from "@/components/customers/action-form"; +import { TeamStatusPill } from "@/components/customers/status"; +import { buttonLinkClass, primaryButtonClass } from "@/components/customers/form-ui"; +import { TeamForm, type TeamFormValues } from "@/components/teams/team-form"; +import { deleteTeamAction, saveTeamAction } from "@/server/actions/teams/teams"; +import { requirePageContext } from "@/server/api/context"; +import { can } from "@/server/services/context"; +import { listTeams, teamUserOptions } from "@/server/services/teams/teams"; -export default function Page() { - return ; +type SearchParams = Promise<{ new?: string; edit?: string; inactive?: string }>; + +const day = (d: Date | null) => (d ? d.toISOString().slice(0, 10) : ""); + +/** Teams (spec §11.1): list with team lead, members (validity), phone, vehicle, area; edit popup. */ +export default async function TeamsPage({ searchParams }: { searchParams: SearchParams }) { + const ctx = await requirePageContext("teams"); + if (!can(ctx, "team:read")) notFound(); + const [t, tc, format] = await Promise.all([getTranslations("teams"), getTranslations("common"), getFormatter()]); + const sp = await searchParams; + const includeInactive = sp.inactive === "1"; + const canManage = can(ctx, "team:manage"); + + const [teams, users] = await Promise.all([listTeams(ctx, { includeInactive }), canManage ? teamUserOptions(ctx) : Promise.resolve([])]); + const listHref = includeInactive ? "/teams?inactive=1" : "/teams"; + const withParam = (k: string, v: string) => `${listHref}${listHref.includes("?") ? "&" : "?"}${k}=${encodeURIComponent(v)}`; + const now = new Date(); + const isCurrent = (m: { validFrom: Date; validTo: Date | null }) => m.validFrom <= now && (!m.validTo || m.validTo > now); + const editTeam = sp.edit ? teams.find((x) => x.id === sp.edit) : undefined; + + const initialFor = (team?: (typeof teams)[number]): TeamFormValues => + team + ? { + name: team.name, + leaderUserId: team.leaderUserId, + status: team.status, + phone: team.phone, + vehicle: team.vehicle, + area: team.area, + notes: team.notes, + members: team.members.map((m) => ({ userId: m.userId, validFrom: day(m.validFrom), validTo: day(m.validTo) })), + } + : { status: "active", members: [] }; + + return ( +
+ + + {includeInactive ? t("hideInactive") : t("showInactive")} + + {canManage && ( + + {t("new")} + + )} + + } + /> + {!canManage &&

{t("readOnly")}

} + +
+ + + + {t("columns.name")} + {t("columns.leader")} + {t("columns.members")} + {t("columns.phone")} + {t("columns.vehicle")} + {t("columns.area")} + {t("columns.status")} + + + + {teams.map((team) => { + const current = team.members.filter(isCurrent); + return ( + + + {canManage ? ( + + {team.name} + + ) : ( + {team.name} + )} + + {team.leader?.name ?? "—"} + + {t("members.count", { count: current.length })} + {team.members.length > 0 && ( +
    + {team.members.map((m) => ( +
  • + {m.user.name} ·{" "} + {isCurrent(m) + ? t("members.current") + : m.validFrom > now + ? t("members.upcoming", { date: format.dateTime(m.validFrom, { dateStyle: "medium" }) }) + : t("members.ended")} +
  • + ))} +
+ )} +
+ {team.phone ?? "—"} + {team.vehicle ?? "—"} + {team.area ?? "—"} + + + +
+ ); + })} + {teams.length === 0 && ( + + + {t("empty")} + + + )} +
+
+
+ + {canManage && sp.new && ( + + + + )} + + {canManage && editTeam && ( + +

{t("form.deleteHint")}

+ + + } + > + +
+ )} +
+ ); } diff --git a/src/components/audit-trail.tsx b/src/components/audit-trail.tsx index b9d10d6..a7ce82f 100644 --- a/src/components/audit-trail.tsx +++ b/src/components/audit-trail.tsx @@ -59,6 +59,7 @@ const ENTITY_LABEL: Record = { platformAdmin: "Plattform-Admin", // Craftvia-Fachobjekte (Labels vorab, Module folgen) customer: "Kunde", + contact: "Ansprechpartner", site: "Objekt", team: "Team", work_order: "Auftrag", diff --git a/src/components/customers/action-form.tsx b/src/components/customers/action-form.tsx new file mode 100644 index 0000000..0731259 --- /dev/null +++ b/src/components/customers/action-form.tsx @@ -0,0 +1,100 @@ +"use client"; + +import { useActionState, useEffect } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import { cn } from "@/lib/utils"; +import type { ActionState } from "@/server/api/action-state"; +import { buttonLinkClass, primaryButtonClass } from "@/components/customers/form-ui"; + +export type FormAction = (prev: ActionState, fd: FormData) => Promise; + +export const IDLE_STATE: ActionState = { status: "idle" }; + +/** Translate an action error: specific reason first, then the generic code. */ +export function useErrorText(namespace: string) { + const t = useTranslations(namespace); + return (state: ActionState): string | null => { + if (state.status !== "error") return null; + if (state.reason && t.has(`errors.${state.reason}`)) return t(`errors.${state.reason}`); + return t(`errors.${state.code}`); + }; +} + +/** Field error text (reason code → message, otherwise "invalidField"). */ +export function useFieldError(namespace: string) { + const t = useTranslations(namespace); + return (state: ActionState, field: string): string | undefined => { + if (state.status !== "error" || !state.fieldErrors?.[field]) return undefined; + const code = state.fieldErrors[field]; + return t.has(`errors.${code}`) ? t(`errors.${code}`) : t("errors.invalidField"); + }; +} + +export function FormError({ namespace, state }: { namespace: string; state: ActionState }) { + const text = useErrorText(namespace)(state); + if (!text) return null; + return ( +

+ {text} +

+ ); +} + +/** One-button form for simple mutations (confirm, delete …) with optional browser confirmation. */ +export function ActionButtonForm({ + action, + label, + pendingLabel, + confirmText, + namespace, + tone = "outline", + successHref, + className, +}: { + action: FormAction; + label: string; + pendingLabel?: string; + confirmText?: string; + namespace: string; + tone?: "primary" | "outline" | "danger"; + successHref?: string; + className?: string; +}) { + const router = useRouter(); + const [state, formAction, pending] = useActionState(action, IDLE_STATE); + const errorText = useErrorText(namespace)(state); + + useEffect(() => { + if (state.status === "ok") { + if (successHref) router.push(successHref); + else router.refresh(); + } + }, [state, successHref, router]); + + return ( +
{ + if (confirmText && !window.confirm(confirmText)) e.preventDefault(); + }} + > + + {errorText && ( + + {errorText} + + )} +
+ ); +} diff --git a/src/components/customers/contact-form.tsx b/src/components/customers/contact-form.tsx new file mode 100644 index 0000000..0f06832 --- /dev/null +++ b/src/components/customers/contact-form.tsx @@ -0,0 +1,74 @@ +"use client"; + +import Link from "next/link"; +import { useActionState, useEffect, useState } from "react"; +import { useRouter } from "next/navigation"; +import { useTranslations } from "next-intl"; +import type { ActionState } from "@/server/api/action-state"; +import { buttonLinkClass, controlClass, Field, primaryButtonClass, textareaClass } from "@/components/customers/form-ui"; +import { FormError, IDLE_STATE, useFieldError, type FormAction } from "@/components/customers/action-form"; + +type Values = Record; + +export function ContactForm({ action, initial = {}, closeHref }: { action: FormAction; initial?: Values; closeHref: string }) { + const t = useTranslations("customers"); + const router = useRouter(); + const fieldError = useFieldError("customers"); + const [values, setValues] = useState(initial); + const [state, formAction, pending] = useActionState(async (prev, fd) => { + setValues(Object.fromEntries([...fd.entries()].map(([k, v]) => [k, String(v)]))); + return action(prev, fd); + }, IDLE_STATE); + + useEffect(() => { + if (state.status === "ok") router.push(closeHref); + }, [state, closeHref, router]); + + const input = (name: string, type = "text", required = false) => ( + + + + ); + + return ( +
+ {input("name", "text", true)} + {input("role")} + {input("phone", "tel")} + {input("mobile", "tel")} + {input("email", "email")} + + + + +