From 9b087168d142ca4b5aa9580471234a2f22f4f490 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 15 Sep 2026 10:35:07 +0200 Subject: [PATCH 1/5] =?UTF-8?q?L14=20Abrechnungs=C3=BCbersicht:=20Demo-See?= =?UTF-8?q?d=20auf=20frischer=20Datenbank=20lauff=C3=A4hig?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Seit L12 darf ein Nutzer nur eine laufende Uhr haben. DEMO-06/DEMO-07 (laufende Uhren) werden deshalb nach den vergangenen Einsätzen derselben Monteure angelegt; Inhalte unverändert. Co-Authored-By: Claude Opus 5 --- scripts/lib/demo-seed.ts | 32 ++++++++++++++++++-------------- 1 file changed, 18 insertions(+), 14 deletions(-) diff --git a/scripts/lib/demo-seed.ts b/scripts/lib/demo-seed.ts index 995ee55..911b1d5 100644 --- a/scripts/lib/demo-seed.ts +++ b/scripts/lib/demo-seed.ts @@ -273,20 +273,8 @@ export async function seedDemoTenant(tenantId: string, users: DemoUsers): Promis await assign(id, nord, [users.tech2]); await transitionWorkOrder(tech2, { workOrderId: id, to: "accepted" }); }); - await order({ ext: "DEMO-06", customer: "K-D006", site: "Halle 1 – Kühllager", title: "Störung Kältemittelleitung", orderTypeId: type("stoerung"), priority: "urgent", plannedStart: day(0, 9), plannedEnd: day(0, 13) }, async (id) => { - await assign(id, sued, [users.tech3]); - await travel(tech3, id, day(0, 8, 30)); - }); - await order({ ext: "DEMO-07", customer: "K-D003", site: "Quartier Poppenbüttel – Block 3", title: "Montage Gas-Brennwerttherme", orderTypeId: type("montage"), plannedStart: day(0, 7), plannedEnd: day(0, 17), materials: heating, scope: "Demontage Altgerät, Montage Neugerät, Abgasanschluss, Inbetriebnahme." }, async (id) => { - await assign(id, nord, [users.tech]); - await travel(tech, id, day(0, 6, 45)); - await work(tech, id, day(0, 7, 10)); - await photo(tech, id, { phase: "before", requirementKey: "ausgangszustand", comment: "Altgerät vor Demontage" }); - await note(tech, id, "work_done", "Altgerät demontiert, Anschlüsse verschlossen."); - await note(tech, id, "deviation", "Abgasrohr muss um 30 cm verlängert werden."); - await planMaterial(tech, id, 1, 14, "fully_used", "Leitungsführung um den Schacht"); - await material(tech, id, { clientId: randomUUID(), name: "Abgasrohr-Verlängerung DN80", articleNumber: "AG-80-05", quantity: 1, unit: "Stk", usageStatus: "additional", deviationReason: "Abgasrohr zu kurz" }); - }); + // DEMO-06/DEMO-07 (laufende Uhren von Sven Südfeld und Max Monteur) werden erst nach DEMO-19 angelegt: seit + // L12 darf ein Nutzer nur eine laufende Uhr haben – spätere Einsätze derselben Monteure würden sonst kollidieren. await order({ ext: "DEMO-08", customer: "K-D002", site: "Filiale Eppendorf", title: "Undichte Leitung unter der Theke", orderTypeId: type("reparatur"), plannedStart: day(0, 7), plannedEnd: day(0, 11) }, async (id) => { await assign(id, sued, [users.tech3]); await work(tech3, id, day(0, 7, 5)); @@ -376,6 +364,22 @@ export async function seedDemoTenant(tenantId: string, users: DemoUsers): Promis await markBilled(bo, { workOrderId: id }); }); + // Heute laufende Uhren (nach allen vergangenen Einsätzen derselben Monteure, s. o.) + await order({ ext: "DEMO-06", customer: "K-D006", site: "Halle 1 – Kühllager", title: "Störung Kältemittelleitung", orderTypeId: type("stoerung"), priority: "urgent", plannedStart: day(0, 9), plannedEnd: day(0, 13) }, async (id) => { + await assign(id, sued, [users.tech3]); + await travel(tech3, id, day(0, 8, 30)); + }); + await order({ ext: "DEMO-07", customer: "K-D003", site: "Quartier Poppenbüttel – Block 3", title: "Montage Gas-Brennwerttherme", orderTypeId: type("montage"), plannedStart: day(0, 7), plannedEnd: day(0, 17), materials: heating, scope: "Demontage Altgerät, Montage Neugerät, Abgasanschluss, Inbetriebnahme." }, async (id) => { + await assign(id, nord, [users.tech]); + await travel(tech, id, day(0, 6, 45)); + await work(tech, id, day(0, 7, 10)); + await photo(tech, id, { phase: "before", requirementKey: "ausgangszustand", comment: "Altgerät vor Demontage" }); + await note(tech, id, "work_done", "Altgerät demontiert, Anschlüsse verschlossen."); + await note(tech, id, "deviation", "Abgasrohr muss um 30 cm verlängert werden."); + await planMaterial(tech, id, 1, 14, "fully_used", "Leitungsführung um den Schacht"); + await material(tech, id, { clientId: randomUUID(), name: "Abgasrohr-Verlängerung DN80", articleNumber: "AG-80-05", quantity: 1, unit: "Stk", usageStatus: "additional", deviationReason: "Abgasrohr zu kurz" }); + }); + // Notdienst mit vorläufigem Kunden (feste Client-IDs → idempotent) const emergencyExisting = await bo.db.workSession.findFirst({ where: { clientId: "7c1d6a0e-3b1f-4c55-9d2a-00000000d020" }, select: { workOrderId: true } }); if (!emergencyExisting) { From 6358d5762c8d97558d2112e9272b0aa6518c0cd6 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 15 Sep 2026 10:35:20 +0200 Subject: [PATCH 2/5] =?UTF-8?q?L14=20Abrechnungs=C3=BCbersicht:=20Datenmod?= =?UTF-8?q?ell,=20Migration,=20Rechte=20und=20Modul?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Neue Tenant-Tabellen work_order_milestones und billing_records (RLS, TENANT_MODELS, PII-Felder), Partial-Unique-Indizes je Quelle, billing_record_id an time_entries/material_usages. Rechte billing:read/billing:write (Backoffice, Mandantenadmin), Modul billing, Events milestone.*. Co-Authored-By: Claude Opus 5 --- messages/de/modules.json | 3 +- messages/en/modules.json | 3 +- .../migration.sql | 100 ++++++++++++++++++ prisma/schema.prisma | 92 ++++++++++++++++ src/components/audit-trail.tsx | 2 + src/lib/events.ts | 6 +- src/lib/modules.ts | 1 + src/server/backup/topology.ts | 3 + src/server/db.ts | 3 + src/server/dsgvo/pii-fields.ts | 7 ++ src/server/rbac.ts | 3 + 11 files changed, 220 insertions(+), 3 deletions(-) create mode 100644 prisma/migrations/20260915150000_abrechnung_uebersicht/migration.sql diff --git a/messages/de/modules.json b/messages/de/modules.json index b1a1757..2bab86d 100644 --- a/messages/de/modules.json +++ b/messages/de/modules.json @@ -14,5 +14,6 @@ "crumb": "Modul", "status": "Modul in Umsetzung", "hint": "Dieser Bereich wird gerade gebaut. Navigation, Modul-Freischaltung und Berechtigungen sind bereits aktiv." - } + }, + "billing": "Abrechnungsübersicht" } diff --git a/messages/en/modules.json b/messages/en/modules.json index 02e47ae..eeab815 100644 --- a/messages/en/modules.json +++ b/messages/en/modules.json @@ -14,5 +14,6 @@ "crumb": "Module", "status": "Module under construction", "hint": "This area is currently being built. Navigation, module activation and permissions are already in place." - } + }, + "billing": "Billing overview" } diff --git a/prisma/migrations/20260915150000_abrechnung_uebersicht/migration.sql b/prisma/migrations/20260915150000_abrechnung_uebersicht/migration.sql new file mode 100644 index 0000000..5f8b3e5 --- /dev/null +++ b/prisma/migrations/20260915150000_abrechnung_uebersicht/migration.sql @@ -0,0 +1,100 @@ +-- CreateEnum +CREATE TYPE "MilestoneStatus" AS ENUM ('open', 'reached', 'confirmed', 'billed'); + +-- CreateEnum +CREATE TYPE "BillingRecordKind" AS ENUM ('order_completion', 'milestone', 'daily_report'); + +-- CreateEnum +CREATE TYPE "BillingRecordStatus" AS ENUM ('open', 'billed', 'voided'); + +-- AlterTable +ALTER TABLE "material_usages" ADD COLUMN "billing_record_id" TEXT; + +-- AlterTable +ALTER TABLE "time_entries" ADD COLUMN "billing_record_id" TEXT; + +-- CreateTable +CREATE TABLE "work_order_milestones" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "work_order_id" TEXT NOT NULL, + "title" TEXT NOT NULL, + "description" TEXT, + "sort_order" INTEGER NOT NULL DEFAULT 0, + "status" "MilestoneStatus" NOT NULL DEFAULT 'open', + "reached_by_id" TEXT, + "reached_at" TIMESTAMP(3), + "reached_note" TEXT, + "confirmed_by_id" TEXT, + "confirmed_at" TIMESTAMP(3), + "rejected_by_id" TEXT, + "rejected_at" TIMESTAMP(3), + "rejection_reason" TEXT, + "billing_record_id" TEXT, + "created_by_id" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + "deleted_at" TIMESTAMP(3), + + CONSTRAINT "work_order_milestones_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "billing_records" ( + "id" TEXT NOT NULL, + "tenant_id" TEXT NOT NULL, + "work_order_id" TEXT NOT NULL, + "kind" "BillingRecordKind" NOT NULL, + "milestone_id" TEXT, + "report_id" TEXT, + "period_from" TIMESTAMP(3) NOT NULL, + "period_to" TIMESTAMP(3) NOT NULL, + "status" "BillingRecordStatus" NOT NULL DEFAULT 'open', + "snapshot" JSONB, + "pdf_document_id" TEXT, + "pdf_checksum" TEXT, + "invoice_number" TEXT, + "billed_by_id" TEXT, + "billed_at" TIMESTAMP(3), + "voided_by_id" TEXT, + "voided_at" TIMESTAMP(3), + "void_reason" TEXT, + "created_at" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updated_at" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "billing_records_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "work_order_milestones_tenant_id_work_order_id_idx" ON "work_order_milestones"("tenant_id", "work_order_id"); + +-- CreateIndex +CREATE INDEX "work_order_milestones_tenant_id_status_idx" ON "work_order_milestones"("tenant_id", "status"); + +-- CreateIndex +CREATE INDEX "billing_records_tenant_id_status_created_at_idx" ON "billing_records"("tenant_id", "status", "created_at"); + +-- CreateIndex +CREATE INDEX "billing_records_tenant_id_work_order_id_idx" ON "billing_records"("tenant_id", "work_order_id"); + +-- CreateIndex +CREATE INDEX "material_usages_tenant_id_billing_record_id_idx" ON "material_usages"("tenant_id", "billing_record_id"); + +-- CreateIndex +CREATE INDEX "time_entries_tenant_id_billing_record_id_idx" ON "time_entries"("tenant_id", "billing_record_id"); + +-- AddForeignKey +ALTER TABLE "work_order_milestones" ADD CONSTRAINT "work_order_milestones_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "billing_records" ADD CONSTRAINT "billing_records_work_order_id_fkey" FOREIGN KEY ("work_order_id") REFERENCES "work_orders"("id") ON DELETE CASCADE ON UPDATE CASCADE; + + +-- L14 Abrechnungsübersicht: Mandantentrennung (RLS) für die neuen Tabellen +SELECT enable_tenant_rls('work_order_milestones'); +SELECT enable_tenant_rls('billing_records'); + +-- Höchstens ein nicht stornierter Abrechnungseintrag je Quelle (Stornierung legt einen neuen offenen Eintrag an) +CREATE UNIQUE INDEX "billing_records_order_completion_active_key" ON "billing_records"("tenant_id", "work_order_id") WHERE "kind" = 'order_completion' AND "status" <> 'voided'; +CREATE UNIQUE INDEX "billing_records_milestone_active_key" ON "billing_records"("tenant_id", "milestone_id") WHERE "kind" = 'milestone' AND "status" <> 'voided'; +CREATE UNIQUE INDEX "billing_records_daily_report_active_key" ON "billing_records"("tenant_id", "report_id") WHERE "kind" = 'daily_report' AND "status" <> 'voided'; diff --git a/prisma/schema.prisma b/prisma/schema.prisma index b0f5a6d..0de70c1 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -737,6 +737,8 @@ model WorkOrder { voiceNotes VoiceNote[] reports Report[] documents Document[] + milestones WorkOrderMilestone[] // L14 + billingRecords BillingRecord[] // L14 @@unique([tenantId, number]) @@index([tenantId, status]) @@ -859,6 +861,8 @@ model MaterialUsage { photoId String? @map("photo_id") recordedById String? @map("recorded_by_id") clientId String? @map("client_id") // offline local id + /// L14: billed billing record containing this position (set on „abgerechnet", cleared on void) + billingRecordId String? @map("billing_record_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") @@ -866,6 +870,7 @@ model MaterialUsage { materialPlan MaterialPlan? @relation(fields: [materialPlanId], references: [id], onDelete: SetNull) workSession WorkSession? @relation(fields: [workSessionId], references: [id], onDelete: SetNull) + @@index([tenantId, billingRecordId]) @@index([tenantId, workOrderId]) @@unique([tenantId, clientId]) @@map("material_usages") @@ -953,11 +958,14 @@ model TimeEntry { /// proposed correction of this entry {startedAt, endedAt, type, reason, requestedAt}; old values stay valid until approval pendingChange Json? @map("pending_change") note String? + /// L14: billed billing record containing this entry (set on „abgerechnet", cleared on void) + billingRecordId String? @map("billing_record_id") createdAt DateTime @default(now()) @map("created_at") updatedAt DateTime @updatedAt @map("updated_at") workSession WorkSession @relation(fields: [workSessionId], references: [id], onDelete: Cascade) + @@index([tenantId, billingRecordId]) @@index([tenantId, workSessionId]) @@index([tenantId, userId, startedAt]) @@index([tenantId, approvalStatus]) @@ -1328,3 +1336,87 @@ model AiGeneration { @@index([tenantId, entityType, entityId]) @@map("ai_generations") } + +// ---------- L14 Abrechnungsübersicht (keine Buchhaltung: keine Preise/Beträge/Steuern) ---------- + +enum MilestoneStatus { + open + reached // reported by technician / team lead + confirmed // confirmed by back office → billing record + billed +} + +enum BillingRecordKind { + order_completion + milestone + daily_report +} + +enum BillingRecordStatus { + open + billed + voided +} + +/// Optional billing section of a work order (defined by back office, reported by the field, confirmed by back office). +model WorkOrderMilestone { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + workOrderId String @map("work_order_id") + title String + description String? + sortOrder Int @default(0) @map("sort_order") + status MilestoneStatus @default(open) + reachedById String? @map("reached_by_id") + reachedAt DateTime? @map("reached_at") + reachedNote String? @map("reached_note") + confirmedById String? @map("confirmed_by_id") + confirmedAt DateTime? @map("confirmed_at") + rejectedById String? @map("rejected_by_id") + rejectedAt DateTime? @map("rejected_at") + rejectionReason String? @map("rejection_reason") + /// billed record this milestone belongs to (plain id, no FK) + billingRecordId String? @map("billing_record_id") + createdById String? @map("created_by_id") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + deletedAt DateTime? @map("deleted_at") + + workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade) + + @@index([tenantId, workOrderId]) + @@index([tenantId, status]) + @@map("work_order_milestones") +} + +/// Billing overview entry: an order completion, a confirmed milestone or an approved daily report that the +/// accounting (outside Craftvia) can invoice. `snapshot` freezes the statement when marked as billed. +/// Partial unique indexes (SQL, migration `*_abrechnung`): one non-voided record per source. +model BillingRecord { + id String @id @default(cuid()) + tenantId String @map("tenant_id") + workOrderId String @map("work_order_id") + kind BillingRecordKind + milestoneId String? @map("milestone_id") + reportId String? @map("report_id") + periodFrom DateTime @map("period_from") + periodTo DateTime @map("period_to") + status BillingRecordStatus @default(open) + snapshot Json? + pdfDocumentId String? @map("pdf_document_id") + pdfChecksum String? @map("pdf_checksum") + invoiceNumber String? @map("invoice_number") + billedById String? @map("billed_by_id") + billedAt DateTime? @map("billed_at") + voidedById String? @map("voided_by_id") + voidedAt DateTime? @map("voided_at") + voidReason String? @map("void_reason") + createdAt DateTime @default(now()) @map("created_at") + updatedAt DateTime @updatedAt @map("updated_at") + + workOrder WorkOrder @relation(fields: [workOrderId], references: [id], onDelete: Cascade) + + @@index([tenantId, status, createdAt]) + @@index([tenantId, workOrderId]) + @@map("billing_records") +} diff --git a/src/components/audit-trail.tsx b/src/components/audit-trail.tsx index 81844ce..c1c0623 100644 --- a/src/components/audit-trail.tsx +++ b/src/components/audit-trail.tsx @@ -71,6 +71,8 @@ const ENTITY_LABEL: Record = { import_job: "Auftragsimport", material_usage: "Material", time_entry: "Arbeitszeit", + billing_record: "Abrechnungseintrag", + work_order_milestone: "Meilenstein", work_session: "Einsatzzeit", photo: "Foto", voice_note: "Sprachnotiz", diff --git a/src/lib/events.ts b/src/lib/events.ts index 16c2c17..d94b633 100644 --- a/src/lib/events.ts +++ b/src/lib/events.ts @@ -23,13 +23,17 @@ export const EVENT_TYPES = [ "time.approval_requested", "time.approved", "time.rejected", + // L14 Abrechnungsübersicht (Meilensteine) + "milestone.reached", + "milestone.confirmed", + "milestone.rejected", ] as const; export type EventType = (typeof EVENT_TYPES)[number]; export type DomainEvent = { type: EventType; - entityType: "work_order" | "report" | "import_job" | "sync_operation" | "time_entry"; + entityType: "work_order" | "report" | "import_job" | "sync_operation" | "time_entry" | "milestone"; entityId: string; /** Short, human-readable facts for templates (no PII beyond what the recipient may see). */ data?: Record; diff --git a/src/lib/modules.ts b/src/lib/modules.ts index 67139c9..80bee93 100644 --- a/src/lib/modules.ts +++ b/src/lib/modules.ts @@ -28,6 +28,7 @@ export const MODULES = [ { key: "documents", name: "Dokumente", href: "/documents", nav: "documents" }, { key: "notifications", name: "Benachrichtigungen", href: "/notifications", nav: "notifications" }, { key: "lotse", name: "Lotse (KI-Assistent)", href: null, nav: "lotse" }, + { key: "billing", name: "Abrechnungsübersicht", href: "/billing", nav: "billing" }, ] as const satisfies readonly ModuleDef[]; export type ModuleKey = (typeof MODULES)[number]["key"]; diff --git a/src/server/backup/topology.ts b/src/server/backup/topology.ts index 8c2c856..382cba3 100644 --- a/src/server/backup/topology.ts +++ b/src/server/backup/topology.ts @@ -72,6 +72,9 @@ export const TENANT_MODELS: readonly string[] = [ "Notification", "SyncOperation", "AiGeneration", + // L14 Abrechnungsübersicht + "WorkOrderMilestone", + "BillingRecord", ]; /** diff --git a/src/server/db.ts b/src/server/db.ts index b469b18..111ecaf 100644 --- a/src/server/db.ts +++ b/src/server/db.ts @@ -123,6 +123,9 @@ const TENANT_MODELS = new Set([ "Notification", "SyncOperation", "AiGeneration", + // L14 Abrechnungsübersicht + "WorkOrderMilestone", + "BillingRecord", // WebAuthnCredential/Identity sind identitäts-global (kein tenant_id) → NICHT hier. // Craftvia-Fachmodelle hier ergänzen — UND in src/server/backup/topology.ts // (TENANT_MODELS) sowie per `SELECT enable_tenant_rls('')` in der Migration diff --git a/src/server/dsgvo/pii-fields.ts b/src/server/dsgvo/pii-fields.ts index ed081e2..1483356 100644 --- a/src/server/dsgvo/pii-fields.ts +++ b/src/server/dsgvo/pii-fields.ts @@ -48,6 +48,13 @@ export const PII_REFERENCE_FIELDS: readonly PiiReference[] = [ { model: "SyncOperation", field: "userId" }, { model: "SyncOperation", field: "resolvedById" }, { model: "AiGeneration", field: "createdById" }, + // L14 Abrechnungsübersicht + { model: "WorkOrderMilestone", field: "createdById" }, + { model: "WorkOrderMilestone", field: "reachedById" }, + { model: "WorkOrderMilestone", field: "confirmedById" }, + { model: "WorkOrderMilestone", field: "rejectedById" }, + { model: "BillingRecord", field: "billedById" }, + { model: "BillingRecord", field: "voidedById" }, // Free-text person data of END CUSTOMERS (Customer/Contact/Site/Signature.signerName) is // tenant business data under data processing — not part of the employee subject export. ]; diff --git a/src/server/rbac.ts b/src/server/rbac.ts index f50e0f3..4cc7579 100644 --- a/src/server/rbac.ts +++ b/src/server/rbac.ts @@ -57,6 +57,9 @@ export const PERMISSIONS = [ "settings:templates", // Lotse (KI-Assistent) "lotse:use", + // L14 Abrechnungsübersicht (keine Buchhaltung): Übersicht lesen / abgerechnet markieren, stornieren, Meilensteine bestätigen + "billing:read", + "billing:write", ] as const; export type Permission = (typeof PERMISSIONS)[number]; From ac2d857e26c472737d9383fc2ee374cb396e8fc2 Mon Sep 17 00:00:00 2001 From: Martin Date: Tue, 15 Sep 2026 10:35:38 +0200 Subject: [PATCH 3/5] =?UTF-8?q?L14=20Abrechnungs=C3=BCbersicht:=20Services?= =?UTF-8?q?=20f=C3=BCr=20Eintr=C3=A4ge,=20Aufstellung,=20Abrechnen/Stornie?= =?UTF-8?q?ren,=20Meilensteine=20und=20PDF?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit syncBillingCandidates (idempotent, Event-Hook für work_order.released_for_billing und report.approved), Aufstellung ohne Preise (nur freigegebene Zeiten, Fahrzeit getrennt, Pausen informativ, Anfahrten-Zählung, Material), markBilled mit eingefrorenem Snapshot und Positionszuordnung, voidBilling mit Grund und neuem offenen Eintrag (billed → released_for_billing nur über applyTransition-Option billingVoid), Meilenstein-Flow mit Events, Job billing-pdf (Dokument other/backoffice_only), Sync-Op milestone.reach. Co-Authored-By: Claude Opus 5 --- messages/de/billing.json | 250 ++++++++++++++ messages/de/notifications.json | 17 +- messages/en/billing.json | 250 ++++++++++++++ messages/en/notifications.json | 17 +- src/components/billing/statement-document.tsx | 307 ++++++++++++++++++ src/lib/billing/schemas.ts | 77 +++++ src/lib/billing/statement.ts | 148 +++++++++ src/lib/sync/envelope.ts | 2 + src/lib/sync/ops.ts | 1 + src/server/events.ts | 9 + src/server/jobs/processors/billing-pdf.ts | 19 ++ src/server/jobs/processors/index.ts | 1 + src/server/jobs/queues.ts | 1 + src/server/pdf/templates/billing.tsx | 46 +++ src/server/services/billing/candidates.ts | 90 +++++ src/server/services/billing/common.ts | 31 ++ src/server/services/billing/events.ts | 16 + src/server/services/billing/milestones.ts | 172 ++++++++++ src/server/services/billing/pdf.ts | 60 ++++ src/server/services/billing/queries.ts | 183 +++++++++++ src/server/services/billing/records.ts | 180 ++++++++++ src/server/services/billing/statement.ts | 261 +++++++++++++++ src/server/services/billing/sync-ops.ts | 17 + .../services/notifications/handle-event.ts | 4 + .../services/notifications/recipients.ts | 14 + src/server/services/sync/external-ops.ts | 2 + src/server/services/work-orders/transition.ts | 9 +- 27 files changed, 2179 insertions(+), 5 deletions(-) create mode 100644 messages/de/billing.json create mode 100644 messages/en/billing.json create mode 100644 src/components/billing/statement-document.tsx create mode 100644 src/lib/billing/schemas.ts create mode 100644 src/lib/billing/statement.ts create mode 100644 src/server/jobs/processors/billing-pdf.ts create mode 100644 src/server/pdf/templates/billing.tsx create mode 100644 src/server/services/billing/candidates.ts create mode 100644 src/server/services/billing/common.ts create mode 100644 src/server/services/billing/events.ts create mode 100644 src/server/services/billing/milestones.ts create mode 100644 src/server/services/billing/pdf.ts create mode 100644 src/server/services/billing/queries.ts create mode 100644 src/server/services/billing/records.ts create mode 100644 src/server/services/billing/statement.ts create mode 100644 src/server/services/billing/sync-ops.ts diff --git a/messages/de/billing.json b/messages/de/billing.json new file mode 100644 index 0000000..5ef7d23 --- /dev/null +++ b/messages/de/billing.json @@ -0,0 +1,250 @@ +{ + "crumb": "Abrechnung", + "title": "Abrechnungsübersicht", + "subtitle": "Abgeschlossene Aufträge, bestätigte Meilensteine und freigegebene Tagesberichte für die Buchhaltung. Keine Preise, keine Rechnungen.", + "hm": "{value} h", + "tabs": { "open": "Offen", "billed": "Abgerechnet", "voided": "Storniert" }, + "filter": { + "title": "Filter", + "q": "Suche", + "qPlaceholder": "Auftragsnummer oder Kunde", + "from": "Zeitraum von", + "to": "Zeitraum bis", + "customer": "Kunde", + "team": "Team", + "kind": "Art", + "any": "Alle", + "apply": "Anwenden", + "reset": "Zurücksetzen" + }, + "list": { + "noPermission": "Die Abrechnungsübersicht ist für Ihre Rolle nicht freigeschaltet.", + "empty": { + "open": "Nichts offen – alle Einträge sind abgerechnet.", + "billed": "Noch keine Einträge abgerechnet.", + "voided": "Keine stornierten Einträge." + }, + "selectHint": "Einträge auswählen und gemeinsam drucken.", + "print": "Abrechnungsblätter drucken", + "select": "Auswahl", + "selectRow": "{number} auswählen", + "order": "Auftrag", + "customerSite": "Kunde / Objekt", + "section": "Art / Abschnitt", + "period": "Zeitraum", + "work": "Arbeitszeit", + "travel": "Fahrzeit", + "trips": "Anfahrten", + "material": "Material", + "readySince": "Bereit seit", + "billedAt": "Abgerechnet am", + "voidedAt": "Storniert am", + "pending": "{value} h offene Zeiten", + "invoice": "Rechnung {number}", + "pagination": "Seiten", + "prev": "Zurück", + "next": "Weiter", + "page": "Seite {page} von {pages}" + }, + "kind": { "order_completion": "Auftragsabschluss", "milestone": "Meilenstein", "daily_report": "Tagesbericht" }, + "status": { "open": "Offen", "billed": "Abgerechnet", "voided": "Storniert" }, + "milestoneStatus": { "open": "Offen", "reached": "Erreicht gemeldet", "confirmed": "Bestätigt", "billed": "Abgerechnet" }, + "detail": { + "back": "Zur Abrechnungsübersicht", + "title": "Abrechnungsblatt {number}", + "markBilled": "Als abgerechnet markieren", + "pdf": "PDF", + "print": "Druckansicht", + "changeInvoice": "Rechnungsnummer ändern", + "void": "Stornieren", + "openOrder": "Zum Auftrag", + "photos": "Fotos", + "report": "Bericht {number} · v{version}", + "done": { + "billed": "Als abgerechnet markiert. Die Aufstellung ist eingefroren, das PDF wird erstellt.", + "voided": "Storniert. Die Positionen sind wieder frei – hier ist der neue offene Eintrag.", + "invoice": "Rechnungsnummer gespeichert." + }, + "pdfPending": "Das PDF-Abrechnungsblatt wird erstellt.", + "pdfRequest": "PDF erneut anstoßen", + "pdfRequested": "PDF-Erstellung angestoßen.", + "voidedInfo": "Storniert am {at} von {by}" + }, + "bill": { + "title": "Als abgerechnet markieren", + "sub": "Die Rechnung stellt Ihre Buchhaltung außerhalb von Craftvia.", + "submit": "Abgerechnet", + "pending": "Wird gespeichert …", + "invoiceNumber": "Rechnungsnummer (optional)", + "invoiceHint": "Nummer aus Ihrer Buchhaltung, falls schon vorhanden.", + "pendingWarning": "{value} h offene Zeiten ({count}) sind noch nicht freigegeben und nicht enthalten.", + "confirmPending": "Trotzdem abrechnen – offene Zeiten bleiben außen vor", + "freezeHint": "Die Aufstellung wird eingefroren; enthaltene Zeiten und Materialien können kein zweites Mal abgerechnet werden." + }, + "invoice": { "title": "Rechnungsnummer ändern", "submit": "Speichern" }, + "void": { + "title": "Abrechnung stornieren", + "sub": "Zeiten und Material werden wieder frei, ein neuer offener Eintrag entsteht.", + "submit": "Stornieren", + "pending": "Wird storniert …", + "reason": "Grund" + }, + "print": { + "back": "Zur Übersicht", + "count": "{count, plural, =1 {1 Abrechnungsblatt} other {# Abrechnungsblätter}}", + "print": "Drucken", + "empty": "Keine Einträge ausgewählt." + }, + "tab": { + "milestones": "Meilensteine", + "milestonesHint": "Erreichte Meilensteine bestätigen – danach erscheinen sie in der Abrechnungsübersicht.", + "noMilestonesHint": "Ohne Meilensteine wird bei mehrtägigen Aufträgen jeder freigegebene Tagesbericht als Abschnitt abrufbar.", + "noMilestones": "Keine Meilensteine definiert.", + "reachedBy": "Erreicht gemeldet von {name} am {at}", + "confirmedBy": "Bestätigt von {name} am {at}", + "rejected": "Zuletzt abgelehnt: {reason}", + "up": "Nach oben", + "down": "Nach unten", + "delete": "Löschen", + "confirm": "Bestätigen", + "reject": "Ablehnen", + "rejectReason": "Grund der Ablehnung", + "add": "Meilenstein anlegen", + "newTitle": "Titel", + "newTitlePlaceholder": "z. B. Rohinstallation abgeschlossen", + "newDescription": "Beschreibung", + "records": "Abrechnungseinträge", + "noRecords": "Noch keine Abrechnungseinträge.", + "open": "Öffnen" + }, + "mobile": { + "title": "Meilensteine", + "rejected": "Vom Büro abgelehnt: {reason}", + "waiting": "Gemeldet – wartet auf Bestätigung durch das Büro.", + "queued": "Gespeichert – wird übertragen, sobald Verbindung besteht.", + "sent": "Erreicht gemeldet.", + "note": "Notiz", + "notePlaceholder": "Notiz (optional)", + "reachAria": "{title} als erreicht melden", + "confirmReach": "Jetzt melden", + "reach": "Erreicht melden", + "cancel": "Abbrechen" + }, + "sheet": { + "title": "Abrechnungsblatt", + "dailyReportOf": "Tagesbericht vom {date}", + "period": "Zeitraum", + "status": "Status", + "invoiceNumber": "Rechnungsnummer", + "noAccounting": "Aufstellung für die Buchhaltung – keine Rechnung, ohne Preise." + }, + "section": { + "order": "Auftragsdaten", + "time": "Zeiten", + "trips": "Anfahrten", + "material": "Material", + "texts": "Zusatzleistungen, Abweichungen, offene Restarbeiten", + "reports": "Berichte und Unterschrift" + }, + "field": { + "customer": "Kunde", + "customerNumber": "Kundennummer", + "billingAddress": "Rechnungsadresse", + "site": "Objekt", + "orderNumber": "Auftragsnummer", + "externalOrderNumber": "Externe Auftragsnummer", + "offerNumber": "Angebotsnummer", + "title": "Auftrag", + "orderType": "Auftragsart", + "billingType": "Abrechnungsart", + "team": "Team", + "section": "Abschnitt", + "workDates": "Einsatztage", + "billedAt": "Abgerechnet", + "billingNotes": "Abrechnungshinweise des Kunden" + }, + "billingType": { "fixed": "Festpreis", "time_material": "Nach Aufwand", "maintenance_contract": "Wartungsvertrag", "warranty": "Gewährleistung" }, + "time": { + "hm": "{value} h", + "empty": "Keine freigegebenen Zeiten in diesem Abschnitt.", + "person": "Person", + "work": "Arbeit", + "travel": "Fahrzeit", + "materialProcurement": "Materialbeschaffung", + "total": "Summe", + "breaks": "Pausen (Info)", + "manual": "davon manuell nachgetragen (freigegeben): {value} h", + "sum": "Gesamt", + "breaksInfo": "Fahrzeit = Anfahrt + Rückfahrt. Pausen sind nur zur Information aufgeführt und nicht in den Summen enthalten.", + "pendingHint": "{value} h offen ({count} Einträge), noch nicht freigegeben – nicht enthalten.", + "runningHint": "Eine Zeit läuft noch und ist nicht enthalten." + }, + "trips": { + "count": "{count, plural, =0 {Keine Anfahrt} =1 {1 Anfahrt} other {# Anfahrten}}", + "rule": "Gemeinsame Anfahrt einer Kolonne zählt als eine Fahrt." + }, + "material": { + "name": "Bezeichnung", + "articleNumber": "Art.-Nr.", + "quantity": "Menge", + "planned": "Geplant", + "status": "Status", + "reason": "Abweichungsgrund", + "undocumented": "Nicht erfasst", + "empty": "Kein Material in diesem Abschnitt.", + "used": "Verwendetes Material", + "additional": "Zusätzlich verwendetes Material", + "notUsed": "Nicht verwendete Planpositionen", + "notUsedInfo": "Nur zur Information." + }, + "materialStatus": { "fully_used": "Vollständig verwendet", "partially_used": "Teilweise verwendet", "not_used": "Nicht verwendet", "additional": "Zusätzlich" }, + "texts": { + "fromReport": "({number})", + "empty": "Keine Angaben in den Berichten.", + "additionalWork": "Zusatzleistungen", + "deviations": "Abweichungen", + "openItems": "Offene Restarbeiten" + }, + "reports": { + "empty": "Kein freigegebener Bericht in diesem Abschnitt.", + "number": "Bericht", + "type": "Art", + "date": "Datum", + "approvedAt": "Freigegeben", + "signature": "Unterschrift", + "noSignature": "Keine Unterschrift" + }, + "reportType": { "daily": "Tagesbericht", "completion": "Abschlussbericht" }, + "outcome": { "signed": "Unterschrieben", "customer_absent": "Kunde nicht anwesend", "refused": "Unterschrift verweigert", "later": "Wird nachgereicht", "not_required": "Nicht erforderlich" }, + "pdf": { + "documentTitle": "Abrechnungsblatt {number} – {kind}", + "page": "Seite {page} von {pages}", + "recordId": "Eintrag-ID", + "created": "Erstellt" + }, + "errors": { + "not_found": "Nicht gefunden oder keine Berechtigung.", + "forbidden": "Dafür fehlt die Berechtigung.", + "invalid": "Eingaben prüfen.", + "conflict": "Der Eintrag wurde inzwischen geändert. Seite neu laden.", + "blocked": "Noch nicht möglich.", + "internal": "Vorgang nicht möglich.", + "validation_failed": "Pflichtfelder prüfen.", + "billing_record_not_found": "Abrechnungseintrag nicht gefunden.", + "billing_record_not_open": "Der Eintrag ist nicht mehr offen.", + "billing_record_not_billed": "Der Eintrag ist nicht abgerechnet.", + "pending_time_entries": "Es gibt noch offene Zeiten. Bitte bestätigen, dass sie nicht enthalten sind.", + "positions_already_billed": "Einige Positionen wurden inzwischen anders abgerechnet. Seite neu laden.", + "milestone_not_found": "Meilenstein nicht gefunden.", + "milestone_not_open": "Nur offene Meilensteine können geändert werden.", + "milestone_not_confirmable": "Dieser Meilenstein kann nicht bestätigt werden.", + "milestone_not_reached": "Nur gemeldete Meilensteine können abgelehnt werden.", + "not_editable": "Der Auftrag kann in diesem Status nicht mehr geändert werden.", + "work_order_not_found": "Auftrag nicht gefunden.", + "work_order_status": "Für diesen Auftrag ist das nicht mehr möglich.", + "transition_blocked": "Der Auftragsstatus kann nicht zurückgesetzt werden – es fehlen Angaben.", + "transition_forbidden": "Diesen Statuswechsel dürfen Sie nicht ausführen.", + "reason_required": "Bitte einen Grund angeben.", + "version_conflict": "Der Auftrag wurde inzwischen geändert. Seite neu laden." + } +} diff --git a/messages/de/notifications.json b/messages/de/notifications.json index 5685b8c..0712c2d 100644 --- a/messages/de/notifications.json +++ b/messages/de/notifications.json @@ -48,7 +48,10 @@ "sync_failed": "Synchronisationsfehler", "time_approval_requested": "Zeit zur Freigabe", "time_approved": "Zeit freigegeben", - "time_rejected": "Zeit abgelehnt" + "time_rejected": "Zeit abgelehnt", + "milestone_reached": "Meilenstein erreicht", + "milestone_confirmed": "Meilenstein bestätigt", + "milestone_rejected": "Meilenstein abgelehnt" }, "events": { "work_order_assigned": { @@ -130,6 +133,18 @@ "time_rejected": { "title": "Zeit abgelehnt: {number}", "message": "{actor} hat Ihre Zeit zu {number} abgelehnt: {reason}" + }, + "milestone_reached": { + "title": "Meilenstein erreicht: {number}", + "message": "{actor} meldet einen Meilenstein zu {number} als erreicht. Bitte bestätigen." + }, + "milestone_confirmed": { + "title": "Meilenstein bestätigt: {number}", + "message": "{actor} hat Ihren gemeldeten Meilenstein zu {number} bestätigt." + }, + "milestone_rejected": { + "title": "Meilenstein abgelehnt: {number}", + "message": "{actor} hat Ihren gemeldeten Meilenstein zu {number} abgelehnt: {reason}" } }, "fallback": { diff --git a/messages/en/billing.json b/messages/en/billing.json new file mode 100644 index 0000000..7ae8a7c --- /dev/null +++ b/messages/en/billing.json @@ -0,0 +1,250 @@ +{ + "crumb": "Billing", + "title": "Billing overview", + "subtitle": "Completed orders, confirmed milestones and approved daily reports for your accounting. No prices, no invoices.", + "hm": "{value} h", + "tabs": { "open": "Open", "billed": "Billed", "voided": "Voided" }, + "filter": { + "title": "Filter", + "q": "Search", + "qPlaceholder": "Order number or customer", + "from": "Period from", + "to": "Period to", + "customer": "Customer", + "team": "Team", + "kind": "Type", + "any": "All", + "apply": "Apply", + "reset": "Reset" + }, + "list": { + "noPermission": "The billing overview is not enabled for your role.", + "empty": { + "open": "Nothing open – all entries are billed.", + "billed": "No entries billed yet.", + "voided": "No voided entries." + }, + "selectHint": "Select entries to print them together.", + "print": "Print billing sheets", + "select": "Selection", + "selectRow": "Select {number}", + "order": "Order", + "customerSite": "Customer / site", + "section": "Type / section", + "period": "Period", + "work": "Working time", + "travel": "Travel time", + "trips": "Trips", + "material": "Material", + "readySince": "Ready since", + "billedAt": "Billed on", + "voidedAt": "Voided on", + "pending": "{value} h pending times", + "invoice": "Invoice {number}", + "pagination": "Pages", + "prev": "Previous", + "next": "Next", + "page": "Page {page} of {pages}" + }, + "kind": { "order_completion": "Order completion", "milestone": "Milestone", "daily_report": "Daily report" }, + "status": { "open": "Open", "billed": "Billed", "voided": "Voided" }, + "milestoneStatus": { "open": "Open", "reached": "Reported as reached", "confirmed": "Confirmed", "billed": "Billed" }, + "detail": { + "back": "Back to billing overview", + "title": "Billing sheet {number}", + "markBilled": "Mark as billed", + "pdf": "PDF", + "print": "Print view", + "changeInvoice": "Change invoice number", + "void": "Void", + "openOrder": "Open order", + "photos": "Photos", + "report": "Report {number} · v{version}", + "done": { + "billed": "Marked as billed. The statement is frozen, the PDF is being created.", + "voided": "Voided. The positions are free again – this is the new open entry.", + "invoice": "Invoice number saved." + }, + "pdfPending": "The PDF billing sheet is being created.", + "pdfRequest": "Request PDF again", + "pdfRequested": "PDF creation requested.", + "voidedInfo": "Voided on {at} by {by}" + }, + "bill": { + "title": "Mark as billed", + "sub": "Your accounting issues the invoice outside of Craftvia.", + "submit": "Billed", + "pending": "Saving …", + "invoiceNumber": "Invoice number (optional)", + "invoiceHint": "Number from your accounting, if already available.", + "pendingWarning": "{value} h pending times ({count}) are not approved yet and not included.", + "confirmPending": "Bill anyway – pending times stay excluded", + "freezeHint": "The statement is frozen; included times and materials cannot be billed a second time." + }, + "invoice": { "title": "Change invoice number", "submit": "Save" }, + "void": { + "title": "Void billing", + "sub": "Times and material become free again, a new open entry is created.", + "submit": "Void", + "pending": "Voiding …", + "reason": "Reason" + }, + "print": { + "back": "Back to overview", + "count": "{count, plural, =1 {1 billing sheet} other {# billing sheets}}", + "print": "Print", + "empty": "No entries selected." + }, + "tab": { + "milestones": "Milestones", + "milestonesHint": "Confirm reached milestones – they then appear in the billing overview.", + "noMilestonesHint": "Without milestones, every approved daily report of a multi-day order becomes a billable section.", + "noMilestones": "No milestones defined.", + "reachedBy": "Reported as reached by {name} on {at}", + "confirmedBy": "Confirmed by {name} on {at}", + "rejected": "Last rejected: {reason}", + "up": "Move up", + "down": "Move down", + "delete": "Delete", + "confirm": "Confirm", + "reject": "Reject", + "rejectReason": "Reason for rejection", + "add": "Add milestone", + "newTitle": "Title", + "newTitlePlaceholder": "e.g. rough installation completed", + "newDescription": "Description", + "records": "Billing entries", + "noRecords": "No billing entries yet.", + "open": "Open" + }, + "mobile": { + "title": "Milestones", + "rejected": "Rejected by the office: {reason}", + "waiting": "Reported – waiting for confirmation by the office.", + "queued": "Saved – will be sent as soon as there is a connection.", + "sent": "Reported as reached.", + "note": "Note", + "notePlaceholder": "Note (optional)", + "reachAria": "Report {title} as reached", + "confirmReach": "Report now", + "reach": "Report as reached", + "cancel": "Cancel" + }, + "sheet": { + "title": "Billing sheet", + "dailyReportOf": "Daily report of {date}", + "period": "Period", + "status": "Status", + "invoiceNumber": "Invoice number", + "noAccounting": "Statement for your accounting – not an invoice, no prices." + }, + "section": { + "order": "Order data", + "time": "Times", + "trips": "Trips", + "material": "Material", + "texts": "Additional work, deviations, open items", + "reports": "Reports and signature" + }, + "field": { + "customer": "Customer", + "customerNumber": "Customer number", + "billingAddress": "Billing address", + "site": "Site", + "orderNumber": "Order number", + "externalOrderNumber": "External order number", + "offerNumber": "Offer number", + "title": "Order", + "orderType": "Order type", + "billingType": "Billing type", + "team": "Team", + "section": "Section", + "workDates": "Work days", + "billedAt": "Billed", + "billingNotes": "Customer billing notes" + }, + "billingType": { "fixed": "Fixed price", "time_material": "Time and material", "maintenance_contract": "Maintenance contract", "warranty": "Warranty" }, + "time": { + "hm": "{value} h", + "empty": "No approved times in this section.", + "person": "Person", + "work": "Work", + "travel": "Travel time", + "materialProcurement": "Material procurement", + "total": "Total", + "breaks": "Breaks (info)", + "manual": "of which recorded manually (approved): {value} h", + "sum": "Total", + "breaksInfo": "Travel time = outbound + return travel. Breaks are listed for information only and are not part of the totals.", + "pendingHint": "{value} h pending ({count} entries), not approved yet – not included.", + "runningHint": "A time entry is still running and is not included." + }, + "trips": { + "count": "{count, plural, =0 {No trip} =1 {1 trip} other {# trips}}", + "rule": "A crew travelling together counts as one trip." + }, + "material": { + "name": "Description", + "articleNumber": "Item no.", + "quantity": "Quantity", + "planned": "Planned", + "status": "Status", + "reason": "Reason for deviation", + "undocumented": "Not recorded", + "empty": "No material in this section.", + "used": "Material used", + "additional": "Additional material", + "notUsed": "Planned items not used", + "notUsedInfo": "For information only." + }, + "materialStatus": { "fully_used": "Fully used", "partially_used": "Partially used", "not_used": "Not used", "additional": "Additional" }, + "texts": { + "fromReport": "({number})", + "empty": "No details in the reports.", + "additionalWork": "Additional work", + "deviations": "Deviations", + "openItems": "Open items" + }, + "reports": { + "empty": "No approved report in this section.", + "number": "Report", + "type": "Type", + "date": "Date", + "approvedAt": "Approved", + "signature": "Signature", + "noSignature": "No signature" + }, + "reportType": { "daily": "Daily report", "completion": "Completion report" }, + "outcome": { "signed": "Signed", "customer_absent": "Customer absent", "refused": "Signature refused", "later": "Signature to follow", "not_required": "Not required" }, + "pdf": { + "documentTitle": "Billing sheet {number} – {kind}", + "page": "Page {page} of {pages}", + "recordId": "Entry ID", + "created": "Created" + }, + "errors": { + "not_found": "Not found or no permission.", + "forbidden": "You are not allowed to do this.", + "invalid": "Please check your input.", + "conflict": "The entry has changed in the meantime. Reload the page.", + "blocked": "Not possible yet.", + "internal": "Action not possible.", + "validation_failed": "Please check the required fields.", + "billing_record_not_found": "Billing entry not found.", + "billing_record_not_open": "The entry is no longer open.", + "billing_record_not_billed": "The entry is not billed.", + "pending_time_entries": "There are pending times. Please confirm that they are excluded.", + "positions_already_billed": "Some positions have been billed elsewhere in the meantime. Reload the page.", + "milestone_not_found": "Milestone not found.", + "milestone_not_open": "Only open milestones can be changed.", + "milestone_not_confirmable": "This milestone cannot be confirmed.", + "milestone_not_reached": "Only reported milestones can be rejected.", + "not_editable": "The order can no longer be changed in this status.", + "work_order_not_found": "Order not found.", + "work_order_status": "This is no longer possible for this order.", + "transition_blocked": "The order status cannot be reset – details are missing.", + "transition_forbidden": "You are not allowed to perform this status change.", + "reason_required": "Please enter a reason.", + "version_conflict": "The order has changed in the meantime. Reload the page." + } +} diff --git a/messages/en/notifications.json b/messages/en/notifications.json index 13ca202..c8fd112 100644 --- a/messages/en/notifications.json +++ b/messages/en/notifications.json @@ -48,7 +48,10 @@ "sync_failed": "Sync failed", "time_approval_requested": "Time to approve", "time_approved": "Time approved", - "time_rejected": "Time rejected" + "time_rejected": "Time rejected", + "milestone_reached": "Milestone reached", + "milestone_confirmed": "Milestone confirmed", + "milestone_rejected": "Milestone rejected" }, "events": { "work_order_assigned": { @@ -130,6 +133,18 @@ "time_rejected": { "title": "Time rejected: {number}", "message": "{actor} rejected your time for {number}: {reason}" + }, + "milestone_reached": { + "title": "Milestone reached: {number}", + "message": "{actor} reports a milestone of {number} as reached. Please confirm." + }, + "milestone_confirmed": { + "title": "Milestone confirmed: {number}", + "message": "{actor} confirmed your reported milestone of {number}." + }, + "milestone_rejected": { + "title": "Milestone rejected: {number}", + "message": "{actor} rejected your reported milestone of {number}: {reason}" } }, "fallback": { diff --git a/src/components/billing/statement-document.tsx b/src/components/billing/statement-document.tsx new file mode 100644 index 0000000..3a03d86 --- /dev/null +++ b/src/components/billing/statement-document.tsx @@ -0,0 +1,307 @@ +import type { Address, BillingStatement, StatementMaterialLine, StatementTextBlock } from "@/lib/billing/statement"; +import { formatHm } from "@/lib/billing/statement"; + +/** + * Billing statement document (L14 §3.3) — pure markup without hooks, shared by the detail page, + * the print view (`/billing/print`) and the PDF template (react-dom/server). Styling lives in + * `statementCss` (scoped `.bs-*` classes), so screen, print and PDF show the same layout. + * No prices, amounts or taxes. + */ + +export type Translate = (key: string, values?: Record) => string; + +export type StatementTheme = { + text: string; + muted: string; + accent: string; + rule: string; + headBg: string; + warn: string; + font?: string; +}; + +/** Token theme for screen/print pages (CSS variables, no hex values). */ +export const SCREEN_THEME: StatementTheme = { + text: "var(--foreground)", + muted: "var(--txt-muted)", + accent: "var(--ui-primary)", + rule: "var(--line)", + headBg: "var(--muted)", + warn: "var(--warn)", +}; + +export function statementCss(th: StatementTheme): string { + return ` +.bs{color:${th.text};${th.font ? `font-family:${th.font};` : ""}font-size:13px;line-height:1.45;} +.bs+.bs{margin-top:32px;} +.bs h1{font-size:20px;margin:0 0 4px;color:${th.accent};} +.bs h2{font-size:14px;margin:18px 0 6px;padding-bottom:3px;border-bottom:1px solid ${th.rule};color:${th.accent};break-after:avoid;} +.bs h3{font-size:13px;margin:10px 0 4px;break-after:avoid;} +.bs-muted{color:${th.muted};} +.bs-head{display:flex;flex-wrap:wrap;justify-content:space-between;gap:12px;border-bottom:2px solid ${th.accent};padding-bottom:8px;} +.bs-org{font-weight:700;font-size:15px;color:${th.accent};} +.bs-meta{text-align:right;} +.bs-note{margin:8px 0 0;font-size:12px;color:${th.muted};} +.bs-kv{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:4px 20px;margin:0;} +.bs-kv dt{font-size:11px;text-transform:uppercase;letter-spacing:.03em;color:${th.muted};margin:0;} +.bs-kv dd{margin:0 0 6px;overflow-wrap:anywhere;} +.bs-callout{border:1px solid ${th.warn};border-left-width:4px;padding:8px 10px;margin:10px 0;border-radius:6px;break-inside:avoid;} +.bs-scroll{overflow-x:auto;} +.bs table{width:100%;border-collapse:collapse;margin:4px 0 8px;} +.bs th{background:${th.headBg};text-align:left;font-size:12px;padding:5px 6px;border-bottom:1px solid ${th.rule};} +.bs td{padding:5px 6px;border-bottom:1px solid ${th.rule};vertical-align:top;} +.bs tr{break-inside:avoid;} +.bs .num{text-align:right;white-space:nowrap;} +.bs .bs-sum td{font-weight:700;} +.bs-badge{display:inline-block;border:1px solid ${th.accent};color:${th.accent};border-radius:999px;padding:0 8px;font-size:12px;font-weight:600;} +.bs-flag{font-weight:700;color:${th.warn};} +.bs-hint{border-left:4px solid ${th.warn};padding:4px 8px;margin:6px 0;} +.bs-text{white-space:pre-wrap;} +.bs-add td{font-weight:600;} +@media print{.bs-break{break-before:page;}.bs+.bs{margin-top:0;}} +`; +} + +const addr = (a: Address) => [a.line1, a.line2].filter(Boolean).join(", "); + +function Kv({ label, value }: { label: string; value?: string | null }) { + if (!value) return null; + return ( +
+
{label}
+
{value}
+
+ ); +} + +function MaterialTable({ t, lines, highlight }: { t: Translate; lines: StatementMaterialLine[]; highlight?: boolean }) { + return ( +
+
+ + + + + + + + + + + + {lines.map((m, i) => ( + + + + + + + + + ))} + +
{t("material.name")}{t("material.articleNumber")}{t("material.quantity")}{t("material.planned")}{t("material.status")}{t("material.reason")}
{m.name}{m.articleNumber ?? "—"}{m.quantity ? `${m.quantity} ${m.unit}` : "—"}{m.plannedQuantity ? `${m.plannedQuantity} ${m.unit}` : "—"}{m.status ? t(`materialStatus.${m.status}`) : t("material.undocumented")}{m.deviationReason ?? ""}
+ + ); +} + +function TextBlocks({ t, title, blocks }: { t: Translate; title: string; blocks: StatementTextBlock[] }) { + if (!blocks.length) return null; + return ( + <> +

{title}

+ {blocks.map((b, i) => ( +
+ {b.reportNumber ? {t("texts.fromReport", { number: b.reportNumber })} : null} + {b.text} +
+ ))} + + ); +} + +export function StatementDocument({ s, t, locale, timeZone, pageBreak }: { s: BillingStatement; t: Translate; locale: string; timeZone: string; pageBreak?: boolean }) { + const date = (iso: string) => new Intl.DateTimeFormat(locale, { timeZone, dateStyle: "medium" }).format(new Date(iso)); + const dateTime = (iso: string) => new Intl.DateTimeFormat(locale, { timeZone, dateStyle: "medium", timeStyle: "short" }).format(new Date(iso)); + const day = (key: string) => new Intl.DateTimeFormat(locale, { timeZone: "UTC", dateStyle: "medium" }).format(new Date(`${key}T00:00:00Z`)); + const hm = (m: number) => t("time.hm", { value: formatHm(m) }); + const r = s.record; + // the period end of a daily report is the start of the next day → show the report day + const period = r.kind === "daily_report" && r.reportDate ? day(r.reportDate) : `${date(r.periodFrom)} – ${date(r.periodTo)}`; + const section = r.kind === "milestone" ? r.milestoneTitle : r.kind === "daily_report" && r.reportDate ? t("sheet.dailyReportOf", { date: day(r.reportDate) }) : null; + const tt = s.time.totals; + const hasMaterial = s.materials.used.length + s.materials.additional.length + s.materials.notUsed.length > 0; + const hasTexts = s.texts.additionalWork.length + s.texts.deviations.length + s.texts.openItems.length > 0; + + return ( +
+
+
+
{s.tenant.name}
+
{[s.tenant.address, s.tenant.phone, s.tenant.email].filter(Boolean).join(" · ")}
+
+
+

{t("sheet.title")}

+
+ {t(`kind.${r.kind}`)} + {section ? ` · ${section}` : ""} +
+
+ {t("sheet.period")}: {period} +
+
+ {t("sheet.status")}: {t(`status.${r.status}`)} +
+ {r.invoiceNumber ? ( +
+ {t("sheet.invoiceNumber")}: {r.invoiceNumber} +
+ ) : null} +
+
+

{t("sheet.noAccounting")}

+ +

{t("section.order")}

+
+ + + + + + + + + + + + + + +
+ {r.milestoneDescription ?

{r.milestoneDescription}

: null} + {s.customer.billingNotes ? ( +
+ {t("field.billingNotes")} +
{s.customer.billingNotes}
+
+ ) : null} + +

{t("section.time")}

+ {s.time.persons.length === 0 ? ( +

{t("time.empty")}

+ ) : ( +
+ + + + + + + + + + + + + {s.time.persons.map((p) => ( + + + + + + + + + ))} + + + + + + + + + +
{t("time.person")}{t("time.work")}{t("time.travel")}{t("time.materialProcurement")}{t("time.total")}{t("time.breaks")}
+ {p.name} + {p.manualMinutes > 0 ?
{t("time.manual", { value: formatHm(p.manualMinutes) })}
: null} +
{hm(p.work)}{hm(p.travel)}{hm(p.materialProcurement)}{hm(p.total)}{hm(p.breaks)}
{t("time.sum")}{hm(tt.work)}{hm(tt.travel)}{hm(tt.materialProcurement)}{hm(tt.total)}{hm(tt.breaks)}
+
+ )} +

{t("time.breaksInfo")}

+ {s.time.pendingMinutes > 0 ?

{t("time.pendingHint", { value: formatHm(s.time.pendingMinutes), count: s.time.pendingCount })}

: null} + {s.time.hasRunningEntries ?

{t("time.runningHint")}

: null} + +

{t("section.trips")}

+

+ {t("trips.count", { count: s.trips.count })} + {s.trips.dates.length ? · {s.trips.dates.map(day).join(", ")} : null} +

+

{t("trips.rule")}

+ +

{t("section.material")}

+ {!hasMaterial ?

{t("material.empty")}

: null} + {s.materials.used.length ? ( + <> +

{t("material.used")}

+ + + ) : null} + {s.materials.additional.length ? ( + <> +

{t("material.additional")}

+ + + ) : null} + {s.materials.notUsed.length ? ( + <> +

{t("material.notUsed")}

+

{t("material.notUsedInfo")}

+ + + ) : null} + +

{t("section.texts")}

+ {!hasTexts ?

{t("texts.empty")}

: null} + + + + +

{t("section.reports")}

+ {s.reports.length === 0 ? ( +

{t("reports.empty")}

+ ) : ( +
+ + + + + + + + + + + + {s.reports.map((rep) => ( + + + + + + + + ))} + +
{t("reports.number")}{t("reports.type")}{t("reports.date")}{t("reports.approvedAt")}{t("reports.signature")}
+ {rep.number ?? "—"} · v{rep.version} + {t(`reportType.${rep.type}`)}{day(rep.reportDate)}{rep.approvedAt ? date(rep.approvedAt) : "—"} + {rep.signature + ? `${t(`outcome.${rep.signature.outcome}`)}${rep.signature.signerName ? ` · ${rep.signature.signerName}` : ""} · ${dateTime(rep.signature.signedAt)}` + : t("reports.noSignature")} +
+
+ )} +
+ ); +} diff --git a/src/lib/billing/schemas.ts b/src/lib/billing/schemas.ts new file mode 100644 index 0000000..832ca5a --- /dev/null +++ b/src/lib/billing/schemas.ts @@ -0,0 +1,77 @@ +import { z } from "zod"; + +/** + * L14 Abrechnungsübersicht — input schemas and constants (client-safe). + * Scope: overview for the external accounting. No prices, amounts, taxes, invoices or payments. + */ + +export const BILLING_KINDS = ["order_completion", "milestone", "daily_report"] as const; +export type BillingKind = (typeof BILLING_KINDS)[number]; + +export const BILLING_STATUSES = ["open", "billed", "voided"] as const; +export type BillingStatus = (typeof BILLING_STATUSES)[number]; + +export const MILESTONE_STATUSES = ["open", "reached", "confirmed", "billed"] as const; +export type MilestoneStatus = (typeof MILESTONE_STATUSES)[number]; + +const id = z.string().trim().min(1).max(64); +const optionalText = (max: number) => + z + .string() + .trim() + .max(max) + .nullish() + .transform((v) => (v ? v : null)); + +export const invoiceNumberField = optionalText(100); + +export const markBilledSchema = z.object({ + recordId: id, + invoiceNumber: invoiceNumberField, + /** required when approved-pending time entries exist: they are NOT part of the billed section */ + confirmPendingExcluded: z.boolean().optional(), +}); +export type MarkBilledInput = z.input; + +export const voidBillingSchema = z.object({ recordId: id, reason: z.string().trim().min(1).max(2000) }); +export type VoidBillingInput = z.input; + +export const updateInvoiceNumberSchema = z.object({ recordId: id, invoiceNumber: invoiceNumberField }); + +export const createMilestoneSchema = z.object({ + workOrderId: id, + title: z.string().trim().min(1).max(200), + description: optionalText(2000), +}); +export type CreateMilestoneInput = z.input; + +export const updateMilestoneSchema = z.object({ + milestoneId: id, + title: z.string().trim().min(1).max(200).optional(), + description: optionalText(2000).optional(), +}); + +export const moveMilestoneSchema = z.object({ milestoneId: id, direction: z.enum(["up", "down"]) }); + +export const rejectMilestoneSchema = z.object({ milestoneId: id, reason: z.string().trim().min(1).max(2000) }); + +/** Sync op `milestone.reach` (offline-capable, idempotent: an already reached milestone stays unchanged). */ +export const milestoneReachPayload = z.object({ + workOrderId: id, + milestoneId: id, + note: optionalText(2000), +}); + +export const billingListFilterSchema = z.object({ + status: z.enum(BILLING_STATUSES).catch("open").default("open"), + kind: z.enum(BILLING_KINDS).optional().catch(undefined), + customerId: id.optional().catch(undefined), + teamId: id.optional().catch(undefined), + /** period overlap (YYYY-MM-DD, tenant time zone) */ + from: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().catch(undefined), + to: z.string().regex(/^\d{4}-\d{2}-\d{2}$/).optional().catch(undefined), + q: z.string().trim().max(100).optional().catch(undefined), + page: z.coerce.number().int().min(1).catch(1).default(1), + pageSize: z.coerce.number().int().min(1).max(100).catch(25).default(25), +}); +export type BillingListFilter = z.input; diff --git a/src/lib/billing/statement.ts b/src/lib/billing/statement.ts new file mode 100644 index 0000000..63379fa --- /dev/null +++ b/src/lib/billing/statement.ts @@ -0,0 +1,148 @@ +import type { BillingKind, BillingStatus } from "./schemas"; + +/** + * L14 billing statement ("Abrechnungsblatt") — client-safe type + pure helpers. + * Built by src/server/services/billing/statement.ts; frozen as `BillingRecord.snapshot` when the + * record is marked as billed. NO prices, amounts or taxes — only order data, times, trips, material. + */ + +export type Address = { line1: string | null; line2: string | null }; + +export type StatementMaterialLine = { + usageId: string | null; + planId: string | null; + name: string; + articleNumber: string | null; + quantity: string | null; + plannedQuantity: string | null; + unit: string; + status: "fully_used" | "partially_used" | "not_used" | "additional" | null; + deviationReason: string | null; +}; + +export type StatementPersonTime = { + userId: string; + name: string; + /** minutes */ + work: number; + /** Anfahrt + Rückfahrt */ + travel: number; + materialProcurement: number; + /** informative only, never part of a total */ + breaks: number; + /** informative only (interruptions), never part of a total */ + interruption: number; + /** work + travel + materialProcurement */ + total: number; + /** approved minutes that were recorded manually (L12 Nachtrag) */ + manualMinutes: number; +}; + +export type StatementTextBlock = { reportNumber: string | null; text: string }; + +export type BillingStatement = { + schemaVersion: 1; + generatedAt: string; + record: { + id: string; + kind: BillingKind; + status: BillingStatus; + periodFrom: string; + periodTo: string; + readySince: string; + milestoneTitle: string | null; + milestoneDescription: string | null; + /** YYYY-MM-DD of the daily report */ + reportDate: string | null; + invoiceNumber: string | null; + billedAt: string | null; + billedByName: string | null; + }; + tenant: { name: string; address: string | null; phone: string | null; email: string | null; logoDocumentId: string | null }; + customer: { id: string; number: string | null; name: string; billingAddress: Address; billingNotes: string | null }; + site: { id: string; name: string; address: Address } | null; + workOrder: { + id: string; + number: string; + externalOrderNumber: string | null; + offerNumber: string | null; + title: string; + orderType: string | null; + billingType: string | null; + team: string | null; + }; + reports: Array<{ + id: string; + number: string | null; + type: "daily" | "completion"; + version: number; + reportDate: string; + approvedAt: string | null; + signature: { outcome: string; signerName: string | null; signedAt: string } | null; + }>; + /** YYYY-MM-DD (tenant time zone) with billed time */ + workDates: string[]; + time: { + persons: StatementPersonTime[]; + totals: Omit; + /** pending (not yet approved) minutes in the period — NOT included */ + pendingMinutes: number; + pendingCount: number; + /** running entries are never included */ + hasRunningEntries: boolean; + }; + trips: { count: number; dates: string[] }; + materials: { used: StatementMaterialLine[]; additional: StatementMaterialLine[]; notUsed: StatementMaterialLine[] }; + texts: { additionalWork: StatementTextBlock[]; deviations: StatementTextBlock[]; openItems: StatementTextBlock[] }; + positions: { timeEntryIds: string[]; materialUsageIds: string[] }; +}; + +/** "7:05 h" style (hours:minutes, no locale dependency). */ +export function formatHm(minutes: number): string { + const m = Math.max(0, Math.round(minutes)); + return `${Math.floor(m / 60)}:${String(m % 60).padStart(2, "0")}`; +} + +/** YYYY-MM-DD of an instant in `timeZone`. */ +export function dateKeyIn(instant: Date, timeZone: string): string { + return new Intl.DateTimeFormat("en-CA", { timeZone, year: "numeric", month: "2-digit", day: "2-digit" }).format(instant); +} + +/** Travel segments starting within this many ms of a trip's start count as the same trip (crew leaving together). */ +export const TRIP_TOGETHER_TOLERANCE_MS = 15 * 60_000; + +/** + * Count trips ("Anfahrten") from outbound travel segments of one work order. + * Rule (L14): per local calendar day, segments of different persons that overlap in time — or start + * within 15 minutes of each other — are ONE trip (a crew driving together); a later, separate + * outbound segment on the same day is another trip. Return travel is not a trip. + * Returns one date entry per trip (dates repeat when there are several trips a day). + */ +export function countTrips(segments: Array<{ startedAt: Date; endedAt: Date | null }>, timeZone: string): { count: number; dates: string[] } { + const byDay = new Map>(); + for (const s of segments) { + const start = s.startedAt.getTime(); + const end = Math.max(start, (s.endedAt ?? s.startedAt).getTime()); + const key = dateKeyIn(s.startedAt, timeZone); + const list = byDay.get(key) ?? []; + list.push({ start, end }); + byDay.set(key, list); + } + const dates: string[] = []; + for (const key of [...byDay.keys()].sort()) { + const list = byDay.get(key)!.sort((a, b) => a.start - b.start); + let tripStart = -Infinity; + let tripEnd = -Infinity; + for (const seg of list) { + const together = seg.start <= tripEnd || seg.start - tripStart <= TRIP_TOGETHER_TOLERANCE_MS; + if (!together) { + dates.push(key); + tripStart = seg.start; + tripEnd = seg.end; + } else { + tripEnd = Math.max(tripEnd, seg.end); + } + } + } + return { count: dates.length, dates }; +} diff --git a/src/lib/sync/envelope.ts b/src/lib/sync/envelope.ts index 46aee1d..3301036 100644 --- a/src/lib/sync/envelope.ts +++ b/src/lib/sync/envelope.ts @@ -25,6 +25,8 @@ export const SYNC_OP_TYPES = [ "session.segment", "time.add_manual", "time.propose_correction", + // L14 Abrechnungsübersicht (additive, conflict-free; registered in services/sync/external-ops.ts) + "milestone.reach", ] as const; export type SyncOpType = (typeof SYNC_OP_TYPES)[number]; diff --git a/src/lib/sync/ops.ts b/src/lib/sync/ops.ts index 8044583..d524536 100644 --- a/src/lib/sync/ops.ts +++ b/src/lib/sync/ops.ts @@ -200,6 +200,7 @@ export const OP_PAYLOAD_SCHEMAS = { "session.segment": sessionSegmentPayload, "time.add_manual": timeAddManualPayload, "time.propose_correction": timeProposeCorrectionPayload, + "milestone.reach": passthrough, // L14: validated in services/billing/sync-ops.ts (lib/billing/schemas.ts#milestoneReachPayload) } satisfies Record; export type OpPayload = z.input<(typeof OP_PAYLOAD_SCHEMAS)[T]>; diff --git a/src/server/events.ts b/src/server/events.ts index f592edd..f94da33 100644 --- a/src/server/events.ts +++ b/src/server/events.ts @@ -13,4 +13,13 @@ export async function emitEvent(ctx: ServiceCtx, event: DomainEvent): Promise { + const ctx: ServiceCtx = { + db: dbForTenant(payload.tenantId), + tenantId: payload.tenantId, + userId: payload.actorId ?? "system", + permissions: new Set(["billing:read", "work_order:read_all", "document:read_internal", "document:write"]), + }; + const res = await generateBillingPdf(ctx, payload.entityId); + console.info(`[billing-pdf] ${payload.entityId}: ${res.skipped ? "already rendered" : `stored ${res.documentId}`}`); +} diff --git a/src/server/jobs/processors/index.ts b/src/server/jobs/processors/index.ts index bc7653b..8ee3afc 100644 --- a/src/server/jobs/processors/index.ts +++ b/src/server/jobs/processors/index.ts @@ -13,6 +13,7 @@ export const PROCESSORS: Partial Promise import(/* turbopackIgnore: true */ "./report-pdf").then((m) => m.process), // worker-only (react-dom/server + Chromium), kept out of the app bundle "image-derivatives": () => import("./image-derivatives").then((m) => m.process), "ai-retention": () => import("./ai-retention").then((m) => m.process), // L10b: daily AI log retention (scheduled by the worker) + "billing-pdf": () => import(/* turbopackIgnore: true */ "./billing-pdf").then((m) => m.process), // L14: worker-only billing sheet PDF (react-dom/server + Chromium) }; /** Inline fallback when no Redis is available (dev/demo). */ diff --git a/src/server/jobs/queues.ts b/src/server/jobs/queues.ts index f310555..65daf11 100644 --- a/src/server/jobs/queues.ts +++ b/src/server/jobs/queues.ts @@ -13,6 +13,7 @@ export const JOB_QUEUES = { reportPdf: "report-pdf", imageDerivatives: "image-derivatives", aiRetention: "ai-retention", + billingPdf: "billing-pdf", // L14 Abrechnungsblatt } as const; export type JobQueueName = (typeof JOB_QUEUES)[keyof typeof JOB_QUEUES]; diff --git a/src/server/pdf/templates/billing.tsx b/src/server/pdf/templates/billing.tsx new file mode 100644 index 0000000..7afb5bb --- /dev/null +++ b/src/server/pdf/templates/billing.tsx @@ -0,0 +1,46 @@ +/* eslint-disable @next/next/no-head-element -- standalone print document for Chromium, not a Next.js page */ +import { renderToStaticMarkup } from "react-dom/server"; +import type { BillingStatement } from "@/lib/billing/statement"; +import { DOCUMENT_THEME, documentFooterLine } from "@/lib/document-brand"; +import { StatementDocument, statementCss, type Translate } from "@/components/billing/statement-document"; + +/** + * L14 billing sheet PDF (React SSR → static HTML → src/server/pdf/render.ts). Same markup as the + * detail/print pages (`StatementDocument`), themed with the Craftvia document CD. + */ + +const esc = (s: string) => s.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); + +export function renderBillingHtml(input: { statement: BillingStatement; t: Translate; locale: string; timeZone: string; fontDataUri?: string | null }) { + const { statement: s, t } = input; + const th = DOCUMENT_THEME; + const font = input.fontDataUri ? `"CraftviaInter",${th.bodyFont}` : th.bodyFont; + const css = + `${input.fontDataUri ? `@font-face{font-family:"CraftviaInter";src:url(${input.fontDataUri}) format("truetype");font-weight:100 900;}` : ""}` + + `@page{size:A4;}*{box-sizing:border-box;}html,body{margin:0;padding:0;background:${th.pageBackground};}` + + statementCss({ text: th.text, muted: th.textMuted, accent: th.accent, rule: th.rule, headBg: th.tableHeaderBackground, warn: th.accentStrong, font }).replace(/font-size:13px/, "font-size:9.5pt"); + const html = + "" + + renderToStaticMarkup( + + + + {`${t("sheet.title")} ${s.workOrder.number}`} +