From d18f4fe4313f172c49acc6835e7b0d3214e334df Mon Sep 17 00:00:00 2001 From: Martin Date: Mon, 14 Sep 2026 12:26:27 +0200 Subject: [PATCH] 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" && ( +
+ {canWrite && ( + + {t("contacts.new")} + + )} + {customer.contacts.length === 0 &&

{t("contacts.empty")}

} +
    + {customer.contacts.map((c) => ( +
  • +
    +
    +

    {c.name}

    + {c.role &&

    {c.role}

    } +
    + {c.preferredChannel && {t("contacts.preferred")}: {t(`contacts.channel.${c.preferredChannel}`)}} +
    + + {c.notes &&

    {c.notes}

    } + {canWrite && ( +
    + + {tc("edit")} + + +
    + )} +
  • + ))} +
+
+ )} + + {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")} + + + + +