L2 Aufträge & Backoffice: Backoffice-UI (Liste, Detail, Konflikte, Dashboard, Suche, Einstellungen) + Texte de/en

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-14 12:30:32 +02:00
co-authored by Claude Opus 5
parent a962fa8be9
commit b96016d593
23 changed files with 3545 additions and 34 deletions
+30 -3
View File
@@ -2,7 +2,34 @@
"title": "Dashboard",
"crumb": "Übersicht",
"subtitle": "Willkommen, {name} — {tenant}",
"placeholderTitle": "Dashboard in Umsetzung",
"placeholder": "Hier erscheinen künftig offene Aufträge, heutige Einsätze, freizugebende Berichte und Notdienst-Meldungen.",
"moduleDisabled": "Das angeforderte Modul ist für Ihren Betrieb nicht freigeschaltet."
"moduleDisabled": "Das angeforderte Modul ist für Ihren Betrieb nicht freigeschaltet.",
"workOrdersDisabled": "Das Modul Aufträge ist nicht freigeschaltet – es gibt keine Kennzahlen.",
"filterTitle": "Kennzahlen eingrenzen",
"tiles": {
"open": "Offene Aufträge",
"today": "Heutige Einsätze",
"running": "Laufende Einsätze",
"not_accepted": "Nicht angenommen",
"overdue": "Überfällig",
"reports_in_review": "Berichte zur Prüfung",
"completed": "Abgeschlossen",
"billing": "Zur Abrechnung",
"emergency_new": "Neue Notdienste",
"missing_signatures": "Fehlende Unterschriften",
"sync_conflicts": "Sync-Konflikte"
},
"hints": {
"open": "Noch nicht abgerechnet oder storniert",
"today": "Für heute geplant",
"running": "Unterwegs, in Arbeit, pausiert",
"not_accepted": "Zugewiesen, noch nicht angenommen",
"overdue": "Geplantes Ende überschritten",
"reports_in_review": "Eingereicht, noch nicht freigegeben",
"completed": "Technisch abgeschlossen oder weiter",
"billing": "Bereit zur Abrechnung",
"emergency_new": "Neu oder zur Prüfung",
"missing_signatures": "Unterschrift ausstehend",
"sync_conflicts": "Offline-Änderungen prüfen"
},
"open": "Anzeigen"
}
+28
View File
@@ -0,0 +1,28 @@
{
"crumb": "Suche",
"title": "Suche",
"placeholder": "Aufträge, Kunden, Objekte, Adressen, Dokumente, Notizen",
"submit": "Suchen",
"hint": "Mindestens 2 Zeichen eingeben.",
"noResults": "Keine Treffer für „{q}“.",
"resultsFor": "Treffer für „{q}“",
"filters": {
"from": "Von",
"to": "Bis",
"status": "Status",
"team": "Team",
"any": "Alle",
"apply": "Filtern",
"reset": "Filter zurücksetzen",
"note": "Zeitraum, Status und Team grenzen Aufträge und alles am Auftrag ein."
},
"sections": {
"workOrders": "Aufträge",
"customers": "Kunden",
"sites": "Objekte",
"contacts": "Ansprechpartner",
"documents": "Dokumente",
"notes": "Notizen"
},
"maxHint": "Es werden höchstens 20 Treffer je Bereich angezeigt."
}
+73
View File
@@ -0,0 +1,73 @@
{
"crumb": "Einstellungen",
"nav": {
"orderTypes": "Auftragsarten",
"checklists": "Checklisten-Vorlagen",
"numbering": "Nummernkreise"
},
"orderTypes": {
"title": "Auftragsarten",
"sub": "Auftragsarten je Betrieb – mit Vorgabe, ob eine Kundenunterschrift nötig ist",
"key": "Schlüssel",
"name": "Bezeichnung",
"signatureRequired": "Unterschrift erforderlich",
"active": "Aktiv",
"sortOrder": "Reihenfolge",
"create": "Auftragsart anlegen",
"save": "Speichern",
"keyHint": "Kleinbuchstaben, Ziffern, Unterstrich – nicht änderbar",
"inactive": "Inaktiv",
"yes": "Ja",
"no": "Nein"
},
"checklists": {
"title": "Checklisten-Vorlagen",
"sub": "Vorlagen je Auftragsart – werden beim Anlegen eines Auftrags übernommen",
"empty": "Noch keine Vorlagen.",
"new": "Neue Vorlage",
"edit": "Bearbeiten",
"name": "Name",
"orderType": "Auftragsart",
"noOrderType": "— keine —",
"active": "Aktiv",
"items": "Checklistenpunkte",
"itemsHint": "Eine Zeile je Punkt. Zusätze: „*“ am Anfang = Pflicht, „[Foto]“ am Ende = mit Foto.",
"photos": "Pflichtfotos",
"photosHint": "Eine Zeile je Pflichtfoto.",
"suggestion": "Standardvorschlag einfügen",
"suggestionHint": "Vorschlag aus den Standardpunkten; vor dem Speichern anpassen.",
"save": "Vorlage speichern",
"itemCount": "{count, plural, =0 {Keine Punkte} one {# Punkt} other {# Punkte}}",
"photoCount": "{count, plural, =0 {keine Pflichtfotos} one {# Pflichtfoto} other {# Pflichtfotos}}",
"inactive": "Inaktiv",
"photoSuffix": "[Foto]"
},
"numbering": {
"title": "Nummernkreise",
"sub": "Präfix und Stellenzahl der fortlaufenden Nummern. Der Zähler läuft immer weiter.",
"key": "Nummernkreis",
"prefix": "Präfix",
"padding": "Stellen",
"next": "Nächste Nummer",
"save": "Speichern",
"keys": {
"work_order": "Aufträge",
"emergency": "Notdienste",
"customer": "Kunden",
"report": "Berichte"
}
},
"saved": "Gespeichert.",
"errors": {
"forbidden": "Dafür fehlt die Berechtigung.",
"invalid": "Eingaben prüfen.",
"not_found": "Nicht gefunden.",
"internal": "Vorgang nicht möglich.",
"validation_failed": "Pflichtfelder prüfen.",
"order_type_key_taken": "Dieser Schlüssel ist bereits vergeben.",
"order_type_not_found": "Auftragsart nicht gefunden.",
"template_not_found": "Vorlage nicht gefunden.",
"duplicate_item_key": "Zwei Checklistenpunkte haben dieselbe Bezeichnung.",
"duplicate_photo_key": "Zwei Pflichtfotos haben dieselbe Bezeichnung."
}
}
+445
View File
@@ -0,0 +1,445 @@
{
"crumb": "Aufträge",
"title": "Aufträge",
"subtitle": "Planen, zuweisen, prüfen und zur Abrechnung freigeben",
"new": "Neuer Auftrag",
"conflictsLink": "Sync-Konflikte",
"allGroups": "Alle",
"empty": "Keine Aufträge für diese Auswahl.",
"resultCount": "{count, plural, =0 {Keine Aufträge} one {# Auftrag} other {# Aufträge}}",
"presetActive": "Kachel-Filter: {name}",
"removePreset": "Kachel-Filter entfernen",
"view": { "cards": "Karten", "table": "Tabelle" },
"pagination": { "prev": "Zurück", "next": "Weiter", "page": "Seite {page} von {pages}" },
"filter": {
"title": "Filter",
"q": "Nummer, Titel, Kunde, Ort",
"from": "Von",
"to": "Bis",
"customer": "Kunde",
"site": "Objekt",
"team": "Team",
"user": "Monteur",
"status": "Status",
"orderType": "Auftragsart",
"priority": "Priorität",
"sort": "Sortierung",
"any": "Alle",
"apply": "Filtern",
"reset": "Zurücksetzen"
},
"sort": {
"plannedStart": "Geplanter Beginn",
"createdAt": "Angelegt",
"updatedAt": "Zuletzt geändert",
"number": "Nummer",
"priority": "Priorität",
"status": "Status",
"asc": "aufsteigend",
"desc": "absteigend"
},
"columns": {
"number": "Nummer",
"title": "Titel",
"customer": "Kunde",
"site": "Objekt",
"planned": "Geplant",
"team": "Team",
"status": "Status",
"priority": "Priorität"
},
"status": {
"draft": "Entwurf",
"review_required": "Prüfung erforderlich",
"planned": "Geplant",
"assigned": "Zugewiesen",
"accepted": "Angenommen",
"en_route": "In Anfahrt",
"in_progress": "In Bearbeitung",
"paused": "Pausiert",
"waiting_material": "Wartet auf Material",
"daily_report_created": "Tagesbericht erstellt",
"technically_completed": "Technisch abgeschlossen",
"signature_pending": "Unterschrift ausstehend",
"in_review": "Zur Prüfung",
"released_for_billing": "Zur Abrechnung freigegeben",
"billed": "Abgerechnet",
"cancelled": "Storniert"
},
"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"
},
"priority": { "low": "Niedrig", "normal": "Normal", "high": "Hoch", "urgent": "Dringend" },
"billingType": {
"fixed": "Pauschal",
"time_material": "Nach Aufwand",
"maintenance_contract": "Wartungsvertrag",
"warranty": "Gewährleistung"
},
"emergency": "Notdienst",
"unassigned": "Nicht zugewiesen",
"noDate": "Ohne Termin",
"create": {
"title": "Neuer Auftrag",
"sub": "Kunde wählen, Auftrag erfassen",
"stepCustomer": "1. Kunde suchen",
"customerSearch": "Firma, Name, Kundennummer oder Ort",
"searchButton": "Suchen",
"noCustomers": "Kein Kunde gefunden. Kunden werden unter „Kunden“ angelegt.",
"chooseCustomer": "Auswählen",
"changeCustomer": "Anderen Kunden wählen",
"stepDetails": "2. Auftrag erfassen",
"submit": "Auftrag anlegen",
"pending": "Lege an…"
},
"fields": {
"title": "Titel",
"customer": "Kunde",
"site": "Objekt",
"contact": "Ansprechpartner",
"none": "— keine Auswahl —",
"orderType": "Auftragsart",
"priority": "Priorität",
"plannedStart": "Geplanter Beginn",
"plannedEnd": "Geplantes Ende",
"description": "Beschreibung",
"scope": "Leistungsumfang",
"internalNotes": "Hinweise intern (nur Büro)",
"technicianNotes": "Hinweise für Monteure",
"signatureRequired": "Unterschrift erforderlich",
"signatureFromType": "Wie Auftragsart",
"yes": "Ja",
"no": "Nein",
"billingType": "Abrechnungsart",
"externalOrderNumber": "Externe Auftragsnummer",
"offerNumber": "Angebotsnummer",
"status": "Anlegen als",
"timezoneHint": "Zeiten in {tz}"
},
"detail": {
"back": "Zurück zu Aufträgen",
"version": "Version {version}",
"nextStep": "Nächster Schritt",
"moreActions": "Weitere Statusänderungen",
"edit": "Bearbeiten",
"assign": "Zuweisen",
"reassign": "Zuweisung ändern",
"cancel": "Stornieren",
"noActions": "Für Sie ist gerade keine Statusänderung möglich.",
"blockersTitle": "Vor dem Abschluss fehlt noch:",
"tabs": {
"overview": "Übersicht",
"checklist": "Checkliste & Pflichtfotos",
"material": "Material",
"times": "Zeiten",
"photos": "Fotos",
"notes": "Notizen",
"reports": "Berichte",
"documents": "Dokumente",
"history": "Verlauf"
}
},
"overview": {
"customer": "Kunde",
"site": "Objekt",
"contact": "Ansprechpartner",
"planning": "Planung",
"assignment": "Zuweisung",
"team": "Team",
"teamLead": "Teamleiter",
"assignees": "Monteure",
"order": "Auftrag",
"created": "Angelegt",
"updated": "Geändert",
"accessNotes": "Zugang",
"safetyNotes": "Sicherheit"
},
"checklist": {
"items": "Checkliste",
"photos": "Pflichtfotos",
"empty": "Keine Checklistenpunkte.",
"emptyPhotos": "Keine Pflichtfotos.",
"required": "Pflicht",
"optional": "Optional",
"requiresPhoto": "mit Foto",
"done": "Erledigt",
"open": "Offen",
"photoCount": "{count, plural, =0 {Kein Foto} one {# Foto} other {# Fotos}}",
"addItem": "Punkt hinzufügen",
"addPhoto": "Pflichtfoto hinzufügen",
"label": "Bezeichnung",
"applyTemplate": "Vorlage übernehmen",
"template": "Vorlage",
"applyButton": "Übernehmen",
"remove": "Entfernen",
"complete": "Alle Abschluss-Voraussetzungen erfüllt."
},
"material": {
"title": "Material – geplant und tatsächlich",
"empty": "Kein Material geplant oder erfasst.",
"name": "Bezeichnung",
"articleNumber": "Artikelnummer",
"planned": "Geplant",
"actual": "Tatsächlich",
"deviation": "Abweichung",
"unit": "Einheit",
"notes": "Bemerkung",
"reason": "Begründung",
"additional": "Zusätzlich",
"notRecorded": "Noch nicht erfasst",
"add": "Material planen",
"remove": "Entfernen",
"usage": {
"fully_used": "Vollständig verwendet",
"partially_used": "Teilweise verwendet",
"not_used": "Nicht verwendet",
"additional": "Zusätzlich verwendet"
}
},
"times": {
"empty": "Noch keine Zeiten erfasst.",
"person": "Person",
"start": "Beginn",
"end": "Ende",
"duration": "Arbeitszeit",
"minutes": "{minutes} min",
"corrected": "Korrigiert: {reason}",
"sessionStatus": {
"en_route": "Unterwegs",
"running": "Läuft",
"paused": "Pausiert",
"ended": "Beendet"
},
"entryType": {
"travel": "Anfahrt",
"work": "Arbeit",
"break": "Pause",
"material_procurement": "Materialbeschaffung",
"return_travel": "Rückfahrt",
"interruption": "Unterbrechung"
}
},
"photos": {
"empty": "Noch keine Fotos.",
"phase": { "before": "Vorher", "during": "Während", "after": "Nachher" },
"open": "Öffnen",
"inReport": "Im Bericht"
},
"notes": {
"empty": "Keine Notizen.",
"internal": "Hinweise intern",
"technician": "Hinweise für Monteure",
"kind": {
"work_done": "Ausgeführt",
"deviation": "Abweichung",
"problem": "Problem",
"additional_work": "Zusatzarbeit",
"not_executable": "Nicht ausführbar",
"follow_up": "Folgearbeit",
"recommendation": "Empfehlung",
"customer_note": "Kundenhinweis",
"general": "Allgemein"
}
},
"reports": {
"empty": "Noch keine Berichte.",
"type": { "daily": "Tagesbericht", "completion": "Abschlussbericht" },
"status": {
"draft": "Entwurf",
"submitted": "Zur Prüfung",
"team_approved": "Vom Teamleiter freigegeben",
"approved": "Freigegeben",
"rejected": "Zurückgewiesen",
"superseded": "Ersetzt"
},
"open": "Bericht öffnen",
"signature": "Unterschrift: {outcome}",
"signatureOutcome": {
"signed": "unterschrieben",
"customer_absent": "Kunde nicht anwesend",
"refused": "verweigert",
"later": "später",
"not_required": "nicht erforderlich"
},
"billingTitle": "Abrechnung",
"billingHint": "Freigabe nur mit freigegebenem Abschlussbericht.",
"release": "Zur Abrechnung freigeben",
"reject": "Zur Korrektur zurückweisen",
"markBilled": "Als abgerechnet markieren",
"revoke": "Freigabe zurücknehmen"
},
"documents": {
"empty": "Keine Dokumente am Auftrag.",
"upload": "Dokument hochladen",
"file": "Datei (PDF, JPG, PNG, WebP)",
"title": "Titel (optional)",
"category": "Kategorie",
"visibility": "Sichtbarkeit",
"submit": "Hochladen",
"archive": "Entfernen",
"download": "Herunterladen",
"stubHint": "Dateien werden bis zur Anbindung der Dokumentenablage direkt im Objektspeicher abgelegt.",
"categories": {
"order_confirmation": "Auftragsbestätigung",
"technical_drawing": "Technische Zeichnung",
"floor_plan": "Grundriss",
"wiring_diagram": "Schaltplan",
"assembly_instructions": "Montageanleitung",
"safety_document": "Sicherheitsdokument",
"product_document": "Produktunterlage",
"customer_note": "Kundennotiz",
"work_record": "Arbeitsnachweis",
"daily_report": "Tagesbericht",
"completion_report": "Abschlussbericht",
"customer_approval": "Kundenfreigabe",
"photo": "Foto",
"voice_note": "Sprachnotiz",
"signature": "Unterschrift",
"other": "Sonstiges"
},
"visibilities": {
"backoffice_only": "Nur Büro",
"team_lead": "Teamleiter",
"team": "Team",
"customer_report": "Kundenbericht"
}
},
"history": {
"statusChanges": "Statusänderungen",
"audit": "Protokoll",
"empty": "Noch keine Einträge.",
"created": "Angelegt als {status}",
"change": "{from} → {to}",
"by": "von {name}",
"system": "System",
"action": { "create": "Erstellt", "update": "Geändert", "delete": "Gelöscht" }
},
"assign": {
"title": "Auftrag zuweisen",
"sub": "Team wählen, optional einzelne Monteure und Teamleiter",
"team": "Team",
"chooseTeam": "— Team wählen —",
"members": "Einzelne Monteure (optional)",
"teamLead": "Zuständiger Teamleiter",
"teamLeadDefault": "Teamleiter des Teams",
"noTeams": "Keine aktiven Teams. Teams werden unter „Teams“ angelegt.",
"submit": "Zuweisen",
"pending": "Weise zu…"
},
"transition": {
"to": {
"draft": "Als Entwurf",
"review_required": "Zur Prüfung markieren",
"planned": "Einplanen",
"assigned": "Zuweisen",
"accepted": "Annehmen",
"en_route": "Anfahrt starten",
"in_progress": "Arbeit aufnehmen",
"paused": "Pausieren",
"waiting_material": "Wartet auf Material",
"daily_report_created": "Tagesbericht erstellt",
"technically_completed": "Technisch abschließen",
"signature_pending": "Unterschrift ausstehend",
"in_review": "Zur Prüfung geben",
"released_for_billing": "Zur Abrechnung freigeben",
"billed": "Als abgerechnet markieren",
"cancelled": "Stornieren"
},
"correction": "Zur Korrektur zurückweisen",
"revoke": "Freigabe zurücknehmen",
"reasonTitle": "Begründung erforderlich",
"reason": "Begründung",
"reasonPlaceholder": "Kurz beschreiben, warum",
"confirm": "Bestätigen",
"pending": "Speichere…",
"done": "Status geändert."
},
"edit": {
"title": "Auftrag bearbeiten",
"submit": "Speichern",
"pending": "Speichere…",
"saved": "Gespeichert."
},
"conflicts": {
"crumb": "Aufträge",
"title": "Sync-Konflikte",
"sub": "Offline erfasste Änderungen, die nicht automatisch übernommen werden konnten",
"empty": "Keine offenen Konflikte.",
"received": "Eingegangen",
"user": "Gerät / Person",
"operation": "Vorgang",
"entity": "Auftrag",
"baseVersion": "Stand Gerät: Version {base} · aktuell: Version {current}",
"payload": "Inhalt",
"apply": "Übernehmen",
"discard": "Verwerfen",
"applied": "Übernommen.",
"discarded": "Verworfen.",
"applyHint": "Übernehmen wendet den Vorgang erneut auf den aktuellen Stand an – im Namen der Person, die ihn erfasst hat.",
"stubHint": "Übernehmen ist bis zur Anbindung der Synchronisation nur für Statusänderungen möglich."
},
"errors": {
"not_found": "Nicht gefunden oder keine Berechtigung.",
"forbidden": "Dafür fehlt die Berechtigung.",
"invalid": "Eingaben prüfen.",
"conflict": "Der Auftrag wurde inzwischen geändert. Seite neu laden und erneut versuchen.",
"blocked": "Noch nicht möglich – es fehlen Angaben.",
"internal": "Vorgang nicht möglich.",
"validation_failed": "Pflichtfelder prüfen.",
"work_order_not_found": "Auftrag nicht gefunden.",
"customer_not_found": "Kunde nicht gefunden.",
"site_not_found": "Objekt nicht gefunden.",
"contact_not_found": "Ansprechpartner nicht gefunden.",
"site_customer_mismatch": "Das Objekt gehört zu einem anderen Kunden.",
"contact_customer_mismatch": "Der Ansprechpartner gehört zu einem anderen Kunden.",
"order_type_not_found": "Auftragsart nicht gefunden.",
"planned_end_before_start": "Das Ende liegt vor dem Beginn.",
"nothing_to_update": "Keine Änderungen.",
"not_editable": "Der Auftrag kann in diesem Status nicht mehr geändert werden.",
"delete_only_draft": "Nur Entwürfe können gelöscht werden.",
"transition_not_allowed": "Dieser Statuswechsel ist nicht vorgesehen.",
"transition_forbidden": "Diesen Statuswechsel dürfen Sie nicht ausführen.",
"transition_blocked": "Noch nicht möglich – es fehlen Angaben.",
"reason_required": "Bitte eine Begründung angeben.",
"version_conflict": "Der Auftrag wurde inzwischen geändert. Seite neu laden und erneut versuchen.",
"team_not_found": "Team nicht gefunden oder nicht aktiv.",
"user_not_found": "Mindestens eine Person ist nicht aktiv.",
"material_plan_not_found": "Materialposition nicht gefunden.",
"material_in_use": "Zu dieser Position wurde bereits Material erfasst.",
"checklist_item_not_found": "Checklistenpunkt nicht gefunden.",
"checklist_item_checked": "Erledigte Punkte bleiben als Nachweis erhalten.",
"photo_requirement_not_found": "Pflichtfoto nicht gefunden.",
"photo_requirement_has_photos": "Zu diesem Pflichtfoto gibt es bereits Fotos.",
"template_not_found": "Vorlage nicht gefunden.",
"sync_conflict_not_found": "Konflikt nicht gefunden oder bereits erledigt.",
"sync_conflict_already_resolved": "Konflikt wurde bereits erledigt.",
"sync_payload_invalid": "Der Vorgang ist unvollständig.",
"sync_user_inactive": "Die Person ist nicht mehr aktiv.",
"reapply_unsupported": "Dieser Vorgang kann noch nicht übernommen werden.",
"document_not_found": "Dokument nicht gefunden.",
"file_type_not_allowed": "Dateityp nicht erlaubt.",
"file_too_large": "Datei ist leer oder zu groß.",
"file_missing": "Bitte eine Datei wählen.",
"category_invalid": "Kategorie nicht erlaubt.",
"visibility_forbidden": "Diese Sichtbarkeit dürfen Sie nicht vergeben.",
"initial_status_not_allowed": "Dieser Anfangsstatus ist nicht erlaubt."
},
"blockers": {
"checklist_item": "Checkliste: {label}",
"photo_requirement": "Pflichtfoto: {label}",
"running_session": "Ein Einsatz läuft noch",
"field": {
"signature": "Unterschrift fehlt",
"approved_completion_report": "Abschlussbericht ist noch nicht freigegeben"
}
},
"saved": "Gespeichert."
}
+30 -3
View File
@@ -2,7 +2,34 @@
"title": "Dashboard",
"crumb": "Overview",
"subtitle": "Welcome, {name} — {tenant}",
"placeholderTitle": "Dashboard under construction",
"placeholder": "Open work orders, today's jobs, reports awaiting approval and emergency calls will appear here.",
"moduleDisabled": "The requested module is not enabled for your company."
"moduleDisabled": "The requested module is not enabled for your company.",
"workOrdersDisabled": "The work orders module is not enabled – there are no figures.",
"filterTitle": "Narrow down figures",
"tiles": {
"open": "Open work orders",
"today": "Today's jobs",
"running": "Running jobs",
"not_accepted": "Not accepted",
"overdue": "Overdue",
"reports_in_review": "Reports to review",
"completed": "Completed",
"billing": "Ready for billing",
"emergency_new": "New emergencies",
"missing_signatures": "Missing signatures",
"sync_conflicts": "Sync conflicts"
},
"hints": {
"open": "Not yet billed or cancelled",
"today": "Planned for today",
"running": "En route, in progress, paused",
"not_accepted": "Assigned, not yet accepted",
"overdue": "Planned end exceeded",
"reports_in_review": "Submitted, not yet approved",
"completed": "Technically completed or beyond",
"billing": "Ready to invoice",
"emergency_new": "New or in review",
"missing_signatures": "Signature pending",
"sync_conflicts": "Review offline changes"
},
"open": "Show"
}
+28
View File
@@ -0,0 +1,28 @@
{
"crumb": "Search",
"title": "Search",
"placeholder": "Work orders, customers, sites, addresses, documents, notes",
"submit": "Search",
"hint": "Enter at least 2 characters.",
"noResults": "No results for “{q}”.",
"resultsFor": "Results for “{q}”",
"filters": {
"from": "From",
"to": "To",
"status": "Status",
"team": "Team",
"any": "All",
"apply": "Apply",
"reset": "Reset filters",
"note": "Period, status and team narrow work orders and everything attached to them."
},
"sections": {
"workOrders": "Work orders",
"customers": "Customers",
"sites": "Sites",
"contacts": "Contact persons",
"documents": "Documents",
"notes": "Notes"
},
"maxHint": "At most 20 results per section are shown."
}
+73
View File
@@ -0,0 +1,73 @@
{
"crumb": "Settings",
"nav": {
"orderTypes": "Order types",
"checklists": "Checklist templates",
"numbering": "Number sequences"
},
"orderTypes": {
"title": "Order types",
"sub": "Order types per company – including whether a customer signature is required",
"key": "Key",
"name": "Name",
"signatureRequired": "Signature required",
"active": "Active",
"sortOrder": "Order",
"create": "Create order type",
"save": "Save",
"keyHint": "Lower-case letters, digits, underscore – cannot be changed",
"inactive": "Inactive",
"yes": "Yes",
"no": "No"
},
"checklists": {
"title": "Checklist templates",
"sub": "Templates per order type – applied when a work order is created",
"empty": "No templates yet.",
"new": "New template",
"edit": "Edit",
"name": "Name",
"orderType": "Order type",
"noOrderType": "— none —",
"active": "Active",
"items": "Checklist items",
"itemsHint": "One line per item. Prefix “*” = required, suffix “[Foto]” = with photo.",
"photos": "Required photos",
"photosHint": "One line per required photo.",
"suggestion": "Insert default suggestion",
"suggestionHint": "Suggestion from the default items; adjust before saving.",
"save": "Save template",
"itemCount": "{count, plural, =0 {No items} one {# item} other {# items}}",
"photoCount": "{count, plural, =0 {no required photos} one {# required photo} other {# required photos}}",
"inactive": "Inactive",
"photoSuffix": "[Foto]"
},
"numbering": {
"title": "Number sequences",
"sub": "Prefix and number of digits of running numbers. The counter always continues.",
"key": "Sequence",
"prefix": "Prefix",
"padding": "Digits",
"next": "Next number",
"save": "Save",
"keys": {
"work_order": "Work orders",
"emergency": "Emergencies",
"customer": "Customers",
"report": "Reports"
}
},
"saved": "Saved.",
"errors": {
"forbidden": "You lack the permission for this.",
"invalid": "Please check your input.",
"not_found": "Not found.",
"internal": "Operation not possible.",
"validation_failed": "Please check required fields.",
"order_type_key_taken": "This key is already taken.",
"order_type_not_found": "Order type not found.",
"template_not_found": "Template not found.",
"duplicate_item_key": "Two checklist items have the same label.",
"duplicate_photo_key": "Two required photos have the same label."
}
}
+445
View File
@@ -0,0 +1,445 @@
{
"crumb": "Work orders",
"title": "Work orders",
"subtitle": "Plan, assign, review and release for billing",
"new": "New work order",
"conflictsLink": "Sync conflicts",
"allGroups": "All",
"empty": "No work orders for this selection.",
"resultCount": "{count, plural, =0 {No work orders} one {# work order} other {# work orders}}",
"presetActive": "Tile filter: {name}",
"removePreset": "Remove tile filter",
"view": { "cards": "Cards", "table": "Table" },
"pagination": { "prev": "Previous", "next": "Next", "page": "Page {page} of {pages}" },
"filter": {
"title": "Filter",
"q": "Number, title, customer, city",
"from": "From",
"to": "To",
"customer": "Customer",
"site": "Site",
"team": "Team",
"user": "Technician",
"status": "Status",
"orderType": "Order type",
"priority": "Priority",
"sort": "Sort",
"any": "All",
"apply": "Apply",
"reset": "Reset"
},
"sort": {
"plannedStart": "Planned start",
"createdAt": "Created",
"updatedAt": "Last changed",
"number": "Number",
"priority": "Priority",
"status": "Status",
"asc": "ascending",
"desc": "descending"
},
"columns": {
"number": "Number",
"title": "Title",
"customer": "Customer",
"site": "Site",
"planned": "Planned",
"team": "Team",
"status": "Status",
"priority": "Priority"
},
"status": {
"draft": "Draft",
"review_required": "Review required",
"planned": "Planned",
"assigned": "Assigned",
"accepted": "Accepted",
"en_route": "En route",
"in_progress": "In progress",
"paused": "Paused",
"waiting_material": "Waiting for material",
"daily_report_created": "Daily report created",
"technically_completed": "Technically completed",
"signature_pending": "Signature pending",
"in_review": "In review",
"released_for_billing": "Released for billing",
"billed": "Billed",
"cancelled": "Cancelled"
},
"statusGroup": {
"new": "New",
"planned": "Planned",
"en_route": "En route",
"in_progress": "In progress",
"documentation_incomplete": "Documentation incomplete",
"in_review": "In review",
"ready_for_billing": "Ready for billing",
"billed": "Billed",
"cancelled": "Cancelled"
},
"priority": { "low": "Low", "normal": "Normal", "high": "High", "urgent": "Urgent" },
"billingType": {
"fixed": "Fixed price",
"time_material": "Time and material",
"maintenance_contract": "Maintenance contract",
"warranty": "Warranty"
},
"emergency": "Emergency",
"unassigned": "Not assigned",
"noDate": "No date",
"create": {
"title": "New work order",
"sub": "Choose a customer, enter the order",
"stepCustomer": "1. Find customer",
"customerSearch": "Company, name, customer number or city",
"searchButton": "Search",
"noCustomers": "No customer found. Customers are created under “Customers”.",
"chooseCustomer": "Select",
"changeCustomer": "Choose another customer",
"stepDetails": "2. Enter order",
"submit": "Create work order",
"pending": "Creating…"
},
"fields": {
"title": "Title",
"customer": "Customer",
"site": "Site",
"contact": "Contact person",
"none": "— none —",
"orderType": "Order type",
"priority": "Priority",
"plannedStart": "Planned start",
"plannedEnd": "Planned end",
"description": "Description",
"scope": "Scope of work",
"internalNotes": "Internal notes (office only)",
"technicianNotes": "Notes for technicians",
"signatureRequired": "Signature required",
"signatureFromType": "As order type",
"yes": "Yes",
"no": "No",
"billingType": "Billing type",
"externalOrderNumber": "External order number",
"offerNumber": "Offer number",
"status": "Create as",
"timezoneHint": "Times in {tz}"
},
"detail": {
"back": "Back to work orders",
"version": "Version {version}",
"nextStep": "Next step",
"moreActions": "Other status changes",
"edit": "Edit",
"assign": "Assign",
"reassign": "Change assignment",
"cancel": "Cancel order",
"noActions": "No status change is available for you right now.",
"blockersTitle": "Still missing before completion:",
"tabs": {
"overview": "Overview",
"checklist": "Checklist & required photos",
"material": "Material",
"times": "Times",
"photos": "Photos",
"notes": "Notes",
"reports": "Reports",
"documents": "Documents",
"history": "History"
}
},
"overview": {
"customer": "Customer",
"site": "Site",
"contact": "Contact person",
"planning": "Planning",
"assignment": "Assignment",
"team": "Team",
"teamLead": "Team lead",
"assignees": "Technicians",
"order": "Order",
"created": "Created",
"updated": "Changed",
"accessNotes": "Access",
"safetyNotes": "Safety"
},
"checklist": {
"items": "Checklist",
"photos": "Required photos",
"empty": "No checklist items.",
"emptyPhotos": "No required photos.",
"required": "Required",
"optional": "Optional",
"requiresPhoto": "with photo",
"done": "Done",
"open": "Open",
"photoCount": "{count, plural, =0 {No photo} one {# photo} other {# photos}}",
"addItem": "Add item",
"addPhoto": "Add required photo",
"label": "Label",
"applyTemplate": "Apply template",
"template": "Template",
"applyButton": "Apply",
"remove": "Remove",
"complete": "All completion requirements met."
},
"material": {
"title": "Material – planned and actual",
"empty": "No material planned or recorded.",
"name": "Name",
"articleNumber": "Article number",
"planned": "Planned",
"actual": "Actual",
"deviation": "Deviation",
"unit": "Unit",
"notes": "Remark",
"reason": "Reason",
"additional": "Additional",
"notRecorded": "Not recorded yet",
"add": "Plan material",
"remove": "Remove",
"usage": {
"fully_used": "Fully used",
"partially_used": "Partially used",
"not_used": "Not used",
"additional": "Additionally used"
}
},
"times": {
"empty": "No times recorded yet.",
"person": "Person",
"start": "Start",
"end": "End",
"duration": "Working time",
"minutes": "{minutes} min",
"corrected": "Corrected: {reason}",
"sessionStatus": {
"en_route": "En route",
"running": "Running",
"paused": "Paused",
"ended": "Ended"
},
"entryType": {
"travel": "Travel",
"work": "Work",
"break": "Break",
"material_procurement": "Material procurement",
"return_travel": "Return travel",
"interruption": "Interruption"
}
},
"photos": {
"empty": "No photos yet.",
"phase": { "before": "Before", "during": "During", "after": "After" },
"open": "Open",
"inReport": "In report"
},
"notes": {
"empty": "No notes.",
"internal": "Internal notes",
"technician": "Notes for technicians",
"kind": {
"work_done": "Work done",
"deviation": "Deviation",
"problem": "Problem",
"additional_work": "Additional work",
"not_executable": "Not executable",
"follow_up": "Follow-up",
"recommendation": "Recommendation",
"customer_note": "Customer note",
"general": "General"
}
},
"reports": {
"empty": "No reports yet.",
"type": { "daily": "Daily report", "completion": "Completion report" },
"status": {
"draft": "Draft",
"submitted": "In review",
"team_approved": "Approved by team lead",
"approved": "Approved",
"rejected": "Rejected",
"superseded": "Superseded"
},
"open": "Open report",
"signature": "Signature: {outcome}",
"signatureOutcome": {
"signed": "signed",
"customer_absent": "customer absent",
"refused": "refused",
"later": "later",
"not_required": "not required"
},
"billingTitle": "Billing",
"billingHint": "Release requires an approved completion report.",
"release": "Release for billing",
"reject": "Send back for correction",
"markBilled": "Mark as billed",
"revoke": "Revoke release"
},
"documents": {
"empty": "No documents on this order.",
"upload": "Upload document",
"file": "File (PDF, JPG, PNG, WebP)",
"title": "Title (optional)",
"category": "Category",
"visibility": "Visibility",
"submit": "Upload",
"archive": "Remove",
"download": "Download",
"stubHint": "Until the document store is connected, files are stored directly in object storage.",
"categories": {
"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"
},
"visibilities": {
"backoffice_only": "Office only",
"team_lead": "Team lead",
"team": "Team",
"customer_report": "Customer report"
}
},
"history": {
"statusChanges": "Status changes",
"audit": "Log",
"empty": "No entries yet.",
"created": "Created as {status}",
"change": "{from} → {to}",
"by": "by {name}",
"system": "System",
"action": { "create": "Created", "update": "Changed", "delete": "Deleted" }
},
"assign": {
"title": "Assign work order",
"sub": "Choose a team, optionally individual technicians and a team lead",
"team": "Team",
"chooseTeam": "— choose team —",
"members": "Individual technicians (optional)",
"teamLead": "Responsible team lead",
"teamLeadDefault": "Team lead of the team",
"noTeams": "No active teams. Teams are created under “Teams”.",
"submit": "Assign",
"pending": "Assigning…"
},
"transition": {
"to": {
"draft": "As draft",
"review_required": "Mark for review",
"planned": "Schedule",
"assigned": "Assign",
"accepted": "Accept",
"en_route": "Start travel",
"in_progress": "Start work",
"paused": "Pause",
"waiting_material": "Waiting for material",
"daily_report_created": "Daily report created",
"technically_completed": "Complete technically",
"signature_pending": "Signature pending",
"in_review": "Submit for review",
"released_for_billing": "Release for billing",
"billed": "Mark as billed",
"cancelled": "Cancel order"
},
"correction": "Send back for correction",
"revoke": "Revoke release",
"reasonTitle": "Reason required",
"reason": "Reason",
"reasonPlaceholder": "Briefly describe why",
"confirm": "Confirm",
"pending": "Saving…",
"done": "Status changed."
},
"edit": {
"title": "Edit work order",
"submit": "Save",
"pending": "Saving…",
"saved": "Saved."
},
"conflicts": {
"crumb": "Work orders",
"title": "Sync conflicts",
"sub": "Changes recorded offline that could not be applied automatically",
"empty": "No open conflicts.",
"received": "Received",
"user": "Device / person",
"operation": "Operation",
"entity": "Work order",
"baseVersion": "Device state: version {base} · current: version {current}",
"payload": "Content",
"apply": "Apply",
"discard": "Discard",
"applied": "Applied.",
"discarded": "Discarded.",
"applyHint": "Apply re-runs the operation against the current state – on behalf of the person who recorded it.",
"stubHint": "Until sync is connected, only status changes can be applied."
},
"errors": {
"not_found": "Not found or no permission.",
"forbidden": "You lack the permission for this.",
"invalid": "Please check your input.",
"conflict": "The order was changed in the meantime. Reload and try again.",
"blocked": "Not possible yet – information is missing.",
"internal": "Operation not possible.",
"validation_failed": "Please check required fields.",
"work_order_not_found": "Work order not found.",
"customer_not_found": "Customer not found.",
"site_not_found": "Site not found.",
"contact_not_found": "Contact person not found.",
"site_customer_mismatch": "The site belongs to another customer.",
"contact_customer_mismatch": "The contact person belongs to another customer.",
"order_type_not_found": "Order type not found.",
"planned_end_before_start": "The end is before the start.",
"nothing_to_update": "No changes.",
"not_editable": "The order can no longer be changed in this status.",
"delete_only_draft": "Only drafts can be deleted.",
"transition_not_allowed": "This status change is not intended.",
"transition_forbidden": "You may not perform this status change.",
"transition_blocked": "Not possible yet – information is missing.",
"reason_required": "Please give a reason.",
"version_conflict": "The order was changed in the meantime. Reload and try again.",
"team_not_found": "Team not found or not active.",
"user_not_found": "At least one person is not active.",
"material_plan_not_found": "Material item not found.",
"material_in_use": "Material has already been recorded for this item.",
"checklist_item_not_found": "Checklist item not found.",
"checklist_item_checked": "Completed items are kept as evidence.",
"photo_requirement_not_found": "Required photo not found.",
"photo_requirement_has_photos": "Photos already exist for this requirement.",
"template_not_found": "Template not found.",
"sync_conflict_not_found": "Conflict not found or already resolved.",
"sync_conflict_already_resolved": "Conflict already resolved.",
"sync_payload_invalid": "The operation is incomplete.",
"sync_user_inactive": "The person is no longer active.",
"reapply_unsupported": "This operation cannot be applied yet.",
"document_not_found": "Document not found.",
"file_type_not_allowed": "File type not allowed.",
"file_too_large": "File is empty or too large.",
"file_missing": "Please choose a file.",
"category_invalid": "Category not allowed.",
"visibility_forbidden": "You may not assign this visibility.",
"initial_status_not_allowed": "This initial status is not allowed."
},
"blockers": {
"checklist_item": "Checklist: {label}",
"photo_requirement": "Required photo: {label}",
"running_session": "A work session is still running",
"field": {
"signature": "Signature missing",
"approved_completion_report": "Completion report not approved yet"
}
},
"saved": "Saved."
}
+153 -23
View File
@@ -1,40 +1,170 @@
import { LayoutDashboard } from "lucide-react";
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { requireSession } from "@/server/auth";
import {
AlarmClock,
CalendarDays,
CheckCheck,
ClipboardCheck,
ClipboardList,
Hourglass,
PenLine,
Receipt,
RefreshCw,
Siren,
Wrench,
type LucideIcon,
} from "lucide-react";
import { PageHead } from "@/components/mockup-ui";
import { buttonCls } from "@/components/work-orders/action-form";
import { pageContext } from "@/components/work-orders/page-context";
import { Field, inputCls } from "@/components/work-orders/ui";
import { isoDay, parseListParams, toQuery, type Preset } from "@/lib/work-orders/filters";
import { WORK_ORDER_PRIORITIES } from "@/lib/work-orders/schemas";
import { getDashboardTiles } from "@/server/services/work-orders/dashboard";
import { customerDisplayName, customerFilterOptions, teamOptions, userOptions } from "@/server/services/work-orders/options";
import { listOrderTypes } from "@/server/services/work-orders/settings";
/**
* Neutrales Dashboard (Platzhalter). Die Kacheln (offene Aufträge, heutige Einsätze,
* freizugebende Berichte, Notdienst) liefern die Fachmodule.
*/
export default async function DashboardPage({
searchParams,
}: {
searchParams: Promise<{ module?: string }>;
}) {
const session = await requireSession();
type SP = Record<string, string | string[] | undefined>;
const TILES: { key: Preset | "sync_conflicts"; icon: LucideIcon; tone: string }[] = [
{ key: "open", icon: ClipboardList, tone: "var(--ui-primary)" },
{ key: "today", icon: CalendarDays, tone: "var(--info)" },
{ key: "running", icon: Wrench, tone: "var(--ui-accent)" },
{ key: "not_accepted", icon: Hourglass, tone: "var(--warn)" },
{ key: "overdue", icon: AlarmClock, tone: "var(--risk)" },
{ key: "reports_in_review", icon: ClipboardCheck, tone: "var(--info)" },
{ key: "completed", icon: CheckCheck, tone: "var(--ok)" },
{ key: "billing", icon: Receipt, tone: "var(--ok)" },
{ key: "emergency_new", icon: Siren, tone: "var(--risk)" },
{ key: "missing_signatures", icon: PenLine, tone: "var(--warn)" },
{ key: "sync_conflicts", icon: RefreshCw, tone: "var(--risk)" },
];
/** Backoffice dashboard (spec §21). Field roles are sent to the mobile start page. */
export default async function DashboardPage({ searchParams }: { searchParams: Promise<SP> }) {
const { session, ctx, can } = await pageContext();
if (!can("work_order:read_all")) redirect("/m");
const sp = await searchParams;
const t = await getTranslations("dashboard");
const tw = await getTranslations("workOrders");
const moduleRow = await ctx.db.tenantModule.findFirst({ where: { moduleKey: "work_orders" }, select: { enabled: true } });
const moduleEnabled = !moduleRow || moduleRow.enabled;
const f = parseListParams(sp);
const filter = { from: f.from, to: f.to, customerId: f.customerId, teamId: f.teamId, userId: f.userId, orderTypeId: f.orderTypeId, priority: f.priority };
const [tiles, customers, teams, users, orderTypes] = moduleEnabled
? await Promise.all([getDashboardTiles(ctx, filter), customerFilterOptions(ctx), teamOptions(ctx), userOptions(ctx), listOrderTypes(ctx)])
: [null, [], [], [], []];
return (
<main className="flex-1 p-6">
<PageHead
crumb={t("crumb")}
title={t("title")}
sub={t("subtitle", { name: session.user.name ?? "", tenant: session.user.tenantSlug ?? "" })}
/>
<main className="flex-1 p-4 md:p-6">
<PageHead crumb={t("crumb")} title={t("title")} sub={t("subtitle", { name: session.user.name ?? "", tenant: session.user.tenantSlug ?? "" })} />
{sp.module === "disabled" && (
<p role="status" className="mb-4 rounded-lg border border-[var(--warn)] bg-card px-4 py-3 text-sm text-[var(--warn)]">
{t("moduleDisabled")}
</p>
)}
<div className="shadow-card flex items-start gap-3 rounded-xl border bg-card p-5">
<LayoutDashboard className="mt-0.5 size-5 shrink-0 text-[var(--primary)]" aria-hidden />
<div>
<p className="font-heading text-sm font-semibold">{t("placeholderTitle")}</p>
<p className="mt-1 text-[13px] text-muted-foreground">{t("placeholder")}</p>
{!tiles ? (
<p className="rounded-lg border bg-card px-4 py-3 text-sm text-muted-foreground">{t("workOrdersDisabled")}</p>
) : (
<>
<details className="shadow-card mb-4 rounded-xl border bg-card" open={Object.values(filter).some(Boolean)}>
<summary className="flex min-h-11 cursor-pointer items-center px-4 font-heading text-sm font-semibold">{t("filterTitle")}</summary>
<form method="get" action="/dashboard" className="grid gap-3 border-t p-4 sm:grid-cols-2 lg:grid-cols-4">
<Field label={tw("filter.from")} htmlFor="d-from">
<input id="d-from" type="date" name="from" defaultValue={f.from ? isoDay(f.from) : ""} className={inputCls} />
</Field>
<Field label={tw("filter.to")} htmlFor="d-to">
<input id="d-to" type="date" name="to" defaultValue={f.to ? isoDay(f.to) : ""} className={inputCls} />
</Field>
<Field label={tw("filter.customer")} htmlFor="d-customer">
<select id="d-customer" name="customerId" defaultValue={f.customerId ?? ""} className={inputCls}>
<option value="">{tw("filter.any")}</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{customerDisplayName(c)}
</option>
))}
</select>
</Field>
<Field label={tw("filter.team")} htmlFor="d-team">
<select id="d-team" name="teamId" defaultValue={f.teamId ?? ""} className={inputCls}>
<option value="">{tw("filter.any")}</option>
{teams.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</Field>
<Field label={tw("filter.user")} htmlFor="d-user">
<select id="d-user" name="userId" defaultValue={f.userId ?? ""} className={inputCls}>
<option value="">{tw("filter.any")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</Field>
<Field label={tw("filter.orderType")} htmlFor="d-type">
<select id="d-type" name="orderTypeId" defaultValue={f.orderTypeId ?? ""} className={inputCls}>
<option value="">{tw("filter.any")}</option>
{orderTypes.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
</Field>
<Field label={tw("filter.priority")} htmlFor="d-prio">
<select id="d-prio" name="priority" defaultValue={f.priority ?? ""} className={inputCls}>
<option value="">{tw("filter.any")}</option>
{WORK_ORDER_PRIORITIES.map((p) => (
<option key={p} value={p}>
{tw(`priority.${p}`)}
</option>
))}
</select>
</Field>
<div className="flex items-end gap-2">
<button type="submit" className={buttonCls("default")}>
{tw("filter.apply")}
</button>
<Link href="/dashboard" className={buttonCls("ghost")}>
{tw("filter.reset")}
</Link>
</div>
</form>
</details>
<ul className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{TILES.filter((tile) => tile.key !== "sync_conflicts" || can("work_order:write")).map(({ key, icon: Icon, tone }) => {
const count = key === "sync_conflicts" ? tiles.syncConflicts : key === "reports_in_review" ? tiles.reportsToReview : tiles[key];
const href = key === "sync_conflicts" ? "/work-orders/conflicts" : `/work-orders${toQuery({ ...filter, preset: key })}`;
return (
<li key={key}>
<Link
href={href}
className="shadow-card flex h-full min-h-24 items-start gap-3 rounded-xl border border-l-4 bg-card p-4 transition-colors hover:bg-muted/40 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
style={{ borderLeftColor: count > 0 ? tone : "var(--line)" }}
>
<Icon className="mt-0.5 size-5 shrink-0" style={{ color: tone }} aria-hidden />
<div className="min-w-0">
<p className="text-[13px] font-semibold text-muted-foreground">{t(`tiles.${key}`)}</p>
<p className="font-heading text-3xl leading-tight font-bold text-foreground">{count}</p>
<p className="text-xs text-muted-foreground">{t(`hints.${key}`)}</p>
</div>
</Link>
</li>
);
})}
</ul>
</>
)}
</main>
);
}
+166
View File
@@ -0,0 +1,166 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Search } from "lucide-react";
import { PageHead } from "@/components/mockup-ui";
import { buttonCls } from "@/components/work-orders/action-form";
import { pageContext } from "@/components/work-orders/page-context";
import { Empty, Field, inputCls, Section, StatusBadge } from "@/components/work-orders/ui";
import { isoDay, parseListParams } from "@/lib/work-orders/filters";
import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status";
import { formatDate, formatDateTime } from "@/lib/work-orders/time";
import { customerDisplayName, teamOptions } from "@/server/services/work-orders/options";
import { searchAll, type SearchResults } from "@/server/services/work-orders/search";
type SP = Record<string, string | string[] | undefined>;
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
/** Tenant search (spec §25) — ILIKE across all areas, restricted by the caller's scopes. */
export default async function SearchPage({ searchParams }: { searchParams: Promise<SP> }) {
const { ctx, locale, tz } = await pageContext();
const t = await getTranslations("search");
const tw = await getTranslations("workOrders");
const td = await getTranslations("workOrders.documents");
const sp = await searchParams;
const q = (one(sp.q) ?? "").trim().slice(0, 100);
const f = parseListParams(sp);
const [results, teams] = await Promise.all([
q.length >= 2 ? searchAll(ctx, { q, from: f.from, to: f.to, statuses: f.statuses, teamId: f.teamId }) : Promise.resolve(null),
teamOptions(ctx),
]);
const total = results ? Object.values(results).reduce((n, list) => n + list.length, 0) : 0;
const hasFilter = Boolean(f.from || f.to || f.statuses || f.teamId);
return (
<main className="flex-1 p-4 md:p-6">
<PageHead crumb={t("crumb")} title={q ? t("resultsFor", { q }) : t("title")} />
<form method="get" action="/search" role="search" className="shadow-card rounded-xl border bg-card p-4">
<div className="flex flex-wrap gap-2">
<input type="search" name="q" defaultValue={q} minLength={2} maxLength={100} aria-label={t("placeholder")} placeholder={t("placeholder")} className={`${inputCls} min-w-0 flex-1`} autoFocus={!q} />
<button type="submit" className={buttonCls("primary")}>
<Search className="size-4" aria-hidden />
{t("submit")}
</button>
</div>
<details className="mt-3" open={hasFilter}>
<summary className="flex min-h-11 cursor-pointer items-center text-sm font-semibold">{t("filters.note")}</summary>
<div className="grid gap-3 pt-2 sm:grid-cols-2 lg:grid-cols-4">
<Field label={t("filters.from")} htmlFor="s-from">
<input id="s-from" type="date" name="from" defaultValue={f.from ? isoDay(f.from) : ""} className={inputCls} />
</Field>
<Field label={t("filters.to")} htmlFor="s-to">
<input id="s-to" type="date" name="to" defaultValue={f.to ? isoDay(f.to) : ""} className={inputCls} />
</Field>
<Field label={t("filters.status")} htmlFor="s-status">
<select id="s-status" name="status" defaultValue={f.statuses?.[0] ?? ""} className={inputCls}>
<option value="">{t("filters.any")}</option>
{WORK_ORDER_STATUSES.map((s) => (
<option key={s} value={s}>
{tw(`status.${s}`)}
</option>
))}
</select>
</Field>
<Field label={t("filters.team")} htmlFor="s-team">
<select id="s-team" name="teamId" defaultValue={f.teamId ?? ""} className={inputCls}>
<option value="">{t("filters.any")}</option>
{teams.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</Field>
</div>
{hasFilter && (
<Link href={`/search?q=${encodeURIComponent(q)}`} className="mt-2 inline-flex min-h-11 items-center text-sm font-semibold text-[var(--primary)] hover:underline">
{t("filters.reset")}
</Link>
)}
</details>
</form>
<div className="mt-4 space-y-4">
{!results ? (
<p className="text-sm text-muted-foreground">{t("hint")}</p>
) : total === 0 ? (
<Empty>{t("noResults", { q })}</Empty>
) : (
<>
<ResultSection title={t("sections.workOrders")} list={results.workOrders}>
{(w: SearchResults["workOrders"][number]) => (
<Link href={`/work-orders/${w.id}`} className="flex flex-wrap items-center justify-between gap-2 py-2 hover:underline">
<span>
<span className="font-mono text-xs text-muted-foreground">{w.number}</span> <span className="font-semibold">{w.title}</span>
<span className="block text-xs text-muted-foreground">
{[customerDisplayName(w.customer), w.site?.name, w.site?.city, w.team?.name, formatDate(w.plannedStart, locale, tz)].filter(Boolean).join(" · ")}
</span>
</span>
<StatusBadge status={w.status} label={tw(`status.${w.status}`)} />
</Link>
)}
</ResultSection>
<ResultSection title={t("sections.customers")} list={results.customers}>
{(c: SearchResults["customers"][number]) => (
<Link href={`/customers/${c.id}`} className="block py-2 hover:underline">
<span className="font-semibold">{customerDisplayName(c)}</span>
<span className="block text-xs text-muted-foreground">{[c.customerNumber, [c.street, c.houseNumber].filter(Boolean).join(" "), [c.postalCode, c.city].filter(Boolean).join(" ")].filter(Boolean).join(" · ")}</span>
</Link>
)}
</ResultSection>
<ResultSection title={t("sections.sites")} list={results.sites}>
{(s: SearchResults["sites"][number]) => (
<Link href={`/sites/${s.id}`} className="block py-2 hover:underline">
<span className="font-semibold">{s.name}</span>
<span className="block text-xs text-muted-foreground">{[customerDisplayName(s.customer), [s.street, s.houseNumber].filter(Boolean).join(" "), [s.postalCode, s.city].filter(Boolean).join(" ")].filter(Boolean).join(" · ")}</span>
</Link>
)}
</ResultSection>
<ResultSection title={t("sections.contacts")} list={results.contacts}>
{(c: SearchResults["contacts"][number]) => (
<Link href={`/customers/${c.customer.id}`} className="block py-2 hover:underline">
<span className="font-semibold">{c.name}</span>
<span className="block text-xs text-muted-foreground">{[c.role, customerDisplayName(c.customer), c.phone, c.email].filter(Boolean).join(" · ")}</span>
</Link>
)}
</ResultSection>
<ResultSection title={t("sections.documents")} list={results.documents}>
{(d: SearchResults["documents"][number]) => (
<Link
href={d.workOrderId ? `/work-orders/${d.workOrderId}?tab=documents` : d.customerId ? `/customers/${d.customerId}` : `/sites/${d.siteId}`}
className="block py-2 hover:underline"
>
<span className="font-semibold">{d.title || d.fileName}</span>
<span className="block text-xs text-muted-foreground">{[td(`categories.${d.category}`), formatDate(d.createdAt, locale, tz)].join(" · ")}</span>
</Link>
)}
</ResultSection>
<ResultSection title={t("sections.notes")} list={results.notes}>
{(n: SearchResults["notes"][number]) => (
<Link href={`/work-orders/${n.workOrder.id}?tab=notes`} className="block py-2 hover:underline">
<span className="line-clamp-2 text-sm">{n.text}</span>
<span className="block text-xs text-muted-foreground">{[n.workOrder.number, n.workOrder.title, formatDateTime(n.createdAt, locale, tz)].join(" · ")}</span>
</Link>
)}
</ResultSection>
<p className="text-xs text-muted-foreground">{t("maxHint")}</p>
</>
)}
</div>
</main>
);
}
function ResultSection<T extends { id: string }>({ title, list, children }: { title: string; list: T[]; children: (item: T) => React.ReactNode }) {
if (list.length === 0) return null;
return (
<Section title={`${title} (${list.length})`}>
<ul className="divide-y">
{list.map((item) => (
<li key={item.id}>{children(item)}</li>
))}
</ul>
</Section>
);
}
+122
View File
@@ -0,0 +1,122 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { z } from "zod";
import { Plus } from "lucide-react";
import { PageHead, Pill } from "@/components/mockup-ui";
import { Modal } from "@/components/modal";
import { ActionForm, buttonCls } from "@/components/work-orders/action-form";
import { SettingsTemplatesNav, templatesPageContext } from "@/components/work-orders/settings-nav";
import { Check, Empty, Field, inputCls, Section } from "@/components/work-orders/ui";
import { DEFAULT_CHECKLIST_ITEMS, DEFAULT_REQUIRED_PHOTOS } from "@/lib/work-orders/defaults";
import { templateItemSchema, templatePhotoSchema, type TemplateItem, type TemplatePhoto } from "@/lib/work-orders/schemas";
import { saveChecklistTemplateAction } from "@/server/actions/work_orders/settings";
import { listChecklistTemplates, listOrderTypes } from "@/server/services/work-orders/settings";
type SP = Record<string, string | string[] | undefined>;
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
const itemsToText = (items: readonly TemplateItem[], suffix: string) =>
items.map((i) => `${i.required ? "* " : ""}${i.label}${i.requiresPhoto ? ` ${suffix}` : ""}`).join("\n");
const photosToText = (photos: readonly TemplatePhoto[]) => photos.map((p) => p.label).join("\n");
/** Checklist templates per order type incl. required photos (spec §12.4 / §14.2). */
export default async function ChecklistTemplatesPage({ searchParams }: { searchParams: Promise<SP> }) {
const { ctx } = await templatesPageContext();
const t = await getTranslations("settingsTemplates");
const tc = await getTranslations("common");
const sp = await searchParams;
const [templates, orderTypes] = await Promise.all([listChecklistTemplates(ctx), listOrderTypes(ctx)]);
const parsed = templates.map((tpl) => ({
...tpl,
itemList: z.array(templateItemSchema).safeParse(tpl.items).data ?? [],
photoList: z.array(templatePhotoSchema).safeParse(tpl.requiredPhotos).data ?? [],
}));
const editId = one(sp.edit);
const editing = editId ? parsed.find((x) => x.id === editId) : undefined;
const isNew = one(sp.new) === "1";
const suggest = one(sp.suggest) === "1";
const base = "/settings/checklists";
return (
<main className="flex-1 p-4 md:p-6">
<PageHead
crumb={t("crumb")}
title={t("checklists.title")}
sub={t("checklists.sub")}
actions={
<Link href={`${base}?new=1&suggest=1`} scroll={false} className={buttonCls("primary")}>
<Plus className="size-4" aria-hidden />
{t("checklists.new")}
</Link>
}
/>
<SettingsTemplatesNav active="checklists" />
{parsed.length === 0 ? (
<Empty>{t("checklists.empty")}</Empty>
) : (
<ul className="grid gap-3 md:grid-cols-2">
{parsed.map((tpl) => (
<li key={tpl.id}>
<Section title={tpl.name} actions={!tpl.active ? <Pill tone="mut">{t("checklists.inactive")}</Pill> : undefined}>
<p className="text-sm text-muted-foreground">
{tpl.orderType?.name ?? t("checklists.noOrderType")} · {t("checklists.itemCount", { count: tpl.itemList.length })} · {t("checklists.photoCount", { count: tpl.photoList.length })}
</p>
<Link href={`${base}?edit=${tpl.id}`} scroll={false} className={`${buttonCls("outline")} mt-3`}>
{t("checklists.edit")}
</Link>
</Section>
</li>
))}
</ul>
)}
{(isNew || editing) && (
<Modal title={editing ? editing.name : t("checklists.new")} sub={t("checklists.itemsHint")} closeHref={base} closeLabel={tc("close")}>
<ActionForm action={saveChecklistTemplateAction} namespace="settingsTemplates" submitLabel={t("checklists.save")} variant="primary" successText={t("saved")} className="grid gap-4 p-5 md:grid-cols-2">
{editing && <input type="hidden" name="id" value={editing.id} />}
<Field label={`${t("checklists.name")} *`} htmlFor="tpl-name">
<input id="tpl-name" name="name" required maxLength={120} defaultValue={editing?.name ?? ""} className={inputCls} />
</Field>
<Field label={t("checklists.orderType")} htmlFor="tpl-type">
<select id="tpl-type" name="orderTypeId" defaultValue={editing?.orderTypeId ?? ""} className={inputCls}>
<option value="">{t("checklists.noOrderType")}</option>
{orderTypes.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
</Field>
<Field label={t("checklists.items")} htmlFor="tpl-items" hint={t("checklists.itemsHint")} className="md:col-span-1">
<textarea
id="tpl-items"
name="items"
rows={10}
defaultValue={editing ? itemsToText(editing.itemList, t("checklists.photoSuffix")) : suggest ? itemsToText(DEFAULT_CHECKLIST_ITEMS, t("checklists.photoSuffix")) : ""}
className={`${inputCls} py-2 font-mono text-xs`}
/>
</Field>
<Field label={t("checklists.photos")} htmlFor="tpl-photos" hint={t("checklists.photosHint")}>
<textarea
id="tpl-photos"
name="requiredPhotos"
rows={10}
defaultValue={editing ? photosToText(editing.photoList) : suggest ? photosToText(DEFAULT_REQUIRED_PHOTOS) : ""}
className={`${inputCls} py-2 font-mono text-xs`}
/>
</Field>
<Check id="tpl-active" name="active" label={t("checklists.active")} defaultChecked={editing ? editing.active : true} />
{!editing && suggest && <p className="self-center text-xs text-muted-foreground">{t("checklists.suggestionHint")}</p>}
{!editing && !suggest && (
<Link href={`${base}?new=1&suggest=1`} scroll={false} className="self-center text-sm font-semibold text-[var(--primary)] hover:underline">
{t("checklists.suggestion")}
</Link>
)}
</ActionForm>
</Modal>
)}
</main>
);
}
+44
View File
@@ -0,0 +1,44 @@
import { getTranslations } from "next-intl/server";
import { PageHead } from "@/components/mockup-ui";
import { ActionForm } from "@/components/work-orders/action-form";
import { SettingsTemplatesNav, templatesPageContext } from "@/components/work-orders/settings-nav";
import { Field, inputCls, Section } from "@/components/work-orders/ui";
import { updateNumberingAction } from "@/server/actions/work_orders/settings";
import { listNumberSequences } from "@/server/services/work-orders/settings";
/** Number sequences: prefix and digits (the counter itself is never reset). */
export default async function NumberingPage() {
const { ctx } = await templatesPageContext();
const t = await getTranslations("settingsTemplates");
const sequences = await listNumberSequences(ctx);
return (
<main className="flex-1 p-4 md:p-6">
<PageHead crumb={t("crumb")} title={t("numbering.title")} sub={t("numbering.sub")} />
<SettingsTemplatesNav active="numbering" />
<Section>
<ul className="divide-y">
{sequences.map((s) => (
<li key={s.key} className="py-3">
<ActionForm action={updateNumberingAction} namespace="settingsTemplates" submitLabel={t("numbering.save")} variant="outline" successText={t("saved")} className="grid items-end gap-3 sm:grid-cols-2 md:grid-cols-[1fr_140px_120px_1fr_auto]" footerClassName="mt-0">
<input type="hidden" name="key" value={s.key} />
<Field label={t("numbering.key")}>
<p className="flex min-h-11 items-center font-semibold">{t(`numbering.keys.${s.key}`)}</p>
</Field>
<Field label={t("numbering.prefix")} htmlFor={`n-prefix-${s.key}`}>
<input id={`n-prefix-${s.key}`} name="prefix" maxLength={12} pattern="[A-Za-z0-9\-_/]*" defaultValue={s.prefix} className={inputCls} />
</Field>
<Field label={t("numbering.padding")} htmlFor={`n-pad-${s.key}`}>
<input id={`n-pad-${s.key}`} name="padding" type="number" min={1} max={10} defaultValue={s.padding} className={inputCls} />
</Field>
<Field label={t("numbering.next")}>
<p className="flex min-h-11 items-center font-mono text-sm">{`${s.prefix}${String(s.nextValue).padStart(s.padding, "0")}`}</p>
</Field>
</ActionForm>
</li>
))}
</ul>
</Section>
</main>
);
}
@@ -0,0 +1,59 @@
import { getTranslations } from "next-intl/server";
import { PageHead } from "@/components/mockup-ui";
import { ActionForm } from "@/components/work-orders/action-form";
import { SettingsTemplatesNav, templatesPageContext } from "@/components/work-orders/settings-nav";
import { Check, Field, inputCls, Section } from "@/components/work-orders/ui";
import { createOrderTypeAction, updateOrderTypeAction } from "@/server/actions/work_orders/settings";
import { listOrderTypes } from "@/server/services/work-orders/settings";
/** Order types per tenant (spec §10.2). Defaults are created on first access. */
export default async function OrderTypesPage() {
const { ctx } = await templatesPageContext();
const t = await getTranslations("settingsTemplates");
const types = await listOrderTypes(ctx);
return (
<main className="flex-1 p-4 md:p-6">
<PageHead crumb={t("crumb")} title={t("orderTypes.title")} sub={t("orderTypes.sub")} />
<SettingsTemplatesNav active="order-types" />
<Section>
<ul className="divide-y">
{types.map((ot) => (
<li key={ot.id} className="py-3">
<ActionForm action={updateOrderTypeAction} namespace="settingsTemplates" submitLabel={t("orderTypes.save")} variant="outline" successText={t("saved")} className="grid items-end gap-3 md:grid-cols-[1fr_2fr_auto_auto_120px_auto]" footerClassName="mt-0">
<input type="hidden" name="id" value={ot.id} />
<Field label={t("orderTypes.key")}>
<p className="flex min-h-11 items-center font-mono text-xs text-muted-foreground">{ot.key}</p>
</Field>
<Field label={t("orderTypes.name")} htmlFor={`ot-name-${ot.id}`}>
<input id={`ot-name-${ot.id}`} name="name" required maxLength={80} defaultValue={ot.name} className={inputCls} />
</Field>
<Check id={`ot-sig-${ot.id}`} name="signatureRequired" label={t("orderTypes.signatureRequired")} defaultChecked={ot.signatureRequired} />
<Check id={`ot-act-${ot.id}`} name="active" label={t("orderTypes.active")} defaultChecked={ot.active} />
<Field label={t("orderTypes.sortOrder")} htmlFor={`ot-sort-${ot.id}`}>
<input id={`ot-sort-${ot.id}`} name="sortOrder" type="number" min={0} max={10000} defaultValue={ot.sortOrder} className={inputCls} />
</Field>
</ActionForm>
</li>
))}
</ul>
</Section>
<Section title={t("orderTypes.create")} className="mt-4">
<ActionForm action={createOrderTypeAction} namespace="settingsTemplates" submitLabel={t("orderTypes.create")} variant="primary" successText={t("saved")} className="grid items-end gap-3 md:grid-cols-[1fr_2fr_auto_120px]">
<Field label={t("orderTypes.key")} htmlFor="ot-new-key" hint={t("orderTypes.keyHint")}>
<input id="ot-new-key" name="key" pattern="[a-z0-9_]{2,40}" maxLength={40} className={inputCls} />
</Field>
<Field label={`${t("orderTypes.name")} *`} htmlFor="ot-new-name">
<input id="ot-new-name" name="name" required maxLength={80} className={inputCls} />
</Field>
<Check id="ot-new-sig" name="signatureRequired" label={t("orderTypes.signatureRequired")} defaultChecked />
<Field label={t("orderTypes.sortOrder")} htmlFor="ot-new-sort">
<input id="ot-new-sort" name="sortOrder" type="number" min={0} max={10000} defaultValue={100} className={inputCls} />
</Field>
</ActionForm>
</Section>
</main>
);
}
+296
View File
@@ -0,0 +1,296 @@
import Link from "next/link";
import { notFound } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ArrowLeft, Pencil, Siren, UsersRound } from "lucide-react";
import { Modal } from "@/components/modal";
import { ActionForm, buttonCls } from "@/components/work-orders/action-form";
import {
ChecklistTab,
DocumentsTab,
HistoryTab,
MaterialTab,
NotesTab,
OverviewTab,
PhotosTab,
ReportsTab,
TimesTab,
} from "@/components/work-orders/detail-tabs";
import { pageContext } from "@/components/work-orders/page-context";
import { Field, inputCls, LinkTabs, StatusBadge } from "@/components/work-orders/ui";
import { WorkOrderFields } from "@/components/work-orders/work-order-fields";
import { WORK_ORDER_STATUSES, type WorkOrderStatus } from "@/lib/work-orders/status";
import { assignAction, transitionAction, updateWorkOrderAction } from "@/server/actions/work_orders/work-orders";
import { ServiceError } from "@/server/services/context";
import { FINAL_STATUSES, PLANNING_LOCKED } from "@/server/services/work-orders/_shared";
import { availableTransitions, getWorkOrderDetail } from "@/server/services/work-orders/detail";
import { customerDisplayName, customerOption, teamOptions, userOptions } from "@/server/services/work-orders/options";
import { listOrderTypes } from "@/server/services/work-orders/settings";
import { reasonRequired } from "@/server/services/work-orders/transition";
const TABS = ["overview", "checklist", "material", "times", "photos", "notes", "reports", "documents", "history"] as const;
type Tab = (typeof TABS)[number];
type SP = Record<string, string | string[] | undefined>;
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
const idx = (s: WorkOrderStatus) => WORK_ORDER_STATUSES.indexOf(s);
/** Backoffice work order detail: header with status + next primary action, tabs, popups (assign/edit/reason). */
export default async function WorkOrderDetailPage({ params, searchParams }: { params: Promise<{ id: string }>; searchParams: Promise<SP> }) {
const { id } = await params;
const sp = await searchParams;
const { ctx, locale, tz, can } = await pageContext();
const t = await getTranslations("workOrders");
const tc = await getTranslations("common");
let wo;
try {
wo = await getWorkOrderDetail(ctx, id);
} catch (err) {
if (err instanceof ServiceError && err.code === "not_found") notFound();
throw err;
}
const tabParam = one(sp.tab);
const tab: Tab = (TABS as readonly string[]).includes(tabParam ?? "") ? (tabParam as Tab) : "overview";
const selfHref = `/work-orders/${wo.id}${tab === "overview" ? "" : `?tab=${tab}`}`;
const withParam = (k: string, v: string) => `/work-orders/${wo.id}?${tab === "overview" ? "" : `tab=${tab}&`}${k}=${v}`;
const transitions = availableTransitions(ctx, wo.status);
const canAssign = can("work_order:assign") && !PLANNING_LOCKED.includes(wo.status);
const canWrite = can("work_order:write") && !FINAL_STATUSES.includes(wo.status);
const canEditPlanning = can("work_order:write") && !PLANNING_LOCKED.includes(wo.status);
const assignFirst = canAssign && ["draft", "review_required", "planned"].includes(wo.status);
// Primary = furthest forward step (never cancel/backward); assignment goes through the popup.
const forward = transitions.filter((to) => to !== "cancelled" && idx(to) > idx(wo.status) && !(assignFirst && to === "assigned"));
const primary = assignFirst ? null : forward.sort((a, b) => idx(b) - idx(a))[0] ?? null;
const secondary = transitions.filter((to) => to !== primary && to !== "cancelled" && !(assignFirst && to === "assigned"));
const label = (to: WorkOrderStatus) =>
wo.status === "in_review" && to === "in_progress"
? t("transition.correction")
: wo.status === "released_for_billing" && to === "in_review"
? t("transition.revoke")
: t(`transition.to.${to}`);
const transitionButton = (to: WorkOrderStatus, variant: "primary" | "outline" | "danger") =>
reasonRequired(wo.status, to) ? (
<Link key={to} href={withParam("transition", to)} scroll={false} className={buttonCls(variant)}>
{label(to)}
</Link>
) : (
<ActionForm key={to} action={transitionAction} submitLabel={label(to)} pendingLabel={t("transition.pending")} variant={variant} footerClassName="mt-0">
<input type="hidden" name="workOrderId" value={wo.id} />
<input type="hidden" name="to" value={to} />
<input type="hidden" name="baseVersion" value={wo.version} />
<input type="hidden" name="returnTab" value={tab} />
</ActionForm>
);
// Popups
const reasonTo = one(sp.transition) as WorkOrderStatus | undefined;
const showReason = reasonTo && transitions.includes(reasonTo) && reasonRequired(wo.status, reasonTo);
const showAssign = canAssign && one(sp.assign) === "1";
const showEdit = canWrite && one(sp.edit) === "1";
const [teams, users, editData, orderTypes] = await Promise.all([
showAssign ? teamOptions(ctx) : Promise.resolve([]),
showAssign ? userOptions(ctx) : Promise.resolve([]),
showEdit ? customerOption(ctx, wo.customerId) : Promise.resolve(null),
showEdit ? listOrderTypes(ctx) : Promise.resolve([]),
]);
const billingTransitions = transitions.filter((to) => ["released_for_billing", "billed"].includes(to) || (wo.status === "in_review" && to === "in_progress") || (wo.status === "released_for_billing" && to === "in_review"));
const tabProps = { ctx, wo, locale, tz, canEdit: canEditPlanning };
return (
<main className="flex-1 p-4 md:p-6">
<Link href="/work-orders" className="inline-flex min-h-11 items-center gap-1.5 text-[13px] font-semibold text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" aria-hidden />
{t("detail.back")}
</Link>
<header className="shadow-card mt-1 rounded-xl border border-l-4 bg-card p-4 md:p-5" style={{ borderLeftColor: "var(--ui-primary)" }}>
<div className="flex flex-wrap items-start justify-between gap-4">
<div className="min-w-0">
<p className="flex flex-wrap items-center gap-2 text-xs text-muted-foreground">
<span className="font-mono">{wo.number}</span>
<span>· {t("detail.version", { version: wo.version })}</span>
{wo.isEmergency && (
<span className="inline-flex items-center gap-1 font-semibold text-[var(--risk)]">
<Siren className="size-3.5" aria-hidden />
{t("emergency")}
</span>
)}
</p>
<h1 className="mt-1 text-[22px] break-words">{wo.title}</h1>
<p className="mt-1 text-sm">
{customerDisplayName(wo.customer)}
{wo.site && <span className="text-muted-foreground"> · {wo.site.name}</span>}
</p>
<div className="mt-2 flex flex-wrap items-center gap-3">
<StatusBadge status={wo.status} label={t(`status.${wo.status}`)} className="text-sm" />
<span className="inline-flex items-center gap-1 text-xs text-muted-foreground">
<UsersRound className="size-3.5" aria-hidden />
{wo.team?.name ?? t("unassigned")}
</span>
</div>
</div>
<div className="flex flex-col items-stretch gap-2 sm:items-end">
<span className="text-xs font-semibold text-muted-foreground">{t("detail.nextStep")}</span>
<div className="flex flex-wrap gap-2 sm:justify-end">
{assignFirst && (
<Link href={withParam("assign", "1")} scroll={false} className={buttonCls("primary")}>
{t("detail.assign")}
</Link>
)}
{primary && transitionButton(primary, "primary")}
{!assignFirst && !primary && <span className="text-sm text-muted-foreground">{t("detail.noActions")}</span>}
</div>
</div>
</div>
<div className="mt-4 flex flex-wrap items-center gap-2 border-t pt-3">
{secondary.map((to) => transitionButton(to, "outline"))}
{canAssign && !assignFirst && (
<Link href={withParam("assign", "1")} scroll={false} className={buttonCls("outline")}>
<UsersRound className="size-4" aria-hidden />
{t("detail.reassign")}
</Link>
)}
{canWrite && (
<Link href={withParam("edit", "1")} scroll={false} className={buttonCls("outline")}>
<Pencil className="size-4" aria-hidden />
{t("detail.edit")}
</Link>
)}
{transitions.includes("cancelled") && (
<Link href={withParam("transition", "cancelled")} scroll={false} className={`${buttonCls("danger")} sm:ml-auto`}>
{t("detail.cancel")}
</Link>
)}
</div>
</header>
<div className="mt-4">
<LinkTabs
label={t("detail.tabs.overview")}
items={TABS.map((k) => ({ href: `/work-orders/${wo.id}${k === "overview" ? "" : `?tab=${k}`}`, label: t(`detail.tabs.${k}`), active: tab === k }))}
/>
</div>
<div className="mt-4">
{tab === "overview" && <OverviewTab {...tabProps} />}
{tab === "checklist" && <ChecklistTab {...tabProps} />}
{tab === "material" && <MaterialTab {...tabProps} />}
{tab === "times" && <TimesTab {...tabProps} />}
{tab === "photos" && <PhotosTab {...tabProps} />}
{tab === "notes" && <NotesTab {...tabProps} />}
{tab === "reports" && (
<ReportsTab
{...tabProps}
billing={
billingTransitions.length > 0 ? (
<div className="flex flex-wrap gap-2">
{billingTransitions.map((to) => transitionButton(to, to === "released_for_billing" || to === "billed" ? "primary" : "outline"))}
</div>
) : undefined
}
/>
)}
{tab === "documents" && <DocumentsTab {...tabProps} uploadError={one(sp.uploadError)} uploaded={one(sp.uploaded) === "1"} />}
{tab === "history" && <HistoryTab {...tabProps} />}
</div>
{showReason && reasonTo && (
<Modal title={label(reasonTo)} sub={t("transition.reasonTitle")} closeHref={selfHref} closeLabel={tc("close")}>
<ActionForm action={transitionAction} submitLabel={t("transition.confirm")} pendingLabel={t("transition.pending")} variant={reasonTo === "cancelled" ? "danger" : "default"} className="p-5">
<input type="hidden" name="workOrderId" value={wo.id} />
<input type="hidden" name="to" value={reasonTo} />
<input type="hidden" name="baseVersion" value={wo.version} />
<input type="hidden" name="returnTab" value={tab} />
<Field label={`${t("transition.reason")} *`} htmlFor="tr-reason">
<textarea id="tr-reason" name="reason" required rows={4} maxLength={2000} placeholder={t("transition.reasonPlaceholder")} className={`${inputCls} py-2`} autoFocus />
</Field>
</ActionForm>
</Modal>
)}
{showAssign && (
<Modal title={t("assign.title")} sub={t("assign.sub")} closeHref={selfHref} closeLabel={tc("close")}>
{teams.length === 0 ? (
<p className="p-5 text-sm text-muted-foreground">{t("assign.noTeams")}</p>
) : (
<ActionForm action={assignAction} submitLabel={t("assign.submit")} pendingLabel={t("assign.pending")} variant="primary" className="grid gap-4 p-5 md:grid-cols-2">
<input type="hidden" name="workOrderId" value={wo.id} />
<input type="hidden" name="baseVersion" value={wo.version} />
<Field label={`${t("assign.team")} *`} htmlFor="as-team">
<select id="as-team" name="teamId" required defaultValue={wo.team?.id ?? ""} className={inputCls}>
<option value="">{t("assign.chooseTeam")}</option>
{teams.map((tm) => (
<option key={tm.id} value={tm.id}>
{tm.name}
</option>
))}
</select>
</Field>
<Field label={t("assign.teamLead")} htmlFor="as-lead">
<select id="as-lead" name="teamLeadUserId" defaultValue={wo.teamLead?.id ?? ""} className={inputCls}>
<option value="">{t("assign.teamLeadDefault")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</Field>
<fieldset className="md:col-span-2">
<legend className="mb-1 text-[13px] font-semibold">{t("assign.members")}</legend>
<div className="grid gap-x-4 sm:grid-cols-2 lg:grid-cols-3">
{users.map((u) => (
<label key={u.id} className="flex min-h-11 items-center gap-2.5 text-sm">
<input type="checkbox" name="userIds" value={u.id} defaultChecked={wo.assignees.some((a) => a.user.id === u.id)} className="size-5 accent-[var(--ui-primary)]" />
{u.name}
<span className="text-xs text-muted-foreground">{teams.filter((tm) => tm.members.some((m) => m.user.id === u.id)).map((tm) => tm.name).join(", ")}</span>
</label>
))}
</div>
</fieldset>
</ActionForm>
)}
</Modal>
)}
{showEdit && editData && (
<Modal title={t("edit.title")} sub={`${wo.number} · ${customerDisplayName(wo.customer)}`} closeHref={selfHref} closeLabel={tc("close")}>
<ActionForm action={updateWorkOrderAction.bind(null, wo.id)} submitLabel={t("edit.submit")} pendingLabel={t("edit.pending")} className="p-5">
<input type="hidden" name="baseVersion" value={wo.version} />
<WorkOrderFields
mode="edit"
tz={tz}
sites={editData.sites.map((s) => ({ id: s.id, name: [s.name, s.city].filter(Boolean).join(" · ") }))}
contacts={editData.contacts.map((c) => ({ id: c.id, name: c.name }))}
orderTypes={orderTypes.map((o) => ({ id: o.id, name: o.active ? o.name : `${o.name} (–)` }))}
defaults={{
title: wo.title,
siteId: wo.siteId,
contactId: wo.contactId,
orderTypeId: wo.orderTypeId,
priority: wo.priority,
plannedStart: wo.plannedStart,
plannedEnd: wo.plannedEnd,
description: wo.description,
scope: wo.scope,
internalNotes: wo.internalNotes,
technicianNotes: wo.technicianNotes,
signatureRequired: wo.signatureRequired,
billingType: wo.billingType,
externalOrderNumber: wo.externalOrderNumber,
offerNumber: wo.offerNumber,
}}
/>
</ActionForm>
</Modal>
)}
</main>
);
}
@@ -0,0 +1,83 @@
import Link from "next/link";
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { ArrowLeft } from "lucide-react";
import { PageHead } from "@/components/mockup-ui";
import { ActionForm } from "@/components/work-orders/action-form";
import { pageContext } from "@/components/work-orders/page-context";
import { Empty, Section, StatusBadge } from "@/components/work-orders/ui";
import { formatDateTime } from "@/lib/work-orders/time";
import { applySyncConflictAction, discardSyncConflictAction } from "@/server/actions/work_orders/work-orders";
import { listSyncConflicts } from "@/server/services/work-orders/conflicts";
/** Offline sync conflicts (ARCHITEKTUR §4.6) — apply against current state or discard. */
export default async function SyncConflictsPage() {
const { ctx, locale, tz, can } = await pageContext();
if (!can("work_order:write")) redirect("/work-orders");
const t = await getTranslations("workOrders");
const conflicts = await listSyncConflicts(ctx);
return (
<main className="flex-1 p-4 md:p-6">
<Link href="/work-orders" className="inline-flex min-h-11 items-center gap-1.5 text-[13px] font-semibold text-muted-foreground hover:text-foreground">
<ArrowLeft className="size-4" aria-hidden />
{t("detail.back")}
</Link>
<PageHead crumb={t("conflicts.crumb")} title={t("conflicts.title")} sub={t("conflicts.sub")} />
<p className="mb-4 text-sm text-muted-foreground">
{t("conflicts.applyHint")} {t("conflicts.stubHint")}
</p>
{conflicts.length === 0 ? (
<Empty>{t("conflicts.empty")}</Empty>
) : (
<ul className="space-y-3">
{conflicts.map((c) => {
const payload = JSON.stringify(c.payload, null, 2);
return (
<li key={c.id}>
<Section
title={`${c.opType}`}
actions={<span className="text-xs text-muted-foreground">{t("conflicts.received")}: {formatDateTime(c.receivedAt, locale, tz)}</span>}
>
<div className="grid gap-3 md:grid-cols-[1fr_1fr]">
<div className="space-y-1.5 text-sm">
<p>
<span className="text-muted-foreground">{t("conflicts.user")}: </span>
{c.userName ?? c.userId}
</p>
{c.workOrder && (
<p className="flex flex-wrap items-center gap-2">
<span className="text-muted-foreground">{t("conflicts.entity")}: </span>
<Link href={`/work-orders/${c.workOrder.id}`} className="font-semibold text-[var(--primary)] hover:underline">
{c.workOrder.number} · {c.workOrder.title}
</Link>
<StatusBadge status={c.workOrder.status} label={t(`status.${c.workOrder.status}`)} />
</p>
)}
{c.baseVersion !== null && c.workOrder && (
<p className="text-xs font-semibold text-[var(--warn)]">{t("conflicts.baseVersion", { base: c.baseVersion, current: c.workOrder.version })}</p>
)}
<div className="flex flex-wrap gap-2 pt-2">
<ActionForm action={applySyncConflictAction} submitLabel={t("conflicts.apply")} variant="primary" successText={t("conflicts.applied")} footerClassName="mt-0">
<input type="hidden" name="opId" value={c.id} />
</ActionForm>
<ActionForm action={discardSyncConflictAction} submitLabel={t("conflicts.discard")} variant="outline" successText={t("conflicts.discarded")} footerClassName="mt-0">
<input type="hidden" name="opId" value={c.id} />
</ActionForm>
</div>
</div>
<div>
<p className="mb-1 text-xs font-semibold text-muted-foreground">{t("conflicts.payload")}</p>
<pre className="max-h-56 overflow-auto rounded-lg bg-muted p-3 text-xs">{payload.length > 2000 ? `${payload.slice(0, 2000)}…` : payload}</pre>
</div>
</div>
</Section>
</li>
);
})}
</ul>
)}
</main>
);
}
+351 -3
View File
@@ -1,5 +1,353 @@
import { ModulePlaceholder } from "@/components/module-placeholder";
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { LayoutGrid, Plus, RefreshCw, Rows3, X } from "lucide-react";
import { PageHead } from "@/components/mockup-ui";
import { Modal } from "@/components/modal";
import { ActionForm, buttonCls } from "@/components/work-orders/action-form";
import { pageContext } from "@/components/work-orders/page-context";
import { Empty, Field, GroupIcon, inputCls, LinkTabs } from "@/components/work-orders/ui";
import { WorkOrderFields } from "@/components/work-orders/work-order-fields";
import { WorkOrderCards, WorkOrderTable } from "@/components/work-orders/work-order-list";
import { isoDay, parseListParams, SORT_FIELDS, STATUS_GROUPS, toQuery, type ListParams } from "@/lib/work-orders/filters";
import { WORK_ORDER_PRIORITIES } from "@/lib/work-orders/schemas";
import { WORK_ORDER_STATUSES } from "@/lib/work-orders/status";
import { createWorkOrderAction } from "@/server/actions/work_orders/work-orders";
import { listWorkOrders } from "@/server/services/work-orders/list";
import {
customerDisplayName,
customerFilterOptions,
customerOption,
searchCustomerOptions,
siteFilterOptions,
teamOptions,
userOptions,
} from "@/server/services/work-orders/options";
import { listOrderTypes } from "@/server/services/work-orders/settings";
export default function Page() {
return <ModulePlaceholder moduleKey="work_orders" />;
type SP = Record<string, string | string[] | undefined>;
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
function filterQuery(p: ListParams, extra: Record<string, unknown> = {}) {
return {
q: p.q,
from: p.from,
to: p.to,
customerId: p.customerId,
siteId: p.siteId,
teamId: p.teamId,
userId: p.userId,
status: p.statuses,
group: p.group,
orderTypeId: p.orderTypeId,
priority: p.priority,
preset: p.preset,
sort: p.sort === "plannedStart" ? undefined : p.sort,
dir: p.dir === "asc" ? undefined : p.dir,
...extra,
};
}
/** Backoffice work order list (§21 filters, status group tabs, cards/table, create popup). */
export default async function WorkOrdersPage({ searchParams }: { searchParams: Promise<SP> }) {
const { ctx, locale, tz, can } = await pageContext();
const t = await getTranslations("workOrders");
const td = await getTranslations("dashboard");
const tc = await getTranslations("common");
const sp = await searchParams;
const p = parseListParams(sp);
const view = one(sp.view) === "table" ? "table" : "cards";
const readAll = can("work_order:read_all");
const [result, teams, users, orderTypes, customers, sites] = await Promise.all([
listWorkOrders(ctx, p),
teamOptions(ctx),
readAll ? userOptions(ctx) : Promise.resolve([]),
listOrderTypes(ctx),
readAll ? customerFilterOptions(ctx) : Promise.resolve([]),
p.customerId ? siteFilterOptions(ctx, p.customerId) : Promise.resolve([]),
]);
const base = filterQuery(p, { view: view === "table" ? "table" : undefined });
const pages = Math.max(1, Math.ceil(result.total / p.pageSize));
const allCount = Object.values(result.groupCounts).reduce((a, b) => a + b, 0);
const listHref = `/work-orders${toQuery(base)}`;
// ---- create popup ----
const canCreate = can("work_order:write");
const showNew = canCreate && one(sp.new) === "1";
const newCustomerId = one(sp.customerId_new);
const cq = one(sp.cq);
const [customerHits, chosen, activeTypes] = showNew
? await Promise.all([
newCustomerId ? Promise.resolve([]) : searchCustomerOptions(ctx, cq),
newCustomerId ? customerOption(ctx, newCustomerId) : Promise.resolve(null),
listOrderTypes(ctx, { activeOnly: true }),
])
: [[], null, []];
const newBase = { ...base, new: "1" };
return (
<main className="flex-1 p-4 md:p-6">
<PageHead
crumb={t("crumb")}
title={t("title")}
sub={t("subtitle")}
actions={
<>
{can("work_order:write") && (
<Link href="/work-orders/conflicts" className={buttonCls("outline")}>
<RefreshCw className="size-4" aria-hidden />
{t("conflictsLink")}
</Link>
)}
{canCreate && (
<Link href={`/work-orders${toQuery(newBase)}`} className={buttonCls("primary")} scroll={false}>
<Plus className="size-4" aria-hidden />
{t("new")}
</Link>
)}
</>
}
/>
<LinkTabs
label={t("filter.status")}
items={[
{ href: `/work-orders${toQuery({ ...base, group: undefined, status: undefined })}`, label: t("allGroups"), active: !p.group && !p.statuses, count: allCount },
...STATUS_GROUPS.map((g) => ({
href: `/work-orders${toQuery({ ...base, group: g, status: undefined })}`,
label: t(`statusGroup.${g}`),
active: p.group === g,
count: result.groupCounts[g],
icon: <GroupIcon group={g} className="size-4" />,
})),
]}
/>
<details className="shadow-card mt-4 rounded-xl border bg-card" open={Boolean(p.q || p.from || p.to || p.customerId || p.teamId || p.userId || p.statuses || p.orderTypeId || p.priority)}>
<summary className="flex min-h-11 cursor-pointer items-center px-4 font-heading text-sm font-semibold">{t("filter.title")}</summary>
<form method="get" action="/work-orders" className="grid gap-3 border-t p-4 sm:grid-cols-2 lg:grid-cols-4">
{p.group && <input type="hidden" name="group" value={p.group} />}
{p.preset && <input type="hidden" name="preset" value={p.preset} />}
{view === "table" && <input type="hidden" name="view" value="table" />}
<Field label={t("filter.q")} htmlFor="f-q" className="sm:col-span-2">
<input id="f-q" type="search" name="q" defaultValue={p.q ?? ""} className={inputCls} />
</Field>
<Field label={t("filter.from")} htmlFor="f-from">
<input id="f-from" type="date" name="from" defaultValue={p.from ? isoDay(p.from) : ""} className={inputCls} />
</Field>
<Field label={t("filter.to")} htmlFor="f-to">
<input id="f-to" type="date" name="to" defaultValue={p.to ? isoDay(p.to) : ""} className={inputCls} />
</Field>
{readAll && (
<Field label={t("filter.customer")} htmlFor="f-customer">
<select id="f-customer" name="customerId" defaultValue={p.customerId ?? ""} className={inputCls}>
<option value="">{t("filter.any")}</option>
{customers.map((c) => (
<option key={c.id} value={c.id}>
{customerDisplayName(c)}
</option>
))}
</select>
</Field>
)}
{sites.length > 0 && (
<Field label={t("filter.site")} htmlFor="f-site">
<select id="f-site" name="siteId" defaultValue={p.siteId ?? ""} className={inputCls}>
<option value="">{t("filter.any")}</option>
{sites.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</Field>
)}
<Field label={t("filter.team")} htmlFor="f-team">
<select id="f-team" name="teamId" defaultValue={p.teamId ?? ""} className={inputCls}>
<option value="">{t("filter.any")}</option>
{teams.map((tm) => (
<option key={tm.id} value={tm.id}>
{tm.name}
</option>
))}
</select>
</Field>
{readAll && (
<Field label={t("filter.user")} htmlFor="f-user">
<select id="f-user" name="userId" defaultValue={p.userId ?? ""} className={inputCls}>
<option value="">{t("filter.any")}</option>
{users.map((u) => (
<option key={u.id} value={u.id}>
{u.name}
</option>
))}
</select>
</Field>
)}
<Field label={t("filter.status")} htmlFor="f-status">
<select id="f-status" name="status" defaultValue={p.statuses?.[0] ?? ""} className={inputCls}>
<option value="">{t("filter.any")}</option>
{WORK_ORDER_STATUSES.map((s) => (
<option key={s} value={s}>
{t(`status.${s}`)}
</option>
))}
</select>
</Field>
<Field label={t("filter.orderType")} htmlFor="f-type">
<select id="f-type" name="orderTypeId" defaultValue={p.orderTypeId ?? ""} className={inputCls}>
<option value="">{t("filter.any")}</option>
{orderTypes.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
</Field>
<Field label={t("filter.priority")} htmlFor="f-prio">
<select id="f-prio" name="priority" defaultValue={p.priority ?? ""} className={inputCls}>
<option value="">{t("filter.any")}</option>
{WORK_ORDER_PRIORITIES.map((pr) => (
<option key={pr} value={pr}>
{t(`priority.${pr}`)}
</option>
))}
</select>
</Field>
<Field label={t("filter.sort")} htmlFor="f-sort">
<div className="flex gap-2">
<select id="f-sort" name="sort" defaultValue={p.sort} className={inputCls}>
{SORT_FIELDS.map((s) => (
<option key={s} value={s}>
{t(`sort.${s}`)}
</option>
))}
</select>
<select name="dir" aria-label={t("filter.sort")} defaultValue={p.dir} className={inputCls}>
<option value="asc">{t("sort.asc")}</option>
<option value="desc">{t("sort.desc")}</option>
</select>
</div>
</Field>
<div className="flex items-end gap-2 sm:col-span-2 lg:col-span-4">
<button type="submit" className={buttonCls("default")}>
{t("filter.apply")}
</button>
<Link href="/work-orders" className={buttonCls("ghost")}>
{t("filter.reset")}
</Link>
</div>
</form>
</details>
<div className="mt-4 flex flex-wrap items-center justify-between gap-3">
<div className="flex flex-wrap items-center gap-3 text-sm">
<span className="font-semibold">{t("resultCount", { count: result.total })}</span>
{p.preset && (
<span className="inline-flex items-center gap-1 rounded-full border px-2.5 py-0.5 text-xs font-semibold">
{t("presetActive", { name: td(`tiles.${p.preset}`) })}
<Link href={`/work-orders${toQuery({ ...base, preset: undefined })}`} aria-label={t("removePreset")} className="inline-flex size-6 items-center justify-center rounded-full hover:bg-muted">
<X className="size-3.5" aria-hidden />
</Link>
</span>
)}
</div>
<div className="flex gap-1" role="group" aria-label={t("view.cards")}>
<Link href={`/work-orders${toQuery({ ...base, view: undefined, page: p.page > 1 ? p.page : undefined })}`} aria-current={view === "cards" ? "true" : undefined} className={buttonCls(view === "cards" ? "default" : "outline")}>
<LayoutGrid className="size-4" aria-hidden />
{t("view.cards")}
</Link>
<Link href={`/work-orders${toQuery({ ...base, view: "table", page: p.page > 1 ? p.page : undefined })}`} aria-current={view === "table" ? "true" : undefined} className={buttonCls(view === "table" ? "default" : "outline")}>
<Rows3 className="size-4" aria-hidden />
{t("view.table")}
</Link>
</div>
</div>
<div className="mt-3">
{result.items.length === 0 ? (
<Empty>{t("empty")}</Empty>
) : view === "table" ? (
<WorkOrderTable items={result.items} locale={locale} tz={tz} />
) : (
<WorkOrderCards items={result.items} locale={locale} tz={tz} />
)}
</div>
{pages > 1 && (
<nav aria-label={t("pagination.page", { page: p.page, pages })} className="mt-4 flex items-center justify-center gap-3 text-sm">
{p.page > 1 && (
<Link href={`/work-orders${toQuery({ ...base, page: p.page - 1 })}`} className={buttonCls("outline")}>
{t("pagination.prev")}
</Link>
)}
<span>{t("pagination.page", { page: p.page, pages })}</span>
{p.page < pages && (
<Link href={`/work-orders${toQuery({ ...base, page: p.page + 1 })}`} className={buttonCls("outline")}>
{t("pagination.next")}
</Link>
)}
</nav>
)}
{showNew && (
<Modal title={t("create.title")} sub={t("create.sub")} closeHref={listHref} closeLabel={tc("close")}>
{!chosen ? (
<div className="p-5">
<h3 className="mb-2 font-heading text-sm font-semibold">{t("create.stepCustomer")}</h3>
<form method="get" action="/work-orders" className="flex flex-wrap gap-2">
<input type="hidden" name="new" value="1" />
<input type="search" name="cq" defaultValue={cq ?? ""} aria-label={t("create.customerSearch")} placeholder={t("create.customerSearch")} className={`${inputCls} max-w-md flex-1`} autoFocus />
<button type="submit" className={buttonCls("default")}>
{t("create.searchButton")}
</button>
</form>
{customerHits.length === 0 ? (
<p className="mt-4 text-sm text-muted-foreground">{t("create.noCustomers")}</p>
) : (
<ul className="mt-4 divide-y rounded-lg border">
{customerHits.map((c) => (
<li key={c.id} className="flex flex-wrap items-center justify-between gap-2 px-3 py-2">
<div>
<p className="font-semibold">{customerDisplayName(c)}</p>
<p className="text-xs text-muted-foreground">{[c.customerNumber, c.city].filter(Boolean).join(" · ")}</p>
</div>
<Link href={`/work-orders?new=1&customerId_new=${c.id}`} scroll={false} className={buttonCls("outline")}>
{t("create.chooseCustomer")}
</Link>
</li>
))}
</ul>
)}
</div>
) : (
<div className="p-5">
<div className="mb-4 flex flex-wrap items-center justify-between gap-2 rounded-lg bg-muted/50 px-3 py-2">
<p className="text-sm">
<span className="text-muted-foreground">{t("fields.customer")}: </span>
<span className="font-semibold">{customerDisplayName(chosen.customer)}</span>
</p>
<Link href="/work-orders?new=1" scroll={false} className="text-sm font-semibold text-[var(--primary)] hover:underline">
{t("create.changeCustomer")}
</Link>
</div>
<h3 className="mb-3 font-heading text-sm font-semibold">{t("create.stepDetails")}</h3>
<ActionForm action={createWorkOrderAction} submitLabel={t("create.submit")} pendingLabel={t("create.pending")} variant="primary">
<input type="hidden" name="customerId" value={chosen.customer.id} />
<WorkOrderFields
mode="create"
tz={tz}
sites={chosen.sites.map((s) => ({ id: s.id, name: [s.name, [s.street, s.houseNumber].filter(Boolean).join(" "), s.city].filter(Boolean).join(" · ") }))}
contacts={chosen.contacts.map((c) => ({ id: c.id, name: c.role ? `${c.name} (${c.role})` : c.name }))}
orderTypes={activeTypes.map((o) => ({ id: o.id, name: o.name }))}
defaults={{ siteId: chosen.sites.length === 1 ? chosen.sites[0].id : null }}
/>
</ActionForm>
</div>
)}
</Modal>
)}
</main>
);
}
+109
View File
@@ -0,0 +1,109 @@
"use client";
import { useActionState } from "react";
import { useTranslations } from "next-intl";
import { AlertCircle, CheckCircle2 } from "lucide-react";
import { IDLE_STATE, type ActionState } from "@/lib/work-orders/action-state";
import type { CompletionBlocker } from "@/lib/work-orders/status";
import { cn } from "@/lib/utils";
type Action = (prev: ActionState, fd: FormData) => Promise<ActionState>;
const VARIANTS = {
primary: "bg-cta text-cta-foreground hover:opacity-90",
default: "bg-primary text-primary-foreground hover:opacity-90",
outline: "border border-border bg-background hover:bg-muted",
danger: "border border-[var(--risk)] bg-background text-[var(--risk)] hover:bg-muted",
ghost: "hover:bg-muted text-muted-foreground",
} as const;
export const buttonCls = (variant: keyof typeof VARIANTS = "default") =>
cn(
"inline-flex min-h-11 items-center justify-center gap-2 rounded-lg px-4 font-heading text-sm font-semibold transition-colors focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none disabled:opacity-50",
VARIANTS[variant],
);
/**
* Form bound to a work order server action. Shows translated errors (incl. structured
* completion blockers) and an optional success note. Children are server-rendered fields.
*/
export function ActionForm({
action,
children,
submitLabel,
pendingLabel,
successText,
variant = "default",
namespace = "workOrders",
className,
footerClassName,
hideSubmit,
}: {
action: Action;
children?: React.ReactNode;
submitLabel: string;
pendingLabel?: string;
successText?: string;
variant?: keyof typeof VARIANTS;
namespace?: "workOrders" | "settingsTemplates";
className?: string;
footerClassName?: string;
hideSubmit?: boolean;
}) {
const [state, formAction, pending] = useActionState(action, IDLE_STATE);
const t = useTranslations(namespace);
const tw = useTranslations("workOrders");
let errorText = "";
if (state.status === "error") {
const key = `errors.${state.message}`;
errorText = t.has(key) ? t(key) : t.has(`errors.${state.code}`) ? t(`errors.${state.code}`) : t("errors.internal");
}
return (
<form action={formAction} className={className}>
{children}
{state.status === "error" && (
<div role="alert" className="mt-3 rounded-lg border border-[var(--risk)] bg-card px-3 py-2 text-sm text-[var(--risk)]">
<p className="flex items-center gap-2 font-semibold">
<AlertCircle className="size-4 shrink-0" aria-hidden />
{errorText}
</p>
{state.blockers && state.blockers.length > 0 && (
<ul className="mt-1.5 list-disc space-y-0.5 pl-6 text-foreground">
{state.blockers.map((b, i) => (
<li key={i}>{blockerText(tw, b)}</li>
))}
</ul>
)}
</div>
)}
{state.status === "ok" && successText && (
<p role="status" className="mt-3 flex items-center gap-2 text-sm text-[var(--ok)]">
<CheckCircle2 className="size-4" aria-hidden />
{successText}
</p>
)}
{!hideSubmit && (
<div className={cn("mt-3 flex flex-wrap gap-2", footerClassName)}>
<button type="submit" disabled={pending} className={buttonCls(variant)}>
{pending ? pendingLabel ?? submitLabel : submitLabel}
</button>
</div>
)}
</form>
);
}
export function blockerText(t: ReturnType<typeof useTranslations>, b: CompletionBlocker): string {
switch (b.kind) {
case "checklist_item":
return t("blockers.checklist_item", { label: b.label });
case "photo_requirement":
return t("blockers.photo_requirement", { label: b.label });
case "running_session":
return t("blockers.running_session");
case "missing_field":
return t.has(`blockers.field.${b.field}`) ? t(`blockers.field.${b.field}`) : b.field;
}
}
+562
View File
@@ -0,0 +1,562 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { Camera, Download, FileText } from "lucide-react";
import type { ServiceCtx } from "@/server/services/context";
import {
getChecklistTab,
getDocumentsTab,
getHistoryTab,
getMaterialOverview,
getNotesTab,
getPhotosTab,
getReportsTab,
getTimesTab,
type WorkOrderDetail,
} from "@/server/services/work-orders/detail";
import { UPLOAD_CATEGORIES } from "@/server/services/work-orders/documents";
import { checklistTemplateOptions, customerDisplayName } from "@/server/services/work-orders/options";
import { allowedDocumentVisibility } from "@/server/services/work-orders/visibility";
import {
addChecklistItemAction,
addMaterialPlanAction,
addPhotoRequirementAction,
applyChecklistTemplateAction,
archiveDocumentAction,
removeChecklistItemAction,
removeMaterialPlanAction,
removePhotoRequirementAction,
} from "@/server/actions/work_orders/work-orders";
import { formatDate, formatDateTime } from "@/lib/work-orders/time";
import { ActionForm, buttonCls } from "@/components/work-orders/action-form";
import { Check, Dl, Empty, Field, inputCls, Section } from "@/components/work-orders/ui";
import type { CompletionBlocker } from "@/lib/work-orders/status";
type TabProps = { ctx: ServiceCtx; wo: WorkOrderDetail; locale: string; tz: string; canEdit: boolean };
const Hidden = ({ name, value }: { name: string; value: string }) => <input type="hidden" name={name} value={value} />;
function address(x: { street?: string | null; houseNumber?: string | null; postalCode?: string | null; city?: string | null } | null) {
if (!x) return "";
return [[x.street, x.houseNumber].filter(Boolean).join(" "), [x.postalCode, x.city].filter(Boolean).join(" ")].filter(Boolean).join(", ");
}
export async function OverviewTab({ wo, locale, tz }: TabProps) {
const t = await getTranslations("workOrders");
return (
<div className="grid gap-4 lg:grid-cols-2">
<Section title={t("overview.customer")}>
<Dl
rows={[
[t("overview.customer"), <Link key="c" href={`/customers/${wo.customer.id}`} className="font-semibold text-[var(--primary)] hover:underline">{customerDisplayName(wo.customer)}</Link>],
[t("overview.site"), wo.site ? <Link key="s" href={`/sites/${wo.site.id}`} className="text-[var(--primary)] hover:underline">{[wo.site.name, address(wo.site)].filter(Boolean).join(" · ")}</Link> : null],
[t("overview.contact"), wo.contact ? [wo.contact.name, wo.contact.phone ?? wo.contact.mobile, wo.contact.email].filter(Boolean).join(" · ") : null],
[t("overview.accessNotes"), wo.site?.accessNotes ?? null],
[t("overview.safetyNotes"), wo.site?.safetyNotes ?? null],
]}
/>
</Section>
<Section title={t("overview.planning")}>
<Dl
rows={[
[t("fields.orderType"), wo.orderType?.name ?? null],
[t("fields.priority"), t(`priority.${wo.priority}`)],
[t("fields.plannedStart"), formatDateTime(wo.plannedStart, locale, tz) || t("noDate")],
[t("fields.plannedEnd"), formatDateTime(wo.plannedEnd, locale, tz)],
[t("fields.signatureRequired"), wo.signatureRequired ? t("fields.yes") : t("fields.no")],
[t("fields.billingType"), wo.billingType ? t(`billingType.${wo.billingType}`) : null],
]}
/>
</Section>
<Section title={t("overview.assignment")}>
<Dl
rows={[
[t("overview.team"), wo.team?.name ?? t("unassigned")],
[t("overview.teamLead"), wo.teamLead?.name ?? null],
[t("overview.assignees"), wo.assignees.map((a) => a.user.name).join(", ")],
]}
/>
</Section>
<Section title={t("overview.order")}>
<Dl
rows={[
[t("fields.externalOrderNumber"), wo.externalOrderNumber],
[t("fields.offerNumber"), wo.offerNumber],
[t("overview.created"), formatDateTime(wo.createdAt, locale, tz)],
[t("overview.updated"), formatDateTime(wo.updatedAt, locale, tz)],
]}
/>
</Section>
<Section title={t("fields.description")} className="lg:col-span-2">
<p className="text-sm whitespace-pre-wrap">{wo.description || "—"}</p>
<h3 className="mt-4 mb-1 font-heading text-sm font-semibold">{t("fields.scope")}</h3>
<p className="text-sm whitespace-pre-wrap">{wo.scope || "—"}</p>
</Section>
</div>
);
}
export async function ChecklistTab({ ctx, wo, canEdit }: TabProps) {
const t = await getTranslations("workOrders");
const [{ items, requirements, blockers }, templates] = await Promise.all([getChecklistTab(ctx, wo.id), canEdit ? checklistTemplateOptions(ctx) : Promise.resolve([])]);
return (
<div className="grid gap-4 lg:grid-cols-2">
<BlockerSummary blockers={blockers} />
<Section title={t("checklist.items")}>
{items.length === 0 ? (
<Empty>{t("checklist.empty")}</Empty>
) : (
<ul className="divide-y">
{items.map((i) => (
<li key={i.id} className="flex flex-wrap items-center justify-between gap-2 py-2">
<div>
<p className="text-sm font-semibold">
<span className={i.checked ? "text-[var(--ok)]" : "text-muted-foreground"}>{i.checked ? `✓ ${t("checklist.done")}` : `○ ${t("checklist.open")}`}</span> · {i.label}
</p>
<p className="text-xs text-muted-foreground">
{i.required ? t("checklist.required") : t("checklist.optional")}
{i.requiresPhoto && ` · ${t("checklist.requiresPhoto")} (${t("checklist.photoCount", { count: i._count.photos })})`}
{i.comment && ` · ${i.comment}`}
</p>
</div>
{canEdit && !i.checked && (
<ActionForm action={removeChecklistItemAction} submitLabel={t("checklist.remove")} variant="ghost" footerClassName="mt-0">
<Hidden name="workOrderId" value={wo.id} />
<Hidden name="itemId" value={i.id} />
</ActionForm>
)}
</li>
))}
</ul>
)}
{canEdit && (
<ActionForm action={addChecklistItemAction} submitLabel={t("checklist.addItem")} variant="outline" successText={t("saved")} className="mt-4 border-t pt-4">
<Hidden name="workOrderId" value={wo.id} />
<Field label={t("checklist.label")} htmlFor="cl-label">
<input id="cl-label" name="label" required maxLength={200} className={inputCls} />
</Field>
<div className="flex flex-wrap gap-4">
<Check name="required" id="cl-req" label={t("checklist.required")} />
<Check name="requiresPhoto" id="cl-photo" label={t("checklist.requiresPhoto")} />
</div>
</ActionForm>
)}
</Section>
<Section title={t("checklist.photos")}>
{requirements.length === 0 ? (
<Empty>{t("checklist.emptyPhotos")}</Empty>
) : (
<ul className="divide-y">
{requirements.map((r) => (
<li key={r.id} className="flex flex-wrap items-center justify-between gap-2 py-2">
<p className="text-sm">
<span className={r._count.photos > 0 ? "font-semibold text-[var(--ok)]" : "font-semibold text-[var(--warn)]"}>
{r._count.photos > 0 ? "✓" : "!"} {t("checklist.photoCount", { count: r._count.photos })}
</span>{" "}
· {r.label}
</p>
{canEdit && r._count.photos === 0 && (
<ActionForm action={removePhotoRequirementAction} submitLabel={t("checklist.remove")} variant="ghost" footerClassName="mt-0">
<Hidden name="workOrderId" value={wo.id} />
<Hidden name="requirementId" value={r.id} />
</ActionForm>
)}
</li>
))}
</ul>
)}
{canEdit && (
<>
<ActionForm action={addPhotoRequirementAction} submitLabel={t("checklist.addPhoto")} variant="outline" successText={t("saved")} className="mt-4 border-t pt-4">
<Hidden name="workOrderId" value={wo.id} />
<Field label={t("checklist.label")} htmlFor="pr-label">
<input id="pr-label" name="label" required maxLength={200} className={inputCls} />
</Field>
</ActionForm>
{templates.length > 0 && (
<ActionForm action={applyChecklistTemplateAction} submitLabel={t("checklist.applyButton")} variant="outline" successText={t("saved")} className="mt-4 border-t pt-4">
<Hidden name="workOrderId" value={wo.id} />
<Field label={t("checklist.applyTemplate")} htmlFor="tpl">
<select id="tpl" name="templateId" className={inputCls}>
{templates.map((x) => (
<option key={x.id} value={x.id}>
{x.name}
</option>
))}
</select>
</Field>
</ActionForm>
)}
</>
)}
</Section>
</div>
);
}
async function BlockerSummary({ blockers }: { blockers: CompletionBlocker[] }) {
const t = await getTranslations("workOrders");
if (blockers.length === 0) {
return <p className="rounded-lg border border-[var(--ok)] bg-card px-4 py-2 text-sm font-semibold text-[var(--ok)] lg:col-span-2">✓ {t("checklist.complete")}</p>;
}
return (
<div className="rounded-lg border border-[var(--warn)] bg-card px-4 py-3 text-sm lg:col-span-2" role="status">
<p className="font-semibold text-[var(--warn)]">! {t("detail.blockersTitle")}</p>
<ul className="mt-1 list-disc pl-6">
{blockers.map((b, i) => (
<li key={i}>
{b.kind === "checklist_item"
? t("blockers.checklist_item", { label: b.label })
: b.kind === "photo_requirement"
? t("blockers.photo_requirement", { label: b.label })
: b.kind === "running_session"
? t("blockers.running_session")
: t(`blockers.field.${b.field}` as "blockers.field.signature")}
</li>
))}
</ul>
</div>
);
}
export async function MaterialTab({ ctx, wo, locale, canEdit }: TabProps) {
const t = await getTranslations("workOrders");
const rows = await getMaterialOverview(ctx, wo.id);
const nf = new Intl.NumberFormat(locale === "en" ? "en-GB" : "de-DE", { maximumFractionDigits: 3 });
return (
<Section title={t("material.title")}>
{rows.length === 0 ? (
<Empty>{t("material.empty")}</Empty>
) : (
<div className="overflow-x-auto">
<table className="w-full min-w-[720px] text-sm">
<thead className="border-b text-left text-xs text-muted-foreground">
<tr>
<th className="py-2 pr-3">{t("material.name")}</th>
<th className="py-2 pr-3 text-right">{t("material.planned")}</th>
<th className="py-2 pr-3 text-right">{t("material.actual")}</th>
<th className="py-2 pr-3 text-right">{t("material.deviation")}</th>
<th className="py-2 pr-3">{t("material.reason")}</th>
<th className="py-2" />
</tr>
</thead>
<tbody>
{rows.map((r, idx) => (
<tr key={r.planId ?? `u${idx}`} className="border-b last:border-0 align-top">
<td className="py-2 pr-3">
<p className="font-semibold">{r.name}</p>
<p className="text-xs text-muted-foreground">
{[r.articleNumber, r.planId === null ? t("material.additional") : null, ...r.statuses.map((s) => t(`material.usage.${s}` as "material.usage.fully_used"))].filter(Boolean).join(" · ")}
</p>
{r.notes && <p className="text-xs text-muted-foreground">{r.notes}</p>}
</td>
<td className="py-2 pr-3 text-right whitespace-nowrap">{r.planned === null ? "—" : `${nf.format(r.planned)} ${r.unit}`}</td>
<td className="py-2 pr-3 text-right whitespace-nowrap">{r.actual === null ? <span className="text-muted-foreground">{t("material.notRecorded")}</span> : `${nf.format(r.actual)} ${r.unit}`}</td>
<td className={`py-2 pr-3 text-right font-semibold whitespace-nowrap ${r.deviation ? "text-[var(--warn)]" : ""}`}>
{r.deviation === null ? "—" : `${r.deviation > 0 ? "+" : ""}${nf.format(r.deviation)} ${r.unit}`}
</td>
<td className="py-2 pr-3 text-xs">{r.reasons.join("; ") || "—"}</td>
<td className="py-2 text-right">
{canEdit && r.planId && r.actual === null && (
<ActionForm action={removeMaterialPlanAction} submitLabel={t("material.remove")} variant="ghost" footerClassName="mt-0 justify-end">
<Hidden name="workOrderId" value={wo.id} />
<Hidden name="planId" value={r.planId} />
</ActionForm>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
{canEdit && (
<ActionForm action={addMaterialPlanAction} submitLabel={t("material.add")} variant="outline" successText={t("saved")} className="mt-4 border-t pt-4">
<Hidden name="workOrderId" value={wo.id} />
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-5">
<Field label={`${t("material.name")} *`} htmlFor="m-name" className="lg:col-span-2">
<input id="m-name" name="name" required maxLength={200} className={inputCls} />
</Field>
<Field label={t("material.articleNumber")} htmlFor="m-art">
<input id="m-art" name="articleNumber" maxLength={80} className={inputCls} />
</Field>
<Field label={`${t("material.planned")} *`} htmlFor="m-qty">
<input id="m-qty" name="plannedQuantity" required inputMode="decimal" pattern="[0-9]+([.,][0-9]{1,3})?" className={inputCls} />
</Field>
<Field label={`${t("material.unit")} *`} htmlFor="m-unit">
<input id="m-unit" name="unit" required maxLength={20} className={inputCls} />
</Field>
<Field label={t("material.notes")} htmlFor="m-notes" className="sm:col-span-2 lg:col-span-5">
<input id="m-notes" name="notes" maxLength={1000} className={inputCls} />
</Field>
</div>
</ActionForm>
)}
</Section>
);
}
export async function TimesTab({ ctx, wo, locale, tz }: TabProps) {
const t = await getTranslations("workOrders");
const sessions = await getTimesTab(ctx, wo.id);
if (sessions.length === 0) return <Empty>{t("times.empty")}</Empty>;
return (
<div className="space-y-3">
{sessions.map((s) => (
<Section key={s.id} title={`${s.user.name} · ${t(`times.sessionStatus.${s.status}`)}`} actions={<span className="text-sm font-semibold">{t("times.duration")}: {t("times.minutes", { minutes: s.workMinutes })}</span>}>
<div className="overflow-x-auto">
<table className="w-full min-w-[520px] text-sm">
<thead className="border-b text-left text-xs text-muted-foreground">
<tr>
<th className="py-1.5 pr-3">{t("columns.status")}</th>
<th className="py-1.5 pr-3">{t("times.start")}</th>
<th className="py-1.5 pr-3">{t("times.end")}</th>
<th className="py-1.5" />
</tr>
</thead>
<tbody>
{s.entries.map((e) => (
<tr key={e.id} className="border-b last:border-0">
<td className="py-1.5 pr-3">{t(`times.entryType.${e.type}`)}</td>
<td className="py-1.5 pr-3">{formatDateTime(e.startedAt, locale, tz)}</td>
<td className="py-1.5 pr-3">{formatDateTime(e.endedAt, locale, tz) || "…"}</td>
<td className="py-1.5 text-xs text-[var(--warn)]">{e.corrected ? t("times.corrected", { reason: e.correctionReason ?? "" }) : ""}</td>
</tr>
))}
</tbody>
</table>
</div>
</Section>
))}
</div>
);
}
export async function PhotosTab({ ctx, wo, locale, tz }: TabProps) {
const t = await getTranslations("workOrders");
const photos = await getPhotosTab(ctx, wo.id);
if (photos.length === 0) return <Empty>{t("photos.empty")}</Empty>;
return (
<ul className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-4">
{photos.map((p) => (
<li key={p.id} className="shadow-card overflow-hidden rounded-xl border bg-card">
<a href={`/files/${p.document.storageKey}`} className="block aspect-[4/3] bg-muted">
{/* eslint-disable-next-line @next/next/no-img-element -- tenant file route, no next/image optimisation for private files */}
<img src={`/files/${p.document.previewKey ?? p.document.storageKey}`} alt={p.comment ?? p.photoRequirement?.label ?? p.document.fileName} className="size-full object-cover" loading="lazy" />
</a>
<div className="p-2 text-xs">
<p className="flex items-center gap-1 font-semibold">
<Camera className="size-3.5" aria-hidden />
{p.photoRequirement?.label ?? p.checklistItem?.label ?? (p.phase ? t(`photos.phase.${p.phase}`) : p.document.fileName)}
</p>
{p.comment && <p className="text-muted-foreground">{p.comment}</p>}
<p className="text-muted-foreground">{formatDateTime(p.takenAt, locale, tz)}</p>
</div>
</li>
))}
</ul>
);
}
export async function NotesTab({ ctx, wo, locale, tz }: TabProps) {
const t = await getTranslations("workOrders");
const notes = await getNotesTab(ctx, wo.id);
return (
<div className="grid gap-4 lg:grid-cols-2">
{ctx.permissions.has("work_order:read_all") && (
<Section title={t("notes.internal")}>
<p className="text-sm whitespace-pre-wrap">{wo.internalNotes || "—"}</p>
</Section>
)}
<Section title={t("notes.technician")}>
<p className="text-sm whitespace-pre-wrap">{wo.technicianNotes || "—"}</p>
</Section>
<Section title={t("detail.tabs.notes")} className="lg:col-span-2">
{notes.length === 0 ? (
<Empty>{t("notes.empty")}</Empty>
) : (
<ul className="divide-y">
{notes.map((n) => (
<li key={n.id} className="py-2.5">
<p className="text-xs text-muted-foreground">
<span className="font-semibold text-foreground">{t(`notes.kind.${n.kind}`)}</span> · {n.authorName ?? t("history.system")} · {formatDateTime(n.createdAt, locale, tz)}
</p>
<p className="mt-0.5 text-sm whitespace-pre-wrap">{n.text}</p>
</li>
))}
</ul>
)}
</Section>
</div>
);
}
export async function ReportsTab({ ctx, wo, locale, tz, billing }: TabProps & { billing?: React.ReactNode }) {
const t = await getTranslations("workOrders");
const reports = await getReportsTab(ctx, wo.id);
return (
<div className="space-y-4">
{billing && (
<Section title={t("reports.billingTitle")}>
<p className="mb-2 text-sm text-muted-foreground">{t("reports.billingHint")}</p>
{billing}
</Section>
)}
<Section title={t("detail.tabs.reports")}>
{reports.length === 0 ? (
<Empty>{t("reports.empty")}</Empty>
) : (
<ul className="divide-y">
{reports.map((r) => (
<li key={r.id} className="flex flex-wrap items-center justify-between gap-2 py-2.5">
<div>
<p className="flex items-center gap-1.5 text-sm font-semibold">
<FileText className="size-4" aria-hidden />
{t(`reports.type.${r.type}`)} · {formatDate(r.reportDate, locale, tz)} · v{r.version}
</p>
<p className="text-xs text-muted-foreground">
{t(`reports.status.${r.status}`)}
{r.signature && ` · ${t("reports.signature", { outcome: t(`reports.signatureOutcome.${r.signature.outcome}`) })}`}
{r.rejectionReason && ` · ${r.rejectionReason}`}
</p>
</div>
<Link href={`/reports/${r.id}`} className={buttonCls("outline")}>
{t("reports.open")}
</Link>
</li>
))}
</ul>
)}
</Section>
</div>
);
}
export async function DocumentsTab({ ctx, wo, locale, tz, uploadError, uploaded }: TabProps & { uploadError?: string; uploaded?: boolean }) {
const t = await getTranslations("workOrders");
const docs = await getDocumentsTab(ctx, wo.id);
const canUpload = ctx.permissions.has("document:write");
const visibilities = allowedDocumentVisibility(ctx);
return (
<div className="grid gap-4 lg:grid-cols-[1fr_360px]">
<Section title={t("detail.tabs.documents")}>
{docs.length === 0 ? (
<Empty>{t("documents.empty")}</Empty>
) : (
<ul className="divide-y">
{docs.map((d) => (
<li key={d.id} className="flex flex-wrap items-center justify-between gap-2 py-2.5">
<div className="min-w-0">
<p className="truncate text-sm font-semibold">{d.title || d.fileName}</p>
<p className="text-xs text-muted-foreground">
{t(`documents.categories.${d.category}`)} · {t(`documents.visibilities.${d.visibility}`)} · v{d.version} · {formatDateTime(d.createdAt, locale, tz)}
</p>
</div>
<div className="flex gap-1">
<a href={`/files/${d.storageKey}`} className={buttonCls("outline")}>
<Download className="size-4" aria-hidden />
{t("documents.download")}
</a>
{canUpload && (
<ActionForm action={archiveDocumentAction} submitLabel={t("documents.archive")} variant="ghost" footerClassName="mt-0">
<Hidden name="workOrderId" value={wo.id} />
<Hidden name="documentId" value={d.id} />
</ActionForm>
)}
</div>
</li>
))}
</ul>
)}
</Section>
{canUpload && (
<Section title={t("documents.upload")}>
{uploadError && (
<p role="alert" className="mb-3 rounded-lg border border-[var(--risk)] px-3 py-2 text-sm text-[var(--risk)]">
{t.has(`errors.${uploadError}`) ? t(`errors.${uploadError}` as "errors.internal") : t("errors.internal")}
</p>
)}
{uploaded && <p role="status" className="mb-3 text-sm text-[var(--ok)]">✓ {t("saved")}</p>}
<form method="post" encType="multipart/form-data" action={`/api/v1/work-orders/${wo.id}/documents`} className="space-y-3">
<Field label={`${t("documents.file")} *`} htmlFor="doc-file">
<input id="doc-file" type="file" name="file" required accept="application/pdf,image/jpeg,image/png,image/webp" className={`${inputCls} py-2`} />
</Field>
<Field label={t("documents.title")} htmlFor="doc-title">
<input id="doc-title" name="title" maxLength={200} className={inputCls} />
</Field>
<Field label={t("documents.category")} htmlFor="doc-cat">
<select id="doc-cat" name="category" defaultValue="technical_drawing" className={inputCls}>
{UPLOAD_CATEGORIES.map((c) => (
<option key={c} value={c}>
{t(`documents.categories.${c}`)}
</option>
))}
</select>
</Field>
<Field label={t("documents.visibility")} htmlFor="doc-vis">
<select id="doc-vis" name="visibility" defaultValue="team" className={inputCls}>
{visibilities.map((v) => (
<option key={v} value={v}>
{t(`documents.visibilities.${v}`)}
</option>
))}
</select>
</Field>
<button type="submit" className={buttonCls("default")}>
{t("documents.submit")}
</button>
<p className="text-xs text-muted-foreground">{t("documents.stubHint")}</p>
</form>
</Section>
)}
</div>
);
}
export async function HistoryTab({ ctx, wo, locale, tz }: TabProps) {
const t = await getTranslations("workOrders");
const { changes, audit } = await getHistoryTab(ctx, wo.id);
return (
<div className="grid gap-4 lg:grid-cols-2">
<Section title={t("history.statusChanges")}>
{changes.length === 0 ? (
<Empty>{t("history.empty")}</Empty>
) : (
<ol className="space-y-2">
{changes.map((c) => (
<li key={c.id} className="border-l-2 pl-3 text-sm">
<p className="font-semibold">
{c.fromStatus ? t("history.change", { from: t(`status.${c.fromStatus}`), to: t(`status.${c.toStatus}`) }) : t("history.created", { status: t(`status.${c.toStatus}`) })}
</p>
<p className="text-xs text-muted-foreground">
{formatDateTime(c.createdAt, locale, tz)} · {t("history.by", { name: c.actorName ?? t("history.system") })}
</p>
{c.reason && <p className="text-xs">{c.reason}</p>}
</li>
))}
</ol>
)}
</Section>
{audit.length > 0 && (
<Section title={t("history.audit")}>
<ol className="space-y-2">
{audit.map((a) => {
const after = (a.after ?? {}) as Record<string, unknown>;
const summary = typeof after.op === "string" ? after.op : Object.keys(after).filter((k) => k !== "version").join(", ");
return (
<li key={a.id} className="border-l-2 pl-3 text-sm">
<p className="font-semibold">
{t.has(`history.action.${a.action}`) ? t(`history.action.${a.action}` as "history.action.update") : a.action}
{summary && <span className="font-normal text-muted-foreground"> · {summary}</span>}
</p>
<p className="text-xs text-muted-foreground">
{formatDateTime(a.createdAt, locale, tz)} · {t("history.by", { name: a.actorName ?? t("history.system") })}
</p>
</li>
);
})}
</ol>
</Section>
)}
</div>
);
}
@@ -0,0 +1,21 @@
import { getLocale } from "next-intl/server";
import { requireSession } from "@/server/auth";
import { dbForTenant } from "@/server/db";
import type { ServiceCtx } from "@/server/services/context";
import { tenantTimezone } from "@/server/services/work-orders/_shared";
/**
* Read-path context for server-rendered pages of the work order module. Permissions come from
* the session (read paths; AGENTS.md) — every mutation re-checks DB-authoritatively via moduleGuard.
*/
export async function pageContext() {
const session = await requireSession();
const ctx: ServiceCtx = {
db: dbForTenant(session.user.tenantId),
tenantId: session.user.tenantId,
userId: session.user.id,
permissions: new Set(session.user.permissions ?? []),
};
const [locale, tz] = await Promise.all([getLocale(), tenantTimezone(ctx)]);
return { session, ctx, locale, tz, can: (p: string) => ctx.permissions.has(p) };
}
@@ -0,0 +1,27 @@
import { redirect } from "next/navigation";
import { getTranslations } from "next-intl/server";
import { pageContext } from "@/components/work-orders/page-context";
import { LinkTabs } from "@/components/work-orders/ui";
/** Access check for the template settings pages (`settings:templates`). */
export async function templatesPageContext() {
const pc = await pageContext();
if (!pc.can("settings:templates")) redirect("/dashboard");
return pc;
}
export async function SettingsTemplatesNav({ active }: { active: "order-types" | "checklists" | "numbering" }) {
const t = await getTranslations("settingsTemplates");
return (
<div className="mb-4">
<LinkTabs
label={t("crumb")}
items={[
{ href: "/settings/order-types", label: t("nav.orderTypes"), active: active === "order-types" },
{ href: "/settings/checklists", label: t("nav.checklists"), active: active === "checklists" },
{ href: "/settings/numbering", label: t("nav.numbering"), active: active === "numbering" },
]}
/>
</div>
);
}
+148
View File
@@ -0,0 +1,148 @@
import Link from "next/link";
import {
Ban,
CalendarClock,
CheckCheck,
CircleDashed,
ClipboardCheck,
Receipt,
TriangleAlert,
Truck,
Wrench,
type LucideIcon,
} from "lucide-react";
import { STATUS_GROUP, STATUS_GROUP_TONE, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status";
import { cn } from "@/lib/utils";
/**
* Presentational building blocks of the work order module (server-safe, no hooks).
* Colours only via CSS tokens; status is always text + icon, never colour alone.
*/
export const TONE_VAR: Record<(typeof STATUS_GROUP_TONE)[StatusGroup], string> = {
neutral: "var(--txt-muted)",
info: "var(--info)",
accent: "var(--ui-accent)",
warning: "var(--warn)",
success: "var(--ok)",
danger: "var(--risk)",
};
const GROUP_ICON: Record<StatusGroup, LucideIcon> = {
new: CircleDashed,
planned: CalendarClock,
en_route: Truck,
in_progress: Wrench,
documentation_incomplete: TriangleAlert,
in_review: ClipboardCheck,
ready_for_billing: Receipt,
billed: CheckCheck,
cancelled: Ban,
};
export function statusColor(status: WorkOrderStatus): string {
return TONE_VAR[STATUS_GROUP_TONE[STATUS_GROUP[status]]];
}
export function StatusBadge({ status, label, className }: { status: WorkOrderStatus; label: string; className?: string }) {
const Icon = GROUP_ICON[STATUS_GROUP[status]];
const color = statusColor(status);
return (
<span
className={cn("inline-flex items-center gap-1.5 rounded-full border px-2.5 py-0.5 text-xs font-semibold whitespace-nowrap", className)}
style={{ color, borderColor: color, background: `color-mix(in oklch, ${color} 10%, transparent)` }}
>
<Icon className="size-3.5 shrink-0" aria-hidden />
{label}
</span>
);
}
export function GroupIcon({ group, className }: { group: StatusGroup; className?: string }) {
const Icon = GROUP_ICON[group];
return <Icon className={className} aria-hidden />;
}
export const inputCls =
"min-h-11 w-full rounded-lg border border-input bg-background px-3 text-sm outline-none focus-visible:border-ring focus-visible:ring-3 focus-visible:ring-ring/50";
export function Field({ label, htmlFor, hint, className, children }: { label: string; htmlFor?: string; hint?: string; className?: string; children: React.ReactNode }) {
return (
<div className={className}>
<label htmlFor={htmlFor} className="mb-1 block text-[13px] font-semibold">
{label}
</label>
{children}
{hint && <p className="mt-1 text-xs text-muted-foreground">{hint}</p>}
</div>
);
}
export function Check({ name, label, defaultChecked, value, id }: { name: string; label: string; defaultChecked?: boolean; value?: string; id?: string }) {
return (
<label htmlFor={id} className="flex min-h-11 cursor-pointer items-center gap-2.5 text-sm">
<input id={id} type="checkbox" name={name} value={value} defaultChecked={defaultChecked} className="size-5 accent-[var(--ui-primary)]" />
{label}
</label>
);
}
export type TabItem = { href: string; label: string; active: boolean; count?: number; icon?: React.ReactNode };
/** Link-based tabs (server rendering, deep-linkable, keyboard accessible). */
export function LinkTabs({ items, label }: { items: TabItem[]; label: string }) {
return (
<nav aria-label={label} className="-mx-1 overflow-x-auto">
<ul className="flex min-w-max gap-1 border-b px-1">
{items.map((i) => (
<li key={i.href}>
<Link
href={i.href}
scroll={false}
aria-current={i.active ? "page" : undefined}
className={cn(
"-mb-px inline-flex min-h-11 items-center gap-1.5 border-b-2 px-3 text-[13px] font-semibold whitespace-nowrap transition-colors",
i.active ? "border-[var(--ui-accent)] text-foreground" : "border-transparent text-muted-foreground hover:text-foreground",
)}
>
{i.icon}
{i.label}
{i.count !== undefined && <span className="rounded-full bg-muted px-1.5 text-[11px] text-muted-foreground">{i.count}</span>}
</Link>
</li>
))}
</ul>
</nav>
);
}
export function Section({ title, actions, children, className }: { title?: string; actions?: React.ReactNode; children: React.ReactNode; className?: string }) {
return (
<section className={cn("shadow-card rounded-xl border bg-card p-4 md:p-5", className)}>
{(title || actions) && (
<div className="mb-3 flex flex-wrap items-center justify-between gap-2">
{title && <h2 className="font-heading text-[15px] font-semibold">{title}</h2>}
{actions}
</div>
)}
{children}
</section>
);
}
export function Empty({ children }: { children: React.ReactNode }) {
return <p className="rounded-lg border border-dashed px-4 py-6 text-center text-sm text-muted-foreground">{children}</p>;
}
export function Dl({ rows }: { rows: [string, React.ReactNode][] }) {
return (
<dl className="grid grid-cols-[minmax(110px,auto)_1fr] gap-x-4 gap-y-1.5 text-sm">
{rows.map(([k, v]) => (
<div key={k} className="contents">
<dt className="text-muted-foreground">{k}</dt>
<dd className="min-w-0 break-words">{v || "—"}</dd>
</div>
))}
</dl>
);
}
@@ -0,0 +1,144 @@
import { getTranslations } from "next-intl/server";
import { BILLING_TYPES, WORK_ORDER_PRIORITIES } from "@/lib/work-orders/schemas";
import { toWallTimeInput } from "@/lib/work-orders/time";
import { Field, inputCls } from "@/components/work-orders/ui";
type Option = { id: string; name: string };
export type WorkOrderDefaults = {
title?: string;
siteId?: string | null;
contactId?: string | null;
orderTypeId?: string | null;
priority?: string;
plannedStart?: Date | null;
plannedEnd?: Date | null;
description?: string | null;
scope?: string | null;
internalNotes?: string | null;
technicianNotes?: string | null;
signatureRequired?: boolean;
billingType?: string | null;
externalOrderNumber?: string | null;
offerNumber?: string | null;
};
/** Shared fields of the create and edit popups (server-rendered, posted to server actions). */
export async function WorkOrderFields({
mode,
sites,
contacts,
orderTypes,
tz,
defaults = {},
}: {
mode: "create" | "edit";
sites: Option[];
contacts: Option[];
orderTypes: Option[];
tz: string;
defaults?: WorkOrderDefaults;
}) {
const t = await getTranslations("workOrders");
const d = defaults;
return (
<div className="grid gap-4 md:grid-cols-2">
<Field label={`${t("fields.title")} *`} htmlFor="wo-title" className="md:col-span-2">
<input id="wo-title" name="title" required maxLength={200} defaultValue={d.title ?? ""} className={inputCls} />
</Field>
<Field label={t("fields.site")} htmlFor="wo-site">
<select id="wo-site" name="siteId" defaultValue={d.siteId ?? ""} className={inputCls}>
<option value="">{t("fields.none")}</option>
{sites.map((s) => (
<option key={s.id} value={s.id}>
{s.name}
</option>
))}
</select>
</Field>
<Field label={t("fields.contact")} htmlFor="wo-contact">
<select id="wo-contact" name="contactId" defaultValue={d.contactId ?? ""} className={inputCls}>
<option value="">{t("fields.none")}</option>
{contacts.map((c) => (
<option key={c.id} value={c.id}>
{c.name}
</option>
))}
</select>
</Field>
<Field label={t("fields.orderType")} htmlFor="wo-type">
<select id="wo-type" name="orderTypeId" defaultValue={d.orderTypeId ?? ""} className={inputCls}>
<option value="">{t("fields.none")}</option>
{orderTypes.map((o) => (
<option key={o.id} value={o.id}>
{o.name}
</option>
))}
</select>
</Field>
<Field label={t("fields.priority")} htmlFor="wo-priority">
<select id="wo-priority" name="priority" defaultValue={d.priority ?? "normal"} className={inputCls}>
{WORK_ORDER_PRIORITIES.map((p) => (
<option key={p} value={p}>
{t(`priority.${p}`)}
</option>
))}
</select>
</Field>
<Field label={t("fields.plannedStart")} htmlFor="wo-start" hint={t("fields.timezoneHint", { tz })}>
<input id="wo-start" type="datetime-local" name="plannedStart" defaultValue={toWallTimeInput(d.plannedStart, tz)} className={inputCls} />
</Field>
<Field label={t("fields.plannedEnd")} htmlFor="wo-end">
<input id="wo-end" type="datetime-local" name="plannedEnd" defaultValue={toWallTimeInput(d.plannedEnd, tz)} className={inputCls} />
</Field>
<Field label={t("fields.description")} htmlFor="wo-desc" className="md:col-span-2">
<textarea id="wo-desc" name="description" rows={3} maxLength={10000} defaultValue={d.description ?? ""} className={`${inputCls} py-2`} />
</Field>
<Field label={t("fields.scope")} htmlFor="wo-scope" className="md:col-span-2">
<textarea id="wo-scope" name="scope" rows={3} maxLength={10000} defaultValue={d.scope ?? ""} className={`${inputCls} py-2`} />
</Field>
<Field label={t("fields.technicianNotes")} htmlFor="wo-tech">
<textarea id="wo-tech" name="technicianNotes" rows={3} maxLength={5000} defaultValue={d.technicianNotes ?? ""} className={`${inputCls} py-2`} />
</Field>
<Field label={t("fields.internalNotes")} htmlFor="wo-internal">
<textarea id="wo-internal" name="internalNotes" rows={3} maxLength={5000} defaultValue={d.internalNotes ?? ""} className={`${inputCls} py-2`} />
</Field>
<Field label={t("fields.signatureRequired")} htmlFor="wo-sig">
<select
id="wo-sig"
name="signatureRequired"
defaultValue={mode === "create" ? "" : d.signatureRequired === false ? "no" : "yes"}
className={inputCls}
>
{mode === "create" && <option value="">{t("fields.signatureFromType")}</option>}
<option value="yes">{t("fields.yes")}</option>
<option value="no">{t("fields.no")}</option>
</select>
</Field>
<Field label={t("fields.billingType")} htmlFor="wo-billing">
<select id="wo-billing" name="billingType" defaultValue={d.billingType ?? ""} className={inputCls}>
<option value="">{t("fields.none")}</option>
{BILLING_TYPES.map((b) => (
<option key={b} value={b}>
{t(`billingType.${b}`)}
</option>
))}
</select>
</Field>
<Field label={t("fields.externalOrderNumber")} htmlFor="wo-ext">
<input id="wo-ext" name="externalOrderNumber" maxLength={80} defaultValue={d.externalOrderNumber ?? ""} className={inputCls} />
</Field>
<Field label={t("fields.offerNumber")} htmlFor="wo-offer">
<input id="wo-offer" name="offerNumber" maxLength={80} defaultValue={d.offerNumber ?? ""} className={inputCls} />
</Field>
{mode === "create" && (
<Field label={t("fields.status")} htmlFor="wo-status">
<select id="wo-status" name="status" defaultValue="draft" className={inputCls}>
<option value="draft">{t("status.draft")}</option>
<option value="planned">{t("status.planned")}</option>
</select>
</Field>
)}
</div>
);
}
@@ -0,0 +1,106 @@
import Link from "next/link";
import { getTranslations } from "next-intl/server";
import { MapPin, Siren, UsersRound } from "lucide-react";
import type { WorkOrderListItem } from "@/server/services/work-orders/list";
import { customerDisplayName } from "@/server/services/work-orders/options";
import { formatDateTime } from "@/lib/work-orders/time";
import { StatusBadge, statusColor } from "@/components/work-orders/ui";
function planned(i: WorkOrderListItem, locale: string, tz: string, noDate: string) {
if (!i.plannedStart) return noDate;
const start = formatDateTime(i.plannedStart, locale, tz);
return i.plannedEnd ? `${start} – ${formatDateTime(i.plannedEnd, locale, tz)}` : start;
}
function siteLine(i: WorkOrderListItem) {
if (!i.site) return "";
const addr = [[i.site.street, i.site.houseNumber].filter(Boolean).join(" "), i.site.city].filter(Boolean).join(", ");
return addr ? `${i.site.name} · ${addr}` : i.site.name;
}
/** Cards with status edge (Brandbook §12.2). */
export async function WorkOrderCards({ items, locale, tz }: { items: WorkOrderListItem[]; locale: string; tz: string }) {
const t = await getTranslations("workOrders");
return (
<ul className="grid gap-3 md:grid-cols-2 xl:grid-cols-3">
{items.map((i) => (
<li key={i.id}>
<Link
href={`/work-orders/${i.id}`}
className="shadow-card block h-full rounded-xl border border-l-4 bg-card p-4 transition-colors hover:bg-muted/40 focus-visible:ring-3 focus-visible:ring-ring/50 focus-visible:outline-none"
style={{ borderLeftColor: statusColor(i.status) }}
>
<div className="flex flex-wrap items-center justify-between gap-2">
<span className="font-mono text-xs text-muted-foreground">{i.number}</span>
<StatusBadge status={i.status} label={t(`status.${i.status}`)} />
</div>
<p className="mt-1.5 font-heading text-[15px] font-semibold text-foreground">{i.title}</p>
<p className="mt-0.5 text-[13px]">{customerDisplayName(i.customer)}</p>
{i.site && (
<p className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground">
<MapPin className="size-3.5 shrink-0" aria-hidden />
{siteLine(i)}
</p>
)}
<div className="mt-2 flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
<span>{planned(i, locale, tz, t("noDate"))}</span>
<span className="flex items-center gap-1">
<UsersRound className="size-3.5" aria-hidden />
{i.team?.name ?? t("unassigned")}
</span>
{i.priority !== "normal" && <span className="font-semibold text-foreground">{t(`priority.${i.priority}`)}</span>}
{i.isEmergency && (
<span className="flex items-center gap-1 font-semibold text-[var(--risk)]">
<Siren className="size-3.5" aria-hidden />
{t("emergency")}
</span>
)}
</div>
</Link>
</li>
))}
</ul>
);
}
export async function WorkOrderTable({ items, locale, tz }: { items: WorkOrderListItem[]; locale: string; tz: string }) {
const t = await getTranslations("workOrders");
return (
<div className="shadow-card overflow-x-auto rounded-xl border bg-card">
<table className="w-full min-w-[860px] text-sm">
<thead className="border-b bg-muted/40 text-left text-xs text-muted-foreground">
<tr>
{(["number", "title", "customer", "site", "planned", "team", "priority", "status"] as const).map((c) => (
<th key={c} scope="col" className="px-3 py-2.5 font-semibold">
{t(`columns.${c}`)}
</th>
))}
</tr>
</thead>
<tbody>
{items.map((i) => (
<tr key={i.id} className="border-b last:border-0 hover:bg-muted/30">
<td className="border-l-4 px-3 py-2.5 font-mono text-xs" style={{ borderLeftColor: statusColor(i.status) }}>
<Link href={`/work-orders/${i.id}`} className="inline-flex min-h-11 items-center font-semibold text-[var(--primary)] hover:underline">
{i.number}
</Link>
</td>
<td className="px-3 py-2.5">
{i.title}
{i.isEmergency && <span className="ml-2 text-xs font-semibold text-[var(--risk)]">{t("emergency")}</span>}
</td>
<td className="px-3 py-2.5">{customerDisplayName(i.customer)}</td>
<td className="px-3 py-2.5 text-muted-foreground">{siteLine(i)}</td>
<td className="px-3 py-2.5 text-xs">{planned(i, locale, tz, t("noDate"))}</td>
<td className="px-3 py-2.5">{i.team?.name ?? t("unassigned")}</td>
<td className="px-3 py-2.5">{t(`priority.${i.priority}`)}</td>
<td className="px-3 py-2.5">
<StatusBadge status={i.status} label={t(`status.${i.status}`)} />
</td>
</tr>
))}
</tbody>
</table>
</div>
);
}