L13 Planung: Menüpunkt Planung mit Plantafel und Live-Lage
Plantafel: Standard heute + nächste 4 Werktage (Kolonnen als Zeilen, heutige Spalte mit Live-Status), Heute mit Kolonnen als parallelen Spalten (6–20 Uhr), Woche/nächste Woche, Auslastung, Konflikte/Hinweise, Verzugs- und Gefährdungs-Badges, Drag & Drop (@dnd-kit/core) mit Bestätigungs-Popover und Tastatur-Alternative, ungeplante Aufträge mit Vorschlägen, früher fertig mit Vorziehen, Kolonnenkapazität-Popup. Live-Lage: Leaflet/OSM-Karte mit einem Marker je Kolonne, Liste, Polling 30 s, Hinweis keine GPS-Ortung; CSP img-src für Kacheln. Dashboard-Kacheln Planung heute und Konflikte diese Woche, Einsatz-Vorschläge im Auftragsdetail, Navigation mit Unterpunkten, Smoke-Prüfungen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"dashboard": "Dashboard",
|
||||
"workOrders": "Aufträge",
|
||||
"planning": "Planung",
|
||||
"planningBoard": "Plantafel",
|
||||
"planningLive": "Live-Lage",
|
||||
"emergencyReview": "Notdienst-Prüfung",
|
||||
"imports": "Import",
|
||||
"customers": "Kunden",
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
{
|
||||
"crumb": "Planung",
|
||||
"weekday": { "0": "Mo", "1": "Di", "2": "Mi", "3": "Do", "4": "Fr", "5": "Sa", "6": "So" },
|
||||
"board": {
|
||||
"title": "Plantafel",
|
||||
"sub": "Aufträge je Team und Tag einplanen – Auslastung und Überschneidungen auf einen Blick.",
|
||||
"readOnly": "Nur Lesezugriff – Sie sehen Ihre eigenen Teams.",
|
||||
"viewLabel": "Ansicht",
|
||||
"view": { "today": "Heute", "days": "5 Tage", "week": "Woche", "nextWeek": "Nächste Woche" },
|
||||
"navLabel": "Zeitraum wechseln",
|
||||
"prevWeek": "Vorige Woche",
|
||||
"nextWeek": "Nächste Woche",
|
||||
"prevDay": "Voriger Tag",
|
||||
"nextDay": "Nächster Tag",
|
||||
"prevDays": "Vorige 5 Werktage",
|
||||
"nextDays": "Nächste 5 Werktage",
|
||||
"todayLabel": "Heute · {day}",
|
||||
"time": "Uhrzeit",
|
||||
"today": "Heute",
|
||||
"range": "{from} – {to}",
|
||||
"team": "Team",
|
||||
"noTeams": "Keine aktiven Teams in dieser Auswahl.",
|
||||
"showMembers": "Mitglieder von {team} anzeigen",
|
||||
"hideMembers": "Mitglieder von {team} ausblenden",
|
||||
"memberHint": "Einzeln zugewiesen",
|
||||
"load": "{planned} / {capacity} · {percent} %",
|
||||
"loadNoCapacity": "{planned} · keine Kapazität",
|
||||
"notWorkingDay": "Kein Arbeitstag",
|
||||
"allDay": "Ganztägig",
|
||||
"free": "frei",
|
||||
"narrowHint": "Die Plantafel ist für Bildschirme ab 1024 px ausgelegt. Hier sehen Sie eine einfache Liste.",
|
||||
"capacity": "Kapazität",
|
||||
"capacityOf": "Kapazität von {team} bearbeiten",
|
||||
"move": "Verschieben …",
|
||||
"schedule": "Einplanen …",
|
||||
"dragHint": "Karten mit der Maus auf einen Tag ziehen oder über „Einplanen …“ bzw. „Verschieben …“ per Tastatur einplanen.",
|
||||
"open": "Auftrag öffnen",
|
||||
"saved": "{number} eingeplant: {team}, {day}.",
|
||||
"savedWithConflicts": "{number} eingeplant: {team}, {day}. Achtung: {count} Konflikt(e) am Zieltag.",
|
||||
"priority": "Priorität: {priority}",
|
||||
"emergency": "Notdienst",
|
||||
"multiDay": "mehrtägig",
|
||||
"duration": "Dauer {duration}",
|
||||
"listDayEmpty": "Nichts geplant",
|
||||
"crewCapacity": "Kolonne · {capacity} je Arbeitstag · {count} Personen",
|
||||
"delayRisk": "Verzug droht · {percent} %",
|
||||
"delayOverrun": "Dauer überschritten · {percent} %",
|
||||
"atRisk": "gefährdet durch Verzug {number}"
|
||||
},
|
||||
"freed": {
|
||||
"title": "Früher fertig",
|
||||
"badge": "{team} · {minutes} früher fertig",
|
||||
"available": "{minutes} frei ab {time}",
|
||||
"pullForward": "Vorziehen",
|
||||
"kindPullForward": "Geplanter Auftrag von {team}",
|
||||
"kindNearby": "Ungeplant, {distance} entfernt",
|
||||
"noSuggestions": "Kein passender Auftrag für die freie Zeit.",
|
||||
"hint": "Nur Hinweis – nichts wird automatisch verschoben."
|
||||
},
|
||||
"conflicts": {
|
||||
"label": "Konflikt",
|
||||
"count": "{count, plural, =0 {Keine Konflikte} one {# Konflikt} other {# Konflikte}}",
|
||||
"overbooked": "Überbucht um {over}",
|
||||
"overlap": "Überschneidung: {a} und {b}",
|
||||
"assignee_double_booked": "{user} doppelt eingeplant: {a} und {b}",
|
||||
"outside_working_days": "Außerhalb der Arbeitstage",
|
||||
"crew_incomplete": "Kolonne unvollständig: {count} aktive Person(en)",
|
||||
"hintLabel": "Hinweis",
|
||||
"short": {
|
||||
"overbooked": "Überbucht",
|
||||
"overlap": "Überschneidung",
|
||||
"assignee_double_booked": "Doppelt eingeplant",
|
||||
"outside_working_days": "Kein Arbeitstag",
|
||||
"crew_incomplete": "Kolonne unvollständig"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filter",
|
||||
"team": "Team",
|
||||
"orderType": "Auftragsart",
|
||||
"priority": "Priorität",
|
||||
"any": "Alle",
|
||||
"apply": "Anwenden",
|
||||
"reset": "Zurücksetzen"
|
||||
},
|
||||
"unplanned": {
|
||||
"title": "Ungeplante Aufträge",
|
||||
"empty": "Keine ungeplanten Aufträge.",
|
||||
"noTeam": "ohne Team",
|
||||
"noDate": "ohne Termin",
|
||||
"noLocation": "ohne Ortsangabe",
|
||||
"suggestions": "Vorschläge"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "Auftrag einplanen",
|
||||
"team": "Team",
|
||||
"date": "Datum",
|
||||
"time": "Beginn",
|
||||
"noTime": "Ohne Uhrzeit (ganztägig)",
|
||||
"duration": "Dauer in Minuten",
|
||||
"durationHint": "Vorbelegt aus Auftrag bzw. Auftragsart ({duration}).",
|
||||
"save": "Speichern",
|
||||
"saving": "Speichert …",
|
||||
"cancel": "Abbrechen",
|
||||
"close": "Schließen"
|
||||
},
|
||||
"errors": {
|
||||
"conflict": "Auftrag wurde zwischenzeitlich geändert. Die Ansicht wurde aktualisiert – bitte erneut einplanen.",
|
||||
"forbidden": "Dafür fehlt Ihnen die Berechtigung.",
|
||||
"not_found": "Auftrag nicht gefunden.",
|
||||
"invalid": "Bitte die Eingaben prüfen.",
|
||||
"planned_end_before_start": "Das Ende liegt vor dem Beginn.",
|
||||
"not_schedulable": "Dieser Auftrag läuft bereits und wird hier nicht verschoben.",
|
||||
"team_not_found": "Team nicht gefunden oder nicht aktiv.",
|
||||
"not_editable": "Dieser Auftrag kann nicht mehr geändert werden.",
|
||||
"network": "Keine Verbindung. Bitte erneut versuchen.",
|
||||
"generic": "Speichern war nicht möglich. Bitte erneut versuchen."
|
||||
},
|
||||
"recommend": {
|
||||
"title": "Vorschläge",
|
||||
"sub": "Nur Vorschläge nach Luftlinie und Terminlage – die Zuweisung entscheiden Sie.",
|
||||
"loading": "Vorschläge werden berechnet …",
|
||||
"reason": "{distance} von {number} · {team} hat am {day} noch {free} frei",
|
||||
"reasonTight": "{distance} von {number} · {team} am {day} knapp: {free} frei, {required} nötig",
|
||||
"card": "{day} · {team} · {distance} von {number} · {free} frei",
|
||||
"tight": "knapp",
|
||||
"take": "Übernehmen",
|
||||
"noCoordinates": "Adresse nicht verortet – die Verortung wurde angestoßen. Bitte später erneut öffnen.",
|
||||
"addressNotFound": "Adresse nicht verortet – die Adresse wurde nicht gefunden. Bitte am Objekt prüfen.",
|
||||
"notSchedulable": "Dieser Auftrag ist bereits in Ausführung.",
|
||||
"none": "Kein Team hat in den nächsten {days} Tagen einen Auftrag im Umkreis von {radius} km mit freier Kapazität.",
|
||||
"nearbyTitle": "In der Nähe, ebenfalls ungeplant",
|
||||
"nearbyQuestion": "Diese Aufträge liegen im Umkreis von {radius} km – zusammen einplanen?",
|
||||
"nearbyEmpty": "Keine weiteren ungeplanten Aufträge in der Nähe.",
|
||||
"planTogether": "Gemeinsam einplanen",
|
||||
"close": "Vorschläge schließen",
|
||||
"failed": "Vorschläge konnten nicht geladen werden.",
|
||||
"sectionTitle": "Einsatz-Vorschläge",
|
||||
"openBoard": "Zur Plantafel"
|
||||
},
|
||||
"live": {
|
||||
"title": "Live-Lage",
|
||||
"sub": "Wer ist gerade in welchem Einsatz?",
|
||||
"privacy": "Standort = Einsatzort des laufenden Auftrags, keine GPS-Ortung.",
|
||||
"status": { "en_route": "Unterwegs", "working": "In Arbeit", "paused": "Pause", "free": "Frei" },
|
||||
"freeHint": "kein laufender Einsatz",
|
||||
"since": "seit {time}",
|
||||
"updated": "Zuletzt aktualisiert: {time}",
|
||||
"refresh": "Aktualisieren",
|
||||
"refreshing": "Aktualisiert …",
|
||||
"updateFailed": "Aktualisierung fehlgeschlagen – der letzte Stand wird angezeigt.",
|
||||
"tabsLabel": "Darstellung",
|
||||
"tabs": { "map": "Karte", "list": "Liste" },
|
||||
"filterTeam": "Team",
|
||||
"filterStatus": "Status",
|
||||
"all": "Alle",
|
||||
"noLocation": "ohne Ortsangabe",
|
||||
"hiddenOrder": "Einsatz außerhalb Ihres Bereichs",
|
||||
"next": "Heute noch geplant",
|
||||
"nothingNext": "Heute nichts weiter geplant",
|
||||
"attentionTitle": "Heute ohne Beginn",
|
||||
"attentionEmpty": "Alle heutigen Aufträge sind begonnen oder später geplant.",
|
||||
"reason": { "not_accepted": "Nicht angenommen", "overdue": "Beginn überschritten" },
|
||||
"empty": "Keine Monteure in dieser Auswahl.",
|
||||
"markerCount": "{count} Personen an diesem Ort",
|
||||
"mapLabel": "Karte der laufenden Einsätze",
|
||||
"mapLoading": "Karte wird geladen …",
|
||||
"withoutLocation": "{count} laufende Einsätze ohne Ortsangabe (nur in der Liste)",
|
||||
"noTeam": "ohne Team",
|
||||
"crewsTitle": "Kolonnen",
|
||||
"soloTitle": "Ohne Team",
|
||||
"crewMembers": "Mitglieder",
|
||||
"memberAt": "bei {number}",
|
||||
"delayRisk": "Verzug droht · {percent} %",
|
||||
"delayOverrun": "Dauer überschritten · {percent} %",
|
||||
"worked": "{worked} von {planned} erfasst"
|
||||
},
|
||||
"capacity": {
|
||||
"title": "Teamkapazität",
|
||||
"sub": "Grundlage für Auslastung und Vorschläge",
|
||||
"minutes": "Arbeitstag der Kolonne in Minuten",
|
||||
"minutesHint": "Die Kolonne arbeitet gemeinsam: Kapazität je Arbeitstag, nicht je Person.",
|
||||
"days": "Arbeitstage",
|
||||
"save": "Speichern",
|
||||
"saving": "Speichert …",
|
||||
"errors": {
|
||||
"invalid": "Bitte die Eingaben prüfen.",
|
||||
"forbidden": "Dafür fehlt Ihnen die Berechtigung.",
|
||||
"not_found": "Team nicht gefunden.",
|
||||
"generic": "Speichern war nicht möglich."
|
||||
}
|
||||
},
|
||||
"dashboardTile": {
|
||||
"title": "Konflikte diese Woche",
|
||||
"hint": "Überbuchung, Überschneidung, Doppelbuchung",
|
||||
"todayTitle": "Planung heute",
|
||||
"todayHint": "Teams im Einsatz · {conflicts} Konflikt(e) · {unplanned} ungeplante Aufträge"
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,9 @@
|
||||
{
|
||||
"dashboard": "Dashboard",
|
||||
"workOrders": "Work orders",
|
||||
"planning": "Planning",
|
||||
"planningBoard": "Planning board",
|
||||
"planningLive": "Live situation",
|
||||
"emergencyReview": "Emergency review",
|
||||
"imports": "Import",
|
||||
"customers": "Customers",
|
||||
|
||||
@@ -0,0 +1,199 @@
|
||||
{
|
||||
"crumb": "Planning",
|
||||
"weekday": { "0": "Mon", "1": "Tue", "2": "Wed", "3": "Thu", "4": "Fri", "5": "Sat", "6": "Sun" },
|
||||
"board": {
|
||||
"title": "Planning board",
|
||||
"sub": "Schedule orders per team and day – utilization and overlaps at a glance.",
|
||||
"readOnly": "Read-only – you see your own teams.",
|
||||
"viewLabel": "View",
|
||||
"view": { "today": "Today", "days": "5 days", "week": "Week", "nextWeek": "Next week" },
|
||||
"navLabel": "Change period",
|
||||
"prevWeek": "Previous week",
|
||||
"nextWeek": "Next week",
|
||||
"prevDay": "Previous day",
|
||||
"nextDay": "Next day",
|
||||
"prevDays": "Previous 5 working days",
|
||||
"nextDays": "Next 5 working days",
|
||||
"todayLabel": "Today · {day}",
|
||||
"time": "Time",
|
||||
"today": "Today",
|
||||
"range": "{from} – {to}",
|
||||
"team": "Team",
|
||||
"noTeams": "No active teams in this selection.",
|
||||
"showMembers": "Show members of {team}",
|
||||
"hideMembers": "Hide members of {team}",
|
||||
"memberHint": "Individually assigned",
|
||||
"load": "{planned} / {capacity} · {percent} %",
|
||||
"loadNoCapacity": "{planned} · no capacity",
|
||||
"notWorkingDay": "Not a working day",
|
||||
"allDay": "All day",
|
||||
"free": "free",
|
||||
"narrowHint": "The planning board is designed for screens from 1024 px. Here you see a simple list.",
|
||||
"capacity": "Capacity",
|
||||
"capacityOf": "Edit capacity of {team}",
|
||||
"move": "Move …",
|
||||
"schedule": "Schedule …",
|
||||
"dragHint": "Drag cards onto a day with the mouse or use “Schedule …” / “Move …” with the keyboard.",
|
||||
"open": "Open order",
|
||||
"saved": "{number} scheduled: {team}, {day}.",
|
||||
"savedWithConflicts": "{number} scheduled: {team}, {day}. Attention: {count} conflict(s) on the target day.",
|
||||
"priority": "Priority: {priority}",
|
||||
"emergency": "Emergency",
|
||||
"multiDay": "multi-day",
|
||||
"duration": "Duration {duration}",
|
||||
"listDayEmpty": "Nothing planned",
|
||||
"crewCapacity": "Crew · {capacity} per working day · {count} people",
|
||||
"delayRisk": "Delay likely · {percent} %",
|
||||
"delayOverrun": "Duration exceeded · {percent} %",
|
||||
"atRisk": "at risk due to delay of {number}"
|
||||
},
|
||||
"freed": {
|
||||
"title": "Finished early",
|
||||
"badge": "{team} · {minutes} early",
|
||||
"available": "{minutes} free from {time}",
|
||||
"pullForward": "Pull forward",
|
||||
"kindPullForward": "Planned order of {team}",
|
||||
"kindNearby": "Unplanned, {distance} away",
|
||||
"noSuggestions": "No suitable order for the free time.",
|
||||
"hint": "Hint only – nothing is moved automatically."
|
||||
},
|
||||
"conflicts": {
|
||||
"label": "Conflict",
|
||||
"count": "{count, plural, =0 {No conflicts} one {# conflict} other {# conflicts}}",
|
||||
"overbooked": "Overbooked by {over}",
|
||||
"overlap": "Overlap: {a} and {b}",
|
||||
"assignee_double_booked": "{user} double-booked: {a} and {b}",
|
||||
"outside_working_days": "Outside working days",
|
||||
"crew_incomplete": "Crew incomplete: {count} active person(s)",
|
||||
"hintLabel": "Hint",
|
||||
"short": {
|
||||
"overbooked": "Overbooked",
|
||||
"overlap": "Overlap",
|
||||
"assignee_double_booked": "Double-booked",
|
||||
"outside_working_days": "Not a working day",
|
||||
"crew_incomplete": "Crew incomplete"
|
||||
}
|
||||
},
|
||||
"filters": {
|
||||
"title": "Filter",
|
||||
"team": "Team",
|
||||
"orderType": "Order type",
|
||||
"priority": "Priority",
|
||||
"any": "All",
|
||||
"apply": "Apply",
|
||||
"reset": "Reset"
|
||||
},
|
||||
"unplanned": {
|
||||
"title": "Unplanned orders",
|
||||
"empty": "No unplanned orders.",
|
||||
"noTeam": "no team",
|
||||
"noDate": "no date",
|
||||
"noLocation": "no location",
|
||||
"suggestions": "Suggestions"
|
||||
},
|
||||
"dialog": {
|
||||
"title": "Schedule order",
|
||||
"team": "Team",
|
||||
"date": "Date",
|
||||
"time": "Start",
|
||||
"noTime": "No time (all day)",
|
||||
"duration": "Duration in minutes",
|
||||
"durationHint": "Prefilled from the order or order type ({duration}).",
|
||||
"save": "Save",
|
||||
"saving": "Saving …",
|
||||
"cancel": "Cancel",
|
||||
"close": "Close"
|
||||
},
|
||||
"errors": {
|
||||
"conflict": "The order was changed in the meantime. The view was refreshed – please schedule again.",
|
||||
"forbidden": "You are not allowed to do this.",
|
||||
"not_found": "Order not found.",
|
||||
"invalid": "Please check your input.",
|
||||
"planned_end_before_start": "The end is before the start.",
|
||||
"not_schedulable": "This order is already in progress and is not moved here.",
|
||||
"team_not_found": "Team not found or not active.",
|
||||
"not_editable": "This order can no longer be changed.",
|
||||
"network": "No connection. Please try again.",
|
||||
"generic": "Saving was not possible. Please try again."
|
||||
},
|
||||
"recommend": {
|
||||
"title": "Suggestions",
|
||||
"sub": "Suggestions only, by straight-line distance and schedule – you decide the assignment.",
|
||||
"loading": "Calculating suggestions …",
|
||||
"reason": "{distance} from {number} · {team} still has {free} free on {day}",
|
||||
"reasonTight": "{distance} from {number} · {team} on {day} tight: {free} free, {required} needed",
|
||||
"card": "{day} · {team} · {distance} from {number} · {free} free",
|
||||
"tight": "tight",
|
||||
"take": "Use",
|
||||
"noCoordinates": "Address not located – geocoding was requested. Please open again later.",
|
||||
"addressNotFound": "Address not located – the address was not found. Please check the site.",
|
||||
"notSchedulable": "This order is already in progress.",
|
||||
"none": "No team has an order within {radius} km with free capacity in the next {days} days.",
|
||||
"nearbyTitle": "Nearby, also unplanned",
|
||||
"nearbyQuestion": "These orders are within {radius} km – schedule them together?",
|
||||
"nearbyEmpty": "No other unplanned orders nearby.",
|
||||
"planTogether": "Schedule together",
|
||||
"close": "Close suggestions",
|
||||
"failed": "Suggestions could not be loaded.",
|
||||
"sectionTitle": "Assignment suggestions",
|
||||
"openBoard": "Open planning board"
|
||||
},
|
||||
"live": {
|
||||
"title": "Live situation",
|
||||
"sub": "Who is currently on which job?",
|
||||
"privacy": "Location = site of the running order, no GPS tracking.",
|
||||
"status": { "en_route": "En route", "working": "Working", "paused": "Break", "free": "Free" },
|
||||
"freeHint": "no running job",
|
||||
"since": "since {time}",
|
||||
"updated": "Last updated: {time}",
|
||||
"refresh": "Refresh",
|
||||
"refreshing": "Refreshing …",
|
||||
"updateFailed": "Update failed – showing the last state.",
|
||||
"tabsLabel": "Display",
|
||||
"tabs": { "map": "Map", "list": "List" },
|
||||
"filterTeam": "Team",
|
||||
"filterStatus": "Status",
|
||||
"all": "All",
|
||||
"noLocation": "no location",
|
||||
"hiddenOrder": "Job outside your area",
|
||||
"next": "Still planned today",
|
||||
"nothingNext": "Nothing else planned today",
|
||||
"attentionTitle": "Today without start",
|
||||
"attentionEmpty": "All of today's orders have started or are planned later.",
|
||||
"reason": { "not_accepted": "Not accepted", "overdue": "Start time passed" },
|
||||
"empty": "No technicians in this selection.",
|
||||
"markerCount": "{count} people at this location",
|
||||
"mapLabel": "Map of running jobs",
|
||||
"mapLoading": "Loading map …",
|
||||
"withoutLocation": "{count} running jobs without location (list only)",
|
||||
"noTeam": "no team",
|
||||
"crewsTitle": "Crews",
|
||||
"soloTitle": "Without team",
|
||||
"crewMembers": "Members",
|
||||
"memberAt": "at {number}",
|
||||
"delayRisk": "Delay likely · {percent} %",
|
||||
"delayOverrun": "Duration exceeded · {percent} %",
|
||||
"worked": "{worked} of {planned} recorded"
|
||||
},
|
||||
"capacity": {
|
||||
"title": "Team capacity",
|
||||
"sub": "Basis for utilization and suggestions",
|
||||
"minutes": "Crew working day in minutes",
|
||||
"minutesHint": "The crew works together: capacity per working day, not per person.",
|
||||
"days": "Working days",
|
||||
"save": "Save",
|
||||
"saving": "Saving …",
|
||||
"errors": {
|
||||
"invalid": "Please check your input.",
|
||||
"forbidden": "You are not allowed to do this.",
|
||||
"not_found": "Team not found.",
|
||||
"generic": "Saving was not possible."
|
||||
}
|
||||
},
|
||||
"dashboardTile": {
|
||||
"title": "Conflicts this week",
|
||||
"hint": "Overbooking, overlap, double booking",
|
||||
"todayTitle": "Planning today",
|
||||
"todayHint": "Teams on the job · {conflicts} conflict(s) · {unplanned} unplanned orders"
|
||||
}
|
||||
}
|
||||
+10
-1
@@ -5,6 +5,15 @@ import createNextIntlPlugin from "next-intl/plugin";
|
||||
// zum Dev-Server. Diese Lockerungen gelten NUR in der Entwicklung, nie im Build.
|
||||
const isDev = process.env.NODE_ENV !== "production";
|
||||
|
||||
// L13 Planung: map tiles of the live map (MAP_TILE_URL, default OpenStreetMap). Only the tile host(s)
|
||||
// are added to img-src — evaluated at build time, so set MAP_TILE_URL before `next build`.
|
||||
function mapTileSources(): string {
|
||||
const raw = process.env.MAP_TILE_URL?.trim();
|
||||
if (!raw) return "https://tile.openstreetmap.org https://*.tile.openstreetmap.org";
|
||||
const m = /^(https?):\/\/([^/?#]+)/i.exec(raw);
|
||||
return m ? `${m[1].toLowerCase()}://${m[2].replace(/\{[a-z]\}/gi, "*")}` : "";
|
||||
}
|
||||
|
||||
// Content-Security-Policy (F-07).
|
||||
//
|
||||
// Hinweis Skripte: 'unsafe-inline' ist ein bewusster Zwischenstand. Next.js liefert
|
||||
@@ -16,7 +25,7 @@ const csp = [
|
||||
// Next.js und Tailwind v4 setzen Inline-Styles.
|
||||
"style-src 'self' 'unsafe-inline'",
|
||||
// data: für den MFA-QR-Code; blob: für clientseitige Foto-Vorschauen (Einsatz-Fotos).
|
||||
"img-src 'self' data: blob:",
|
||||
`img-src 'self' data: blob: ${mapTileSources()}`.trim(),
|
||||
// blob: für lokale Wiedergabe von Sprachnotizen vor dem Upload.
|
||||
"media-src 'self' blob:",
|
||||
"font-src 'self' data:",
|
||||
|
||||
Generated
+65
@@ -11,6 +11,7 @@
|
||||
"@anthropic-ai/sdk": "^0.115.0",
|
||||
"@aws-sdk/client-s3": "^3.1103.0",
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
@@ -21,6 +22,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"ioredis": "^5.11.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"next": "16.2.12",
|
||||
"next-auth": "5.0.0-beta.32",
|
||||
@@ -38,6 +40,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
@@ -896,6 +899,45 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/accessibility": {
|
||||
"version": "3.1.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/accessibility/-/accessibility-3.1.1.tgz",
|
||||
"integrity": "sha512-2P+YgaXF+gRsIihwwY1gCsQSYnu9Zyj2py8kY5fFvUM1qm2WA2u639R6YNVfU4GWr+ZM5mqEsfHZZLoRONbemw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/core": {
|
||||
"version": "6.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/core/-/core-6.3.1.tgz",
|
||||
"integrity": "sha512-xkGBRQQab4RLwgXxoqETICr6S5JlogafbhNsidmrkVv2YRs5MLwpjoF2qpiGjQt8S9AoxtIV603s0GIUpY5eYQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@dnd-kit/accessibility": "^3.1.1",
|
||||
"@dnd-kit/utilities": "^3.2.2",
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0",
|
||||
"react-dom": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dnd-kit/utilities": {
|
||||
"version": "3.2.2",
|
||||
"resolved": "https://registry.npmjs.org/@dnd-kit/utilities/-/utilities-3.2.2.tgz",
|
||||
"integrity": "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tslib": "^2.0.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": ">=16.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@dotenvx/dotenvx": {
|
||||
"version": "1.75.1",
|
||||
"resolved": "https://registry.npmjs.org/@dotenvx/dotenvx/-/dotenvx-1.75.1.tgz",
|
||||
@@ -4832,6 +4874,13 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/geojson": {
|
||||
"version": "7946.0.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/geojson/-/geojson-7946.0.16.tgz",
|
||||
"integrity": "sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/json-schema": {
|
||||
"version": "7.0.15",
|
||||
"resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz",
|
||||
@@ -4846,6 +4895,16 @@
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/leaflet": {
|
||||
"version": "1.9.22",
|
||||
"resolved": "https://registry.npmjs.org/@types/leaflet/-/leaflet-1.9.22.tgz",
|
||||
"integrity": "sha512-h3lhECYEKDasG7LFHu+GiHqAvsgLuQvlJvVZzJDGONo3sEL+wUOqSFLnwkZlK0qVxnxbuGFW8iBlJNYs5wgndA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/geojson": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/node": {
|
||||
"version": "20.19.43",
|
||||
"resolved": "https://registry.npmjs.org/@types/node/-/node-20.19.43.tgz",
|
||||
@@ -9567,6 +9626,12 @@
|
||||
"node": ">=0.10"
|
||||
}
|
||||
},
|
||||
"node_modules/leaflet": {
|
||||
"version": "1.9.4",
|
||||
"resolved": "https://registry.npmjs.org/leaflet/-/leaflet-1.9.4.tgz",
|
||||
"integrity": "sha512-nxS1ynzJOmOlHp+iL3FyWqK89GtNL8U8rvlMOsQdTTssxZwCXh8N2NB3GDQOL+YR3XnWyZAxwQixURb+FA74PA==",
|
||||
"license": "BSD-2-Clause"
|
||||
},
|
||||
"node_modules/levn": {
|
||||
"version": "0.4.1",
|
||||
"resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz",
|
||||
|
||||
@@ -20,6 +20,7 @@
|
||||
"@anthropic-ai/sdk": "^0.115.0",
|
||||
"@aws-sdk/client-s3": "^3.1103.0",
|
||||
"@base-ui/react": "^1.6.0",
|
||||
"@dnd-kit/core": "^6.3.1",
|
||||
"@node-rs/argon2": "^2.0.2",
|
||||
"@prisma/adapter-pg": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
@@ -30,6 +31,7 @@
|
||||
"clsx": "^2.1.1",
|
||||
"dotenv": "^17.4.2",
|
||||
"ioredis": "^5.11.1",
|
||||
"leaflet": "^1.9.4",
|
||||
"lucide-react": "^1.23.0",
|
||||
"next": "16.2.12",
|
||||
"next-auth": "5.0.0-beta.32",
|
||||
@@ -47,6 +49,7 @@
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/leaflet": "^1.9.22",
|
||||
"@types/node": "^20",
|
||||
"@types/nodemailer": "^8.0.1",
|
||||
"@types/qrcode": "^1.5.6",
|
||||
|
||||
@@ -99,6 +99,17 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
|
||||
{ path: "/search?q=Elbblick", mustContain: "Elbblick", perf: true },
|
||||
{ path: "/teams" },
|
||||
{ path: "/settings/users", expect: [200, 307, 403] },
|
||||
// L13 Planung
|
||||
{ path: "/planning", mustContain: ["Plantafel", 'data-planning-view="days"', 'data-day-count="5"', "Heute", "Team Nord", "Team Süd", "Ungeplante Aufträge"], perf: true },
|
||||
{ path: "/planning?view=today", mustContain: ['data-planning-view="today"', "Ganztägig", "Team Nord", "Team Süd"] },
|
||||
{ path: "/planning?view=week", mustContain: ['data-planning-view="week"', 'data-day-count="7"'] },
|
||||
{ path: "/dashboard", mustContain: ["Planung heute", "Teams im Einsatz"] },
|
||||
{ path: `/planning?schedule=${x.d07.id}`, mustContain: "Plantafel" },
|
||||
{ path: "/planning/live", mustContain: ["Live-Lage", "keine GPS-Ortung", "OpenStreetMap"], mustNotContain: ["startLat", "startLng"], perf: true },
|
||||
{ path: "/api/v1/planning/board", mustContain: ['"teams"', '"conflictCount"'] },
|
||||
{ path: "/api/v1/planning/live", mustContain: '"technicians"', mustNotContain: ["startLat", "startLng", "deviceInfo"] },
|
||||
{ path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, mustContain: '"recommendations"' },
|
||||
{ path: "/dashboard", mustContain: "Konflikte diese Woche" },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -113,6 +124,9 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
|
||||
{ path: "/reports" },
|
||||
...(x.inReview ? [{ path: `/reports/${x.inReview.id}`, mustContain: x.d16?.number }] : []),
|
||||
{ path: m(x.d08), expect: [404] },
|
||||
// L13 Planung: team lead read-only, own team only
|
||||
{ path: "/planning", mustContain: ["Plantafel", "Team Nord", "Nur Lesezugriff"], mustNotContain: ["Team Süd"] },
|
||||
{ path: "/planning/live", mustContain: "keine GPS-Ortung", mustNotContain: ["Team Süd"] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -141,6 +155,8 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
|
||||
{ path: m(x.d08), expect: [404] },
|
||||
{ path: `/customers/${x.foreignCustomer.id}`, expect: [404, 307] },
|
||||
{ path: "/dashboard", expect: [307, 308], redirectTo: "/m" },
|
||||
{ path: "/planning", expect: [404] },
|
||||
{ path: "/planning/live", expect: [404] },
|
||||
],
|
||||
},
|
||||
{
|
||||
@@ -167,6 +183,9 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
|
||||
...(x.emergency ? [{ path: `/work-orders/emergency-review/${x.emergency.id}`, expect: [404] }] : []),
|
||||
...(x.imp ? [{ path: `/imports/${x.imp.id}`, expect: [404] }] : []),
|
||||
...(x.photo ? [{ path: `/files/${x.photo.documentId}`, expect: [404] }] : []),
|
||||
{ path: "/planning", mustNotContain: ["Team Nord", x.d07.number] },
|
||||
{ path: "/planning/live", mustNotContain: ["Team Nord", "Max Monteur"] },
|
||||
{ path: `/api/v1/planning/recommendations?workOrderId=${x.d07.id}`, expect: [404] },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { PlanningTiles } from "@/components/planning/conflicts-tile";
|
||||
import { buttonCls } from "@/components/work-orders/button-cls";
|
||||
import { pageContext } from "@/components/work-orders/page-context";
|
||||
import { Field, inputCls } from "@/components/work-orders/ui";
|
||||
@@ -162,6 +163,7 @@ export default async function DashboardPage({ searchParams }: { searchParams: Pr
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
<PlanningTiles ctx={ctx} />
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -67,7 +67,8 @@ export default async function AppLayout({
|
||||
</div>
|
||||
<nav className="flex-1 space-y-0.5 overflow-y-auto px-2.5 py-3">
|
||||
{mainNav.map((item) => (
|
||||
<NavLink key={item.href} href={item.href}>
|
||||
<NavLink key={`${item.href}:${item.label}`} href={item.href} exact={item.exact}>
|
||||
{item.sub && <span className="w-4 shrink-0" aria-hidden />}
|
||||
<item.icon className="size-[18px] opacity-85" />
|
||||
{t(item.label)}
|
||||
</NavLink>
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import { requireModule } from "@/server/modules";
|
||||
|
||||
/** Modul-Gate „work_orders" für Plantafel und Live-Lage (L13 Planung). */
|
||||
export default async function PlanningLayout({ children }: Readonly<{ children: React.ReactNode }>) {
|
||||
await requireModule("work_orders");
|
||||
return <>{children}</>;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ShieldCheck } from "lucide-react";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { LiveSituationView } from "@/components/planning/live-situation";
|
||||
import { pageContext } from "@/components/work-orders/page-context";
|
||||
import { ServiceError } from "@/server/services/context";
|
||||
import { mapConfig } from "@/server/services/geo/config";
|
||||
import { getLiveSituation } from "@/server/services/planning/live";
|
||||
|
||||
/** Live-Lage (L13): technicians at the site of their running order — no GPS tracking. */
|
||||
export default async function PlanningLivePage() {
|
||||
const { ctx, locale } = await pageContext();
|
||||
let initial;
|
||||
try {
|
||||
initial = await getLiveSituation(ctx);
|
||||
} catch (err) {
|
||||
if (err instanceof ServiceError && err.code === "forbidden") notFound();
|
||||
throw err;
|
||||
}
|
||||
const t = await getTranslations("planning");
|
||||
const map = mapConfig();
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<PageHead crumb={t("crumb")} title={t("live.title")} sub={t("live.sub")} />
|
||||
<p className="mb-3 flex items-center gap-2 rounded-lg border border-[var(--info)] bg-card px-3 py-2 text-sm">
|
||||
<ShieldCheck className="size-4 shrink-0 text-[var(--info)]" aria-hidden />
|
||||
{t("live.privacy")}
|
||||
</p>
|
||||
<LiveSituationView initial={initial} tileUrl={map.tileUrl} attribution={map.attribution} locale={locale} />
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,295 @@
|
||||
import Link from "next/link";
|
||||
import { notFound } from "next/navigation";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ChevronLeft, ChevronRight, Info, MonitorSmartphone, TriangleAlert } from "lucide-react";
|
||||
import { Modal } from "@/components/modal";
|
||||
import { PageHead } from "@/components/mockup-ui";
|
||||
import { CapacityForm } from "@/components/planning/capacity-form";
|
||||
import { PlanningBoardView, type TeamLive } from "@/components/planning/planning-board";
|
||||
import { buttonCls } from "@/components/work-orders/button-cls";
|
||||
import { pageContext } from "@/components/work-orders/page-context";
|
||||
import { Field, inputCls } from "@/components/work-orders/ui";
|
||||
import { addDays, addWorkingDays, dayKeyOf, dayRange, isDayKey, nextWorkingDays, startOfWeek, wallClock } from "@/lib/planning/days";
|
||||
import { formatDayShort, formatMinutes } from "@/lib/planning/text";
|
||||
import { WORK_ORDER_PRIORITIES } from "@/lib/work-orders/schemas";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { saveTeamCapacityAction } from "@/server/actions/work_orders/planning";
|
||||
import { ServiceError } from "@/server/services/context";
|
||||
import { planningAccess } from "@/server/services/planning/access";
|
||||
import { getPlanningBoard } from "@/server/services/planning/board";
|
||||
import { loadOrder, loadTeams, toBoardOrder } from "@/server/services/planning/data";
|
||||
import { getLiveSituation } from "@/server/services/planning/live";
|
||||
import { listOrderTypes } from "@/server/services/work-orders/settings";
|
||||
|
||||
type SP = Record<string, string | string[] | undefined>;
|
||||
type View = "today" | "days" | "week";
|
||||
const one = (v: string | string[] | undefined) => (Array.isArray(v) ? v[0] : v);
|
||||
const TIME_RE = /^([01]\d|2[0-3]):[0-5]\d$/;
|
||||
|
||||
/**
|
||||
* Planung (L13): default "Heute + nächste 4 Werktage" (teams as rows, today highlighted with the live
|
||||
* status of each crew); switcher Heute (day view, teams as parallel columns) · 5 Tage · Woche · Nächste Woche.
|
||||
*/
|
||||
export default async function PlanningPage({ searchParams }: { searchParams: Promise<SP> }) {
|
||||
const { ctx, locale, tz } = await pageContext();
|
||||
const sp = await searchParams;
|
||||
let access;
|
||||
try {
|
||||
access = await planningAccess(ctx);
|
||||
} catch (err) {
|
||||
if (err instanceof ServiceError) notFound();
|
||||
throw err;
|
||||
}
|
||||
const [t, tw, tc] = await Promise.all([getTranslations("planning"), getTranslations("workOrders"), getTranslations("common")]);
|
||||
|
||||
const today = dayKeyOf(new Date(), tz);
|
||||
const viewParam = one(sp.view);
|
||||
const view: View = viewParam === "today" || viewParam === "day" ? "today" : viewParam === "week" ? "week" : "days";
|
||||
const date = isDayKey(one(sp.date)) ? (one(sp.date) as string) : today;
|
||||
const visibleDays = view === "today" ? [date] : view === "week" ? dayRange(startOfWeek(date), addDays(startOfWeek(date), 6)) : nextWorkingDays(date, 5);
|
||||
const from = visibleDays[0];
|
||||
const to = visibleDays[visibleDays.length - 1];
|
||||
const teamId = one(sp.teamId) || undefined;
|
||||
const orderTypeId = one(sp.orderTypeId) || undefined;
|
||||
const priorityParam = one(sp.priority);
|
||||
const priority = (WORK_ORDER_PRIORITIES as readonly string[]).includes(priorityParam ?? "") ? (priorityParam as (typeof WORK_ORDER_PRIORITIES)[number]) : undefined;
|
||||
const includesToday = visibleDays.includes(today);
|
||||
|
||||
const [board, teams, orderTypes, live] = await Promise.all([
|
||||
getPlanningBoard(ctx, { from, to, teamIds: teamId ? [teamId] : undefined, orderTypeId, priority }),
|
||||
loadTeams(ctx, access.teamIds),
|
||||
listOrderTypes(ctx, { activeOnly: true }),
|
||||
includesToday ? getLiveSituation(ctx, { teamId }).catch(() => null) : Promise.resolve(null),
|
||||
]);
|
||||
const teamLive: Record<string, TeamLive> | null = live
|
||||
? Object.fromEntries(
|
||||
live.crews.map((c) => [c.teamId, { status: c.status, delay: c.delay, freedMinutes: c.freedMinutes, active: c.members.filter((m) => m.status !== "free").length }]),
|
||||
)
|
||||
: null;
|
||||
|
||||
const query = (overrides: Record<string, string | undefined>) => {
|
||||
const qs = new URLSearchParams();
|
||||
const base: Record<string, string | undefined> = { view: view === "days" ? undefined : view, date: date === today ? undefined : date, teamId, orderTypeId, priority, ...overrides };
|
||||
for (const [k, v] of Object.entries(base)) if (v) qs.set(k, v);
|
||||
const s = qs.toString();
|
||||
return `/planning${s ? `?${s}` : ""}`;
|
||||
};
|
||||
const selfHref = query({});
|
||||
const prevDate = view === "today" ? addDays(date, -1) : view === "week" ? addDays(date, -7) : addWorkingDays(date, -5);
|
||||
const nextDate = view === "today" ? addDays(date, 1) : view === "week" ? addDays(date, 7) : addWorkingDays(to, 1);
|
||||
const thisWeek = startOfWeek(today);
|
||||
const nextWeek = addDays(thisWeek, 7);
|
||||
const switcher: { key: "today" | "days" | "week" | "nextWeek"; href: string; active: boolean }[] = [
|
||||
{ key: "today", href: query({ view: "today", date: undefined }), active: view === "today" && date === today },
|
||||
{ key: "days", href: query({ view: undefined, date: undefined }), active: view === "days" && date === today },
|
||||
{ key: "week", href: query({ view: "week", date: undefined }), active: view === "week" && startOfWeek(date) === thisWeek },
|
||||
{ key: "nextWeek", href: query({ view: "week", date: nextWeek }), active: view === "week" && startOfWeek(date) === nextWeek },
|
||||
];
|
||||
const navLabels = view === "today" ? [t("board.prevDay"), t("board.nextDay")] : view === "week" ? [t("board.prevWeek"), t("board.nextWeek")] : [t("board.prevDays"), t("board.nextDays")];
|
||||
|
||||
// Prefill from the work order detail ("Übernehmen"), live situation ("Vorziehen") or deep links.
|
||||
let prefill = null;
|
||||
const scheduleId = one(sp.schedule);
|
||||
if (scheduleId && access.canSchedule) {
|
||||
const row = await loadOrder(ctx, scheduleId);
|
||||
const prefillTeam = one(sp.scheduleTeam);
|
||||
if (row) {
|
||||
prefill = {
|
||||
order: board.orders[row.id] ?? board.unplanned.find((o) => o.id === row.id) ?? toBoardOrder(row, tz),
|
||||
teamId: prefillTeam && board.teams.some((tm) => tm.id === prefillTeam) ? prefillTeam : null,
|
||||
day: isDayKey(one(sp.day)) ? (one(sp.day) as string) : null,
|
||||
time: TIME_RE.test(one(sp.time) ?? "") ? (one(sp.time) as string) : null,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const capacityTeam = access.canManageTeams ? teams.find((tm) => tm.id === one(sp.capacity)) : undefined;
|
||||
const listDays = board.days.filter((d) => visibleDays.includes(d.key));
|
||||
|
||||
return (
|
||||
<main className="flex-1 p-4 md:p-6">
|
||||
<PageHead
|
||||
crumb={t("crumb")}
|
||||
title={t("board.title")}
|
||||
sub={t("board.sub")}
|
||||
actions={
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<nav aria-label={t("board.viewLabel")} className="flex flex-wrap rounded-lg border p-0.5">
|
||||
{switcher.map((s) => (
|
||||
<Link
|
||||
key={s.key}
|
||||
href={s.href}
|
||||
aria-current={s.active ? "page" : undefined}
|
||||
className={cn("inline-flex min-h-10 items-center rounded-md px-3 text-sm font-semibold", s.active ? "bg-primary text-primary-foreground" : "text-muted-foreground hover:bg-muted")}
|
||||
>
|
||||
{t(`board.view.${s.key}`)}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
<nav aria-label={t("board.navLabel")} className="flex items-center gap-1">
|
||||
<Link href={query({ date: prevDate })} aria-label={navLabels[0]} title={navLabels[0]} className={buttonCls("outline")}>
|
||||
<ChevronLeft className="size-4" aria-hidden />
|
||||
</Link>
|
||||
<Link href={query({ date: undefined })} className={buttonCls("outline")}>
|
||||
{t("board.today")}
|
||||
</Link>
|
||||
<Link href={query({ date: nextDate })} aria-label={navLabels[1]} title={navLabels[1]} className={buttonCls("outline")}>
|
||||
<ChevronRight className="size-4" aria-hidden />
|
||||
</Link>
|
||||
</nav>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
<p className="mb-3 text-sm font-semibold">
|
||||
{view === "today"
|
||||
? date === today
|
||||
? t("board.todayLabel", { day: formatDayShort(date, locale) })
|
||||
: formatDayShort(date, locale)
|
||||
: t("board.range", { from: formatDayShort(from, locale), to: formatDayShort(to, locale) })}
|
||||
</p>
|
||||
{!access.all && (
|
||||
<p className="mb-3 flex items-center gap-1.5 text-[13px] text-muted-foreground">
|
||||
<Info className="size-4" aria-hidden />
|
||||
{t("board.readOnly")}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<details className="shadow-card mb-3 rounded-xl border bg-card" open={Boolean(teamId || orderTypeId || priority)}>
|
||||
<summary className="flex min-h-11 cursor-pointer items-center px-4 font-heading text-sm font-semibold">{t("filters.title")}</summary>
|
||||
<form method="get" action="/planning" className="grid gap-3 border-t p-4 sm:grid-cols-2 lg:grid-cols-4">
|
||||
{view !== "days" && <input type="hidden" name="view" value={view} />}
|
||||
{date !== today && <input type="hidden" name="date" value={date} />}
|
||||
<Field label={t("filters.team")} htmlFor="pf-team">
|
||||
<select id="pf-team" name="teamId" defaultValue={teamId ?? ""} className={inputCls}>
|
||||
<option value="">{t("filters.any")}</option>
|
||||
{teams.map((tm) => (
|
||||
<option key={tm.id} value={tm.id}>
|
||||
{tm.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("filters.orderType")} htmlFor="pf-type">
|
||||
<select id="pf-type" name="orderTypeId" defaultValue={orderTypeId ?? ""} className={inputCls}>
|
||||
<option value="">{t("filters.any")}</option>
|
||||
{orderTypes.map((o) => (
|
||||
<option key={o.id} value={o.id}>
|
||||
{o.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("filters.priority")} htmlFor="pf-prio">
|
||||
<select id="pf-prio" name="priority" defaultValue={priority ?? ""} className={inputCls}>
|
||||
<option value="">{t("filters.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")}>
|
||||
{t("filters.apply")}
|
||||
</button>
|
||||
<Link href={query({ teamId: undefined, orderTypeId: undefined, priority: undefined })} className={buttonCls("ghost")}>
|
||||
{t("filters.reset")}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
</details>
|
||||
|
||||
<p className="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<TriangleAlert className="size-3.5" aria-hidden />
|
||||
{t("conflicts.count", { count: board.conflictCount })}
|
||||
{board.access.canSchedule && <span className="hidden lg:inline"> · {t("board.dragHint")}</span>}
|
||||
</p>
|
||||
|
||||
<div className="hidden lg:block">
|
||||
<PlanningBoardView board={board} view={view} visibleDays={visibleDays} locale={locale} prefill={prefill} capacityHref={selfHref} freed={live?.freed ?? null} teamLive={teamLive} />
|
||||
</div>
|
||||
|
||||
{/* Below 1024 px: hint + simple list (no drag & drop). */}
|
||||
<div className="space-y-3 lg:hidden">
|
||||
<p className="flex items-start gap-2 rounded-lg border border-[var(--info)] bg-card px-3 py-2 text-sm">
|
||||
<MonitorSmartphone className="mt-0.5 size-4 shrink-0 text-[var(--info)]" aria-hidden />
|
||||
{t("board.narrowHint")}
|
||||
</p>
|
||||
{listDays.map((d) => (
|
||||
<section key={d.key} className="shadow-card rounded-xl border bg-card p-3">
|
||||
<h2 className="font-heading text-[15px] font-semibold">
|
||||
{formatDayShort(d.key, locale)}
|
||||
{d.key === today && ` · ${t("board.today")}`}
|
||||
</h2>
|
||||
<ul className="mt-1 space-y-2">
|
||||
{board.teams.map((team) => {
|
||||
const td = team.days.find((x) => x.day === d.key)!;
|
||||
const liveTeam = d.key === today ? teamLive?.[team.id] : undefined;
|
||||
return (
|
||||
<li key={team.id} className="text-sm">
|
||||
<p className="font-semibold">
|
||||
{team.name} ·{" "}
|
||||
<span className="font-normal text-muted-foreground">
|
||||
{td.utilization === null
|
||||
? t("board.loadNoCapacity", { planned: formatMinutes(td.plannedMinutes, locale) })
|
||||
: t("board.load", { planned: formatMinutes(td.plannedMinutes, locale), capacity: formatMinutes(td.capacityMinutes, locale), percent: td.utilization })}
|
||||
</span>
|
||||
{liveTeam && <span className="font-normal"> · {t(`live.status.${liveTeam.status}`)}</span>}
|
||||
</p>
|
||||
{td.conflicts.length > 0 && (
|
||||
<p className="flex items-center gap-1 text-xs font-semibold text-[var(--risk)]">
|
||||
<TriangleAlert className="size-3.5" aria-hidden />
|
||||
{td.conflicts.map((c) => t(`conflicts.short.${c.kind}`)).join(", ")}
|
||||
</p>
|
||||
)}
|
||||
{td.orderIds.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t("board.listDayEmpty")}</p>
|
||||
) : (
|
||||
<ul className="text-xs">
|
||||
{td.orderIds.map((id) => {
|
||||
const o = board.orders[id];
|
||||
return (
|
||||
<li key={id}>
|
||||
<Link href={`/work-orders/${id}`} className="inline-flex min-h-11 items-center gap-1.5">
|
||||
{!o.allDay && o.plannedStart && <span className="tabular-nums">{wallClock(new Date(o.plannedStart), tz)}</span>}
|
||||
<span className="font-mono font-semibold">{o.number}</span>
|
||||
<span>{o.customerName}</span>
|
||||
{o.delay && <span className="font-semibold text-[var(--warn)]">{o.delay.percent} %</span>}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</section>
|
||||
))}
|
||||
<section className="shadow-card rounded-xl border bg-card p-3">
|
||||
<h2 className="font-heading text-[15px] font-semibold">
|
||||
{t("unplanned.title")} ({board.unplanned.length})
|
||||
</h2>
|
||||
<ul className="mt-1 text-sm">
|
||||
{board.unplanned.map((o) => (
|
||||
<li key={o.id}>
|
||||
<Link href={`/work-orders/${o.id}`} className="inline-flex min-h-11 items-center gap-1.5">
|
||||
<span className="font-mono font-semibold">{o.number}</span> {o.customerName}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
{capacityTeam && (
|
||||
<Modal title={t("capacity.title")} sub={`${capacityTeam.name} · ${t("capacity.sub")}`} closeHref={selfHref} closeLabel={tc("close")}>
|
||||
<CapacityForm action={saveTeamCapacityAction.bind(null, capacityTeam.id, selfHref)} minutes={capacityTeam.dailyCapacityMinutes} workingDays={capacityTeam.workingDays} closeHref={selfHref} />
|
||||
</Modal>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -3,6 +3,8 @@ 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 { PlanningSuggestions } from "@/components/planning/suggestions-section";
|
||||
import { SCHEDULABLE_STATUSES } from "@/lib/planning/capacity";
|
||||
import { ActionForm } from "@/components/work-orders/action-form";
|
||||
import { buttonCls } from "@/components/work-orders/button-cls";
|
||||
import {
|
||||
@@ -172,6 +174,8 @@ export default async function WorkOrderDetailPage({ params, searchParams }: { pa
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{tab === "overview" && canAssign && SCHEDULABLE_STATUSES.includes(wo.status) && <PlanningSuggestions ctx={ctx} workOrderId={wo.id} locale={locale} tz={tz} />}
|
||||
|
||||
<div className="mt-4">
|
||||
<LinkTabs
|
||||
label={t("detail.tabs.overview")}
|
||||
|
||||
@@ -7,15 +7,18 @@ import { cn } from "@/lib/utils";
|
||||
export function NavLink({
|
||||
href,
|
||||
match,
|
||||
exact,
|
||||
children,
|
||||
}: {
|
||||
href: string;
|
||||
match?: string[];
|
||||
/** L13: only the exact path is active (sub entries such as /planning vs. /planning/live) */
|
||||
exact?: boolean;
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const pathname = usePathname();
|
||||
const active = (match ?? [href]).some(
|
||||
(m) => pathname === m || pathname.startsWith(m + "/")
|
||||
(m) => pathname === m || (!exact && pathname.startsWith(m + "/"))
|
||||
);
|
||||
|
||||
return (
|
||||
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useActionState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AlertCircle } from "lucide-react";
|
||||
import { buttonCls } from "@/components/work-orders/button-cls";
|
||||
import { Field, inputCls } from "@/components/work-orders/ui";
|
||||
import type { ActionState } from "@/server/api/action-state";
|
||||
|
||||
type Action = (prev: ActionState, fd: FormData) => Promise<ActionState>;
|
||||
|
||||
/** Team capacity popup form (L13): minutes per member per working day + working days. */
|
||||
export function CapacityForm({ action, minutes, workingDays, closeHref }: { action: Action; minutes: number; workingDays: number; closeHref: string }) {
|
||||
const t = useTranslations("planning");
|
||||
const [state, formAction, pending] = useActionState(action, { status: "idle" } as ActionState);
|
||||
const errorText =
|
||||
state.status === "error" ? (t.has(`capacity.errors.${state.code}`) ? t(`capacity.errors.${state.code}`) : t("capacity.errors.generic")) : null;
|
||||
|
||||
return (
|
||||
<form action={formAction} className="grid gap-4 p-5">
|
||||
<Field label={t("capacity.minutes")} htmlFor="cap-minutes" hint={t("capacity.minutesHint")}>
|
||||
<input id="cap-minutes" name="minutes" type="number" min={0} max={1440} step={15} required defaultValue={minutes} className={inputCls} autoFocus />
|
||||
</Field>
|
||||
<fieldset>
|
||||
<legend className="mb-1 text-[13px] font-semibold">{t("capacity.days")}</legend>
|
||||
<div className="flex flex-wrap gap-x-4">
|
||||
{[0, 1, 2, 3, 4, 5, 6].map((i) => (
|
||||
<label key={i} className="flex min-h-11 items-center gap-2 text-sm">
|
||||
<input type="checkbox" name="day" value={1 << i} defaultChecked={(workingDays & (1 << i)) !== 0} className="size-5 accent-[var(--ui-primary)]" />
|
||||
{t(`weekday.${i}`)}
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
</fieldset>
|
||||
{errorText && (
|
||||
<p role="alert" className="flex items-center gap-2 rounded-lg border border-[var(--risk)] px-3 py-2 text-sm text-[var(--risk)]">
|
||||
<AlertCircle className="size-4 shrink-0" aria-hidden />
|
||||
{errorText}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button type="submit" disabled={pending} className={buttonCls("primary")}>
|
||||
{pending ? t("capacity.saving") : t("capacity.save")}
|
||||
</button>
|
||||
<Link href={closeHref} className={buttonCls("ghost")}>
|
||||
{t("dialog.cancel")}
|
||||
</Link>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { CalendarClock, CalendarRange } from "lucide-react";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { countWeekConflicts } from "@/server/services/planning/board";
|
||||
import { getPlanningToday } from "@/server/services/planning/summary";
|
||||
|
||||
const tileCls =
|
||||
"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";
|
||||
|
||||
/** Dashboard tiles of the planning lane (L13, additive): "Planung heute" + "Konflikte diese Woche"; hidden without access. */
|
||||
export async function PlanningTiles({ ctx }: { ctx: ServiceCtx }) {
|
||||
const [today, week] = await Promise.all([getPlanningToday(ctx).catch(() => null), countWeekConflicts(ctx).catch(() => null)]);
|
||||
if (!today && !week) return null;
|
||||
const t = await getTranslations("planning");
|
||||
return (
|
||||
<>
|
||||
{today && (
|
||||
<li>
|
||||
<Link href="/planning" className={tileCls} style={{ borderLeftColor: today.conflicts > 0 ? "var(--risk)" : "var(--ui-primary)" }}>
|
||||
<CalendarClock className="mt-0.5 size-5 shrink-0" style={{ color: "var(--ui-primary)" }} aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-semibold text-muted-foreground">{t("dashboardTile.todayTitle")}</p>
|
||||
<p className="font-heading text-3xl leading-tight font-bold text-foreground">
|
||||
{today.teamsWorking}
|
||||
<span className="text-base font-semibold text-muted-foreground"> / {today.teamsTotal}</span>
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">{t("dashboardTile.todayHint", { conflicts: today.conflicts, unplanned: today.unplanned })}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
{week && (
|
||||
<li>
|
||||
<Link href={`/planning?view=week&date=${week.from}`} className={tileCls} style={{ borderLeftColor: week.count > 0 ? "var(--risk)" : "var(--line)" }}>
|
||||
<CalendarRange className="mt-0.5 size-5 shrink-0" style={{ color: week.count > 0 ? "var(--risk)" : "var(--txt-muted)" }} aria-hidden />
|
||||
<div className="min-w-0">
|
||||
<p className="text-[13px] font-semibold text-muted-foreground">{t("dashboardTile.title")}</p>
|
||||
<p className="font-heading text-3xl leading-tight font-bold text-foreground">{week.count}</p>
|
||||
<p className="text-xs text-muted-foreground">{t("dashboardTile.hint")}</p>
|
||||
</div>
|
||||
</Link>
|
||||
</li>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client";
|
||||
|
||||
import "leaflet/dist/leaflet.css";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import type { LayerGroup, Map as LeafletMap } from "leaflet";
|
||||
import { STATUS_TONE } from "./live-tone";
|
||||
|
||||
/**
|
||||
* Leaflet map of the live situation (L13). Tiles from MAP_TILE_URL (default OpenStreetMap, CSP img-src
|
||||
* allows only that host), attribution always visible, marker icons as inline SVG (no CDN assets).
|
||||
* One marker per site; several people at the same site are bundled with a count.
|
||||
*/
|
||||
|
||||
export type LiveMarker = {
|
||||
key: string;
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
status: "en_route" | "working" | "paused";
|
||||
count: number;
|
||||
label: string;
|
||||
lines: string[];
|
||||
/** delay likely / exceeded → red ring (text is part of label and popup) */
|
||||
alert?: boolean;
|
||||
};
|
||||
|
||||
export { STATUS_TONE };
|
||||
|
||||
const GLYPH: Record<LiveMarker["status"], string> = {
|
||||
// truck
|
||||
en_route: '<path d="M3 7h10v8H3zM13 10h4l3 3v2h-7z" fill="none" stroke="#fff" stroke-width="1.8" stroke-linejoin="round"/><circle cx="7" cy="16.5" r="1.6" fill="#fff"/><circle cx="16.5" cy="16.5" r="1.6" fill="#fff"/>',
|
||||
// wrench
|
||||
working: '<path d="M14.5 5.5a4 4 0 0 0-5 5L4.5 15.5l2 2 5-5a4 4 0 0 0 5-5l-2.3 2.3-2-2z" fill="none" stroke="#fff" stroke-width="1.8" stroke-linejoin="round"/>',
|
||||
// pause
|
||||
paused: '<rect x="7" y="6" width="3.2" height="12" rx="1" fill="#fff"/><rect x="13.8" y="6" width="3.2" height="12" rx="1" fill="#fff"/>',
|
||||
};
|
||||
|
||||
const escapeHtml = (s: string) => s.replace(/[&<>"']/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """, "'": "'" })[c] as string);
|
||||
|
||||
function iconHtml(m: LiveMarker): string {
|
||||
const tone = STATUS_TONE[m.status];
|
||||
const badge =
|
||||
m.count > 1
|
||||
? `<span style="position:absolute;top:-6px;right:-8px;min-width:20px;height:20px;padding:0 5px;border-radius:10px;background:var(--brand-graphit);color:#fff;font:700 11px/20px system-ui,sans-serif;text-align:center;border:2px solid #fff">${m.count}</span>`
|
||||
: "";
|
||||
return `<div role="img" aria-label="${escapeHtml(m.label)}" style="position:relative;width:34px;height:34px;color:${tone}"><svg width="34" height="34" viewBox="0 0 24 24" aria-hidden="true"><circle cx="12" cy="12" r="11" fill="currentColor" stroke="${m.alert ? "var(--risk)" : "#fff"}" stroke-width="${m.alert ? 3 : 2}"/><g transform="translate(1.2 0.6) scale(0.9)">${GLYPH[m.status]}</g></svg>${badge}</div>`;
|
||||
}
|
||||
|
||||
export function LiveMap({ markers, tileUrl, attribution, label, loadingLabel }: { markers: LiveMarker[]; tileUrl: string; attribution: string; label: string; loadingLabel: string }) {
|
||||
const container = useRef<HTMLDivElement>(null);
|
||||
const mapRef = useRef<LeafletMap | null>(null);
|
||||
const layerRef = useRef<LayerGroup | null>(null);
|
||||
const leafletRef = useRef<typeof import("leaflet") | null>(null);
|
||||
const fittedRef = useRef<string>("");
|
||||
const [ready, setReady] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
import("leaflet").then((mod) => {
|
||||
const L = (mod as unknown as { default?: typeof import("leaflet") }).default ?? mod;
|
||||
if (cancelled || !container.current || mapRef.current) return;
|
||||
leafletRef.current = L;
|
||||
const map = L.map(container.current, { center: [51.1657, 10.4515], zoom: 6, scrollWheelZoom: true });
|
||||
L.tileLayer(tileUrl, {
|
||||
maxZoom: 19,
|
||||
attribution: `${escapeHtml(attribution)} (<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">ODbL</a>)`,
|
||||
}).addTo(map);
|
||||
layerRef.current = L.layerGroup().addTo(map);
|
||||
mapRef.current = map;
|
||||
setReady(true);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
mapRef.current?.remove();
|
||||
mapRef.current = null;
|
||||
layerRef.current = null;
|
||||
};
|
||||
}, [tileUrl, attribution]);
|
||||
|
||||
useEffect(() => {
|
||||
const L = leafletRef.current;
|
||||
const map = mapRef.current;
|
||||
const layer = layerRef.current;
|
||||
if (!ready || !L || !map || !layer) return;
|
||||
layer.clearLayers();
|
||||
for (const m of markers) {
|
||||
const marker = L.marker([m.latitude, m.longitude], {
|
||||
icon: L.divIcon({ html: iconHtml(m), className: "", iconSize: [34, 34], iconAnchor: [17, 17], popupAnchor: [0, -16] }),
|
||||
title: m.label,
|
||||
alt: m.label,
|
||||
keyboard: true,
|
||||
});
|
||||
marker.bindPopup(`<div style="font:13px/1.4 system-ui,sans-serif">${m.lines.map((l) => `<div>${escapeHtml(l)}</div>`).join("")}</div>`);
|
||||
marker.addTo(layer);
|
||||
}
|
||||
const signature = markers.map((m) => m.key).sort().join("|");
|
||||
if (markers.length && signature !== fittedRef.current) {
|
||||
fittedRef.current = signature;
|
||||
map.fitBounds(L.latLngBounds(markers.map((m) => [m.latitude, m.longitude] as [number, number])), { padding: [40, 40], maxZoom: 14 });
|
||||
}
|
||||
}, [markers, ready]);
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div ref={container} role="region" aria-label={label} className="h-[62vh] min-h-80 w-full overflow-hidden rounded-xl border bg-muted" />
|
||||
{!ready && (
|
||||
<p role="status" className="absolute inset-0 grid place-items-center text-sm text-muted-foreground">
|
||||
{loadingLabel}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,361 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import dynamic from "next/dynamic";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AlarmClock, CircleDashed, Coffee, EyeOff, Hourglass, MapPinOff, RefreshCw, Timer, Truck, UsersRound, Wrench, type LucideIcon } from "lucide-react";
|
||||
import { formatDistance } from "@/lib/geo/distance";
|
||||
import { wallClock } from "@/lib/planning/days";
|
||||
import { formatMinutes } from "@/lib/planning/text";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonCls } from "@/components/work-orders/button-cls";
|
||||
import { inputCls } from "@/components/work-orders/ui";
|
||||
import type { LiveCrew, LiveDelay, LiveSituation, LiveStatus } from "@/server/services/planning/live";
|
||||
import { STATUS_TONE, type LiveMarker } from "./live-map";
|
||||
|
||||
const LiveMap = dynamic(() => import("./live-map").then((m) => m.LiveMap), { ssr: false });
|
||||
|
||||
const STATUS_ICON: Record<LiveStatus, LucideIcon> = { en_route: Truck, working: Wrench, paused: Coffee, free: CircleDashed };
|
||||
const STATUSES: LiveStatus[] = ["en_route", "working", "paused", "free"];
|
||||
const POLL_MS = 30_000;
|
||||
|
||||
/**
|
||||
* Live situation (L13): one marker/card per crew at the site of its running order (members with their
|
||||
* own status), technicians without team individually; delay badges, crews that finished early with
|
||||
* pull-forward links, filters, polling every 30 s, "zuletzt aktualisiert". No GPS.
|
||||
*/
|
||||
export function LiveSituationView({ initial, tileUrl, attribution, locale }: { initial: LiveSituation; tileUrl: string; attribution: string; locale: string }) {
|
||||
const t = useTranslations("planning");
|
||||
const [data, setData] = useState(initial);
|
||||
const [teamId, setTeamId] = useState("");
|
||||
const [status, setStatus] = useState<LiveStatus | "">("");
|
||||
const [tab, setTab] = useState<"map" | "list">("map");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const tz = data.timeZone;
|
||||
|
||||
const fetchLive = useCallback(async (filters: { teamId: string; status: LiveStatus | "" }) => {
|
||||
setLoading(true);
|
||||
try {
|
||||
const qs = new URLSearchParams();
|
||||
if (filters.teamId) qs.set("teamId", filters.teamId);
|
||||
if (filters.status) qs.set("status", filters.status);
|
||||
const res = await fetch(`/api/v1/planning/live?${qs}`, { cache: "no-store" });
|
||||
if (!res.ok) throw new Error(String(res.status));
|
||||
setData((await res.json()) as LiveSituation);
|
||||
setFailed(false);
|
||||
} catch {
|
||||
setFailed(true);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const load = useCallback(() => fetchLive({ teamId, status }), [fetchLive, teamId, status]);
|
||||
|
||||
// Polling every 30 s (skipped while the tab is hidden); setState happens in the fetch callback.
|
||||
useEffect(() => {
|
||||
const timer = window.setInterval(() => {
|
||||
if (!document.hidden) void load();
|
||||
}, POLL_MS);
|
||||
return () => window.clearInterval(timer);
|
||||
}, [load]);
|
||||
|
||||
const changeFilter = (next: { teamId?: string; status?: LiveStatus | "" }) => {
|
||||
const filters = { teamId: next.teamId ?? teamId, status: next.status ?? status };
|
||||
setTeamId(filters.teamId);
|
||||
setStatus(filters.status);
|
||||
void fetchLive(filters);
|
||||
};
|
||||
|
||||
const delayText = useCallback(
|
||||
(d: LiveDelay) => (d.level === "overrun" ? t("live.delayOverrun", { percent: d.percent }) : t("live.delayRisk", { percent: d.percent })),
|
||||
[t],
|
||||
);
|
||||
const solo = data.technicians.filter((x) => !x.crewId);
|
||||
|
||||
const markers = useMemo<LiveMarker[]>(() => {
|
||||
const out: LiveMarker[] = [];
|
||||
for (const crew of data.crews) {
|
||||
if (!crew.current || crew.current.latitude === null || crew.current.longitude === null || crew.status === "free") continue;
|
||||
const active = crew.members.filter((m) => m.status !== "free").length;
|
||||
const head = `${crew.teamName} · ${crew.current.number} · ${t(`live.status.${crew.status}`)}${crew.delay ? ` · ${delayText(crew.delay)}` : ""}`;
|
||||
out.push({
|
||||
key: `crew-${crew.teamId}`,
|
||||
latitude: crew.current.latitude,
|
||||
longitude: crew.current.longitude,
|
||||
status: crew.status as LiveMarker["status"],
|
||||
count: Math.max(1, active),
|
||||
alert: !!crew.delay,
|
||||
label: `${head}: ${t("live.markerCount", { count: active })}`,
|
||||
lines: [head, crew.current.siteName ?? "", ...crew.members.map((m) => `${m.name} – ${t(`live.status.${m.status}`)}${m.orderNumber && m.orderNumber !== crew.current?.number ? ` (${m.orderNumber})` : ""}`)].filter(Boolean),
|
||||
});
|
||||
}
|
||||
for (const tech of solo) {
|
||||
if (!tech.current?.visible || tech.current.latitude === null || tech.current.longitude === null || tech.status === "free") continue;
|
||||
const line = `${tech.name} – ${t(`live.status.${tech.status}`)} – ${tech.current.number}${tech.delay ? ` · ${delayText(tech.delay)}` : ""}`;
|
||||
out.push({ key: `solo-${tech.userId}`, latitude: tech.current.latitude, longitude: tech.current.longitude, status: tech.status as LiveMarker["status"], count: 1, alert: !!tech.delay, label: line, lines: [tech.current.siteName ?? tech.current.number, line] });
|
||||
}
|
||||
return out;
|
||||
}, [data.crews, solo, t, delayText]);
|
||||
|
||||
const statusBadge = (s: LiveStatus) => {
|
||||
const Icon = STATUS_ICON[s];
|
||||
return (
|
||||
<span className="inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-xs font-semibold" style={{ color: STATUS_TONE[s], borderColor: STATUS_TONE[s] }}>
|
||||
<Icon className="size-3.5" aria-hidden />
|
||||
{t(`live.status.${s}`)}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
const delayBadge = (d: LiveDelay | null) =>
|
||||
d && (
|
||||
<span className={cn("inline-flex items-center gap-1 text-xs font-semibold", d.level === "overrun" ? "text-[var(--risk)]" : "text-[var(--warn)]")} title={t("live.worked", { worked: formatMinutes(d.workedMinutes, locale), planned: formatMinutes(d.plannedMinutes, locale) })}>
|
||||
<Hourglass className="size-3.5" aria-hidden />
|
||||
{delayText(d)}
|
||||
</span>
|
||||
);
|
||||
const crewCard = (crew: LiveCrew) => (
|
||||
<li key={crew.teamId} className="shadow-card rounded-xl border border-l-4 bg-card p-3 text-sm" style={{ borderLeftColor: crew.delay ? (crew.delay.level === "overrun" ? "var(--risk)" : "var(--warn)") : STATUS_TONE[crew.status] }}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="flex items-center gap-1.5 font-semibold">
|
||||
<UsersRound className="size-4" aria-hidden />
|
||||
{crew.teamName}
|
||||
</p>
|
||||
{statusBadge(crew.status)}
|
||||
</div>
|
||||
{delayBadge(crew.delay)}
|
||||
{crew.freedMinutes !== null && (
|
||||
<p className="flex items-center gap-1 text-xs font-semibold text-[var(--ok)]">
|
||||
<Timer className="size-3.5" aria-hidden />
|
||||
{t("freed.badge", { team: crew.teamName, minutes: formatMinutes(crew.freedMinutes, locale) })}
|
||||
</p>
|
||||
)}
|
||||
{crew.current ? (
|
||||
<div className="mt-1">
|
||||
<Link href={`/work-orders/${crew.current.id}`} className="font-mono text-xs font-semibold underline-offset-2 hover:underline">
|
||||
{crew.current.number}
|
||||
</Link>{" "}
|
||||
<span className="text-xs">{crew.current.title}</span>
|
||||
<p className="text-xs">{crew.current.customerName}</p>
|
||||
<p className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||
{crew.current.latitude === null && <MapPinOff className="size-3.5" aria-hidden />}
|
||||
{crew.current.address ?? crew.current.siteName ?? ""}
|
||||
{crew.current.latitude === null && ` · ${t("live.noLocation")}`}
|
||||
</p>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t("live.freeHint")}</p>
|
||||
)}
|
||||
<p className="mt-1.5 border-t pt-1.5 text-[11px] font-semibold text-muted-foreground">{t("live.crewMembers")}</p>
|
||||
<ul className="space-y-0.5">
|
||||
{crew.members.map((m) => (
|
||||
<li key={m.userId} className="flex flex-wrap items-center gap-x-2 text-xs">
|
||||
<span className="font-semibold">{m.name}</span>
|
||||
<span style={{ color: STATUS_TONE[m.status] }}>{t(`live.status.${m.status}`)}</span>
|
||||
{m.since && <span className="text-muted-foreground">{t("live.since", { time: wallClock(new Date(m.since), tz) })}</span>}
|
||||
{m.orderNumber && m.orderNumber !== crew.current?.number && <span className="text-muted-foreground">{t("live.memberAt", { number: m.orderNumber })}</span>}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</li>
|
||||
);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
<label className="grid gap-1 text-[13px] font-semibold">
|
||||
{t("live.filterTeam")}
|
||||
<select value={teamId} onChange={(e) => changeFilter({ teamId: e.target.value })} className={`${inputCls} min-w-44`}>
|
||||
<option value="">{t("live.all")}</option>
|
||||
{data.teams.map((tm) => (
|
||||
<option key={tm.id} value={tm.id}>
|
||||
{tm.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<label className="grid gap-1 text-[13px] font-semibold">
|
||||
{t("live.filterStatus")}
|
||||
<select value={status} onChange={(e) => changeFilter({ status: e.target.value as LiveStatus | "" })} className={`${inputCls} min-w-40`}>
|
||||
<option value="">{t("live.all")}</option>
|
||||
{STATUSES.map((s) => (
|
||||
<option key={s} value={s}>
|
||||
{t(`live.status.${s}`)}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<button type="button" onClick={() => void load()} disabled={loading} className={buttonCls("outline")}>
|
||||
<RefreshCw className={cn("size-4", loading && "animate-spin")} aria-hidden />
|
||||
{loading ? t("live.refreshing") : t("live.refresh")}
|
||||
</button>
|
||||
<p role="status" aria-live="polite" className="min-h-11 content-center text-xs text-muted-foreground">
|
||||
{t("live.updated", { time: wallClock(new Date(data.generatedAt), tz) })}
|
||||
{failed && <span className="ml-2 font-semibold text-[var(--risk)]">{t("live.updateFailed")}</span>}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ul className="flex flex-wrap gap-2" aria-label={t("live.filterStatus")}>
|
||||
{STATUSES.map((s) => {
|
||||
const Icon = STATUS_ICON[s];
|
||||
return (
|
||||
<li key={s} className="inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-xs font-semibold" style={{ color: STATUS_TONE[s], borderColor: STATUS_TONE[s] }}>
|
||||
<Icon className="size-3.5" aria-hidden />
|
||||
{t(`live.status.${s}`)}
|
||||
<span className="text-foreground tabular-nums">{data.counts[s]}</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
|
||||
{data.freed.length > 0 && (
|
||||
<section aria-labelledby="live-freed" className="shadow-card rounded-xl border border-l-4 border-l-[var(--ok)] bg-card p-3">
|
||||
<h2 id="live-freed" className="flex items-center gap-1.5 font-heading text-[15px] font-semibold">
|
||||
<Timer className="size-4 text-[var(--ok)]" aria-hidden />
|
||||
{t("freed.title")}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{t("freed.hint")}</p>
|
||||
<ul className="mt-1 space-y-2">
|
||||
{data.freed.map((f) => (
|
||||
<li key={f.teamId} className="text-sm">
|
||||
<p className="font-semibold text-[var(--ok)]">{t("freed.badge", { team: f.teamName, minutes: formatMinutes(f.earlyMinutes, locale) })}</p>
|
||||
{f.availableMinutes > 0 && <p className="text-xs">{t("freed.available", { minutes: formatMinutes(f.availableMinutes, locale), time: wallClock(new Date(f.availableFrom), tz) })}</p>}
|
||||
{f.suggestions.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t("freed.noSuggestions")}</p>
|
||||
) : (
|
||||
<ul className="mt-0.5 space-y-0.5">
|
||||
{f.suggestions.map((s) => (
|
||||
<li key={`${s.kind}-${s.order.id}`} className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<span className="font-mono font-semibold">{s.order.number}</span>
|
||||
<span>{s.order.customerName}</span>
|
||||
<span className="text-muted-foreground">
|
||||
{s.kind === "pull_forward" ? t("freed.kindPullForward", { team: f.teamName }) : t("freed.kindNearby", { distance: formatDistance(s.distanceKm ?? 0, locale) })}
|
||||
</span>
|
||||
<Link
|
||||
href={`/planning?view=day&schedule=${s.order.id}&scheduleTeam=${f.teamId}&day=${s.day}&time=${wallClock(new Date(s.suggestedStart), tz)}`}
|
||||
className="ml-auto inline-flex min-h-11 items-center font-semibold underline-offset-2 hover:underline"
|
||||
>
|
||||
{t("freed.pullForward")}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div role="tablist" aria-label={t("live.tabsLabel")} className="flex gap-1 border-b lg:hidden">
|
||||
{(["map", "list"] as const).map((k) => (
|
||||
<button
|
||||
key={k}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={tab === k}
|
||||
onClick={() => setTab(k)}
|
||||
className={cn("-mb-px min-h-12 border-b-2 px-4 text-sm font-semibold", tab === k ? "border-[var(--ui-accent)]" : "border-transparent text-muted-foreground")}
|
||||
>
|
||||
{t(`live.tabs.${k}`)}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
|
||||
<div className={cn(tab !== "map" && "hidden lg:block")}>
|
||||
<LiveMap markers={markers} tileUrl={tileUrl} attribution={attribution} label={t("live.mapLabel")} loadingLabel={t("live.mapLoading")} />
|
||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
||||
{attribution} ·{" "}
|
||||
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer" className="underline">
|
||||
openstreetmap.org/copyright
|
||||
</a>
|
||||
</p>
|
||||
{data.withoutLocation > 0 && (
|
||||
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<MapPinOff className="size-3.5" aria-hidden />
|
||||
{t("live.withoutLocation", { count: data.withoutLocation })}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className={cn("space-y-4", tab !== "list" && "hidden lg:block")}>
|
||||
{data.crews.length === 0 && solo.length === 0 ? (
|
||||
<p className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">{t("live.empty")}</p>
|
||||
) : (
|
||||
<>
|
||||
{data.crews.length > 0 && (
|
||||
<section aria-labelledby="live-crews">
|
||||
<h2 id="live-crews" className="mb-1 font-heading text-[15px] font-semibold">
|
||||
{t("live.crewsTitle")}
|
||||
</h2>
|
||||
<ul className="space-y-2">{data.crews.map(crewCard)}</ul>
|
||||
</section>
|
||||
)}
|
||||
{solo.length > 0 && (
|
||||
<section aria-labelledby="live-solo">
|
||||
<h2 id="live-solo" className="mb-1 font-heading text-[15px] font-semibold">
|
||||
{t("live.soloTitle")}
|
||||
</h2>
|
||||
<ul className="space-y-2">
|
||||
{solo.map((tech) => (
|
||||
<li key={tech.userId} className="shadow-card rounded-xl border border-l-4 bg-card p-3 text-sm" style={{ borderLeftColor: STATUS_TONE[tech.status] }}>
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<p className="font-semibold">{tech.name}</p>
|
||||
{statusBadge(tech.status)}
|
||||
</div>
|
||||
{tech.since && <p className="text-xs text-muted-foreground">{t("live.since", { time: wallClock(new Date(tech.since), tz) })}</p>}
|
||||
{delayBadge(tech.delay)}
|
||||
{tech.current === null && <p className="mt-1 text-xs text-muted-foreground">{t("live.freeHint")}</p>}
|
||||
{tech.current && !tech.current.visible && (
|
||||
<p className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
|
||||
<EyeOff className="size-3.5" aria-hidden />
|
||||
{t("live.hiddenOrder")}
|
||||
</p>
|
||||
)}
|
||||
{tech.current?.visible && (
|
||||
<p className="mt-1 text-xs">
|
||||
<Link href={`/work-orders/${tech.current.id}`} className="font-mono font-semibold underline-offset-2 hover:underline">
|
||||
{tech.current.number}
|
||||
</Link>{" "}
|
||||
{tech.current.customerName} · {tech.current.address ?? t("live.noLocation")}
|
||||
</p>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
|
||||
<section aria-labelledby="live-attention" className="shadow-card rounded-xl border bg-card p-3">
|
||||
<h2 id="live-attention" className="flex items-center gap-1.5 font-heading text-[15px] font-semibold">
|
||||
<AlarmClock className="size-4 text-[var(--warn)]" aria-hidden />
|
||||
{t("live.attentionTitle")}
|
||||
</h2>
|
||||
{data.attention.length === 0 ? (
|
||||
<p className="mt-1 text-xs text-muted-foreground">{t("live.attentionEmpty")}</p>
|
||||
) : (
|
||||
<ul className="mt-1 space-y-1.5">
|
||||
{data.attention.map((a) => (
|
||||
<li key={a.id} className="text-xs">
|
||||
<span className="font-semibold text-[var(--warn)]">{t(`live.reason.${a.reason}`)}</span> ·{" "}
|
||||
<span className="tabular-nums">{wallClock(new Date(a.plannedStart), tz)}</span>{" "}
|
||||
<Link href={`/work-orders/${a.id}`} className="font-mono font-semibold underline-offset-2 hover:underline">
|
||||
{a.number}
|
||||
</Link>{" "}
|
||||
{a.customerName}
|
||||
{a.teamName && ` · ${a.teamName}`}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,13 @@
|
||||
import { CircleDashed, Coffee, Truck, Wrench, type LucideIcon } from "lucide-react";
|
||||
|
||||
/** Live status tones + icons (L13), shared by the live map, the live list and the planning board. Always combined with text. */
|
||||
export type LiveStatusKey = "en_route" | "working" | "paused" | "free";
|
||||
|
||||
export const STATUS_TONE: Record<LiveStatusKey, string> = {
|
||||
en_route: "var(--info)",
|
||||
working: "var(--ui-accent)",
|
||||
paused: "var(--warn)",
|
||||
free: "var(--txt-muted)",
|
||||
};
|
||||
|
||||
export const STATUS_ICON: Record<LiveStatusKey, LucideIcon> = { en_route: Truck, working: Wrench, paused: Coffee, free: CircleDashed };
|
||||
@@ -0,0 +1,689 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { DndContext, KeyboardSensor, PointerSensor, useDraggable, useDroppable, useSensor, useSensors, type DragEndEvent } from "@dnd-kit/core";
|
||||
import { CalendarPlus, CheckCircle2, ChevronDown, ChevronRight, GripVertical, Hourglass, Info, Lightbulb, MapPinOff, Settings2, Siren, Timer, TriangleAlert } from "lucide-react";
|
||||
import { formatDistance } from "@/lib/geo/distance";
|
||||
import { SCHEDULABLE_STATUSES, type PlanningConflict } from "@/lib/planning/capacity";
|
||||
import { dayKeyOf, minutesOfDay, wallClock } from "@/lib/planning/days";
|
||||
import { formatDayShort, formatMinutes } from "@/lib/planning/text";
|
||||
import { STATUS_GROUP_TONE } from "@/lib/work-orders/status";
|
||||
import { cn } from "@/lib/utils";
|
||||
import { buttonCls } from "@/components/work-orders/button-cls";
|
||||
import { GroupIcon, TONE_VAR } from "@/components/work-orders/ui";
|
||||
import type { PlanningBoard } from "@/server/services/planning/board";
|
||||
import type { BoardOrder } from "@/server/services/planning/data";
|
||||
import type { LiveDelay, LiveStatus } from "@/server/services/planning/live";
|
||||
import type { FreedTeam } from "@/server/services/planning/watch";
|
||||
import { STATUS_ICON, STATUS_TONE } from "./live-tone";
|
||||
import { RecommendationsPanel, type Recommendation } from "./recommendations-panel";
|
||||
import { ScheduleDialog, type DialogState, type ScheduleResponse } from "./schedule-dialog";
|
||||
|
||||
/**
|
||||
* Planning board (L13, desktop ≥ 1024 px).
|
||||
* - "days" (default: today + next 4 working days) / "week": rows = crews (teams) with member short list,
|
||||
* columns = days (today highlighted, with the live status of each crew), cells with order cards,
|
||||
* crew utilization bar, conflicts (red edge + icon + text), hints (amber), delay / at-risk badges.
|
||||
* - "today": crews as PARALLEL COLUMNS, time running vertically (6–20 h), overlapping orders side by side.
|
||||
* Unplanned orders in the sidebar; crews that finished early with pull-forward suggestions. Drag & drop
|
||||
* (dnd-kit, mouse + keyboard sensor on the grip) or "Einplanen …" open the confirmation popover — nothing
|
||||
* is ever saved or moved automatically.
|
||||
*/
|
||||
|
||||
const HOUR_PX = 72;
|
||||
const START_H = 6;
|
||||
const END_H = 20;
|
||||
const HOURS = Array.from({ length: END_H - START_H }, (_, i) => START_H + i);
|
||||
|
||||
export type TeamLive = { status: LiveStatus; delay: LiveDelay | null; freedMinutes: number | null; active: number };
|
||||
type Prefill = { order: BoardOrder; teamId: string | null; day: string | null; time: string | null } | null;
|
||||
type T = ReturnType<typeof useTranslations<"planning">>;
|
||||
type TeamDayT = PlanningBoard["teams"][number]["days"][number];
|
||||
|
||||
function layoutLanes(orders: BoardOrder[], tz: string) {
|
||||
const lo = START_H * 60;
|
||||
const hi = END_H * 60;
|
||||
const sorted = orders
|
||||
.map((o) => {
|
||||
const s = minutesOfDay(new Date(o.plannedStart!), tz);
|
||||
return { o, s, e: s + o.durationMinutes };
|
||||
})
|
||||
.sort((a, b) => a.s - b.s);
|
||||
const laneEnds: number[] = [];
|
||||
const items = sorted.map((it) => {
|
||||
let lane = laneEnds.findIndex((end) => end <= it.s);
|
||||
if (lane < 0) {
|
||||
lane = laneEnds.length;
|
||||
laneEnds.push(it.e);
|
||||
} else {
|
||||
laneEnds[lane] = it.e;
|
||||
}
|
||||
const s = Math.min(Math.max(it.s, lo), hi - 60);
|
||||
const e = Math.max(Math.min(it.e, hi), s + 60);
|
||||
return { o: it.o, lane, top: ((s - lo) / 60) * HOUR_PX, height: ((e - s) / 60) * HOUR_PX };
|
||||
});
|
||||
return { items, lanes: Math.max(1, laneEnds.length) };
|
||||
}
|
||||
|
||||
export function PlanningBoardView({
|
||||
board,
|
||||
view,
|
||||
visibleDays,
|
||||
locale,
|
||||
prefill,
|
||||
capacityHref,
|
||||
freed,
|
||||
teamLive,
|
||||
}: {
|
||||
board: PlanningBoard;
|
||||
view: "today" | "days" | "week";
|
||||
visibleDays: string[];
|
||||
locale: string;
|
||||
prefill: Prefill;
|
||||
capacityHref: string;
|
||||
freed: FreedTeam[] | null;
|
||||
teamLive: Record<string, TeamLive> | null;
|
||||
}) {
|
||||
const t = useTranslations("planning");
|
||||
const tw = useTranslations("workOrders");
|
||||
const router = useRouter();
|
||||
const tz = board.timeZone;
|
||||
const canSchedule = board.access.canSchedule;
|
||||
|
||||
const initialDialog = (order: BoardOrder, teamId: string | null, day: string | null, time: string | null): DialogState => {
|
||||
const start = order.plannedStart ? new Date(order.plannedStart) : null;
|
||||
return {
|
||||
order,
|
||||
teamId: teamId ?? order.teamId ?? board.teams[0]?.id ?? "",
|
||||
day: day ?? (start ? dayKeyOf(start, tz) : board.from),
|
||||
time: time ?? (start && !order.allDay ? wallClock(start, tz) : "08:00"),
|
||||
duration: order.plannedDurationMinutes ?? order.durationMinutes,
|
||||
};
|
||||
};
|
||||
|
||||
const [expanded, setExpanded] = useState<Set<string>>(() => new Set());
|
||||
const [dialog, setDialog] = useState<DialogState | null>(() => (prefill && canSchedule ? initialDialog(prefill.order, prefill.teamId, prefill.day, prefill.time) : null));
|
||||
const [panelFor, setPanelFor] = useState<string | null>(null);
|
||||
const [notice, setNotice] = useState<{ tone: "ok" | "warn" | "error"; text: string } | null>(null);
|
||||
const sensors = useSensors(useSensor(PointerSensor, { activationConstraint: { distance: 6 } }), useSensor(KeyboardSensor));
|
||||
|
||||
const allOrders = useMemo(() => {
|
||||
const m = new Map<string, BoardOrder>(Object.entries(board.orders));
|
||||
for (const o of board.unplanned) m.set(o.id, o);
|
||||
for (const f of freed ?? []) for (const s of f.suggestions) if (!m.has(s.order.id)) m.set(s.order.id, s.order);
|
||||
if (prefill) m.set(prefill.order.id, prefill.order);
|
||||
return m;
|
||||
}, [board, prefill, freed]);
|
||||
const userNames = useMemo(() => new Map(board.teams.flatMap((tm) => tm.members.map((m) => [m.userId, m.name] as const))), [board.teams]);
|
||||
const teamOptions = board.teams.map((tm) => ({ id: tm.id, name: tm.name }));
|
||||
const dayCols = board.days.filter((d) => visibleDays.includes(d.key));
|
||||
|
||||
const open = (order: BoardOrder, teamId: string | null, day: string | null, time: string | null) => {
|
||||
if (!canSchedule || !SCHEDULABLE_STATUSES.includes(order.status)) return;
|
||||
setNotice(null);
|
||||
setDialog(initialDialog(order, teamId, day, time));
|
||||
};
|
||||
|
||||
const onDragEnd = (e: DragEndEvent) => {
|
||||
const order = allOrders.get(String(e.active.id).replace(/^order:/, ""));
|
||||
if (!order || !e.over) return;
|
||||
const [kind, teamId, day, hour] = String(e.over.id).split(":");
|
||||
if (kind === "cell") open(order, teamId, day, null);
|
||||
if (kind === "slot") open(order, teamId, day, `${hour.padStart(2, "0")}:00`);
|
||||
};
|
||||
|
||||
const num = (id?: string) => (id && allOrders.get(id)?.number) || "…";
|
||||
const conflictText = (c: PlanningConflict) => {
|
||||
switch (c.kind) {
|
||||
case "overbooked":
|
||||
return t("conflicts.overbooked", { over: formatMinutes(c.minutesOver ?? 0, locale) });
|
||||
case "overlap":
|
||||
return t("conflicts.overlap", { a: num(c.orderIds[0]), b: num(c.orderIds[1]) });
|
||||
case "assignee_double_booked":
|
||||
return t("conflicts.assignee_double_booked", { user: userNames.get(c.userId ?? "") ?? "—", a: num(c.orderIds[0]), b: num(c.orderIds[1]) });
|
||||
case "crew_incomplete":
|
||||
return t("conflicts.crew_incomplete", { count: c.memberCount ?? 0 });
|
||||
default:
|
||||
return t("conflicts.outside_working_days");
|
||||
}
|
||||
};
|
||||
|
||||
const onSaved = (res: ScheduleResponse) => {
|
||||
setDialog(null);
|
||||
const vars = { number: res.number, team: board.teams.find((tm) => tm.id === res.teamId)?.name ?? "", day: formatDayShort(dayKeyOf(new Date(res.plannedStart), tz), locale) };
|
||||
const count = res.conflicts.filter((c) => c.severity === "conflict").length;
|
||||
setNotice(count ? { tone: "warn", text: t("board.savedWithConflicts", { ...vars, count }) } : { tone: "ok", text: t("board.saved", vars) });
|
||||
router.refresh();
|
||||
};
|
||||
const onConflict = (text: string) => {
|
||||
setDialog(null);
|
||||
setNotice({ tone: "error", text });
|
||||
router.refresh();
|
||||
};
|
||||
|
||||
const toggle = (teamId: string) =>
|
||||
setExpanded((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(teamId)) next.delete(teamId);
|
||||
else next.add(teamId);
|
||||
return next;
|
||||
});
|
||||
|
||||
const cardProps = { canSchedule, locale, tz, onOpen: open, t, tw };
|
||||
|
||||
const loadLine = (td: TeamDayT) => {
|
||||
if (!td.workingDay && td.plannedMinutes === 0) return <p className="text-[11px] text-muted-foreground">{t("board.notWorkingDay")}</p>;
|
||||
const tone = td.utilization === null || td.utilization > 100 ? "var(--risk)" : td.utilization >= 85 ? "var(--warn)" : "var(--ok)";
|
||||
const label =
|
||||
td.utilization === null
|
||||
? t("board.loadNoCapacity", { planned: formatMinutes(td.plannedMinutes, locale) })
|
||||
: t("board.load", { planned: formatMinutes(td.plannedMinutes, locale), capacity: formatMinutes(td.capacityMinutes, locale), percent: td.utilization });
|
||||
return (
|
||||
<div>
|
||||
<div className="h-1.5 overflow-hidden rounded-full bg-muted" aria-hidden>
|
||||
<div className="h-full rounded-full" style={{ width: `${Math.min(100, td.utilization ?? 100)}%`, background: tone }} />
|
||||
</div>
|
||||
<p className="mt-0.5 text-[11px] font-semibold tabular-nums">{label}</p>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const conflictList = (conflicts: PlanningConflict[]) =>
|
||||
conflicts.length > 0 && (
|
||||
<ul className="space-y-0.5">
|
||||
{conflicts.map((c, i) => {
|
||||
const hint = c.severity === "hint";
|
||||
const Icon = hint ? Info : TriangleAlert;
|
||||
return (
|
||||
<li key={i} className={cn("flex items-start gap-1 text-[11px] font-semibold", hint ? "text-[var(--warn)]" : "text-[var(--risk)]")} title={conflictText(c)}>
|
||||
<Icon className="mt-px size-3.5 shrink-0" aria-hidden />
|
||||
<span>
|
||||
<span className="sr-only">{hint ? t("conflicts.hintLabel") : t("conflicts.label")}: </span>
|
||||
{conflictText(c)}
|
||||
</span>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
|
||||
const hasConflict = (conflicts: PlanningConflict[]) => conflicts.some((c) => c.severity === "conflict");
|
||||
|
||||
const liveLine = (teamId: string, teamName: string) => {
|
||||
const live = teamLive?.[teamId];
|
||||
if (!live) return null;
|
||||
const Icon = STATUS_ICON[live.status];
|
||||
return (
|
||||
<div className="flex flex-wrap items-center gap-x-2 gap-y-0.5 rounded-md bg-muted/60 px-1.5 py-0.5 text-[11px] font-semibold">
|
||||
<span className="inline-flex items-center gap-1" style={{ color: STATUS_TONE[live.status] }}>
|
||||
<Icon className="size-3.5" aria-hidden />
|
||||
{t(`live.status.${live.status}`)}
|
||||
{live.active > 0 && <span className="text-foreground">· {live.active}</span>}
|
||||
</span>
|
||||
{live.delay && (
|
||||
<span className={cn("inline-flex items-center gap-1", live.delay.level === "overrun" ? "text-[var(--risk)]" : "text-[var(--warn)]")}>
|
||||
<Hourglass className="size-3.5" aria-hidden />
|
||||
{live.delay.level === "overrun" ? t("board.delayOverrun", { percent: live.delay.percent }) : t("board.delayRisk", { percent: live.delay.percent })}
|
||||
</span>
|
||||
)}
|
||||
{live.freedMinutes !== null && (
|
||||
<span className="inline-flex items-center gap-1 text-[var(--ok)]">
|
||||
<Timer className="size-3.5" aria-hidden />
|
||||
{t("freed.badge", { team: teamName, minutes: formatMinutes(live.freedMinutes, locale) })}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
const memberOrders = (userId: string, day: string) =>
|
||||
Object.values(board.orders).filter((o) => o.assigneeIds.includes(userId) && o.plannedStart && dayKeyOf(new Date(o.plannedStart), tz) === day);
|
||||
|
||||
const showFreed = freed && freed.length > 0 && (view === "today" || visibleDays.includes(board.today));
|
||||
|
||||
return (
|
||||
<DndContext sensors={sensors} onDragEnd={onDragEnd}>
|
||||
<div data-planning-view={view} data-day-count={view === "today" ? 1 : dayCols.length}>
|
||||
<p role="status" aria-live="polite" className={cn("min-h-6 text-sm font-semibold", notice?.tone === "ok" && "text-[var(--ok)]", notice?.tone === "warn" && "text-[var(--warn)]", notice?.tone === "error" && "text-[var(--risk)]")}>
|
||||
{notice && (
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
{notice.tone === "ok" ? <CheckCircle2 className="size-4" aria-hidden /> : <TriangleAlert className="size-4" aria-hidden />}
|
||||
{notice.text}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
|
||||
{showFreed && (
|
||||
<section aria-labelledby="freed-title" className="shadow-card mb-3 rounded-xl border border-l-4 border-l-[var(--ok)] bg-card p-3">
|
||||
<h2 id="freed-title" className="flex items-center gap-1.5 font-heading text-[15px] font-semibold">
|
||||
<Timer className="size-4 text-[var(--ok)]" aria-hidden />
|
||||
{t("freed.title")}
|
||||
</h2>
|
||||
<p className="text-xs text-muted-foreground">{t("freed.hint")}</p>
|
||||
<ul className="mt-2 grid gap-3 xl:grid-cols-2">
|
||||
{freed!.map((f) => (
|
||||
<li key={f.teamId} className="rounded-lg border p-2 text-sm">
|
||||
<p className="font-semibold text-[var(--ok)]">{t("freed.badge", { team: f.teamName, minutes: formatMinutes(f.earlyMinutes, locale) })}</p>
|
||||
{f.availableMinutes > 0 && <p className="text-xs">{t("freed.available", { minutes: formatMinutes(f.availableMinutes, locale), time: wallClock(new Date(f.availableFrom), tz) })}</p>}
|
||||
{f.suggestions.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t("freed.noSuggestions")}</p>
|
||||
) : (
|
||||
<ul className="mt-1 space-y-1">
|
||||
{f.suggestions.map((s) => (
|
||||
<li key={`${s.kind}-${s.order.id}`} className="flex flex-wrap items-center gap-2 text-xs">
|
||||
<Link href={`/work-orders/${s.order.id}`} className="font-mono font-semibold underline-offset-2 hover:underline">
|
||||
{s.order.number}
|
||||
</Link>
|
||||
<span>{s.order.customerName}</span>
|
||||
<span className="text-muted-foreground">
|
||||
· {formatMinutes(s.order.durationMinutes, locale)} ·{" "}
|
||||
{s.kind === "pull_forward" ? t("freed.kindPullForward", { team: f.teamName }) : t("freed.kindNearby", { distance: formatDistance(s.distanceKm ?? 0, locale) })}
|
||||
</span>
|
||||
{canSchedule && SCHEDULABLE_STATUSES.includes(s.order.status) && (
|
||||
<button type="button" onClick={() => open(s.order, f.teamId, s.day, wallClock(new Date(s.suggestedStart), tz))} className={`${buttonCls("outline")} ml-auto`}>
|
||||
{t("freed.pullForward")}
|
||||
</button>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="shadow-card min-w-0 flex-1 overflow-x-auto rounded-xl border bg-card">
|
||||
{board.teams.length === 0 ? (
|
||||
<p className="p-6 text-sm text-muted-foreground">{t("board.noTeams")}</p>
|
||||
) : view !== "today" ? (
|
||||
<div role="table" aria-label={t("board.title")} className="grid min-w-[980px]" style={{ gridTemplateColumns: `220px repeat(${dayCols.length}, minmax(150px, 1fr))` }}>
|
||||
<div role="row" className="contents">
|
||||
<div role="columnheader" className="sticky left-0 z-10 border-b bg-card p-2 text-xs font-semibold text-muted-foreground">
|
||||
{t("board.team")}
|
||||
</div>
|
||||
{dayCols.map((d) => (
|
||||
<div role="columnheader" key={d.key} className={cn("border-b border-l p-2 text-xs font-semibold", d.key === board.today && "border-t-4 border-t-[var(--ui-accent)] bg-[var(--ui-primary-soft)]")}>
|
||||
{d.key === board.today && <span className="mr-1 text-[var(--ui-primary)]">{t("board.today")} ·</span>}
|
||||
{formatDayShort(d.key, locale)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{board.teams.map((team) => (
|
||||
<TeamRows key={team.id}>
|
||||
<div role="row" className="contents">
|
||||
<TeamHeader team={team} expanded={expanded.has(team.id)} onToggle={() => toggle(team.id)} canManage={board.access.canManageTeams} capacityHref={capacityHref} t={t} locale={locale} />
|
||||
{team.days
|
||||
.filter((td) => visibleDays.includes(td.day))
|
||||
.map((td) => (
|
||||
<DropCell
|
||||
key={td.day}
|
||||
id={`cell:${team.id}:${td.day}`}
|
||||
disabled={!canSchedule}
|
||||
className={cn(
|
||||
"flex min-h-32 flex-col gap-1.5 border-b border-l p-1.5",
|
||||
!td.workingDay && "bg-muted/50",
|
||||
td.day === board.today && "bg-[var(--ui-primary-soft)]",
|
||||
hasConflict(td.conflicts) && "border-2 border-[var(--risk)]",
|
||||
)}
|
||||
>
|
||||
{td.day === board.today && liveLine(team.id, team.name)}
|
||||
{loadLine(td)}
|
||||
{conflictList(td.conflicts)}
|
||||
{td.orderIds.map((id) => board.orders[id] && <OrderCard key={id} order={board.orders[id]} {...cardProps} />)}
|
||||
</DropCell>
|
||||
))}
|
||||
</div>
|
||||
{expanded.has(team.id) &&
|
||||
team.members.map((m) => (
|
||||
<div role="row" key={m.userId} className="contents">
|
||||
<div role="rowheader" className="sticky left-0 z-10 border-b bg-card py-1.5 pr-2 pl-6 text-xs">
|
||||
<p className="font-semibold">{m.name}</p>
|
||||
<p className="text-muted-foreground">{t("board.memberHint")}</p>
|
||||
</div>
|
||||
{dayCols.map((d) => (
|
||||
<div role="cell" key={d.key} className="border-b border-l p-1.5 text-[11px]">
|
||||
{memberOrders(m.userId, d.key).map((o) => (
|
||||
<Link key={o.id} href={`/work-orders/${o.id}`} className="flex min-h-11 items-center gap-1 rounded px-1 hover:bg-muted">
|
||||
<span className="font-mono font-semibold">{o.number}</span>
|
||||
{!o.allDay && o.plannedStart && <span>{wallClock(new Date(o.plannedStart), tz)}</span>}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</TeamRows>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div role="table" aria-label={t("board.title")} className="grid min-w-max" style={{ gridTemplateColumns: `56px repeat(${board.teams.length}, minmax(240px, 1fr))` }}>
|
||||
<div role="row" className="contents">
|
||||
<div role="columnheader" className="sticky left-0 z-20 border-b bg-card p-2 text-[11px] font-semibold text-muted-foreground">
|
||||
{t("board.time")}
|
||||
</div>
|
||||
{board.teams.map((team) => {
|
||||
const td = team.days[0];
|
||||
return (
|
||||
<div role="columnheader" key={team.id} className={cn("space-y-1 border-b border-l p-2", hasConflict(td.conflicts) && "border-t-4 border-t-[var(--risk)]")}>
|
||||
<TeamHeader team={team} expanded={false} onToggle={null} canManage={board.access.canManageTeams} capacityHref={capacityHref} t={t} locale={locale} bare />
|
||||
{td.day === board.today && liveLine(team.id, team.name)}
|
||||
{loadLine(td)}
|
||||
{conflictList(td.conflicts)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div role="row" className="contents">
|
||||
<div role="rowheader" className="sticky left-0 z-10 border-b bg-card p-1 text-[11px] font-semibold text-muted-foreground">
|
||||
{t("board.allDay")}
|
||||
</div>
|
||||
{board.teams.map((team) => {
|
||||
const td = team.days[0];
|
||||
const untimed = td.orderIds.map((id) => board.orders[id]).filter((o) => o && (o.allDay || o.multiDay));
|
||||
return (
|
||||
<DropCell key={team.id} id={`cell:${team.id}:${td.day}`} disabled={!canSchedule} className="flex min-h-14 flex-col gap-1 border-b border-l p-1">
|
||||
{untimed.map((o) => (
|
||||
<OrderCard key={o.id} order={o} {...cardProps} />
|
||||
))}
|
||||
</DropCell>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
<div role="row" className="contents">
|
||||
<div role="rowheader" className="sticky left-0 z-10 bg-card" style={{ height: HOURS.length * HOUR_PX, position: "sticky" }}>
|
||||
{HOURS.map((h, i) => (
|
||||
<span key={h} className="absolute right-1 text-[11px] text-muted-foreground tabular-nums" style={{ top: i * HOUR_PX + 2 }}>
|
||||
{h}:00
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
{board.teams.map((team) => {
|
||||
const td = team.days[0];
|
||||
const timed = td.orderIds.map((id) => board.orders[id]).filter((o) => o && !o.allDay && !o.multiDay && o.plannedStart);
|
||||
const { items, lanes } = layoutLanes(timed, tz);
|
||||
return (
|
||||
<div role="cell" key={team.id} className="relative border-l" style={{ height: HOURS.length * HOUR_PX }}>
|
||||
{HOURS.map((h, i) => (
|
||||
<DropCell key={h} plain id={`slot:${team.id}:${td.day}:${h}`} disabled={!canSchedule} label={`${team.name} ${h}:00`} className="absolute inset-x-0 border-t" style={{ top: i * HOUR_PX, height: HOUR_PX }} />
|
||||
))}
|
||||
{items.map(({ o, lane, top, height }) => (
|
||||
<div key={o.id} className="absolute z-10 p-0.5" style={{ top, height, left: `${(lane / lanes) * 100}%`, width: `${100 / lanes}%` }}>
|
||||
<OrderCard order={o} {...cardProps} compact />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside aria-labelledby="unplanned-title" className="w-80 shrink-0 space-y-2">
|
||||
<h2 id="unplanned-title" className="font-heading text-[15px] font-semibold">
|
||||
{t("unplanned.title")} <span className="text-muted-foreground">({board.unplanned.length})</span>
|
||||
</h2>
|
||||
{board.unplanned.length === 0 && <p className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">{t("unplanned.empty")}</p>}
|
||||
<ul className="space-y-3">
|
||||
{board.unplanned.map((o) => (
|
||||
<li key={o.id} className="space-y-1.5">
|
||||
<OrderCard order={o} {...cardProps} unplanned />
|
||||
{canSchedule && (
|
||||
<button type="button" aria-expanded={panelFor === o.id} onClick={() => setPanelFor(panelFor === o.id ? null : o.id)} className={`${buttonCls("ghost")} w-full`}>
|
||||
<Lightbulb className="size-4" aria-hidden />
|
||||
{t("unplanned.suggestions")}
|
||||
</button>
|
||||
)}
|
||||
{panelFor === o.id && (
|
||||
<RecommendationsPanel
|
||||
workOrderId={o.id}
|
||||
locale={locale}
|
||||
onClose={() => setPanelFor(null)}
|
||||
onTake={(r: Recommendation) => open(o, r.teamId, r.day, wallClock(new Date(r.suggestedStart), tz))}
|
||||
onPlanTogether={(nearby, r, required) =>
|
||||
open(nearby, r?.teamId ?? null, r?.day ?? null, r ? wallClock(new Date(new Date(r.suggestedStart).getTime() + required * 60_000), tz) : null)
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</aside>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{dialog && (
|
||||
<ScheduleDialog key={`${dialog.order.id}-${dialog.teamId}-${dialog.day}-${dialog.time}`} state={dialog} teams={teamOptions} timeZone={tz} locale={locale} onClose={() => setDialog(null)} onSaved={onSaved} onConflict={onConflict} />
|
||||
)}
|
||||
</DndContext>
|
||||
);
|
||||
}
|
||||
|
||||
function TeamRows({ children }: { children: React.ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
|
||||
function TeamHeader({
|
||||
team,
|
||||
expanded,
|
||||
onToggle,
|
||||
canManage,
|
||||
capacityHref,
|
||||
t,
|
||||
locale,
|
||||
bare,
|
||||
}: {
|
||||
team: PlanningBoard["teams"][number];
|
||||
expanded: boolean;
|
||||
onToggle: (() => void) | null;
|
||||
canManage: boolean;
|
||||
capacityHref: string;
|
||||
t: T;
|
||||
locale: string;
|
||||
bare?: boolean;
|
||||
}) {
|
||||
const names = team.members.map((m) => m.name).join(", ");
|
||||
const shortNames = team.members.map((m) => m.name.split(" ")[0]).join(", ");
|
||||
const body = (
|
||||
<>
|
||||
<div className="flex items-start justify-between gap-1">
|
||||
<div className="min-w-0">
|
||||
<p className="truncate text-sm font-semibold">{team.name}</p>
|
||||
{shortNames && (
|
||||
<p className="truncate text-[11px]" title={names}>
|
||||
{shortNames}
|
||||
</p>
|
||||
)}
|
||||
<p className="text-[11px] text-muted-foreground">{t("board.crewCapacity", { capacity: formatMinutes(team.dailyCapacityMinutes, locale), count: team.members.length })}</p>
|
||||
</div>
|
||||
{canManage && (
|
||||
<Link href={`${capacityHref}${capacityHref.includes("?") ? "&" : "?"}capacity=${team.id}`} scroll={false} aria-label={t("board.capacityOf", { team: team.name })} title={t("board.capacity")} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground">
|
||||
<Settings2 className="size-4" aria-hidden />
|
||||
</Link>
|
||||
)}
|
||||
</div>
|
||||
{onToggle && team.members.length > 0 && (
|
||||
<button type="button" aria-expanded={expanded} onClick={onToggle} className="mt-1 inline-flex min-h-11 items-center gap-1 rounded-md px-1 text-xs font-semibold text-muted-foreground hover:bg-muted hover:text-foreground">
|
||||
{expanded ? <ChevronDown className="size-3.5" aria-hidden /> : <ChevronRight className="size-3.5" aria-hidden />}
|
||||
{expanded ? t("board.hideMembers", { team: team.name }) : t("board.showMembers", { team: team.name })}
|
||||
</button>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
if (bare) return body;
|
||||
return (
|
||||
<div role="rowheader" className="sticky left-0 z-10 border-b bg-card p-2">
|
||||
{body}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function DropCell({
|
||||
id,
|
||||
disabled,
|
||||
className,
|
||||
style,
|
||||
label,
|
||||
plain,
|
||||
children,
|
||||
}: {
|
||||
id: string;
|
||||
disabled: boolean;
|
||||
className?: string;
|
||||
style?: React.CSSProperties;
|
||||
label?: string;
|
||||
plain?: boolean;
|
||||
children?: React.ReactNode;
|
||||
}) {
|
||||
const { setNodeRef, isOver } = useDroppable({ id, disabled });
|
||||
return (
|
||||
<div ref={setNodeRef} role={plain ? undefined : "cell"} aria-label={label} className={cn(className, isOver && "bg-[var(--ui-primary-soft)] outline-2 outline-[var(--ui-accent)]")} style={style}>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function OrderCard({
|
||||
order,
|
||||
canSchedule,
|
||||
locale,
|
||||
tz,
|
||||
onOpen,
|
||||
t,
|
||||
tw,
|
||||
unplanned,
|
||||
compact,
|
||||
}: {
|
||||
order: BoardOrder;
|
||||
canSchedule: boolean;
|
||||
locale: string;
|
||||
tz: string;
|
||||
onOpen: (order: BoardOrder, teamId: string | null, day: string | null, time: string | null) => void;
|
||||
t: T;
|
||||
tw: ReturnType<typeof useTranslations<"workOrders">>;
|
||||
unplanned?: boolean;
|
||||
compact?: boolean;
|
||||
}) {
|
||||
const movable = canSchedule && SCHEDULABLE_STATUSES.includes(order.status);
|
||||
const { attributes, listeners, setNodeRef, transform, isDragging } = useDraggable({ id: `order:${order.id}`, disabled: !movable });
|
||||
const conflict = order.conflictKinds.length > 0;
|
||||
const alert = conflict || !!order.atRisk || order.delay?.level === "overrun";
|
||||
const tone = TONE_VAR[STATUS_GROUP_TONE[order.statusGroup]];
|
||||
const style: React.CSSProperties = {
|
||||
borderLeftColor: alert ? "var(--risk)" : order.delay ? "var(--warn)" : tone,
|
||||
...(transform ? { transform: `translate3d(${transform.x}px, ${transform.y}px, 0)`, zIndex: 60, position: "relative" } : {}),
|
||||
};
|
||||
const time = order.plannedStart && !order.allDay ? wallClock(new Date(order.plannedStart), tz) : null;
|
||||
const conflictLabel = order.conflictKinds.map((k) => t(`conflicts.short.${k}` as "conflicts.short.overbooked")).join(", ");
|
||||
const grip = movable && (
|
||||
<button type="button" {...listeners} {...attributes} aria-label={`${order.number} – ${t("board.move")}`} className="-m-0.5 grid size-8 shrink-0 cursor-grab touch-none place-items-center rounded text-muted-foreground hover:bg-muted active:cursor-grabbing">
|
||||
<GripVertical className="size-4" aria-hidden />
|
||||
</button>
|
||||
);
|
||||
const delayLine = order.delay && (
|
||||
<p className={cn("inline-flex items-center gap-1 font-semibold", order.delay.level === "overrun" ? "text-[var(--risk)]" : "text-[var(--warn)]")}>
|
||||
<Hourglass className="size-3.5 shrink-0" aria-hidden />
|
||||
{order.delay.level === "overrun" ? t("board.delayOverrun", { percent: order.delay.percent }) : t("board.delayRisk", { percent: order.delay.percent })}
|
||||
</p>
|
||||
);
|
||||
const atRiskLine = order.atRisk && (
|
||||
<p className="flex items-center gap-1 font-semibold text-[var(--risk)]">
|
||||
<TriangleAlert className="size-3.5 shrink-0" aria-hidden />
|
||||
{t("board.atRisk", { number: order.atRisk.byNumber })}
|
||||
</p>
|
||||
);
|
||||
|
||||
if (compact) {
|
||||
return (
|
||||
<article ref={setNodeRef} style={style} className={cn("flex h-full flex-col overflow-hidden rounded-md border border-l-4 bg-card px-1 text-[11px] shadow-sm", conflict && "border-[var(--risk)]", isDragging && "opacity-80 shadow-lg")}>
|
||||
<div className="flex items-center gap-0.5">
|
||||
{grip}
|
||||
<Link href={`/work-orders/${order.id}`} className="font-mono font-semibold underline-offset-2 hover:underline" title={order.title}>
|
||||
{order.number}
|
||||
</Link>
|
||||
{conflict && (
|
||||
<span className="text-[var(--risk)]" title={conflictLabel}>
|
||||
<TriangleAlert className="size-3.5" aria-hidden />
|
||||
<span className="sr-only">{conflictLabel}</span>
|
||||
</span>
|
||||
)}
|
||||
{movable && (
|
||||
<button type="button" onClick={() => onOpen(order, null, null, null)} aria-label={`${order.number} – ${t("board.move")}`} title={t("board.move")} className="ml-auto grid size-11 shrink-0 place-items-center rounded hover:bg-muted">
|
||||
<CalendarPlus className="size-4" aria-hidden />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<p className="truncate font-semibold">{order.customerName}</p>
|
||||
<p className="truncate text-muted-foreground">
|
||||
{time} · {formatMinutes(order.durationMinutes, locale)} · <span style={{ color: tone }}>{tw(`statusGroup.${order.statusGroup}`)}</span>
|
||||
</p>
|
||||
{delayLine}
|
||||
{atRiskLine}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<article ref={setNodeRef} style={style} className={cn("rounded-lg border border-l-4 bg-card p-1.5 text-xs shadow-sm", conflict && "border-[var(--risk)]", isDragging && "opacity-80 shadow-lg")}>
|
||||
<div className="flex items-start gap-1">
|
||||
{grip}
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className="flex items-center gap-1">
|
||||
<Link href={`/work-orders/${order.id}`} className="font-mono font-semibold underline-offset-2 hover:underline" title={t("board.open")}>
|
||||
{order.number}
|
||||
</Link>
|
||||
{order.isEmergency && <Siren className="size-3.5 text-[var(--risk)]" aria-label={t("board.emergency")} />}
|
||||
{conflict && (
|
||||
<span className="ml-auto inline-flex items-center gap-0.5 font-semibold text-[var(--risk)]" title={conflictLabel}>
|
||||
<TriangleAlert className="size-3.5" aria-hidden />
|
||||
<span className="sr-only">{conflictLabel}</span>
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
<p className="truncate font-semibold" title={order.title}>
|
||||
{order.customerName}
|
||||
</p>
|
||||
<p className="truncate text-muted-foreground" title={order.siteAddress ?? order.siteName ?? ""}>
|
||||
{order.siteAddress ?? order.siteName ?? "—"}
|
||||
</p>
|
||||
<p className="mt-0.5 flex flex-wrap items-center gap-x-1.5 text-muted-foreground">
|
||||
{time && <span className="tabular-nums">{time}</span>}
|
||||
<span>{formatMinutes(order.durationMinutes, locale)}</span>
|
||||
{order.multiDay && <span>· {t("board.multiDay")}</span>}
|
||||
<span className="inline-flex items-center gap-0.5 font-semibold" style={{ color: tone }}>
|
||||
<GroupIcon group={order.statusGroup} className="size-3" />
|
||||
{tw(`statusGroup.${order.statusGroup}`)}
|
||||
</span>
|
||||
{(order.priority === "high" || order.priority === "urgent") && <span className="font-semibold text-[var(--warn)]">{tw(`priority.${order.priority}`)}</span>}
|
||||
</p>
|
||||
{delayLine}
|
||||
{atRiskLine}
|
||||
{unplanned && (
|
||||
<p className="mt-0.5 flex flex-wrap gap-x-2 text-[11px] text-muted-foreground">
|
||||
{!order.teamId && <span>{t("unplanned.noTeam")}</span>}
|
||||
{!order.plannedStart && <span>{t("unplanned.noDate")}</span>}
|
||||
{!order.hasCoordinates && (
|
||||
<span className="inline-flex items-center gap-0.5">
|
||||
<MapPinOff className="size-3" aria-hidden />
|
||||
{t("unplanned.noLocation")}
|
||||
</span>
|
||||
)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
{movable && (
|
||||
<button type="button" onClick={() => onOpen(order, null, null, null)} className="mt-1 inline-flex min-h-11 w-full items-center justify-center gap-1 rounded-md border text-[11px] font-semibold hover:bg-muted">
|
||||
<CalendarPlus className="size-3.5" aria-hidden />
|
||||
{order.plannedStart && order.teamId ? t("board.move") : t("board.schedule")}
|
||||
</button>
|
||||
)}
|
||||
</article>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { Info, Lightbulb, MapPin, X } from "lucide-react";
|
||||
import { formatDistance } from "@/lib/geo/distance";
|
||||
import { formatDayShort, formatMinutes } from "@/lib/planning/text";
|
||||
import { buttonCls } from "@/components/work-orders/button-cls";
|
||||
import type { BoardOrder } from "@/server/services/planning/data";
|
||||
|
||||
export type Recommendation = {
|
||||
teamId: string;
|
||||
teamName: string;
|
||||
day: string;
|
||||
distanceKm: number;
|
||||
nearOrder: { id: string; number: string };
|
||||
freeMinutes: number;
|
||||
requiredMinutes: number;
|
||||
tight: boolean;
|
||||
suggestedStart: string;
|
||||
text: string;
|
||||
};
|
||||
|
||||
type Response = {
|
||||
status: "ok" | "no_coordinates" | "not_schedulable";
|
||||
hint: string | null;
|
||||
requiredMinutes: number;
|
||||
recommendations: Recommendation[];
|
||||
nearby: { status: string; radiusKm: number; items: (BoardOrder & { distanceKm: number })[] };
|
||||
};
|
||||
|
||||
/** Suggestions for one unplanned order (L13). "Übernehmen" only prefills the schedule popover. */
|
||||
export function RecommendationsPanel({
|
||||
workOrderId,
|
||||
locale,
|
||||
onTake,
|
||||
onPlanTogether,
|
||||
onClose,
|
||||
}: {
|
||||
workOrderId: string;
|
||||
locale: string;
|
||||
onTake: (rec: Recommendation) => void;
|
||||
onPlanTogether: (order: BoardOrder, rec: Recommendation | null, requiredMinutes: number) => void;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const t = useTranslations("planning");
|
||||
const [data, setData] = useState<Response | null>(null);
|
||||
const [failed, setFailed] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const ctrl = new AbortController();
|
||||
fetch(`/api/v1/planning/recommendations?workOrderId=${encodeURIComponent(workOrderId)}&locale=${locale === "en" ? "en" : "de"}`, { signal: ctrl.signal })
|
||||
.then((r) => (r.ok ? (r.json() as Promise<Response>) : Promise.reject(new Error(String(r.status)))))
|
||||
.then(setData)
|
||||
.catch(() => {
|
||||
if (!ctrl.signal.aborted) setFailed(true);
|
||||
});
|
||||
return () => ctrl.abort();
|
||||
}, [workOrderId, locale]);
|
||||
|
||||
const headingId = `rec-${workOrderId}`;
|
||||
return (
|
||||
<section aria-labelledby={headingId} className="rounded-lg border border-[var(--info)] bg-background p-3 text-sm">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div>
|
||||
<h3 id={headingId} className="flex items-center gap-1.5 font-heading font-semibold">
|
||||
<Lightbulb className="size-4 text-[var(--info)]" aria-hidden />
|
||||
{t("recommend.title")}
|
||||
</h3>
|
||||
<p className="text-xs text-muted-foreground">{t("recommend.sub")}</p>
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label={t("recommend.close")} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-muted">
|
||||
<X className="size-4" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{!data && !failed && (
|
||||
<p role="status" className="mt-2 text-xs text-muted-foreground">
|
||||
{t("recommend.loading")}
|
||||
</p>
|
||||
)}
|
||||
{failed && (
|
||||
<p role="alert" className="mt-2 text-xs text-[var(--risk)]">
|
||||
{t("recommend.failed")}
|
||||
</p>
|
||||
)}
|
||||
{data?.hint && (
|
||||
<p className="mt-2 flex items-start gap-1.5 text-xs">
|
||||
<Info className="mt-0.5 size-3.5 shrink-0 text-[var(--info)]" aria-hidden />
|
||||
{data.hint}
|
||||
</p>
|
||||
)}
|
||||
|
||||
{data && data.recommendations.length > 0 && (
|
||||
<ul className="mt-2 space-y-2">
|
||||
{data.recommendations.map((r) => (
|
||||
<li key={`${r.teamId}-${r.day}`} className="rounded-md border p-2" title={r.text}>
|
||||
<p className="text-[13px] font-semibold">
|
||||
{t("recommend.card", {
|
||||
day: formatDayShort(r.day, locale),
|
||||
team: r.teamName,
|
||||
distance: formatDistance(r.distanceKm, locale),
|
||||
number: r.nearOrder.number,
|
||||
free: formatMinutes(r.freeMinutes, locale),
|
||||
})}
|
||||
</p>
|
||||
{r.tight && <p className="text-xs font-semibold text-[var(--warn)]">{t("recommend.tight")}</p>}
|
||||
<button type="button" onClick={() => onTake(r)} className={`${buttonCls("outline")} mt-1.5 w-full`}>
|
||||
{t("recommend.take")}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
|
||||
{data && data.nearby.status === "ok" && (
|
||||
<div className="mt-3 border-t pt-2">
|
||||
<h4 className="text-xs font-semibold">{t("recommend.nearbyTitle")}</h4>
|
||||
{data.nearby.items.length === 0 ? (
|
||||
<p className="text-xs text-muted-foreground">{t("recommend.nearbyEmpty")}</p>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-xs text-muted-foreground">{t("recommend.nearbyQuestion", { radius: data.nearby.radiusKm })}</p>
|
||||
<ul className="mt-1 space-y-1.5">
|
||||
{data.nearby.items.map((o) => (
|
||||
<li key={o.id} className="rounded-md border p-2">
|
||||
<p className="flex items-center gap-1 text-xs">
|
||||
<MapPin className="size-3.5 shrink-0" aria-hidden />
|
||||
<Link href={`/work-orders/${o.id}`} className="font-mono font-semibold underline-offset-2 hover:underline">
|
||||
{o.number}
|
||||
</Link>
|
||||
· {formatDistance(o.distanceKm, locale)}
|
||||
</p>
|
||||
<p className="truncate text-xs text-muted-foreground">{o.customerName}</p>
|
||||
<button type="button" onClick={() => onPlanTogether(o, data.recommendations[0] ?? null, data.requiredMinutes)} className={`${buttonCls("ghost")} mt-1 w-full`}>
|
||||
{t("recommend.planTogether")}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,177 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useTranslations } from "next-intl";
|
||||
import { AlertCircle, X } from "lucide-react";
|
||||
import type { PlanningConflict } from "@/lib/planning/capacity";
|
||||
import { formatMinutes } from "@/lib/planning/text";
|
||||
import { wallTimeToUtc } from "@/lib/work-orders/time";
|
||||
import { buttonCls } from "@/components/work-orders/button-cls";
|
||||
import { Field, inputCls } from "@/components/work-orders/ui";
|
||||
import type { BoardOrder } from "@/server/services/planning/data";
|
||||
|
||||
export type DialogState = { order: BoardOrder; teamId: string; day: string; time: string; duration: number };
|
||||
|
||||
export type ScheduleResponse = {
|
||||
id: string;
|
||||
number: string;
|
||||
version: number;
|
||||
status: string;
|
||||
teamId: string;
|
||||
plannedStart: string;
|
||||
plannedEnd: string | null;
|
||||
plannedDurationMinutes: number | null;
|
||||
conflicts: PlanningConflict[];
|
||||
};
|
||||
|
||||
/**
|
||||
* Confirmation popover for drag & drop and the keyboard path ("Einplanen …"): team, date, start and
|
||||
* duration are prefilled; nothing is saved without "Speichern". Escape / backdrop / × close it and focus
|
||||
* returns to the element that opened it.
|
||||
*/
|
||||
export function ScheduleDialog({
|
||||
state,
|
||||
teams,
|
||||
timeZone,
|
||||
locale,
|
||||
onClose,
|
||||
onSaved,
|
||||
onConflict,
|
||||
}: {
|
||||
state: DialogState;
|
||||
teams: { id: string; name: string }[];
|
||||
timeZone: string;
|
||||
locale: string;
|
||||
onClose: () => void;
|
||||
onSaved: (res: ScheduleResponse) => void;
|
||||
onConflict: (message: string) => void;
|
||||
}) {
|
||||
const t = useTranslations("planning");
|
||||
const [teamId, setTeamId] = useState(state.teamId);
|
||||
const [day, setDay] = useState(state.day);
|
||||
const [time, setTime] = useState(state.time || "08:00");
|
||||
const [allDay, setAllDay] = useState(state.time === "");
|
||||
const [duration, setDuration] = useState(state.duration);
|
||||
const [pending, setPending] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const closeRef = useRef(onClose);
|
||||
|
||||
useEffect(() => {
|
||||
closeRef.current = onClose;
|
||||
}, [onClose]);
|
||||
|
||||
useEffect(() => {
|
||||
const opener = document.activeElement as HTMLElement | null;
|
||||
const onKey = (e: KeyboardEvent) => {
|
||||
if (e.key === "Escape") closeRef.current();
|
||||
};
|
||||
window.addEventListener("keydown", onKey);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", onKey);
|
||||
opener?.focus?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
const order = state.order;
|
||||
|
||||
async function submit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
const start = wallTimeToUtc(allDay ? day : `${day}T${time}`, timeZone);
|
||||
if (!start || !teamId || !Number.isFinite(duration) || duration < 15) {
|
||||
setError(t("errors.invalid"));
|
||||
return;
|
||||
}
|
||||
const body: Record<string, unknown> = { workOrderId: order.id, teamId, plannedStart: start.toISOString(), baseVersion: order.version };
|
||||
if (!order.multiDay) {
|
||||
body.plannedDurationMinutes = Math.round(duration);
|
||||
body.plannedEnd = allDay ? null : new Date(start.getTime() + Math.round(duration) * 60_000).toISOString();
|
||||
}
|
||||
setPending(true);
|
||||
try {
|
||||
const res = await fetch("/api/v1/planning/schedule", { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body) });
|
||||
const json = (await res.json().catch(() => null)) as (ScheduleResponse & { error?: { code?: string; message?: string } }) | null;
|
||||
if (res.ok && json) {
|
||||
onSaved(json);
|
||||
return;
|
||||
}
|
||||
if (res.status === 409) {
|
||||
onConflict(t("errors.conflict"));
|
||||
return;
|
||||
}
|
||||
const message = json?.error?.message ?? "";
|
||||
const code = json?.error?.code ?? "";
|
||||
setError(t.has(`errors.${message}`) ? t(`errors.${message}`) : t.has(`errors.${code}`) ? t(`errors.${code}`) : t("errors.generic"));
|
||||
} catch {
|
||||
setError(t("errors.network"));
|
||||
} finally {
|
||||
setPending(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-start justify-center overflow-y-auto bg-black/30 p-6 backdrop-blur-[2px]"
|
||||
onMouseDown={(e) => {
|
||||
if (e.target === e.currentTarget) onClose();
|
||||
}}
|
||||
>
|
||||
<div role="dialog" aria-modal="true" aria-labelledby="schedule-title" className="shadow-card my-auto w-full max-w-lg rounded-2xl border bg-card">
|
||||
<div className="flex items-start justify-between gap-3 border-b p-5">
|
||||
<div className="min-w-0">
|
||||
<h2 id="schedule-title" className="font-heading text-lg font-semibold">
|
||||
{t("dialog.title")}
|
||||
</h2>
|
||||
<p className="text-sm">
|
||||
<span className="font-mono">{order.number}</span> · {order.customerName}
|
||||
</p>
|
||||
{(order.siteAddress || order.siteName) && <p className="truncate text-xs text-muted-foreground">{order.siteAddress ?? order.siteName}</p>}
|
||||
</div>
|
||||
<button type="button" onClick={onClose} aria-label={t("dialog.close")} className="grid size-11 shrink-0 place-items-center rounded-md text-muted-foreground hover:bg-muted hover:text-foreground">
|
||||
<X className="size-4.5" aria-hidden />
|
||||
</button>
|
||||
</div>
|
||||
<form onSubmit={submit} className="grid gap-4 p-5 sm:grid-cols-2">
|
||||
<Field label={t("dialog.team")} htmlFor="sd-team" className="sm:col-span-2">
|
||||
<select id="sd-team" value={teamId} onChange={(e) => setTeamId(e.target.value)} required className={inputCls} autoFocus>
|
||||
{teams.map((tm) => (
|
||||
<option key={tm.id} value={tm.id}>
|
||||
{tm.name}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
<Field label={t("dialog.date")} htmlFor="sd-day">
|
||||
<input id="sd-day" type="date" value={day} onChange={(e) => setDay(e.target.value)} required className={inputCls} />
|
||||
</Field>
|
||||
<Field label={t("dialog.time")} htmlFor="sd-time">
|
||||
<input id="sd-time" type="time" step={900} value={time} onChange={(e) => setTime(e.target.value)} disabled={allDay} required={!allDay} className={inputCls} />
|
||||
</Field>
|
||||
<label className="flex min-h-11 items-center gap-2.5 text-sm sm:col-span-2">
|
||||
<input type="checkbox" checked={allDay} onChange={(e) => setAllDay(e.target.checked)} className="size-5 accent-[var(--ui-primary)]" />
|
||||
{t("dialog.noTime")}
|
||||
</label>
|
||||
{!order.multiDay && (
|
||||
<Field label={t("dialog.duration")} htmlFor="sd-duration" hint={t("dialog.durationHint", { duration: formatMinutes(order.durationMinutes, locale) })} className="sm:col-span-2">
|
||||
<input id="sd-duration" type="number" min={15} max={20160} step={15} value={duration} onChange={(e) => setDuration(Number(e.target.value))} required className={inputCls} />
|
||||
</Field>
|
||||
)}
|
||||
{error && (
|
||||
<p role="alert" className="flex items-center gap-2 rounded-lg border border-[var(--risk)] px-3 py-2 text-sm text-[var(--risk)] sm:col-span-2">
|
||||
<AlertCircle className="size-4 shrink-0" aria-hidden />
|
||||
{error}
|
||||
</p>
|
||||
)}
|
||||
<div className="flex flex-wrap gap-2 sm:col-span-2">
|
||||
<button type="submit" disabled={pending} className={buttonCls("primary")}>
|
||||
{pending ? t("dialog.saving") : t("dialog.save")}
|
||||
</button>
|
||||
<button type="button" onClick={onClose} className={buttonCls("ghost")}>
|
||||
{t("dialog.cancel")}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,98 @@
|
||||
import Link from "next/link";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { Info, Lightbulb, MapPin } from "lucide-react";
|
||||
import { formatDistance } from "@/lib/geo/distance";
|
||||
import { wallClock } from "@/lib/planning/days";
|
||||
import { formatDayShort, formatMinutes } from "@/lib/planning/text";
|
||||
import { buttonCls } from "@/components/work-orders/button-cls";
|
||||
import { Section } from "@/components/work-orders/ui";
|
||||
import type { ServiceCtx } from "@/server/services/context";
|
||||
import { findNearbyUnplanned, recommendAssignments } from "@/server/services/planning/recommend";
|
||||
|
||||
/**
|
||||
* "Einsatz-Vorschläge" in the backoffice work order detail (L13, additive). Never breaks the page:
|
||||
* any error hides the section. "Übernehmen" opens the planning board with a prefilled popover.
|
||||
*/
|
||||
export async function PlanningSuggestions({ ctx, workOrderId, locale, tz }: { ctx: ServiceCtx; workOrderId: string; locale: string; tz: string }) {
|
||||
let rec: Awaited<ReturnType<typeof recommendAssignments>>;
|
||||
let nearby: Awaited<ReturnType<typeof findNearbyUnplanned>>;
|
||||
try {
|
||||
[rec, nearby] = await Promise.all([recommendAssignments(ctx, { workOrderId, locale: locale === "en" ? "en" : "de" }), findNearbyUnplanned(ctx, { workOrderId })]);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (rec.status === "not_schedulable") return null;
|
||||
const t = await getTranslations("planning");
|
||||
const boardLink = (day: string | null, teamId: string | null, time: string | null, id = workOrderId) => {
|
||||
const qs = new URLSearchParams({ schedule: id });
|
||||
if (day) {
|
||||
qs.set("date", day);
|
||||
qs.set("day", day);
|
||||
}
|
||||
if (teamId) qs.set("scheduleTeam", teamId);
|
||||
if (time) qs.set("time", time);
|
||||
return `/planning?${qs}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<Section
|
||||
title={t("recommend.sectionTitle")}
|
||||
className="mt-4"
|
||||
actions={
|
||||
<Link href="/planning" className={buttonCls("ghost")}>
|
||||
{t("recommend.openBoard")}
|
||||
</Link>
|
||||
}
|
||||
>
|
||||
<p className="mb-2 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<Lightbulb className="size-3.5 text-[var(--info)]" aria-hidden />
|
||||
{t("recommend.sub")}
|
||||
</p>
|
||||
{rec.hint && (
|
||||
<p className="mb-2 flex items-start gap-1.5 text-sm">
|
||||
<Info className="mt-0.5 size-4 shrink-0 text-[var(--info)]" aria-hidden />
|
||||
{rec.hint}
|
||||
</p>
|
||||
)}
|
||||
{rec.recommendations.length > 0 && (
|
||||
<ul className="grid gap-2 md:grid-cols-2 xl:grid-cols-3">
|
||||
{rec.recommendations.map((r) => (
|
||||
<li key={`${r.teamId}-${r.day}`} className="rounded-lg border p-3 text-sm" title={r.text}>
|
||||
<p className="font-semibold">
|
||||
{t("recommend.card", { day: formatDayShort(r.day, locale), team: r.teamName, distance: formatDistance(r.distanceKm, locale), number: r.nearOrder.number, free: formatMinutes(r.freeMinutes, locale) })}
|
||||
</p>
|
||||
{r.tight && <p className="text-xs font-semibold text-[var(--warn)]">{t("recommend.tight")}</p>}
|
||||
<Link href={boardLink(r.day, r.teamId, wallClock(new Date(r.suggestedStart), tz))} className={`${buttonCls("outline")} mt-2 w-full`}>
|
||||
{t("recommend.take")}
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{nearby.status === "ok" && nearby.items.length > 0 && (
|
||||
<div className="mt-3 border-t pt-3">
|
||||
<h3 className="text-sm font-semibold">{t("recommend.nearbyTitle")}</h3>
|
||||
<p className="text-xs text-muted-foreground">{t("recommend.nearbyQuestion", { radius: nearby.radiusKm })}</p>
|
||||
<ul className="mt-1.5 space-y-1">
|
||||
{nearby.items.map((o) => {
|
||||
const first = rec.recommendations[0];
|
||||
return (
|
||||
<li key={o.id} className="flex flex-wrap items-center gap-2 text-sm">
|
||||
<MapPin className="size-3.5" aria-hidden />
|
||||
<Link href={`/work-orders/${o.id}`} className="font-mono font-semibold underline-offset-2 hover:underline">
|
||||
{o.number}
|
||||
</Link>
|
||||
<span>{o.customerName}</span>
|
||||
<span className="text-muted-foreground">· {formatDistance(o.distanceKm, locale)}</span>
|
||||
<Link href={boardLink(first?.day ?? null, first?.teamId ?? null, null, o.id)} className="ml-auto inline-flex min-h-11 items-center text-xs font-semibold underline-offset-2 hover:underline">
|
||||
{t("recommend.planTogether")}
|
||||
</Link>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
);
|
||||
}
|
||||
@@ -14,6 +14,9 @@ import {
|
||||
ListChecks,
|
||||
Siren,
|
||||
Compass,
|
||||
CalendarRange,
|
||||
LayoutGrid,
|
||||
MapPinned,
|
||||
type LucideIcon,
|
||||
} from "lucide-react";
|
||||
import type { ModuleKey } from "@/lib/modules";
|
||||
@@ -37,10 +40,18 @@ export interface NavItem {
|
||||
module?: ModuleKey;
|
||||
permissions?: readonly Permission[];
|
||||
section: "main" | "admin";
|
||||
/** L13: indented sub entry of the preceding item */
|
||||
sub?: boolean;
|
||||
/** L13: active only on exactly this path (not on sub paths) */
|
||||
exact?: boolean;
|
||||
}
|
||||
|
||||
export const NAV_ITEMS: readonly NavItem[] = [
|
||||
{ href: "/dashboard", label: "dashboard", icon: LayoutDashboard, section: "main" },
|
||||
// L13 Planung: top-level entry with sub entries; backoffice (read_all) + team leads (report:approve_team, own crews read-only)
|
||||
{ href: "/planning", label: "planning", icon: CalendarRange, module: "work_orders", permissions: ["work_order:read_all", "report:approve_team"], section: "main" },
|
||||
{ href: "/planning", label: "planningBoard", icon: LayoutGrid, module: "work_orders", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true, exact: true },
|
||||
{ href: "/planning/live", label: "planningLive", icon: MapPinned, module: "work_orders", permissions: ["work_order:read_all", "report:approve_team"], section: "main", sub: true },
|
||||
{
|
||||
href: "/work-orders",
|
||||
label: "workOrders",
|
||||
|
||||
Reference in New Issue
Block a user