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:
2026-09-15 10:13:50 +02:00
co-authored by Claude Opus 5
parent 5e10523df6
commit ee17134863
25 changed files with 2557 additions and 3 deletions
+3
View File
@@ -1,6 +1,9 @@
{
"dashboard": "Dashboard",
"workOrders": "Aufträge",
"planning": "Planung",
"planningBoard": "Plantafel",
"planningLive": "Live-Lage",
"emergencyReview": "Notdienst-Prüfung",
"imports": "Import",
"customers": "Kunden",
+199
View File
@@ -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"
}
}
+3
View File
@@ -1,6 +1,9 @@
{
"dashboard": "Dashboard",
"workOrders": "Work orders",
"planning": "Planning",
"planningBoard": "Planning board",
"planningLive": "Live situation",
"emergencyReview": "Emergency review",
"imports": "Import",
"customers": "Customers",
+199
View File
@@ -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
View File
@@ -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:",
+65
View File
@@ -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",
+3
View File
@@ -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",
+19
View File
@@ -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] },
],
},
];
+2
View File
@@ -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>
</>
)}
+2 -1
View File
@@ -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>
+7
View File
@@ -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}</>;
}
+34
View File
@@ -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>
);
}
+295
View File
@@ -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>
);
}
+4
View File
@@ -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")}
+4 -1
View File
@@ -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 (
+52
View File
@@ -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>
)}
</>
);
}
+112
View File
@@ -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) => ({ "&": "&amp;", "<": "&lt;", ">": "&gt;", '"': "&quot;", "'": "&#39;" })[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>
);
}
+361
View File
@@ -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>
);
}
+13
View File
@@ -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 };
+689
View File
@@ -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>
);
}
+177
View File
@@ -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>
);
}
+11
View File
@@ -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",