diff --git a/messages/de/nav.json b/messages/de/nav.json index efed3cc..b207ef8 100644 --- a/messages/de/nav.json +++ b/messages/de/nav.json @@ -1,6 +1,9 @@ { "dashboard": "Dashboard", "workOrders": "Aufträge", + "planning": "Planung", + "planningBoard": "Plantafel", + "planningLive": "Live-Lage", "emergencyReview": "Notdienst-Prüfung", "imports": "Import", "customers": "Kunden", diff --git a/messages/de/planning.json b/messages/de/planning.json new file mode 100644 index 0000000..62e8dc6 --- /dev/null +++ b/messages/de/planning.json @@ -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" + } +} diff --git a/messages/en/nav.json b/messages/en/nav.json index 91f4ba6..73602b5 100644 --- a/messages/en/nav.json +++ b/messages/en/nav.json @@ -1,6 +1,9 @@ { "dashboard": "Dashboard", "workOrders": "Work orders", + "planning": "Planning", + "planningBoard": "Planning board", + "planningLive": "Live situation", "emergencyReview": "Emergency review", "imports": "Import", "customers": "Customers", diff --git a/messages/en/planning.json b/messages/en/planning.json new file mode 100644 index 0000000..aafe3b3 --- /dev/null +++ b/messages/en/planning.json @@ -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" + } +} diff --git a/next.config.ts b/next.config.ts index fe81c08..ee17c7d 100644 --- a/next.config.ts +++ b/next.config.ts @@ -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:", diff --git a/package-lock.json b/package-lock.json index 62be9a7..31746df 100644 --- a/package-lock.json +++ b/package-lock.json @@ -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", diff --git a/package.json b/package.json index 50fd72b..9570f39 100644 --- a/package.json +++ b/package.json @@ -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", diff --git a/scripts/smoke-auth.ts b/scripts/smoke-auth.ts index 2ef0bca..694e374 100644 --- a/scripts/smoke-auth.ts +++ b/scripts/smoke-auth.ts @@ -99,6 +99,17 @@ function plans(x: Awaited>): 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>): 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>): 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>): 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] }, ], }, ]; diff --git a/src/app/(app)/dashboard/page.tsx b/src/app/(app)/dashboard/page.tsx index 0261dee..46607b2 100644 --- a/src/app/(app)/dashboard/page.tsx +++ b/src/app/(app)/dashboard/page.tsx @@ -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 ); })} + )} diff --git a/src/app/(app)/layout.tsx b/src/app/(app)/layout.tsx index 8f77b5e..13ff9d7 100644 --- a/src/app/(app)/layout.tsx +++ b/src/app/(app)/layout.tsx @@ -67,7 +67,8 @@ export default async function AppLayout({