L1 Stammdaten: Backoffice-Seiten Kunden, Objekte, Teams und Dokumente
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 <noreply@anthropic.com>
This commit is contained in:
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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."
|
||||
}
|
||||
}
|
||||
@@ -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<string, unknown>): Record<string, string> {
|
||||
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 (
|
||||
<main className="flex-1 p-4 sm:p-6">
|
||||
<Link href="/customers" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" aria-hidden /> {t("detail.back")}
|
||||
</Link>
|
||||
<PageHead
|
||||
crumb={`${t("title")} · ${customer.customerNumber ?? ""}`}
|
||||
title={name}
|
||||
sub={formatAddress(customer, { withCountry: true }) || undefined}
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<CustomerStatusPill status={customer.status} label={t(`status.${customer.status}`)} />
|
||||
{canWrite && customer.status === "provisional" && (
|
||||
<ActionButtonForm action={confirmCustomerAction.bind(null, id)} label={t("detail.confirm")} tone="primary" namespace="customers" />
|
||||
)}
|
||||
{canWrite && (
|
||||
<Link href={withParam("edit", "1")} className={buttonLinkClass}>
|
||||
{t("detail.edit")}
|
||||
</Link>
|
||||
)}
|
||||
{canMerge && (
|
||||
<Link href={withParam("merge", "1")} className={buttonLinkClass}>
|
||||
{t("detail.merge")}
|
||||
</Link>
|
||||
)}
|
||||
{canWrite && (
|
||||
<Link href={withParam("delete", "1")} className={buttonLinkClass}>
|
||||
{t("detail.delete")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{sp.merged && <Banner tone="ok">{t("detail.mergedOk")}</Banner>}
|
||||
{customer.status === "provisional" && <Banner tone="warn">{t("provisionalHint")}</Banner>}
|
||||
{mergedTarget && (
|
||||
<Banner tone="info">
|
||||
{t("detail.mergedInto")}{" "}
|
||||
<Link href={`/customers/${mergedTarget.id}`} className="underline">
|
||||
{customerDisplayName(mergedTarget)} ({mergedTarget.customerNumber})
|
||||
</Link>
|
||||
</Banner>
|
||||
)}
|
||||
|
||||
<TabNav
|
||||
label={t("title")}
|
||||
active={tab}
|
||||
tabs={TABS.map((k) => ({ key: k, label: t(`tabs.${k}`), href: tabHref(k), count: k === "contacts" ? customer.contacts.length : undefined }))}
|
||||
/>
|
||||
|
||||
{tab === "master" && (
|
||||
<Card>
|
||||
<DefinitionList
|
||||
items={[
|
||||
{ label: t("fields.customerNumber"), value: customer.customerNumber },
|
||||
{ label: t("fields.status"), value: t(`status.${customer.status}`) },
|
||||
{ label: t("fields.companyName"), value: customer.companyName },
|
||||
{ label: `${t("fields.salutation")} / ${t("fields.firstName")} / ${t("fields.lastName")}`, value: [customer.salutation, customer.firstName, customer.lastName].filter(Boolean).join(" ") },
|
||||
{ label: t("sections.address"), value: formatAddress(customer) },
|
||||
{ label: t("fields.country"), value: customer.country },
|
||||
{ label: t("fields.phone"), value: customer.phone },
|
||||
{ label: t("fields.mobile"), value: customer.mobile },
|
||||
{ label: t("fields.email"), value: customer.email },
|
||||
{ label: t("fields.notes"), value: customer.notes },
|
||||
{ label: t("fields.billingNotes"), value: customer.billingNotes },
|
||||
{ label: t("fields.createdAt"), value: format.dateTime(customer.createdAt, { dateStyle: "medium", timeStyle: "short" }) },
|
||||
{ label: t("fields.updatedAt"), value: format.dateTime(customer.updatedAt, { dateStyle: "medium", timeStyle: "short" }) },
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
{tab === "contacts" && (
|
||||
<div className="space-y-3">
|
||||
{canWrite && (
|
||||
<Link href={withParam("contact", "new")} className={primaryButtonClass}>
|
||||
<Plus className="size-4" aria-hidden /> {t("contacts.new")}
|
||||
</Link>
|
||||
)}
|
||||
{customer.contacts.length === 0 && <p className="text-[13px] text-muted-foreground">{t("contacts.empty")}</p>}
|
||||
<ul className="grid gap-3 md:grid-cols-2">
|
||||
{customer.contacts.map((c) => (
|
||||
<li key={c.id} className="shadow-card rounded-xl border bg-card p-4">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="font-semibold">{c.name}</p>
|
||||
{c.role && <p className="text-[12.5px] text-muted-foreground">{c.role}</p>}
|
||||
</div>
|
||||
{c.preferredChannel && <Pill tone="info">{t("contacts.preferred")}: {t(`contacts.channel.${c.preferredChannel}`)}</Pill>}
|
||||
</div>
|
||||
<ul className="mt-2 space-y-1 text-[13px]">
|
||||
{c.phone && (
|
||||
<li className="flex items-center gap-2">
|
||||
<Phone className="size-3.5 text-muted-foreground" aria-hidden />
|
||||
<a href={`tel:${c.phone}`} className="inline-flex min-h-8 items-center hover:underline">{c.phone}</a>
|
||||
</li>
|
||||
)}
|
||||
{c.mobile && (
|
||||
<li className="flex items-center gap-2">
|
||||
<Smartphone className="size-3.5 text-muted-foreground" aria-hidden />
|
||||
<a href={`tel:${c.mobile}`} className="inline-flex min-h-8 items-center hover:underline">{c.mobile}</a>
|
||||
</li>
|
||||
)}
|
||||
{c.email && (
|
||||
<li className="flex items-center gap-2">
|
||||
<Mail className="size-3.5 text-muted-foreground" aria-hidden />
|
||||
<a href={`mailto:${c.email}`} className="inline-flex min-h-8 items-center break-all hover:underline">{c.email}</a>
|
||||
</li>
|
||||
)}
|
||||
</ul>
|
||||
{c.notes && <p className="mt-2 text-[12.5px] whitespace-pre-line text-muted-foreground">{c.notes}</p>}
|
||||
{canWrite && (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
<Link href={withParam("contact", c.id)} className={buttonLinkClass}>
|
||||
{tc("edit")}
|
||||
</Link>
|
||||
<ActionButtonForm
|
||||
action={deleteContactAction.bind(null, c.id, id)}
|
||||
label={t("contacts.delete")}
|
||||
confirmText={t("contacts.deleteConfirm")}
|
||||
namespace="customers"
|
||||
tone="danger"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "sites" && <SitesTab customerId={id} ctx={ctx} canCreate={can(ctx, "site:write") && !isMerged} />}
|
||||
|
||||
{tab === "orders" && (
|
||||
<OrdersTab customerId={id} ctx={ctx} />
|
||||
)}
|
||||
|
||||
{tab === "documents" && (
|
||||
<DocumentPanel
|
||||
ctx={ctx}
|
||||
rows={(await listDocuments(ctx, { customerId: id, pageSize: 500 })).items}
|
||||
baseHref={here}
|
||||
links={{ customerId: id }}
|
||||
searchParams={sp}
|
||||
defaultCategory="order_confirmation"
|
||||
/>
|
||||
)}
|
||||
|
||||
{canWrite && sp.edit && (
|
||||
<Modal title={t("form.editTitle")} sub={name} closeHref={here} closeLabel={tc("close")}>
|
||||
<CustomerForm mode="edit" action={updateCustomerAction.bind(null, id)} initial={toFormValues(customer)} closeHref={here} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{canWrite && sp.contact && (sp.contact === "new" || customer.contacts.some((c) => c.id === sp.contact)) && (
|
||||
<Modal title={sp.contact === "new" ? t("contacts.createTitle") : t("contacts.editTitle")} sub={name} closeHref={here} closeLabel={tc("close")}>
|
||||
<ContactForm
|
||||
action={sp.contact === "new" ? createContactAction.bind(null, id) : updateContactAction.bind(null, sp.contact, id)}
|
||||
initial={sp.contact === "new" ? {} : toFormValues(customer.contacts.find((c) => c.id === sp.contact)!)}
|
||||
closeHref={here}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{canMerge && sp.merge && (
|
||||
<Modal title={t("merge.title")} sub={t("merge.sub")} closeHref={here} closeLabel={tc("close")}>
|
||||
<MergeForm
|
||||
action={mergeCustomerAction.bind(null, id)}
|
||||
source={{ displayName: name, customerNumber: customer.customerNumber }}
|
||||
candidates={await findDuplicateCustomers(ctx, customer, { excludeId: id })}
|
||||
closeHref={here}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{canWrite && sp.delete && (
|
||||
<Modal title={t("detail.deleteTitle")} sub={name} closeHref={here} closeLabel={tc("close")}>
|
||||
<div className="space-y-4 p-5">
|
||||
<p className="text-[13px]">{t("detail.deleteHint")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ActionButtonForm action={deleteCustomerAction.bind(null, id)} label={t("detail.deleteConfirm")} tone="danger" namespace="customers" />
|
||||
<Link href={here} className={buttonLinkClass}>
|
||||
{t("form.cancel")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
<span hidden>{ts("title")}</span>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
async function SitesTab({ customerId, ctx, canCreate }: { customerId: string; ctx: Awaited<ReturnType<typeof requirePageContext>>; canCreate: boolean }) {
|
||||
const [t, ts] = await Promise.all([getTranslations("customers"), getTranslations("sites")]);
|
||||
if (!can(ctx, "site:read")) return <p className="text-[13px] text-muted-foreground">{t("errors.forbidden")}</p>;
|
||||
const sites = await listSites(ctx, { customerId, status: "all", pageSize: 100 });
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{canCreate && (
|
||||
<Link href={`/sites?new=1&customerId=${customerId}`} className={primaryButtonClass}>
|
||||
<Plus className="size-4" aria-hidden /> {t("sites.new")}
|
||||
</Link>
|
||||
)}
|
||||
{sites.items.length === 0 ? (
|
||||
<p className="text-[13px] text-muted-foreground">{t("sites.empty")}</p>
|
||||
) : (
|
||||
<div className="shadow-card overflow-hidden rounded-xl border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{ts("columns.name")}</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">{ts("columns.address")}</TableHead>
|
||||
<TableHead>{ts("columns.status")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{sites.items.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="p-0">
|
||||
<Link href={`/sites/${s.id}`} className="block px-3 py-3 font-semibold">{s.name}</Link>
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground sm:table-cell">{formatAddress(s) || "—"}</TableCell>
|
||||
<TableCell>
|
||||
<SiteStatusPill status={s.status} label={ts(`status.${s.status}`)} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
async function OrdersTab({ customerId, ctx }: { customerId: string; ctx: Awaited<ReturnType<typeof requirePageContext>> }) {
|
||||
const [t, ts, format] = await Promise.all([getTranslations("customers"), getTranslations("sites"), getFormatter()]);
|
||||
const orders = await listCustomerWorkOrders(ctx, customerId);
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<p className="text-[12.5px] text-muted-foreground">{t("orders.hint")}</p>
|
||||
{orders.length === 0 ? (
|
||||
<p className="text-[13px] text-muted-foreground">{t("orders.empty")}</p>
|
||||
) : (
|
||||
<div className="shadow-card overflow-hidden rounded-xl border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("orders.columns.number")}</TableHead>
|
||||
<TableHead>{t("orders.columns.title")}</TableHead>
|
||||
<TableHead className="hidden md:table-cell">{t("orders.columns.site")}</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">{t("orders.columns.team")}</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">{t("orders.columns.date")}</TableHead>
|
||||
<TableHead>{t("orders.columns.status")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{orders.map((o) => (
|
||||
<TableRow key={o.id}>
|
||||
<TableCell className="p-0">
|
||||
<Link href={`/work-orders/${o.id}`} className="block px-3 py-3 font-mono text-[12.5px] font-semibold">{o.number}</Link>
|
||||
</TableCell>
|
||||
<TableCell className="p-0">
|
||||
<Link href={`/work-orders/${o.id}`} className="block px-3 py-3">{o.title}</Link>
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">{o.site?.name ?? "—"}</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">{o.team?.name ?? "—"}</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">{o.plannedStart ? format.dateTime(o.plannedStart, { dateStyle: "medium" }) : "—"}</TableCell>
|
||||
<TableCell>
|
||||
<OrderStatusPill status={o.status} label={ts(`statusGroup.${orderStatusGroup(o.status)}`)} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 <ModulePlaceholder moduleKey="customers" />;
|
||||
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 (
|
||||
<main className="flex-1 p-4 sm:p-6">
|
||||
<PageHead
|
||||
crumb={t("crumb")}
|
||||
title={t("title")}
|
||||
sub={t("sub")}
|
||||
actions={
|
||||
canWrite ? (
|
||||
<Link href={hrefWith("/customers", { q, status: status === "all" ? undefined : status, page: page > 1 ? page : undefined, new: 1 })} className={primaryButtonClass}>
|
||||
<Plus className="size-4" aria-hidden /> {t("new")}
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<form method="get" action="/customers" className="mb-4 flex flex-wrap items-end gap-2" role="search">
|
||||
<label className="flex min-w-[14rem] flex-1 flex-col gap-1 text-[12.5px] font-semibold">
|
||||
{t("searchLabel")}
|
||||
<input name="q" type="search" defaultValue={q ?? ""} placeholder={t("searchPlaceholder")} className={controlClass} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[12.5px] font-semibold">
|
||||
{t("filter.status")}
|
||||
<select name="status" defaultValue={status} className={controlClass}>
|
||||
<option value="all">{t("filter.all")}</option>
|
||||
{CUSTOMER_LIST_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`status.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" className={buttonLinkClass}>
|
||||
{t("filter.apply")}
|
||||
</button>
|
||||
{(q || status !== "all") && (
|
||||
<Link href="/customers" className={buttonLinkClass}>
|
||||
{t("filter.reset")}
|
||||
</Link>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="shadow-card overflow-hidden rounded-xl border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("columns.number")}</TableHead>
|
||||
<TableHead>{t("columns.name")}</TableHead>
|
||||
<TableHead className="hidden md:table-cell">{t("columns.city")}</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">{t("columns.contact")}</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">{t("columns.sites")}</TableHead>
|
||||
<TableHead>{t("columns.status")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{result.items.map((c) => {
|
||||
const href = `/customers/${c.id}`;
|
||||
return (
|
||||
<TableRow key={c.id}>
|
||||
<TableCell className="p-0">
|
||||
<Link href={href} className="block px-3 py-3 font-mono text-[12.5px]">
|
||||
{c.customerNumber ?? "—"}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="p-0">
|
||||
<Link href={href} className="block px-3 py-3 font-semibold">
|
||||
{customerDisplayName(c) || "—"}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="hidden p-0 md:table-cell">
|
||||
<Link href={href} className="block px-3 py-3 text-muted-foreground">
|
||||
{[c.postalCode, c.city].filter(Boolean).join(" ") || "—"}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="hidden p-0 text-[12.5px] lg:table-cell">
|
||||
<Link href={href} className="block px-3 py-3 text-muted-foreground">
|
||||
{c.phone || c.email || "—"}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">{c._count.sites}</TableCell>
|
||||
<TableCell>
|
||||
<CustomerStatusPill status={c.status} label={t(`status.${c.status}`)} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{result.items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={6} className="py-6 text-center text-muted-foreground">
|
||||
{t("empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
page={result.page}
|
||||
pageSize={result.pageSize}
|
||||
total={result.total}
|
||||
hrefFor={listHref}
|
||||
labels={{ prev: t("pagination.prev"), next: t("pagination.next"), summary: t("pagination.summary", paginationSummary(result.page, result.pageSize, result.total)) }}
|
||||
/>
|
||||
|
||||
{canWrite && sp.new && (
|
||||
<Modal title={t("form.createTitle")} sub={t("form.createSub")} closeHref={closeHref} closeLabel={tc("close")}>
|
||||
<CustomerForm mode="create" action={createCustomerAction} closeHref={closeHref} />
|
||||
</Modal>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <ModulePlaceholder moduleKey="documents" />;
|
||||
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 (
|
||||
<main className="flex-1 p-4 sm:p-6">
|
||||
<PageHead crumb={t("crumb")} title={t("title")} sub={t("sub")} />
|
||||
|
||||
<form method="get" action="/documents" className="mb-4 grid gap-2 sm:grid-cols-2 lg:grid-cols-[2fr_1fr_1fr_1fr_auto]" role="search">
|
||||
{siteId && <input type="hidden" name="siteId" value={siteId} />}
|
||||
<label className="flex flex-col gap-1 text-[12.5px] font-semibold">
|
||||
{t("filter.q")}
|
||||
<input name="q" type="search" defaultValue={q ?? ""} className={controlClass} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[12.5px] font-semibold">
|
||||
{t("filter.category")}
|
||||
<select name="category" defaultValue={category ?? ""} className={controlClass}>
|
||||
<option value="">{t("filter.allCategories")}</option>
|
||||
{DOCUMENT_CATEGORIES.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`category.${c}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[12.5px] font-semibold">
|
||||
{t("filter.customer")}
|
||||
<select name="customerId" defaultValue={customerId ?? ""} className={controlClass}>
|
||||
<option value="">{t("filter.allCustomers")}</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{customerDisplayName(c)} · {c.customerNumber}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[12.5px] font-semibold">
|
||||
{t("filter.workOrder")}
|
||||
<input name="workOrder" defaultValue={workOrderNumber ?? ""} className={controlClass} />
|
||||
</label>
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<label className="flex min-h-11 items-center gap-2 text-[12.5px] font-semibold">
|
||||
<input type="checkbox" name="all" value="1" defaultChecked={!latestOnly} className="size-4" />
|
||||
{t("versions.older", { count: 2 }).replace(/^\d+\s*/, "")}
|
||||
</label>
|
||||
<button type="submit" className={buttonLinkClass}>
|
||||
{t("filter.apply")}
|
||||
</button>
|
||||
{Object.values(filters).some(Boolean) && (
|
||||
<Link href="/documents" className={buttonLinkClass}>
|
||||
{t("filter.reset")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
</form>
|
||||
|
||||
<DocumentPanel ctx={ctx} rows={result.items} baseHref={listHref(page)} links={{}} searchParams={sp} showLinks />
|
||||
|
||||
<Pagination
|
||||
page={result.page}
|
||||
pageSize={result.pageSize}
|
||||
total={result.total}
|
||||
hrefFor={listHref}
|
||||
labels={{ prev: t("pagination.prev"), next: t("pagination.next"), summary: t("pagination.summary", paginationSummary(result.page, result.pageSize, result.total)) }}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 (
|
||||
<main className="flex-1 p-4 sm:p-6">
|
||||
<Link href="/sites" className="inline-flex min-h-11 items-center gap-1.5 text-[12.5px] font-semibold text-muted-foreground hover:text-foreground">
|
||||
<ArrowLeft className="size-4" aria-hidden /> {t("detail.back")}
|
||||
</Link>
|
||||
<PageHead
|
||||
crumb={`${t("title")} · ${customerDisplayName(site.customer)}`}
|
||||
title={site.name}
|
||||
sub={formatAddress(site, { withCountry: true }) || undefined}
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<SiteStatusPill status={site.status} label={t(`status.${site.status}`)} />
|
||||
{mapUrl ? (
|
||||
<a href={mapUrl} target="_blank" rel="noopener noreferrer" className={buttonLinkClass}>
|
||||
<MapPin className="size-4" aria-hidden /> {t("detail.map")}
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-[12px] text-muted-foreground">{t("detail.noMap")}</span>
|
||||
)}
|
||||
{canWrite && (
|
||||
<Link href={`${here}${here.includes("?") ? "&" : "?"}edit=1`} className={buttonLinkClass}>
|
||||
{t("detail.edit")}
|
||||
</Link>
|
||||
)}
|
||||
{canWrite && (
|
||||
<Link href={`${here}${here.includes("?") ? "&" : "?"}delete=1`} className={buttonLinkClass}>
|
||||
{t("detail.delete")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<TabNav label={t("title")} active={tab} tabs={TABS.map((k) => ({ key: k, label: t(`tabs.${k}`), href: tabHref(k) }))} />
|
||||
|
||||
{tab === "master" && (
|
||||
<div className="grid gap-4 lg:grid-cols-[3fr_2fr]">
|
||||
<Card>
|
||||
<DefinitionList
|
||||
items={[
|
||||
{
|
||||
label: t("detail.customer"),
|
||||
value: can(ctx, "customer:read") ? (
|
||||
<Link href={`/customers/${site.customer.id}`} className="font-semibold hover:underline">
|
||||
{customerDisplayName(site.customer)} · {site.customer.customerNumber}
|
||||
</Link>
|
||||
) : (
|
||||
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,
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</Card>
|
||||
<section aria-label={t("sections.notes")} className="space-y-3">
|
||||
{notes.length === 0 && <p className="text-[13px] text-muted-foreground">{t("detail.noNotes")}</p>}
|
||||
{notes.map((n) => (
|
||||
<div
|
||||
key={n.key}
|
||||
className={
|
||||
n.tone === "warn"
|
||||
? "shadow-card rounded-xl border border-l-4 border-l-[var(--warn)] bg-card p-4"
|
||||
: "shadow-card rounded-xl border bg-card p-4"
|
||||
}
|
||||
>
|
||||
<p className={`flex items-center gap-2 text-[12.5px] font-semibold ${n.tone === "warn" ? "text-[var(--warn)]" : "text-muted-foreground"}`}>
|
||||
<n.icon className="size-4" aria-hidden /> {t(`fields.${n.key}`)}
|
||||
</p>
|
||||
<p className="mt-1 text-sm whitespace-pre-line">{n.value}</p>
|
||||
</div>
|
||||
))}
|
||||
</section>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{tab === "documents" && (
|
||||
<DocumentPanel
|
||||
ctx={ctx}
|
||||
rows={(await listDocuments(ctx, { siteId: id, pageSize: 500 })).items}
|
||||
baseHref={here}
|
||||
links={{ siteId: id, customerId: site.customer.id }}
|
||||
searchParams={sp}
|
||||
defaultCategory="technical_drawing"
|
||||
/>
|
||||
)}
|
||||
|
||||
{tab === "history" && (
|
||||
<HistoryTab siteId={id} ctx={ctx} onlyApproved={!fullHistoryAccess || sp.approved === "1"} canToggle={fullHistoryAccess} />
|
||||
)}
|
||||
|
||||
{canWrite && sp.edit && (
|
||||
<Modal title={t("form.editTitle")} sub={site.name} closeHref={here} closeLabel={tc("close")}>
|
||||
<SiteForm
|
||||
mode="edit"
|
||||
action={updateSiteAction.bind(null, id)}
|
||||
customers={await editCustomerOptions(ctx, site.customer)}
|
||||
contacts={await siteContacts(ctx, site.customer.id)}
|
||||
initial={Object.fromEntries(Object.entries(site).map(([k, v]) => [k, v === null || typeof v === "object" ? "" : String(v)]))}
|
||||
closeHref={here}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{canWrite && sp.delete && (
|
||||
<Modal title={t("detail.deleteTitle")} sub={site.name} closeHref={here} closeLabel={tc("close")}>
|
||||
<div className="space-y-4 p-5">
|
||||
<p className="text-[13px]">{t("detail.deleteHint")}</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<ActionButtonForm action={deleteSiteAction.bind(null, id)} label={t("detail.deleteConfirm")} tone="danger" namespace="sites" />
|
||||
<Link href={here} className={buttonLinkClass}>
|
||||
{t("form.cancel")}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
async function editCustomerOptions(ctx: Awaited<ReturnType<typeof requirePageContext>>, 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<ReturnType<typeof requirePageContext>>, 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<ReturnType<typeof requirePageContext>>; 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 (
|
||||
<section aria-label={t("history.title")} className="space-y-3">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<h2 className="font-heading text-[15px] font-semibold">{t("history.title")}</h2>
|
||||
{canToggle && (
|
||||
<div className="flex gap-2">
|
||||
<Link href={base} aria-current={!onlyApproved ? "page" : undefined} className={`${buttonLinkClass} ${!onlyApproved ? "border-[var(--ui-accent)]" : ""}`}>
|
||||
{t("history.showAll")}
|
||||
</Link>
|
||||
<Link href={`${base}&approved=1`} aria-current={onlyApproved ? "page" : undefined} className={`${buttonLinkClass} ${onlyApproved ? "border-[var(--ui-accent)]" : ""}`}>
|
||||
{t("history.onlyApproved")}
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{history.onlyApproved && <Banner tone="info">{t("history.approvedOnlyHint")}</Banner>}
|
||||
<SiteHistory entries={history.items} />
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -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 <ModulePlaceholder moduleKey="sites" />;
|
||||
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 (
|
||||
<main className="flex-1 p-4 sm:p-6">
|
||||
<PageHead
|
||||
crumb={t("crumb")}
|
||||
title={t("title")}
|
||||
sub={t("sub")}
|
||||
actions={
|
||||
canWrite ? (
|
||||
<Link href={hrefWith("/sites", { q, status: status === "all" ? undefined : status, customerId: filterCustomer, new: 1 })} className={primaryButtonClass}>
|
||||
<Plus className="size-4" aria-hidden /> {t("new")}
|
||||
</Link>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
|
||||
<form method="get" action="/sites" className="mb-4 flex flex-wrap items-end gap-2" role="search">
|
||||
{filterCustomer && <input type="hidden" name="customerId" value={filterCustomer} />}
|
||||
<label className="flex min-w-[14rem] flex-1 flex-col gap-1 text-[12.5px] font-semibold">
|
||||
{t("searchLabel")}
|
||||
<input name="q" type="search" defaultValue={q ?? ""} placeholder={t("searchPlaceholder")} className={controlClass} />
|
||||
</label>
|
||||
<label className="flex flex-col gap-1 text-[12.5px] font-semibold">
|
||||
{t("filter.status")}
|
||||
<select name="status" defaultValue={status} className={controlClass}>
|
||||
<option value="all">{t("filter.all")}</option>
|
||||
{SITE_STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`status.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="submit" className={buttonLinkClass}>
|
||||
{t("filter.apply")}
|
||||
</button>
|
||||
{(q || status !== "all" || filterCustomer) && (
|
||||
<Link href="/sites" className={buttonLinkClass}>
|
||||
{t("filter.reset")}
|
||||
</Link>
|
||||
)}
|
||||
</form>
|
||||
|
||||
<div className="shadow-card overflow-hidden rounded-xl border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("columns.name")}</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">{t("columns.customer")}</TableHead>
|
||||
<TableHead className="hidden md:table-cell">{t("columns.address")}</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">{t("columns.orders")}</TableHead>
|
||||
<TableHead>{t("columns.status")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{result.items.map((s) => (
|
||||
<TableRow key={s.id}>
|
||||
<TableCell className="p-0">
|
||||
<Link href={`/sites/${s.id}`} className="block px-3 py-3 font-semibold">
|
||||
{s.name}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="hidden p-0 sm:table-cell">
|
||||
<Link href={`/sites/${s.id}`} className="block px-3 py-3 text-muted-foreground">
|
||||
{customerDisplayName(s.customer)}
|
||||
</Link>
|
||||
</TableCell>
|
||||
<TableCell className="hidden text-muted-foreground md:table-cell">{formatAddress(s) || "—"}</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">{s._count.workOrders}</TableCell>
|
||||
<TableCell>
|
||||
<SiteStatusPill status={s.status} label={t(`status.${s.status}`)} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{result.items.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="py-6 text-center text-muted-foreground">
|
||||
{t("empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
<Pagination
|
||||
page={result.page}
|
||||
pageSize={result.pageSize}
|
||||
total={result.total}
|
||||
hrefFor={listHref}
|
||||
labels={{ prev: t("pagination.prev"), next: t("pagination.next"), summary: t("pagination.summary", paginationSummary(result.page, result.pageSize, result.total)) }}
|
||||
/>
|
||||
|
||||
{canWrite && sp.new && (
|
||||
<Modal title={t("form.createTitle")} sub={t("form.createSub")} closeHref={closeHref} closeLabel={tc("close")}>
|
||||
<SiteForm mode="create" action={createSiteAction} customers={formCustomers} contacts={contacts} initial={{ customerId: customerId ?? "" }} closeHref={closeHref} />
|
||||
</Modal>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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 <ModulePlaceholder moduleKey="teams" />;
|
||||
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 (
|
||||
<main className="flex-1 p-4 sm:p-6">
|
||||
<PageHead
|
||||
crumb={t("crumb")}
|
||||
title={t("title")}
|
||||
sub={t("sub")}
|
||||
actions={
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Link href={includeInactive ? "/teams" : "/teams?inactive=1"} className={buttonLinkClass}>
|
||||
{includeInactive ? t("hideInactive") : t("showInactive")}
|
||||
</Link>
|
||||
{canManage && (
|
||||
<Link href={withParam("new", "1")} className={primaryButtonClass}>
|
||||
<Plus className="size-4" aria-hidden /> {t("new")}
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
{!canManage && <p className="mb-3 text-[12.5px] text-muted-foreground">{t("readOnly")}</p>}
|
||||
|
||||
<div className="shadow-card overflow-hidden rounded-xl border bg-card">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>{t("columns.name")}</TableHead>
|
||||
<TableHead className="hidden sm:table-cell">{t("columns.leader")}</TableHead>
|
||||
<TableHead>{t("columns.members")}</TableHead>
|
||||
<TableHead className="hidden md:table-cell">{t("columns.phone")}</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">{t("columns.vehicle")}</TableHead>
|
||||
<TableHead className="hidden lg:table-cell">{t("columns.area")}</TableHead>
|
||||
<TableHead>{t("columns.status")}</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{teams.map((team) => {
|
||||
const current = team.members.filter(isCurrent);
|
||||
return (
|
||||
<TableRow key={team.id}>
|
||||
<TableCell className="p-0">
|
||||
{canManage ? (
|
||||
<Link href={withParam("edit", team.id)} className="block px-3 py-3 font-semibold">
|
||||
{team.name}
|
||||
</Link>
|
||||
) : (
|
||||
<span className="block px-3 py-3 font-semibold">{team.name}</span>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden sm:table-cell">{team.leader?.name ?? "—"}</TableCell>
|
||||
<TableCell className="whitespace-normal">
|
||||
<span className="text-[12.5px] font-semibold">{t("members.count", { count: current.length })}</span>
|
||||
{team.members.length > 0 && (
|
||||
<ul className="mt-0.5 text-[12px] text-muted-foreground">
|
||||
{team.members.map((m) => (
|
||||
<li key={m.id}>
|
||||
{m.user.name} ·{" "}
|
||||
{isCurrent(m)
|
||||
? t("members.current")
|
||||
: m.validFrom > now
|
||||
? t("members.upcoming", { date: format.dateTime(m.validFrom, { dateStyle: "medium" }) })
|
||||
: t("members.ended")}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell className="hidden md:table-cell">{team.phone ?? "—"}</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">{team.vehicle ?? "—"}</TableCell>
|
||||
<TableCell className="hidden lg:table-cell">{team.area ?? "—"}</TableCell>
|
||||
<TableCell>
|
||||
<TeamStatusPill status={team.status} label={t(`status.${team.status}`)} />
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{teams.length === 0 && (
|
||||
<TableRow>
|
||||
<TableCell colSpan={7} className="py-6 text-center text-muted-foreground">
|
||||
{t("empty")}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{canManage && sp.new && (
|
||||
<Modal title={t("form.createTitle")} sub={t("form.sub")} closeHref={listHref} closeLabel={tc("close")}>
|
||||
<TeamForm mode="create" action={saveTeamAction.bind(null, null)} initial={initialFor()} users={users} closeHref={listHref} />
|
||||
</Modal>
|
||||
)}
|
||||
|
||||
{canManage && editTeam && (
|
||||
<Modal
|
||||
title={t("form.editTitle")}
|
||||
sub={editTeam.name}
|
||||
closeHref={listHref}
|
||||
closeLabel={tc("close")}
|
||||
footer={
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-2">
|
||||
<p className="text-[12px] text-muted-foreground">{t("form.deleteHint")}</p>
|
||||
<ActionButtonForm
|
||||
action={deleteTeamAction.bind(null, editTeam.id)}
|
||||
label={t("form.delete")}
|
||||
confirmText={t("form.deleteConfirm")}
|
||||
namespace="teams"
|
||||
tone="danger"
|
||||
successHref={listHref}
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<TeamForm mode="edit" action={saveTeamAction.bind(null, editTeam.id)} initial={initialFor(editTeam)} users={users} closeHref={listHref} />
|
||||
</Modal>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -59,6 +59,7 @@ const ENTITY_LABEL: Record<string, string> = {
|
||||
platformAdmin: "Plattform-Admin",
|
||||
// Craftvia-Fachobjekte (Labels vorab, Module folgen)
|
||||
customer: "Kunde",
|
||||
contact: "Ansprechpartner",
|
||||
site: "Objekt",
|
||||
team: "Team",
|
||||
work_order: "Auftrag",
|
||||
|
||||
@@ -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<ActionState>;
|
||||
|
||||
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 (
|
||||
<p role="alert" className="rounded-lg border-l-4 border-[var(--risk)] bg-card px-3 py-2 text-[13px] font-semibold text-[var(--risk)]">
|
||||
{text}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
/** 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 (
|
||||
<form
|
||||
action={formAction}
|
||||
className={cn("flex flex-col gap-1", className)}
|
||||
onSubmit={(e) => {
|
||||
if (confirmText && !window.confirm(confirmText)) e.preventDefault();
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="submit"
|
||||
disabled={pending}
|
||||
className={cn(
|
||||
tone === "primary" ? primaryButtonClass : buttonLinkClass,
|
||||
tone === "danger" && "border-[var(--risk)] text-[var(--risk)]",
|
||||
)}
|
||||
>
|
||||
{pending && pendingLabel ? pendingLabel : label}
|
||||
</button>
|
||||
{errorText && (
|
||||
<span role="alert" className="text-[12px] font-semibold text-[var(--risk)]">
|
||||
{errorText}
|
||||
</span>
|
||||
)}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -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<string, string | null | undefined>;
|
||||
|
||||
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<Values>(initial);
|
||||
const [state, formAction, pending] = useActionState<ActionState, FormData>(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) => (
|
||||
<Field id={`ct-${name}`} label={t(`contacts.fields.${name}`)} required={required} error={fieldError(state, name)}>
|
||||
<input
|
||||
id={`ct-${name}`}
|
||||
name={name}
|
||||
type={type}
|
||||
required={required}
|
||||
defaultValue={values[name] ?? ""}
|
||||
aria-invalid={fieldError(state, name) ? true : undefined}
|
||||
className={controlClass}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="grid gap-3 p-5 sm:grid-cols-2">
|
||||
{input("name", "text", true)}
|
||||
{input("role")}
|
||||
{input("phone", "tel")}
|
||||
{input("mobile", "tel")}
|
||||
{input("email", "email")}
|
||||
<Field id="ct-preferredChannel" label={t("contacts.fields.preferredChannel")} error={fieldError(state, "preferredChannel")}>
|
||||
<select id="ct-preferredChannel" name="preferredChannel" defaultValue={values.preferredChannel ?? ""} className={controlClass}>
|
||||
<option value="">{t("contacts.channel.none")}</option>
|
||||
{(["phone", "mobile", "email"] as const).map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`contacts.channel.${c}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field id="ct-notes" label={t("contacts.fields.notes")} className="sm:col-span-2">
|
||||
<textarea id="ct-notes" name="notes" defaultValue={values.notes ?? ""} className={textareaClass} />
|
||||
</Field>
|
||||
<div className="sm:col-span-2">
|
||||
<FormError namespace="customers" state={state} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 border-t pt-4 sm:col-span-2">
|
||||
<button type="submit" disabled={pending} className={primaryButtonClass}>
|
||||
{pending ? t("form.saving") : t("form.save")}
|
||||
</button>
|
||||
<Link href={closeHref} className={buttonLinkClass}>
|
||||
{t("form.cancel")}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,142 @@
|
||||
"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 { CustomerFormState } from "@/server/actions/customers/customers";
|
||||
import type { ActionState } from "@/server/api/action-state";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { buttonLinkClass, controlClass, Field, FormSection, primaryButtonClass, textareaClass } from "@/components/customers/form-ui";
|
||||
import { FormError, useFieldError } from "@/components/customers/action-form";
|
||||
|
||||
type Values = Record<string, string | null | undefined>;
|
||||
type CreateAction = (prev: CustomerFormState, fd: FormData) => Promise<CustomerFormState>;
|
||||
|
||||
const STATUSES = ["active", "inactive", "provisional"] as const;
|
||||
|
||||
export function CustomerForm({
|
||||
mode,
|
||||
action,
|
||||
initial = {},
|
||||
closeHref,
|
||||
}: {
|
||||
mode: "create" | "edit";
|
||||
action: CreateAction | ((prev: ActionState, fd: FormData) => Promise<ActionState>);
|
||||
initial?: Values;
|
||||
closeHref: string;
|
||||
}) {
|
||||
const t = useTranslations("customers");
|
||||
const router = useRouter();
|
||||
const fieldError = useFieldError("customers");
|
||||
const [values, setValues] = useState<Values>(initial);
|
||||
const [state, formAction, pending] = useActionState<CustomerFormState, FormData>(async (prev, fd) => {
|
||||
setValues(Object.fromEntries([...fd.entries()].map(([k, v]) => [k, String(v)])));
|
||||
return (action as CreateAction)(prev, fd);
|
||||
}, { status: "idle" });
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === "ok") router.push(closeHref);
|
||||
}, [state, closeHref, router]);
|
||||
|
||||
const plain: ActionState = state.status === "duplicates" ? { status: "idle" } : state;
|
||||
const err = (name: string) => fieldError(plain, name);
|
||||
const text = (name: string, opts: { required?: boolean; type?: string; autoComplete?: string; className?: string; hint?: string } = {}) => (
|
||||
<Field id={`c-${name}`} label={t(`fields.${name}`)} required={opts.required} error={err(name)} hint={opts.hint} className={opts.className}>
|
||||
<input
|
||||
id={`c-${name}`}
|
||||
name={name}
|
||||
type={opts.type ?? "text"}
|
||||
autoComplete={opts.autoComplete ?? "off"}
|
||||
defaultValue={values[name] ?? ""}
|
||||
aria-invalid={err(name) ? true : undefined}
|
||||
aria-describedby={err(name) ? `c-${name}-error` : undefined}
|
||||
className={controlClass}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-5 p-5">
|
||||
{state.status === "duplicates" && (
|
||||
<div role="alert" className="rounded-xl border-l-4 border-[var(--warn)] bg-[var(--surface-soft)] p-4">
|
||||
<p className="font-heading text-sm font-semibold text-[var(--warn)]">{t("duplicates.title")}</p>
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">{t("duplicates.hint")}</p>
|
||||
<ul className="mt-3 space-y-2">
|
||||
{state.candidates.map((c) => (
|
||||
<li key={c.customerId} className="flex flex-wrap items-center justify-between gap-2 rounded-lg border bg-card p-3">
|
||||
<div className="min-w-0">
|
||||
<p className="text-sm font-semibold">
|
||||
{c.displayName} <span className="text-muted-foreground">· {c.customerNumber}</span>
|
||||
{c.city && <span className="text-muted-foreground"> · {c.city}</span>}
|
||||
</p>
|
||||
<p className="mt-1 flex flex-wrap gap-1.5 text-[12px]">
|
||||
<Pill tone="warn">{t("duplicates.score", { percent: Math.round(c.score * 100) })}</Pill>
|
||||
{c.reasons.map((r) => (
|
||||
<Pill key={r} tone="mut">{t(`duplicates.reasons.${r}`)}</Pill>
|
||||
))}
|
||||
</p>
|
||||
</div>
|
||||
<Link href={`/customers/${c.customerId}`} className={buttonLinkClass}>
|
||||
{t("duplicates.open")}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
<input type="hidden" name="acknowledgeDuplicates" value="1" />
|
||||
</div>
|
||||
)}
|
||||
|
||||
<FormSection title={t("sections.customer")}>
|
||||
{text("customerNumber", { hint: mode === "create" ? t("fields.customerNumberHint") : undefined })}
|
||||
<Field id="c-status" label={t("fields.status")} error={err("status")}>
|
||||
<select id="c-status" name="status" defaultValue={values.status ?? "active"} className={controlClass}>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`status.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{text("companyName", { className: "sm:col-span-2", autoComplete: "organization" })}
|
||||
{text("salutation", { autoComplete: "honorific-prefix" })}
|
||||
<div className="hidden sm:block" />
|
||||
{text("firstName", { autoComplete: "given-name" })}
|
||||
{text("lastName", { autoComplete: "family-name" })}
|
||||
</FormSection>
|
||||
|
||||
<FormSection title={t("sections.address")}>
|
||||
{text("street", { autoComplete: "address-line1" })}
|
||||
{text("houseNumber")}
|
||||
{text("postalCode", { autoComplete: "postal-code" })}
|
||||
{text("city", { autoComplete: "address-level2" })}
|
||||
{text("country", { hint: "DE, AT, CH …" })}
|
||||
</FormSection>
|
||||
|
||||
<FormSection title={t("sections.contact")}>
|
||||
{text("phone", { type: "tel", autoComplete: "tel" })}
|
||||
{text("mobile", { type: "tel" })}
|
||||
{text("email", { type: "email", autoComplete: "email", className: "sm:col-span-2" })}
|
||||
</FormSection>
|
||||
|
||||
<FormSection title={t("sections.notes")}>
|
||||
{(["notes", "billingNotes"] as const).map((name) => (
|
||||
<Field key={name} id={`c-${name}`} label={t(`fields.${name}`)} error={err(name)} className="sm:col-span-2">
|
||||
<textarea id={`c-${name}`} name={name} defaultValue={values[name] ?? ""} className={textareaClass} />
|
||||
</Field>
|
||||
))}
|
||||
</FormSection>
|
||||
|
||||
<FormError namespace="customers" state={plain} />
|
||||
|
||||
<div className="flex flex-wrap gap-2 border-t pt-4">
|
||||
<button type="submit" disabled={pending} className={primaryButtonClass}>
|
||||
{pending ? t("form.saving") : state.status === "duplicates" ? t("duplicates.createAnyway") : mode === "create" ? t("form.create") : t("form.save")}
|
||||
</button>
|
||||
<Link href={closeHref} className={buttonLinkClass}>
|
||||
{t("form.cancel")}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import Link from "next/link";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/**
|
||||
* Shared, server-safe building blocks of the master-data screens (customers, sites, teams,
|
||||
* documents). Controls are 44 px high (Brandbook §12.2 touch targets); colors only via tokens.
|
||||
*/
|
||||
|
||||
export const controlClass =
|
||||
"h-11 w-full min-w-0 rounded-lg border border-input bg-card px-3 text-sm outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive aria-invalid:ring-3 aria-invalid:ring-destructive/20 disabled:opacity-60";
|
||||
|
||||
export const textareaClass =
|
||||
"min-h-24 w-full rounded-lg border border-input bg-card px-3 py-2 text-sm outline-none transition-colors focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50 aria-invalid:border-destructive";
|
||||
|
||||
export const buttonLinkClass =
|
||||
"inline-flex min-h-11 items-center justify-center gap-1.5 rounded-lg border border-border bg-background px-4 font-heading text-sm font-semibold transition-colors hover:bg-muted focus-visible:ring-3 focus-visible:ring-ring/50 outline-none";
|
||||
|
||||
export const primaryButtonClass =
|
||||
"inline-flex min-h-11 items-center justify-center gap-1.5 rounded-lg bg-[var(--ui-accent)] px-4 font-heading text-sm font-semibold text-[var(--ui-accent-foreground)] transition-opacity hover:opacity-90 focus-visible:ring-3 focus-visible:ring-ring/50 outline-none disabled:opacity-60";
|
||||
|
||||
export function Field({
|
||||
id,
|
||||
label,
|
||||
error,
|
||||
hint,
|
||||
required,
|
||||
className,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
label: string;
|
||||
error?: string;
|
||||
hint?: string;
|
||||
required?: boolean;
|
||||
className?: string;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className={cn("flex flex-col gap-1", className)}>
|
||||
<label htmlFor={id} className="text-[12.5px] font-semibold text-foreground">
|
||||
{label}
|
||||
{required && <span aria-hidden className="text-[var(--risk)]"> *</span>}
|
||||
</label>
|
||||
{children}
|
||||
{hint && !error && <p className="text-[12px] text-muted-foreground">{hint}</p>}
|
||||
{error && (
|
||||
<p id={`${id}-error`} role="alert" className="text-[12px] font-semibold text-[var(--risk)]">
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function FormSection({ title, children, className }: { title: string; children: React.ReactNode; className?: string }) {
|
||||
return (
|
||||
<fieldset className={cn("grid gap-3 sm:grid-cols-2", className)}>
|
||||
<legend className="mb-2 font-heading text-[13px] font-semibold tracking-wide text-muted-foreground uppercase">{title}</legend>
|
||||
{children}
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
|
||||
export function TabNav({ tabs, active, label }: { tabs: { key: string; label: string; href: string; count?: number }[]; active: string; label: string }) {
|
||||
return (
|
||||
<nav aria-label={label} className="mb-4 flex gap-1 overflow-x-auto border-b">
|
||||
{tabs.map((tab) => {
|
||||
const isActive = tab.key === active;
|
||||
return (
|
||||
<Link
|
||||
key={tab.key}
|
||||
href={tab.href}
|
||||
aria-current={isActive ? "page" : undefined}
|
||||
className={cn(
|
||||
"-mb-px inline-flex min-h-11 items-center gap-1.5 border-b-2 px-3 text-[13.5px] font-semibold whitespace-nowrap transition-colors",
|
||||
isActive ? "border-[var(--ui-accent)] text-foreground" : "border-transparent text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{tab.label}
|
||||
{typeof tab.count === "number" && (
|
||||
<span className="rounded-full bg-muted px-1.5 text-[11px] font-bold text-muted-foreground">{tab.count}</span>
|
||||
)}
|
||||
</Link>
|
||||
);
|
||||
})}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
export function Pagination({
|
||||
page,
|
||||
pageSize,
|
||||
total,
|
||||
hrefFor,
|
||||
labels,
|
||||
}: {
|
||||
page: number;
|
||||
pageSize: number;
|
||||
total: number;
|
||||
hrefFor: (page: number) => string;
|
||||
labels: { prev: string; next: string; summary: string };
|
||||
}) {
|
||||
if (total <= pageSize && page === 1) return total > 0 ? <p className="mt-3 text-[12.5px] text-muted-foreground">{labels.summary}</p> : null;
|
||||
const last = Math.max(1, Math.ceil(total / pageSize));
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap items-center justify-between gap-3">
|
||||
<p className="text-[12.5px] text-muted-foreground">{labels.summary}</p>
|
||||
<div className="flex gap-2">
|
||||
{page > 1 ? (
|
||||
<Link href={hrefFor(page - 1)} className={buttonLinkClass} rel="prev">
|
||||
{labels.prev}
|
||||
</Link>
|
||||
) : (
|
||||
<span aria-disabled className={cn(buttonLinkClass, "pointer-events-none opacity-50")}>{labels.prev}</span>
|
||||
)}
|
||||
{page < last ? (
|
||||
<Link href={hrefFor(page + 1)} className={buttonLinkClass} rel="next">
|
||||
{labels.next}
|
||||
</Link>
|
||||
) : (
|
||||
<span aria-disabled className={cn(buttonLinkClass, "pointer-events-none opacity-50")}>{labels.next}</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Banner({ tone, children }: { tone: "ok" | "warn" | "risk" | "info"; children: React.ReactNode }) {
|
||||
const tones = {
|
||||
ok: "border-[var(--ok)] text-[var(--ok)]",
|
||||
warn: "border-[var(--warn)] text-[var(--warn)]",
|
||||
risk: "border-[var(--risk)] text-[var(--risk)]",
|
||||
info: "border-[var(--info)] text-[var(--info)]",
|
||||
};
|
||||
return (
|
||||
<div role={tone === "risk" ? "alert" : "status"} className={cn("mb-4 rounded-lg border-l-4 bg-card px-4 py-3 text-[13px] font-semibold", tones[tone])}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
/** Label/value list for read views. */
|
||||
export function DefinitionList({ items }: { items: { label: string; value: React.ReactNode }[] }) {
|
||||
return (
|
||||
<dl className="grid gap-x-6 gap-y-3 sm:grid-cols-2">
|
||||
{items.map((it) => (
|
||||
<div key={it.label} className="min-w-0">
|
||||
<dt className="text-[12px] font-semibold text-muted-foreground">{it.label}</dt>
|
||||
<dd className="mt-0.5 text-sm break-words whitespace-pre-line">{it.value || "—"}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
);
|
||||
}
|
||||
|
||||
export function Card({ children, className }: { children: React.ReactNode; className?: string }) {
|
||||
return <div className={cn("shadow-card rounded-xl border bg-card p-5", className)}>{children}</div>;
|
||||
}
|
||||
|
||||
export function paginationSummary(page: number, pageSize: number, total: number) {
|
||||
const from = total === 0 ? 0 : (page - 1) * pageSize + 1;
|
||||
const to = Math.min(total, page * pageSize);
|
||||
return { from, to, total };
|
||||
}
|
||||
|
||||
/** Build `path?…` from params, dropping empty values. */
|
||||
export function hrefWith(path: string, params: Record<string, string | number | undefined | null>): string {
|
||||
const sp = new URLSearchParams();
|
||||
for (const [k, v] of Object.entries(params)) if (v !== undefined && v !== null && v !== "") sp.set(k, String(v));
|
||||
const qs = sp.toString();
|
||||
return qs ? `${path}?${qs}` : path;
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { buttonLinkClass, controlClass, Field, primaryButtonClass } from "@/components/customers/form-ui";
|
||||
import { FormError, IDLE_STATE, useFieldError, type FormAction } from "@/components/customers/action-form";
|
||||
|
||||
export type MergeCandidateView = {
|
||||
customerId: string;
|
||||
displayName: string;
|
||||
customerNumber: string | null;
|
||||
city: string | null;
|
||||
score: number;
|
||||
reasons: string[];
|
||||
};
|
||||
|
||||
export function MergeForm({
|
||||
action,
|
||||
source,
|
||||
candidates,
|
||||
closeHref,
|
||||
}: {
|
||||
action: FormAction;
|
||||
source: { displayName: string; customerNumber: string | null };
|
||||
candidates: MergeCandidateView[];
|
||||
closeHref: string;
|
||||
}) {
|
||||
const t = useTranslations("customers");
|
||||
const fieldError = useFieldError("customers");
|
||||
const [state, formAction, pending] = useActionState(action, IDLE_STATE);
|
||||
const confirmError = state.status === "error" && state.fieldErrors?.confirm ? t("errors.confirm_required") : undefined;
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-4 p-5">
|
||||
<div className="rounded-lg border bg-[var(--surface-soft)] p-3">
|
||||
<p className="text-[12px] font-semibold text-muted-foreground">{t("merge.source")}</p>
|
||||
<p className="text-sm font-semibold">
|
||||
{source.displayName} <span className="text-muted-foreground">· {source.customerNumber}</span>
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<fieldset>
|
||||
<legend className="mb-2 text-[12.5px] font-semibold">{t("merge.candidates")}</legend>
|
||||
{candidates.length === 0 ? (
|
||||
<p className="text-[13px] text-muted-foreground">{t("merge.noCandidates")}</p>
|
||||
) : (
|
||||
<div className="space-y-2">
|
||||
{candidates.map((c) => (
|
||||
<label key={c.customerId} className="flex min-h-11 cursor-pointer items-start gap-3 rounded-lg border bg-card p-3 has-[:checked]:border-[var(--ui-accent)]">
|
||||
<input type="radio" name="targetId" value={c.customerId} className="mt-1 size-4" />
|
||||
<span className="min-w-0">
|
||||
<span className="block text-sm font-semibold">
|
||||
{c.displayName} <span className="text-muted-foreground">· {c.customerNumber}</span>
|
||||
{c.city && <span className="text-muted-foreground"> · {c.city}</span>}
|
||||
</span>
|
||||
<span className="mt-1 flex flex-wrap gap-1.5">
|
||||
<Pill tone="warn">{t("duplicates.score", { percent: Math.round(c.score * 100) })}</Pill>
|
||||
{c.reasons.map((r) => (
|
||||
<Pill key={r} tone="mut">{t(`duplicates.reasons.${r}`)}</Pill>
|
||||
))}
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
{fieldError(state, "targetId") && <p role="alert" className="mt-1 text-[12px] font-semibold text-[var(--risk)]">{fieldError(state, "targetId")}</p>}
|
||||
</fieldset>
|
||||
|
||||
<Field id="m-targetNumber" label={t("merge.targetNumber")} error={fieldError(state, "targetNumber")}>
|
||||
<input id="m-targetNumber" name="targetNumber" className={controlClass} autoComplete="off" />
|
||||
</Field>
|
||||
|
||||
<label className="flex min-h-11 items-start gap-3 rounded-lg border border-[var(--warn)] bg-card p-3 text-[13px]">
|
||||
<input type="checkbox" name="confirm" className="mt-0.5 size-4" aria-invalid={confirmError ? true : undefined} />
|
||||
<span>{t("merge.confirm")}</span>
|
||||
</label>
|
||||
{confirmError && <p role="alert" className="text-[12px] font-semibold text-[var(--risk)]">{confirmError}</p>}
|
||||
|
||||
<FormError namespace="customers" state={state} />
|
||||
|
||||
<div className="flex flex-wrap gap-2 border-t pt-4">
|
||||
<button type="submit" disabled={pending} className={primaryButtonClass}>
|
||||
{pending ? t("form.saving") : t("merge.submit")}
|
||||
</button>
|
||||
<Link href={closeHref} className={buttonLinkClass}>
|
||||
{t("form.cancel")}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { STATUS_GROUP, STATUS_GROUP_TONE, type WorkOrderStatus } from "@/lib/work-orders/status";
|
||||
|
||||
// Status pills always carry text (Brandbook §11.4: color is never the only signal).
|
||||
|
||||
const CUSTOMER_TONE = { active: "ok", inactive: "mut", provisional: "warn", merged: "info" } as const;
|
||||
const SITE_TONE = { active: "ok", inactive: "mut", provisional: "warn" } as const;
|
||||
const TEAM_TONE = { active: "ok", inactive: "mut" } as const;
|
||||
|
||||
export function CustomerStatusPill({ status, label }: { status: keyof typeof CUSTOMER_TONE; label: string }) {
|
||||
return <Pill tone={CUSTOMER_TONE[status]}>{label}</Pill>;
|
||||
}
|
||||
|
||||
export function SiteStatusPill({ status, label }: { status: keyof typeof SITE_TONE; label: string }) {
|
||||
return <Pill tone={SITE_TONE[status]}>{label}</Pill>;
|
||||
}
|
||||
|
||||
export function TeamStatusPill({ status, label }: { status: keyof typeof TEAM_TONE; label: string }) {
|
||||
return <Pill tone={TEAM_TONE[status]}>{label}</Pill>;
|
||||
}
|
||||
|
||||
const GROUP_PILL = { neutral: "mut", info: "info", accent: "orange", warning: "warn", success: "ok", danger: "risk" } as const;
|
||||
|
||||
/** Work order status as Brandbook status group; `label` comes from messages sites.statusGroup.<group>. */
|
||||
export function orderStatusGroup(status: WorkOrderStatus) {
|
||||
return STATUS_GROUP[status];
|
||||
}
|
||||
|
||||
export function OrderStatusPill({ status, label }: { status: WorkOrderStatus; label: string }) {
|
||||
return <Pill tone={GROUP_PILL[STATUS_GROUP_TONE[STATUS_GROUP[status]]]}>{label}</Pill>;
|
||||
}
|
||||
@@ -0,0 +1,67 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState, useEffect } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { buttonLinkClass, controlClass, Field, primaryButtonClass } from "@/components/customers/form-ui";
|
||||
import { FormError, IDLE_STATE, type FormAction } from "@/components/customers/action-form";
|
||||
|
||||
export function DocumentEditForm({
|
||||
action,
|
||||
initial,
|
||||
categories,
|
||||
visibilities,
|
||||
closeHref,
|
||||
}: {
|
||||
action: FormAction;
|
||||
initial: { title: string; category: string; visibility: string };
|
||||
categories: string[];
|
||||
visibilities: string[];
|
||||
closeHref: string;
|
||||
}) {
|
||||
const t = useTranslations("documents");
|
||||
const router = useRouter();
|
||||
const [state, formAction, pending] = useActionState(action, IDLE_STATE);
|
||||
|
||||
useEffect(() => {
|
||||
if (state.status === "ok") router.push(closeHref);
|
||||
}, [state, closeHref, router]);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="grid gap-3 p-5 sm:grid-cols-2">
|
||||
<Field id="d-title" label={t("edit.titleField")} className="sm:col-span-2">
|
||||
<input id="d-title" name="title" defaultValue={initial.title} className={controlClass} />
|
||||
</Field>
|
||||
<Field id="d-category" label={t("upload.category")}>
|
||||
<select id="d-category" name="category" defaultValue={initial.category} className={controlClass}>
|
||||
{categories.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`category.${c}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field id="d-visibility" label={t("upload.visibility")}>
|
||||
<select id="d-visibility" name="visibility" defaultValue={initial.visibility} className={controlClass}>
|
||||
{visibilities.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{t(`visibility.${v}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<div className="sm:col-span-2">
|
||||
<FormError namespace="documents" state={state} />
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-2 border-t pt-4 sm:col-span-2">
|
||||
<button type="submit" disabled={pending} className={primaryButtonClass}>
|
||||
{pending ? t("edit.saving") : t("edit.save")}
|
||||
</button>
|
||||
<Link href={closeHref} className={buttonLinkClass}>
|
||||
{t("edit.cancel")}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { Card } from "@/components/customers/form-ui";
|
||||
import { DocumentTable, type DocumentRow } from "@/components/documents/document-table";
|
||||
import { DocumentUploadForm, UploadFeedback } from "@/components/documents/document-upload-form";
|
||||
import { DocumentEditForm } from "@/components/documents/document-edit-form";
|
||||
import { updateDocumentAction } from "@/server/actions/documents/documents";
|
||||
import { can, type ServiceCtx } from "@/server/services/context";
|
||||
import { DOCUMENT_CATEGORIES } from "@/server/services/documents/store";
|
||||
import { allowedDocumentVisibility } from "@/server/services/work-orders/visibility";
|
||||
|
||||
/**
|
||||
* Documents tab for customer/site detail pages: upload (new document or new version), grouped
|
||||
* list with versions, edit popup. Search params: docOk, docError, docEdit, docVersion.
|
||||
*/
|
||||
export async function DocumentPanel({
|
||||
ctx,
|
||||
rows,
|
||||
baseHref,
|
||||
links,
|
||||
searchParams,
|
||||
categories = DOCUMENT_CATEGORIES,
|
||||
defaultCategory,
|
||||
showLinks = false,
|
||||
}: {
|
||||
ctx: ServiceCtx;
|
||||
rows: DocumentRow[];
|
||||
baseHref: string;
|
||||
links: { customerId?: string; siteId?: string };
|
||||
searchParams: { docOk?: string; docError?: string; docEdit?: string; docVersion?: string };
|
||||
categories?: readonly string[];
|
||||
defaultCategory?: string;
|
||||
showLinks?: boolean;
|
||||
}) {
|
||||
const t = await getTranslations("documents");
|
||||
const tc = await getTranslations("common");
|
||||
const canWrite = can(ctx, "document:write");
|
||||
const visibilities = allowedDocumentVisibility(ctx);
|
||||
const editDoc = searchParams.docEdit ? rows.find((r) => r.id === searchParams.docEdit) : undefined;
|
||||
const versionOf = searchParams.docVersion ? rows.find((r) => r.lineageId === searchParams.docVersion) : undefined;
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<UploadFeedback ok={searchParams.docOk} error={searchParams.docError} />
|
||||
{canWrite && (
|
||||
<Card>
|
||||
<DocumentUploadForm
|
||||
heading={t("upload.title")}
|
||||
returnTo={baseHref}
|
||||
links={links}
|
||||
categories={[...categories]}
|
||||
visibilities={visibilities}
|
||||
defaultCategory={defaultCategory}
|
||||
/>
|
||||
</Card>
|
||||
)}
|
||||
<DocumentTable rows={rows} baseHref={baseHref} canWrite={canWrite} showLinks={showLinks} />
|
||||
|
||||
{canWrite && editDoc && (
|
||||
<Modal title={t("edit.title")} sub={editDoc.fileName} closeHref={baseHref} closeLabel={tc("close")}>
|
||||
<DocumentEditForm
|
||||
action={updateDocumentAction.bind(null, editDoc.id, baseHref)}
|
||||
initial={{ title: editDoc.title ?? "", category: editDoc.category, visibility: editDoc.visibility }}
|
||||
categories={[...DOCUMENT_CATEGORIES]}
|
||||
visibilities={visibilities}
|
||||
closeHref={baseHref}
|
||||
/>
|
||||
</Modal>
|
||||
)}
|
||||
{canWrite && versionOf && (
|
||||
<Modal title={t("upload.newVersionTitle")} sub={t("upload.newVersionOf", { name: versionOf.title || versionOf.fileName })} closeHref={baseHref} closeLabel={tc("close")}>
|
||||
<div className="p-5">
|
||||
<DocumentUploadForm
|
||||
returnTo={baseHref}
|
||||
lineageId={versionOf.lineageId}
|
||||
categories={[...DOCUMENT_CATEGORIES]}
|
||||
visibilities={visibilities}
|
||||
defaultCategory={versionOf.category}
|
||||
defaultVisibility={versionOf.visibility}
|
||||
/>
|
||||
</div>
|
||||
</Modal>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,168 @@
|
||||
import Link from "next/link";
|
||||
import { Download, FileText, Image as ImageIcon, Mic } from "lucide-react";
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import { Pill } from "@/components/mockup-ui";
|
||||
import { ActionButtonForm } from "@/components/customers/action-form";
|
||||
import { buttonLinkClass } from "@/components/customers/form-ui";
|
||||
import { deleteDocumentAction } from "@/server/actions/documents/documents";
|
||||
import { documentHref } from "@/server/services/documents/access";
|
||||
import { customerDisplayName } from "@/server/services/customers/format";
|
||||
|
||||
export type DocumentRow = {
|
||||
id: string;
|
||||
title: string | null;
|
||||
fileName: string;
|
||||
category: string;
|
||||
visibility: string;
|
||||
mimeType: string;
|
||||
fileSize: number;
|
||||
checksum: string;
|
||||
version: number;
|
||||
lineageId: string;
|
||||
createdAt: Date;
|
||||
customer: { id: string; customerNumber: string | null; companyName: string | null; firstName: string | null; lastName: string | null } | null;
|
||||
site: { id: string; name: string } | null;
|
||||
workOrder: { id: string; number: string; title: string } | null;
|
||||
};
|
||||
|
||||
const VISIBILITY_TONE = { backoffice_only: "risk", team_lead: "warn", team: "info", customer_report: "ok" } as const;
|
||||
|
||||
function fileIcon(mime: string) {
|
||||
if (mime.startsWith("image/")) return ImageIcon;
|
||||
if (mime.startsWith("audio/")) return Mic;
|
||||
return FileText;
|
||||
}
|
||||
|
||||
/**
|
||||
* Document list. `groupVersions` shows the newest version per lineage and lists older versions
|
||||
* underneath (spec §24.2). Edit/new-version links open popups on `baseHref` (?docEdit / ?docVersion).
|
||||
*/
|
||||
export async function DocumentTable({
|
||||
rows,
|
||||
baseHref,
|
||||
canWrite,
|
||||
groupVersions = true,
|
||||
showLinks = true,
|
||||
}: {
|
||||
rows: DocumentRow[];
|
||||
baseHref: string;
|
||||
canWrite: boolean;
|
||||
groupVersions?: boolean;
|
||||
showLinks?: boolean;
|
||||
}) {
|
||||
const t = await getTranslations("documents");
|
||||
const format = await getFormatter();
|
||||
if (rows.length === 0) return <p className="text-[13px] text-muted-foreground">{t("empty")}</p>;
|
||||
|
||||
const sep = baseHref.includes("?") ? "&" : "?";
|
||||
const groups = new Map<string, DocumentRow[]>();
|
||||
for (const r of rows) {
|
||||
const key = groupVersions ? r.lineageId : r.id;
|
||||
groups.set(key, [...(groups.get(key) ?? []), r]);
|
||||
}
|
||||
const size = (bytes: number) =>
|
||||
bytes >= 1024 * 1024 ? `${format.number(bytes / 1024 / 1024, { maximumFractionDigits: 1 })} MB` : `${format.number(Math.max(1, Math.round(bytes / 1024)))} KB`;
|
||||
|
||||
return (
|
||||
<ul className="divide-y rounded-xl border bg-card">
|
||||
{[...groups.values()].map((versions) => {
|
||||
const sorted = [...versions].sort((a, b) => b.version - a.version);
|
||||
const doc = sorted[0];
|
||||
const older = sorted.slice(1);
|
||||
const Icon = fileIcon(doc.mimeType);
|
||||
return (
|
||||
<li key={doc.id} className="p-4">
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div className="flex min-w-0 gap-3">
|
||||
<Icon className="mt-0.5 size-5 shrink-0 text-muted-foreground" aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<a href={documentHref(doc.id)} className="font-semibold break-words hover:underline">
|
||||
{doc.title || doc.fileName}
|
||||
</a>
|
||||
<p className="mt-0.5 text-[12px] text-muted-foreground">
|
||||
{doc.title ? `${doc.fileName} · ` : ""}
|
||||
{t("versions.label", { version: doc.version })} · {size(doc.fileSize)} · {format.dateTime(doc.createdAt, { dateStyle: "medium", timeStyle: "short" })}
|
||||
</p>
|
||||
<p className="mt-1.5 flex flex-wrap gap-1.5">
|
||||
<Pill tone="mut">{t(`category.${doc.category}`)}</Pill>
|
||||
<Pill tone={VISIBILITY_TONE[doc.visibility as keyof typeof VISIBILITY_TONE] ?? "mut"}>{t(`visibility.${doc.visibility}`)}</Pill>
|
||||
</p>
|
||||
{showLinks && (doc.customer || doc.site || doc.workOrder) && (
|
||||
<p className="mt-1.5 text-[12px] text-muted-foreground">
|
||||
{doc.customer && (
|
||||
<>
|
||||
{t("link.customer")}:{" "}
|
||||
<Link className="font-semibold hover:underline" href={`/customers/${doc.customer.id}`}>
|
||||
{customerDisplayName(doc.customer)}
|
||||
</Link>{" "}
|
||||
</>
|
||||
)}
|
||||
{doc.site && (
|
||||
<>
|
||||
{t("link.site")}:{" "}
|
||||
<Link className="font-semibold hover:underline" href={`/sites/${doc.site.id}`}>
|
||||
{doc.site.name}
|
||||
</Link>{" "}
|
||||
</>
|
||||
)}
|
||||
{doc.workOrder && (
|
||||
<>
|
||||
{t("link.workOrder")}:{" "}
|
||||
<Link className="font-semibold hover:underline" href={`/work-orders/${doc.workOrder.id}`}>
|
||||
{doc.workOrder.number}
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
<p className="mt-1 font-mono text-[11px] break-all text-muted-foreground" title="SHA-256">
|
||||
{doc.checksum.slice(0, 16)}…
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-start gap-2">
|
||||
<a href={documentHref(doc.id)} className={buttonLinkClass}>
|
||||
<Download className="size-4" aria-hidden /> {t("actions.download")}
|
||||
</a>
|
||||
{canWrite && (
|
||||
<>
|
||||
<Link href={`${baseHref}${sep}docEdit=${doc.id}`} className={buttonLinkClass}>
|
||||
{t("actions.edit")}
|
||||
</Link>
|
||||
<Link href={`${baseHref}${sep}docVersion=${doc.lineageId}`} className={buttonLinkClass}>
|
||||
{t("upload.newVersion")}
|
||||
</Link>
|
||||
<ActionButtonForm
|
||||
action={deleteDocumentAction.bind(null, doc.id, baseHref)}
|
||||
label={t("actions.delete")}
|
||||
confirmText={t("actions.deleteConfirm")}
|
||||
namespace="documents"
|
||||
tone="danger"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{older.length > 0 && (
|
||||
<details className="mt-3 ml-8">
|
||||
<summary className="min-h-11 cursor-pointer text-[12.5px] font-semibold text-muted-foreground">{t("versions.older", { count: older.length })}</summary>
|
||||
<ul className="mt-1 space-y-1">
|
||||
{older.map((o) => (
|
||||
<li key={o.id} className="flex flex-wrap items-center gap-2 text-[12.5px]">
|
||||
<a href={documentHref(o.id)} className="inline-flex min-h-11 items-center font-semibold hover:underline">
|
||||
{t("versions.label", { version: o.version })} · {o.fileName}
|
||||
</a>
|
||||
<span className="text-muted-foreground">
|
||||
{size(o.fileSize)} · {format.dateTime(o.createdAt, { dateStyle: "medium" })}
|
||||
</span>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</details>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { controlClass, Field, primaryButtonClass } from "@/components/customers/form-ui";
|
||||
|
||||
/**
|
||||
* Plain multipart form (works without JavaScript) posting to /documents/upload, which redirects
|
||||
* back to `returnTo` with ?docOk=1 or ?docError=<reason> (see UploadFeedback).
|
||||
*/
|
||||
export async function DocumentUploadForm({
|
||||
returnTo,
|
||||
links = {},
|
||||
lineageId,
|
||||
categories,
|
||||
visibilities,
|
||||
defaultCategory = "other",
|
||||
defaultVisibility = "team",
|
||||
heading,
|
||||
}: {
|
||||
returnTo: string;
|
||||
links?: { customerId?: string; siteId?: string; workOrderId?: string };
|
||||
lineageId?: string;
|
||||
categories: string[];
|
||||
visibilities: string[];
|
||||
defaultCategory?: string;
|
||||
defaultVisibility?: string;
|
||||
heading?: string;
|
||||
}) {
|
||||
const t = await getTranslations("documents");
|
||||
const idp = lineageId ? `v-${lineageId.slice(0, 6)}` : "up";
|
||||
return (
|
||||
<form action="/documents/upload" method="post" encType="multipart/form-data" className="grid gap-3 sm:grid-cols-2">
|
||||
{heading && <p className="font-heading text-sm font-semibold sm:col-span-2">{heading}</p>}
|
||||
<input type="hidden" name="returnTo" value={returnTo} />
|
||||
{links.customerId && <input type="hidden" name="customerId" value={links.customerId} />}
|
||||
{links.siteId && <input type="hidden" name="siteId" value={links.siteId} />}
|
||||
{links.workOrderId && <input type="hidden" name="workOrderId" value={links.workOrderId} />}
|
||||
{lineageId && <input type="hidden" name="lineageId" value={lineageId} />}
|
||||
<Field id={`${idp}-file`} label={t("upload.file")} required hint={t("upload.fileHint")} className="sm:col-span-2">
|
||||
<input
|
||||
id={`${idp}-file`}
|
||||
name="file"
|
||||
type="file"
|
||||
required
|
||||
accept="application/pdf,image/jpeg,image/png,image/webp,image/heic,audio/*"
|
||||
className={`${controlClass} py-2 file:mr-3 file:font-semibold`}
|
||||
/>
|
||||
</Field>
|
||||
<Field id={`${idp}-title`} label={t("upload.titleField")} className="sm:col-span-2">
|
||||
<input id={`${idp}-title`} name="title" className={controlClass} />
|
||||
</Field>
|
||||
<Field id={`${idp}-category`} label={t("upload.category")} required>
|
||||
<select id={`${idp}-category`} name="category" defaultValue={defaultCategory} className={controlClass}>
|
||||
{categories.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{t(`category.${c}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field id={`${idp}-visibility`} label={t("upload.visibility")} required>
|
||||
<select id={`${idp}-visibility`} name="visibility" defaultValue={defaultVisibility} className={controlClass}>
|
||||
{visibilities.map((v) => (
|
||||
<option key={v} value={v}>
|
||||
{t(`visibility.${v}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<div className="sm:col-span-2">
|
||||
<button type="submit" className={primaryButtonClass}>
|
||||
{t("upload.submit")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
|
||||
/** Result banner of the upload redirect. */
|
||||
export async function UploadFeedback({ ok, error }: { ok?: string; error?: string }) {
|
||||
if (!ok && !error) return null;
|
||||
const t = await getTranslations("documents");
|
||||
if (ok) {
|
||||
return (
|
||||
<p role="status" className="mb-3 rounded-lg border-l-4 border-[var(--ok)] bg-card px-3 py-2 text-[13px] font-semibold text-[var(--ok)]">
|
||||
{t("upload.ok")}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
const key = t.has(`uploadErrors.${error}`) ? `uploadErrors.${error}` : "uploadErrors.generic";
|
||||
return (
|
||||
<p role="alert" className="mb-3 rounded-lg border-l-4 border-[var(--risk)] bg-card px-3 py-2 text-[13px] font-semibold text-[var(--risk)]">
|
||||
{t(key)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
"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, FormSection, primaryButtonClass, textareaClass } from "@/components/customers/form-ui";
|
||||
import { FormError, IDLE_STATE, useFieldError, type FormAction } from "@/components/customers/action-form";
|
||||
|
||||
type Values = Record<string, string | null | undefined>;
|
||||
type Option = { id: string; label: string };
|
||||
|
||||
const STATUSES = ["active", "inactive", "provisional"] as const;
|
||||
|
||||
export function SiteForm({
|
||||
mode,
|
||||
action,
|
||||
initial = {},
|
||||
customers,
|
||||
contacts,
|
||||
closeHref,
|
||||
}: {
|
||||
mode: "create" | "edit";
|
||||
action: FormAction;
|
||||
initial?: Values;
|
||||
customers: Option[];
|
||||
/** Contacts of the (fixed) customer; null = customer not yet known. */
|
||||
contacts: Option[] | null;
|
||||
closeHref: string;
|
||||
}) {
|
||||
const t = useTranslations("sites");
|
||||
const router = useRouter();
|
||||
const fieldError = useFieldError("sites");
|
||||
const [values, setValues] = useState<Values>(initial);
|
||||
const [state, formAction, pending] = useActionState<ActionState, FormData>(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 err = (name: string) => fieldError(state, name);
|
||||
const input = (name: string, opts: { required?: boolean; type?: string; className?: string; inputMode?: "decimal" } = {}) => (
|
||||
<Field id={`s-${name}`} label={t(`fields.${name}`)} required={opts.required} error={err(name)} className={opts.className}>
|
||||
<input
|
||||
id={`s-${name}`}
|
||||
name={name}
|
||||
type={opts.type ?? "text"}
|
||||
inputMode={opts.inputMode}
|
||||
required={opts.required}
|
||||
defaultValue={values[name] ?? ""}
|
||||
aria-invalid={err(name) ? true : undefined}
|
||||
className={controlClass}
|
||||
/>
|
||||
</Field>
|
||||
);
|
||||
const area = (name: string) => (
|
||||
<Field id={`s-${name}`} label={t(`fields.${name}`)} error={err(name)} className="sm:col-span-2">
|
||||
<textarea id={`s-${name}`} name={name} defaultValue={values[name] ?? ""} className={textareaClass} />
|
||||
</Field>
|
||||
);
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-5 p-5">
|
||||
<FormSection title={t("sections.base")}>
|
||||
<Field id="s-customerId" label={t("fields.customerId")} required error={err("customerId")} className="sm:col-span-2">
|
||||
<select id="s-customerId" name="customerId" required defaultValue={values.customerId ?? ""} className={controlClass}>
|
||||
<option value="" disabled>
|
||||
{t("fields.selectCustomer")}
|
||||
</option>
|
||||
{customers.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{input("name", { required: true })}
|
||||
<Field id="s-status" label={t("fields.status")} error={err("status")}>
|
||||
<select id="s-status" name="status" defaultValue={values.status ?? "active"} className={controlClass}>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`status.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<FormSection title={t("sections.address")}>
|
||||
{input("street")}
|
||||
{input("houseNumber")}
|
||||
{input("postalCode")}
|
||||
{input("city")}
|
||||
{input("country")}
|
||||
</FormSection>
|
||||
|
||||
<FormSection title={t("sections.onSite")}>
|
||||
{contacts ? (
|
||||
<Field id="s-contactId" label={t("fields.contactId")} error={err("contactId")}>
|
||||
<select id="s-contactId" name="contactId" defaultValue={values.contactId ?? ""} className={controlClass}>
|
||||
<option value="">{t("fields.noContact")}</option>
|
||||
{contacts.map((c) => (
|
||||
<option key={c.id} value={c.id}>
|
||||
{c.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
) : (
|
||||
<p className="self-end text-[12px] text-muted-foreground">{t("form.contactHint")}</p>
|
||||
)}
|
||||
{input("onSiteContact")}
|
||||
{input("phone", { type: "tel" })}
|
||||
</FormSection>
|
||||
|
||||
<FormSection title={t("sections.notes")}>
|
||||
{area("accessNotes")}
|
||||
{area("parkingNotes")}
|
||||
{area("safetyNotes")}
|
||||
{area("technicalNotes")}
|
||||
</FormSection>
|
||||
|
||||
<FormSection title={t("sections.geo")}>
|
||||
{input("latitude", { inputMode: "decimal" })}
|
||||
{input("longitude", { inputMode: "decimal" })}
|
||||
</FormSection>
|
||||
|
||||
<FormError namespace="sites" state={state} />
|
||||
|
||||
<div className="flex flex-wrap gap-2 border-t pt-4">
|
||||
<button type="submit" disabled={pending} className={primaryButtonClass}>
|
||||
{pending ? t("form.saving") : mode === "create" ? t("form.create") : t("form.save")}
|
||||
</button>
|
||||
<Link href={closeHref} className={buttonLinkClass}>
|
||||
{t("form.cancel")}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import Link from "next/link";
|
||||
import { AlertTriangle, Camera, CheckCircle2, FileText, MinusCircle, Siren } from "lucide-react";
|
||||
import { getFormatter, getTranslations } from "next-intl/server";
|
||||
import type { SiteHistoryEntry } from "@/server/services/sites/history";
|
||||
import { OrderStatusPill, orderStatusGroup } from "@/components/customers/status";
|
||||
|
||||
/**
|
||||
* Site history list (spec §8.3): newest first; open follow-up work is highlighted with a warning
|
||||
* border, icon and text (never color alone). Also reused read-only by the field lane.
|
||||
*/
|
||||
export async function SiteHistory({ entries, linkOrders = true }: { entries: SiteHistoryEntry[]; linkOrders?: boolean }) {
|
||||
const t = await getTranslations("sites");
|
||||
const format = await getFormatter();
|
||||
|
||||
if (entries.length === 0) return <p className="text-[13px] text-muted-foreground">{t("history.empty")}</p>;
|
||||
|
||||
return (
|
||||
<ol className="space-y-3">
|
||||
{entries.map((e) => (
|
||||
<li
|
||||
key={e.workOrderId}
|
||||
className={
|
||||
e.hasOpenFollowUp
|
||||
? "shadow-card rounded-xl border border-l-4 border-l-[var(--warn)] bg-card p-4"
|
||||
: "shadow-card rounded-xl border bg-card p-4"
|
||||
}
|
||||
>
|
||||
<div className="flex flex-wrap items-start justify-between gap-2">
|
||||
<div className="min-w-0">
|
||||
<p className="text-[12px] font-semibold text-muted-foreground">
|
||||
<time dateTime={e.date.toISOString()}>{format.dateTime(e.date, { dateStyle: "medium" })}</time>
|
||||
{" · "}
|
||||
{e.orderType ?? "—"}
|
||||
{" · "}
|
||||
{e.team ?? t("history.noTeam")}
|
||||
</p>
|
||||
<p className="mt-0.5 font-heading text-[15px] font-semibold">
|
||||
{linkOrders ? (
|
||||
<Link href={`/work-orders/${e.workOrderId}`} className="hover:underline">
|
||||
{e.number} · {e.title}
|
||||
</Link>
|
||||
) : (
|
||||
<>
|
||||
{e.number} · {e.title}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex flex-wrap items-center gap-1.5">
|
||||
{e.isEmergency && (
|
||||
<span className="inline-flex items-center gap-1 text-[12px] font-semibold text-[var(--risk)]">
|
||||
<Siren className="size-3.5" aria-hidden /> {t("history.emergency")}
|
||||
</span>
|
||||
)}
|
||||
<OrderStatusPill status={e.status} label={t(`statusGroup.${orderStatusGroup(e.status)}`)} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{e.hasOpenFollowUp && (
|
||||
<div className="mt-3 flex gap-2 rounded-lg bg-[var(--surface-soft)] p-3 text-[13px]">
|
||||
<AlertTriangle className="mt-0.5 size-4 shrink-0 text-[var(--warn)]" aria-hidden />
|
||||
<div>
|
||||
<p className="font-semibold text-[var(--warn)]">{t("history.followUp")}</p>
|
||||
<ul className="mt-1 list-disc space-y-0.5 pl-4">
|
||||
{e.followUps.map((f, i) => (
|
||||
<li key={i} className="whitespace-pre-line">{f}</li>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 grid gap-4 md:grid-cols-3">
|
||||
<section>
|
||||
<h4 className="text-[12px] font-semibold text-muted-foreground">{t("history.workDone")}</h4>
|
||||
{e.workDone.length ? (
|
||||
<ul className="mt-1 space-y-1 text-[13px]">
|
||||
{e.workDone.map((w, i) => (
|
||||
<li key={i} className="whitespace-pre-line">{w}</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">{t("history.noWorkDone")}</p>
|
||||
)}
|
||||
</section>
|
||||
<section>
|
||||
<h4 className="text-[12px] font-semibold text-muted-foreground">{t("history.materials")}</h4>
|
||||
{e.materials.length ? (
|
||||
<ul className="mt-1 space-y-0.5 text-[13px]">
|
||||
{e.materials.map((m) => (
|
||||
<li key={`${m.name}|${m.unit}`}>
|
||||
{format.number(m.quantity, { maximumFractionDigits: 3 })} {m.unit} · {m.name}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">{t("history.noMaterials")}</p>
|
||||
)}
|
||||
</section>
|
||||
<section>
|
||||
<h4 className="text-[12px] font-semibold text-muted-foreground">{t("history.reports")}</h4>
|
||||
{e.approvedReports.length ? (
|
||||
<ul className="mt-1 space-y-1 text-[13px]">
|
||||
{e.approvedReports.map((r) => (
|
||||
<li key={r.id}>
|
||||
<Link href={`/reports/${r.id}`} className="inline-flex min-h-8 items-center gap-1.5 font-semibold hover:underline">
|
||||
<FileText className="size-3.5" aria-hidden />
|
||||
{t("history.reportLink", {
|
||||
type: t(`history.reportType.${r.type}`),
|
||||
version: r.version,
|
||||
date: format.dateTime(r.reportDate, { dateStyle: "medium" }),
|
||||
})}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
) : (
|
||||
<p className="mt-1 text-[13px] text-muted-foreground">{t("history.noReports")}</p>
|
||||
)}
|
||||
<p className="mt-2 flex items-center gap-1.5 text-[13px]">
|
||||
<Camera className="size-3.5 text-muted-foreground" aria-hidden /> {t("history.photos", { count: e.photoCount })}
|
||||
</p>
|
||||
<p className="mt-1 flex items-center gap-1.5 text-[13px]">
|
||||
{e.signed ? (
|
||||
<>
|
||||
<CheckCircle2 className="size-3.5 text-[var(--ok)]" aria-hidden /> {t("history.signed")}
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<MinusCircle className="size-3.5 text-muted-foreground" aria-hidden /> {t("history.notSigned")}
|
||||
</>
|
||||
)}
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,157 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState, useEffect, useRef, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Plus, Trash2 } from "lucide-react";
|
||||
import type { ActionState } from "@/server/api/action-state";
|
||||
import { buttonLinkClass, controlClass, Field, FormSection, primaryButtonClass, textareaClass } from "@/components/customers/form-ui";
|
||||
import { FormError, IDLE_STATE, useFieldError, type FormAction } from "@/components/customers/action-form";
|
||||
|
||||
export type TeamFormValues = {
|
||||
name?: string | null;
|
||||
leaderUserId?: string | null;
|
||||
status?: "active" | "inactive";
|
||||
phone?: string | null;
|
||||
vehicle?: string | null;
|
||||
area?: string | null;
|
||||
notes?: string | null;
|
||||
members: { userId: string; validFrom: string; validTo: string }[];
|
||||
};
|
||||
|
||||
type Row = { key: number; userId: string; validFrom: string; validTo: string };
|
||||
|
||||
export function TeamForm({
|
||||
mode,
|
||||
action,
|
||||
initial,
|
||||
users,
|
||||
closeHref,
|
||||
}: {
|
||||
mode: "create" | "edit";
|
||||
action: FormAction;
|
||||
initial: TeamFormValues;
|
||||
users: { id: string; name: string; email: string }[];
|
||||
closeHref: string;
|
||||
}) {
|
||||
const t = useTranslations("teams");
|
||||
const router = useRouter();
|
||||
const fieldError = useFieldError("teams");
|
||||
const nextKey = useRef(initial.members.length);
|
||||
const [rows, setRows] = useState<Row[]>(initial.members.map((m, i) => ({ key: i, ...m })));
|
||||
const [values, setValues] = useState<Record<string, string>>(
|
||||
Object.fromEntries(Object.entries(initial).filter(([k]) => k !== "members").map(([k, v]) => [k, String(v ?? "")])),
|
||||
);
|
||||
const [state, formAction, pending] = useActionState<ActionState, FormData>(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 err = (name: string) => fieldError(state, name);
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const input = (name: string, type = "text", required = false) => (
|
||||
<Field id={`t-${name}`} label={t(`fields.${name}`)} required={required} error={err(name)}>
|
||||
<input id={`t-${name}`} name={name} type={type} required={required} defaultValue={values[name] ?? ""} aria-invalid={err(name) ? true : undefined} className={controlClass} />
|
||||
</Field>
|
||||
);
|
||||
const updateRow = (key: number, patch: Partial<Row>) => setRows((rs) => rs.map((r) => (r.key === key ? { ...r, ...patch } : r)));
|
||||
|
||||
return (
|
||||
<form action={formAction} className="space-y-5 p-5">
|
||||
<FormSection title={t("form.sub")}>
|
||||
{input("name", "text", true)}
|
||||
<Field id="t-status" label={t("fields.status")}>
|
||||
<select id="t-status" name="status" defaultValue={values.status || "active"} className={controlClass}>
|
||||
<option value="active">{t("status.active")}</option>
|
||||
<option value="inactive">{t("status.inactive")}</option>
|
||||
</select>
|
||||
</Field>
|
||||
<Field id="t-leaderUserId" label={t("fields.leaderUserId")} error={err("leaderUserId")}>
|
||||
<select id="t-leaderUserId" name="leaderUserId" defaultValue={values.leaderUserId ?? ""} className={controlClass}>
|
||||
<option value="">{t("fields.noLeader")}</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name} ({u.email})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
{input("phone", "tel")}
|
||||
{input("vehicle")}
|
||||
{input("area")}
|
||||
<Field id="t-notes" label={t("fields.notes")} className="sm:col-span-2">
|
||||
<textarea id="t-notes" name="notes" defaultValue={values.notes ?? ""} className={textareaClass} />
|
||||
</Field>
|
||||
</FormSection>
|
||||
|
||||
<fieldset>
|
||||
<legend className="mb-2 font-heading text-[13px] font-semibold tracking-wide text-muted-foreground uppercase">{t("members.title")}</legend>
|
||||
{rows.length === 0 && <p className="mb-2 text-[13px] text-muted-foreground">{t("members.empty")}</p>}
|
||||
<div className="space-y-2">
|
||||
{rows.map((row, idx) => (
|
||||
<div key={row.key} className="grid gap-2 rounded-lg border bg-[var(--surface-soft)] p-3 sm:grid-cols-[1fr_10rem_10rem_auto] sm:items-end">
|
||||
<Field id={`m-user-${row.key}`} label={t("members.user")}>
|
||||
<select
|
||||
id={`m-user-${row.key}`}
|
||||
name="memberUserId"
|
||||
required
|
||||
value={row.userId}
|
||||
onChange={(e) => updateRow(row.key, { userId: e.target.value })}
|
||||
className={controlClass}
|
||||
>
|
||||
<option value="" disabled>
|
||||
{t("members.selectUser")}
|
||||
</option>
|
||||
{users.map((u) => (
|
||||
<option key={u.id} value={u.id}>
|
||||
{u.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field id={`m-from-${row.key}`} label={t("members.validFrom")}>
|
||||
<input id={`m-from-${row.key}`} type="date" name="memberValidFrom" value={row.validFrom} onChange={(e) => updateRow(row.key, { validFrom: e.target.value })} className={controlClass} />
|
||||
</Field>
|
||||
<Field id={`m-to-${row.key}`} label={t("members.validTo")} hint={idx === 0 ? t("members.validToHint") : undefined}>
|
||||
<input id={`m-to-${row.key}`} type="date" name="memberValidTo" value={row.validTo} onChange={(e) => updateRow(row.key, { validTo: e.target.value })} className={controlClass} />
|
||||
</Field>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setRows((rs) => rs.filter((r) => r.key !== row.key))}
|
||||
className={`${buttonLinkClass} self-end`}
|
||||
aria-label={`${t("members.remove")}: ${users.find((u) => u.id === row.userId)?.name ?? idx + 1}`}
|
||||
>
|
||||
<Trash2 className="size-4" aria-hidden />
|
||||
<span className="sm:sr-only">{t("members.remove")}</span>
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{err("members") && <p role="alert" className="mt-2 text-[12px] font-semibold text-[var(--risk)]">{err("members")}</p>}
|
||||
<button
|
||||
type="button"
|
||||
className={`${buttonLinkClass} mt-2`}
|
||||
onClick={() => setRows((rs) => [...rs, { key: nextKey.current++, userId: "", validFrom: today, validTo: "" }])}
|
||||
>
|
||||
<Plus className="size-4" aria-hidden /> {t("members.add")}
|
||||
</button>
|
||||
</fieldset>
|
||||
|
||||
<FormError namespace="teams" state={state} />
|
||||
|
||||
<div className="flex flex-wrap gap-2 border-t pt-4">
|
||||
<button type="submit" disabled={pending} className={primaryButtonClass}>
|
||||
{pending ? t("form.saving") : mode === "create" ? t("form.create") : t("form.save")}
|
||||
</button>
|
||||
<Link href={closeHref} className={buttonLinkClass}>
|
||||
{t("form.cancel")}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user