Merge lane/planung in feature/craftvia-mvp

Konflikte gelöst (additiv): Benachrichtigungstexte (Zeiterfassung + Planung, JSON zusammengeführt),
Event-Typen, Navigation, Empfänger-Felder, handle-event (reason inkl. rejectionReason + Planungsfelder),
Smoke-Prüfungen (Teamleiter: Zeiten + Planung).

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
2026-09-15 10:15:47 +02:00
co-authored by Claude Opus 5
77 changed files with 6326 additions and 21 deletions
+12
View File
@@ -129,6 +129,18 @@ AI_GENERATION_RETENTION_DAYS=180
# Import-Extraktion ab. 0 = unbegrenzt.
AI_MONTHLY_TOKEN_LIMIT=0
# --- Craftvia: Planung – Karten & Geocoding (L13) ---
# Adresse → Koordinaten nur serverseitig im Worker (Job geocode-site, max. 1 Anfrage/s),
# Ergebnis wird am Objekt gespeichert. nominatim | none (none = keine Verortung, Objekte
# erscheinen „ohne Ortsangabe“). Für Produktion eigenen/vertraglichen Dienst verwenden.
GEOCODING_PROVIDER=nominatim
# GEOCODING_URL=https://nominatim.openstreetmap.org
# Eindeutiger User-Agent (Nominatim-Richtlinie); leer = "Craftvia/<version> (+APP_BASE_URL)".
# GEOCODING_USER_AGENT=
# Kartenkacheln der Live-Lage; der Host wird beim Build in die CSP (img-src) übernommen.
# MAP_TILE_URL=https://tile.openstreetmap.org/{z}/{x}/{y}.png
# MAP_ATTRIBUTION=© OpenStreetMap-Mitwirkende
# --- Optionale Fundament-Variablen (Default leer) ---
# MFA_ENC_KEY: Schlüssel für TOTP-Secrets at-rest (leer = aus AUTH_SECRET abgeleitet).
# ⚠ Nach dem Setzen nicht mehr ändern.
+5 -2
View File
@@ -56,7 +56,10 @@
| MFA | erfüllt | Fundament TOTP + Passkeys, Mandanten-MFA-Pflicht |
| Kartenlink zur Einsatzadresse | erfüllt | L1 OpenStreetMap-Link, L4 Routenlink |
| Push-Benachrichtigungen | offen | nach MVP (Spec §20.2 „später") |
| Kalenderansicht | offen | optional (§11.2) |
| Kalenderansicht | erfüllt (Backoffice) | L13 Menüpunkt „Planung“ → `/planning`: Standard „Heute + nächste 4 Werktage“ (Teams = Kolonnen als Zeilen, heutige Spalte mit Live-Status je Kolonne), Umschalter Heute (Stundenraster 6–20 Uhr, Kolonnen als parallele Spalten) · 5 Tage · Woche · Nächste Woche; Auslastung der Kolonnenkapazität je Tag, Konflikte (überbucht, Überschneidung, Person doppelt eingeplant, außerhalb der Arbeitstage) mit roter Kante + Icon + Text, Hinweis „Kolonne unvollständig“, Drag & Drop mit Bestätigung und Tastatur-Alternative, ungeplante Aufträge in der Seitenleiste; Dashboard-Kacheln „Planung heute“ und „Konflikte diese Woche“; Teamleiter lesend für eigene Kolonnen; `test-planung-core`. Mobile Kalenderansicht für Monteure weiterhin offen (§11.2 optional) |
| Live-Lage der Monteure | erfüllt (ohne GPS) | L13 `/planning/live`: ein Marker/Eintrag je Kolonne am Objekt des laufenden Auftrags, Mitglieder mit individuellem Status aus ihrer WorkSession, Monteure ohne Team einzeln; Karte (OpenStreetMap) + Liste, 30-s-Aktualisierung; keine Geräte-Koordinaten; `test-planung-live` |
| Verzugswarnungen / früher fertig | erfüllt (Hinweis) | L13: erfasste Kolonnenzeit (vereinigte Arbeitssegmente) ≥ 80 % → „Verzug droht“, ≥ 100 % → Meldung `planning.overrun` (erneut je +50 %), gefährdeter Folgeauftrag (Restzeit + geschätzte Fahrzeit bzw. Tageskapazität) → Markierung + `planning.followup_at_risk`, früher fertig → „früher fertig“ + Vorzieh-/Umkreis-Vorschläge + `planning.capacity_freed`; Meldungen nur In-App an Backoffice + Teamleiter, dedupliziert, aus dem 5-Minuten-Job `planning-watch`; nie automatische Umplanung; `test-planung-watch` |
| Einsatz-Empfehlungen | erfüllt (Vorschlag) | L13: Teams mit Auftrag in der Nähe (Luftlinie) und freier Kapazität, Begründung als Text, Übernehmen nur mit Bestätigung; nahe ungeplante Aufträge; Geocoding über Nominatim-Job mit Cache; `test-planung-recommend`, `test-planung-geocode`. Keine Routen-/Fahrzeitberechnung (Spec §35 „automatische Tourenoptimierung“ bleibt außerhalb) |
| Standorterfassung beim Einsatzstart | erfüllt | L4 optional beim Session-Start |
| Barcode/QR-Code | offen | Version 2 |
| Kundenspezifische Statusmodelle | offen | Statusmodell global, Labels gruppiert (Brandbook §12.3) |
@@ -76,7 +79,7 @@
| E-Mail-Dienst | SMTP (Mail-Worker, Queue) | Anbieter, SPF/DKIM/DMARC |
| Hostingstandort | Coolify/Docker (aus Certvia übernommen) | Rechenzentrum/Region |
| MFA-Regelung | optional je Nutzer, Mandanten-Pflicht einschaltbar | Pflicht für Admins? |
| Standortdaten | optional beim Einsatzstart/Foto, kein Dauertracking | Einwilligung/Betriebsvereinbarung |
| Standortdaten | optional beim Einsatzstart/Foto, kein Dauertracking. **Live-Lage (L13) nutzt keine Geräte-Standorte:** angezeigt wird der Einsatzort (Objektadresse) des laufenden Auftrags, Status aus der Zeiterfassung; Start-Koordinaten der WorkSession werden weder gelesen noch ausgeliefert. Objektadressen werden serverseitig über OpenStreetMap Nominatim verortet (nur Adresse, keine Personendaten, max. 1 Anfrage/s, Ergebnis am Objekt gespeichert); Kartenkacheln lädt der Browser von `tile.openstreetmap.org` (IP-Adresse des Nutzers wird dabei an OSM übertragen) | Einwilligung/Betriebsvereinbarung für die Anzeige „wer arbeitet wo“; Datenschutzhinweis zur Kachel-/Geocoding-Nutzung bzw. eigener Karten-/Geocoding-Dienst (EU-Hosting) für Produktion |
| Maximale Offline-Dauer | `OFFLINE_MAX_DAYS=7` | Wert |
| Maximale Dateigrößen | Bild 15 MB, PDF 25 MB, Audio 20 MB | Werte |
| Buchhaltungssoftware | keine Schnittstelle (MVP-Abgrenzung §35) | Zielsystem für V2 |
+4
View File
@@ -91,4 +91,8 @@ Die Pfade sind relativ zu `/api/v1`. „Recht“ nennt das Gate der Route. Mit
| POST | `/uploads` | field | `field:execute` | Foto/Sprachnotiz hochladen → `documentId` |
| GET | `/field/bundle` | field | `field:execute` | Offline-Pull (`?since=<ISO>`, max. 200 Aufträge) |
| GET | `/field/documents/{id}` | field | Service: `document:read` + Sichtbarkeit/Scope | Dokument für die Mobile-App (`?variant=preview`) |
| GET | `/planning/board` | work_orders | Service: `work_order:read_all` oder Teamleiter (eigene Teams) | Plantafel: Aufträge je Team/Tag, Kapazität, Auslastung, Konflikte (`from`, `to` ≤ 42 Tage, `teamId`, `orderTypeId`, `priority`) |
| POST | `/planning/schedule` | work_orders | `work_order:assign` + Service: `work_order:write` | Einplanen (Team + Termin + Dauer, atomar, `baseVersion` Pflicht, 409 „Auftrag wurde zwischenzeitlich geändert“) |
| GET | `/planning/recommendations` | work_orders | `work_order:assign` | Einsatz-Empfehlungen (Luftlinie + freie Kapazität, Top 5) + nahe ungeplante Aufträge; nur Vorschläge |
| GET | `/planning/live` | work_orders | Service: `work_order:read_all` oder Teamleiter | Live-Lage: Status aus aktiver WorkSession, Standort = Objekt des Auftrags, keine Geräte-Koordinaten |
| GET | `/openapi.json` | – | angemeldet | OpenAPI-3.1-Dokument |
+145
View File
@@ -0,0 +1,145 @@
# Lane L13 – Planung (`lane/planung`)
Stand: 2026-09-15 · Basis `d3bc7f2` (`feature/craftvia-mvp`, L1–L11 integriert) · Spec §11, §21, §35/§36, §44 · Brandbook §12
Menüpunkt „Planung“ mit Plantafel (Standard: heute + nächste 4 Werktage) und Live-Lage (ohne GPS), Kolonnenkapazität, Konflikte, Verzugswarnungen, „früher fertig“ mit Vorzieh-Vorschlägen, Einsatz-Empfehlungen nach Luftlinie + Terminlage, Geocoding über OpenStreetMap Nominatim. **Alles sind Hinweise und Vorschläge – es wird nie automatisch umgeplant.**
## 1. Umfang / umgesetzte Nutzerentscheidungen
| Punkt | Umsetzung |
|---|---|
| **Teams = Kolonnen** | Ein Team fährt und arbeitet gemeinsam (2+ Personen). Kapazität = `Team.dailyCapacityMinutes` (Kolonnen-Arbeitstag, Default 480) an `Team.workingDays` (Bitmaske Mo=1 … So=64, Default Mo–Fr) – **nicht** Personenstunden; ohne aktive Mitglieder 0. Auftragsdauer ist Kolonnenzeit. Auslastung = Summe geplanter Kolonnendauern / Kolonnenkapazität. Popup „Teamkapazität“ auf der Plantafel (`team:manage`, Audit). |
| Dauer | `WorkOrder.plannedDurationMinutes` → Beginn/Ende am selben Tag (mit Uhrzeit) → `OrderType.defaultDurationMinutes` → 120 min. Mehrtägig: explizite Dauer gleichmäßig verteilt, sonst je Tag Auftragsart-Standard bzw. 480 min. Ende exakt um Mitternacht zählt zum Vortag. |
| Konflikte | `overbooked` (Minuten über Kapazität), `overlap` (zwei Aufträge derselben Kolonne mit Uhrzeit überlappen), `assignee_double_booked` (Person in zwei Kolonnen/Aufträgen gleichzeitig; in beiden Zeilen sichtbar), `outside_working_days`; **Hinweis** `crew_incomplete` (an Arbeitstagen < 2 aktive Mitglieder; `severity: hint`, gelb mit Info-Icon, zählt nicht als Konflikt). |
| **Menü „Planung“** | Top-Level direkt unter „Dashboard“ mit Unterpunkten „Plantafel“ (Standard, `/planning`) und „Live-Lage“ (`/planning/live`); sichtbar für `work_order:read_all` und Teamleiter (`report:approve_team`). |
| **Plantafel** `/planning` | Standard **„Heute + nächste 4 Werktage“** (5 Spalten, erste Spalte „Heute“ hervorgehoben), Kolonnen als Zeilen mit Name, Mitglieder-Kurzliste und Kolonnenkapazität; je Zelle Auftragskarten (Nummer, Kunde, Ort, Uhrzeit, Dauer, Statusgruppe mit Icon, Priorität, Notdienst), Auslastungsbalken („6 h / 8 h · 75 %“), Konflikt-/Hinweis-Liste, Verzugs- und Gefährdungs-Badges; heutige Spalte zusätzlich **Live-Status je Kolonne** (Unterwegs/In Arbeit/Pause/frei + aktive Personen, Verzug, „früher fertig“). Umschalter **Heute** (Stundenraster 6–20 Uhr, **Kolonnen als parallele Spalten**, Zeit vertikal, überlappende Aufträge nebeneinander) · **5 Tage** · **Woche** · **Nächste Woche**; vor/zurück/heute. Filter Team/Auftragsart/Priorität (wirken auf Karten, nicht auf die Auslastung). Seitenleiste „Ungeplante Aufträge“ mit „Vorschläge“. Panel „Früher fertig“ mit „Vorziehen“. Unter 1024 px Hinweis + einfache Liste. |
| Drag & Drop | `@dnd-kit/core` (Pointer- und Keyboard-Sensor am Griff): Ablegen auf Tag (Uhrzeit bleibt bzw. 08:00) oder Stunde (Heute-Ansicht) → Bestätigungs-Popover (Team, Datum, Beginn, „ohne Uhrzeit“, Dauer vorbelegt) → `POST /api/v1/planning/schedule`. **Tastatur-Alternative:** „Einplanen …“/„Verschieben …“ je Karte öffnet dasselbe Popover (Fokus hinein, Escape schließt, Fokus zurück). Ergebnis in `role="status"` inkl. Konfliktanzahl; Versionskonflikt → Meldung + Neuladen. |
| `scheduleWorkOrder` | `inTransaction`: `baseVersion` Pflicht → `conflict` „Auftrag wurde zwischenzeitlich geändert“; nur Status draft/review_required/planned/assigned/accepted; **L2 `assignWorkOrder`** (Teamwechsel/Erstzuweisung, Event `work_order.assigned`; Einzelzuweisungen bleiben nur als aktive Mitglieder des Zielteams) + **L2 `updateWorkOrder`** (Beginn/Ende) + Dauer (`writeWithVersion`); Audit „planning“ (before/after); Länge bleibt beim Verschieben ohne neues Ende erhalten; Antwort mit Konflikten des Zieltages. Rechte `work_order:assign` + `work_order:write`. |
| **Live-Lage ohne GPS** `/planning/live` | Status aus der aktiven WorkSession je Person (`en_route` → Unterwegs, `running` → In Arbeit, `paused` → Pause, keine → frei), „seit“ aus dem offenen Zeitabschnitt. **Ein Marker je Kolonne** am Objekt des laufenden Auftrags (Auftrag des aktivsten Mitglieds), Popup/Liste mit Mitgliedern und deren individuellem Status (inkl. abweichendem Auftrag); Monteure ohne Team einzeln. Farbe + inline-SVG-Icon + Text, roter Ring bei Verzug. Karte (Leaflet, OSM-Kacheln, Namensnennung) + Liste, mobil Tabs; Filter Team/Status; Polling 30 s + „Zuletzt aktualisiert“; „Heute ohne Beginn“; „Früher fertig“ mit Vorzieh-Links. Hinweis „Standort = Einsatzort des laufenden Auftrags, keine GPS-Ortung.“ |
| **Früher fertig** | `getFreedCapacity(ctx, { date })`: Aufträge des Tages mit Uhrzeit, Status technically_completed/signature_pending/in_review/released_for_billing/billed, Ende der letzten (Uhr-)Session < geplanter Beginn + Dauer (≥ 15 min) → freie Minuten je Kolonne (früher fertig gesamt, ab jetzt verfügbar), tatsächliche Dauer aus freigegebenen Zeiten. Vorschläge (nur heute): spätere Aufträge derselben Kolonne heute/nächste 5 Tage, die in die freie Zeit passen („Vorziehen“), und ungeplante Aufträge im Umkreis 15 km (Luftlinie). Badge „Team Nord · 2 h früher fertig“ in Live-Lage und Plantafel; „Vorziehen“ öffnet das Einplan-Popover vorbelegt. |
| **Verzugswarnungen** | `evaluateDelays`: erfasste Kolonnen-Arbeitszeit des laufenden Auftrags = **zeitliche Vereinigung** der `work`-Segmente aller Sessions des Auftrags (nicht über Personen summiert) vs. geplante Dauer: ≥ 80 % → „Verzug droht“, ≥ 100 % → „Dauer überschritten“ (Live-Lage Marker/Liste, Plantafel Karte, Text + Icon). **Folgeauftrag gefährdet:** nächster geplanter Auftrag derselben Kolonne heute; Restzeit (≥ 0) + Fahrzeit (Luftlinie × 1,3 / 50 km/h, mind. 10 min) → Beginn (mit Uhrzeit) überschritten oder Überziehung + Fahrzeit sprengt die Tageskapazität → Karte „gefährdet durch Verzug A-00041“. |
| **Meldungen** | Job `planning-watch` (BullMQ Job-Scheduler alle 5 min, alle Mandanten): `planning.overrun` (dedupe je Auftrag, erneut je weitere +50 %), `planning.followup_at_risk` (je Folgeauftrag/Tag), `planning.capacity_freed` (je Kolonne/Tag/Auftrag) → **nur In-App** an Backoffice (`work_order:read_all` + `report:approve`) + Teamleiter der Kolonne. Dedupe-Ledger: append-only Audit-Log (`entity = planning_alert`, Schlüssel je Mandant). Anzeige wird bei jedem Laden serverseitig berechnet (auch ohne Redis); Events entstehen nur im Job. |
| **Empfehlungen** | `recommendAssignments(ctx, { workOrderId, days = 10, radiusKm = 25 })`: Zielobjekt braucht Koordinaten (sonst Hinweis „Adresse nicht verortet“ + Geocoding-Job; `not_found` eigener Hinweis). Je Arbeitstag und Kolonne: nächster geplanter Auftrag (Luftlinie) + **freie Kolonnenkapazität**; ohne freie Kapazität ausgeschlossen, zu wenig → „knapp“. Score 0,6 Distanz + 0,2 Kapazität + Datum (Gewicht nach Priorität) − 0,25 knapp; Top 5 mit Text („3,0 km von A-00001 · Team Nord hat am Mo 28.09. noch 4 h frei“) und vorgeschlagenem Beginn. `findNearbyUnplanned` (5 km). UI in der Seitenleiste und im Auftragsdetail; „Übernehmen“ nur vorbelegt, **kein Auto-Speichern**. |
| **Geocoding** | `services/geo`: `GeocodingProvider`, `nominatim.ts` (strukturiert, Timeout 8 s, `Accept-Language: de`, `countrycodes`, User-Agent `Craftvia/<version> (+APP_BASE_URL)` bzw. `GEOCODING_USER_AGENT`, Limiter 1/s je Prozess), `none`. Job `geocode-site` (Concurrency 1 + BullMQ-Limiter 1/s, `failed` → Retry). Cache `Site.latitude/longitude` + `geocodeQuery` + `geocodeStatus` + `geocodedAt`; unveränderte Adresse nie erneut; manuelle Koordinaten nie überschrieben. Auslöser Objekt anlegen/Adressänderung/Import-Bestätigung – nur Queue, nie inline. `scripts/geocode-backfill.ts`. Ohne Netz/Provider: „ohne Ortsangabe“, nie Fehler. |
| Dashboard | Kacheln „Planung heute“ (Kolonnen im Einsatz / gesamt, Konflikte heute, ungeplante Aufträge) und „Konflikte diese Woche“ – additiv. |
## 2. Datenschutz – Live-Lage (verbindliche Entscheidung)
- **Keine Standortdaten des Geräts.** Angezeigt wird ausschließlich der **Einsatzort des laufenden Auftrags** (Objektadresse bzw. deren Koordinaten). `WorkSession.startLat/startLng`, `deviceInfo` und Foto-Koordinaten werden nicht selektiert und sind nie Teil einer Antwort (per Test über das serialisierte Ergebnis aller Sichten geprüft).
- Status und Verzug stammen aus der Zeiterfassung (aktive Uhr-Sessions, getrackte Arbeitssegmente). Kein Tracking, keine Historie der Live-Lage, keine zusätzlich gespeicherten Personendaten; das Dedupe-Ledger enthält nur Auftrags-/Team-IDs und Prozentwerte.
- Sichtbarkeit: Backoffice sieht alle Kolonnen des Mandanten, Teamleiter nur ihre geleiteten Kolonnen (Aufträge außerhalb des Scopes als „Einsatz außerhalb Ihres Bereichs“), Monteure keinen Zugriff. Verzugs-/Früher-fertig-Meldungen gehen nur an Backoffice und den Teamleiter der Kolonne.
- Empfehlung vor Produktivbetrieb (Spec §44 „Umgang mit Standortdaten“): Information der Beschäftigten / ggf. Betriebsvereinbarung, da sichtbar ist, wer gerade an welchem Einsatzort arbeitet und ob ein Einsatz länger dauert.
- Geocoding überträgt nur Objektadressen an den Geocoding-Dienst; Kartenkacheln lädt der Browser vom Kachelserver (IP-Adresse des Nutzers wird übertragen) → Datenschutzhinweis bzw. eigener Kacheldienst.
## 3. OpenStreetMap-Nutzungsrichtlinien & Produktionsempfehlung
- **Nominatim** (<https://operations.osmfoundation.org/policies/nominatim/>): nur serverseitig (Worker/Skript), max. 1 Anfrage/s (Limiter + BullMQ-Limiter + Concurrency 1), eindeutiger User-Agent mit Kontakt-URL, Ergebnisse am Objekt gecacht, kein Massen-Geocoding im Request, keine Autocomplete-Suche. Limiter gilt je Prozess – bei mehreren Worker-Replikas nur eine mit `GEOCODING_PROVIDER=nominatim`.
- **Kacheln** (<https://operations.osmfoundation.org/policies/tiles/>): Namensnennung sichtbar, Browser-Referrer, kein Vorabladen; der öffentliche Kachelserver ist nicht für hohe Last gedacht.
- **Produktion:** eigener bzw. vertraglich gebundener Geocoding- und Kacheldienst (selbst gehostetes Nominatim/Photon + Tile-Server oder EU-Anbieter mit AVV). Umschalten per `GEOCODING_URL`/`GeocodingProvider`, `MAP_TILE_URL` + `MAP_ATTRIBUTION`. **`MAP_TILE_URL` muss beim `next build` gesetzt sein** (Kachel-Host in CSP `img-src`).
- Env (in `.env.example`): `GEOCODING_PROVIDER=nominatim|none`, `GEOCODING_URL`, `GEOCODING_USER_AGENT`, `MAP_TILE_URL` (Default `https://tile.openstreetmap.org/{z}/{x}/{y}.png`), `MAP_ATTRIBUTION` (Default „© OpenStreetMap-Mitwirkende“).
## 4. Datenmodell – Migration `20260915120000_planung`
Nur Spalten (RLS unverändert, keine neue Tabelle, keine TENANT_MODELS-/PII-Änderung):
- `Team.dailyCapacityMinutes Int @default(480)` (Kolonnen-Arbeitstag), `Team.workingDays Int @default(31)`.
- `OrderType.defaultDurationMinutes Int?` + Backfill der Standardarten (Montage 480, Reparatur 180, Wartung 120, Störung 120, Notdienst 120, Besichtigung 60, Abnahme 60, Nacharbeit 120); neue Mandanten über `DEFAULT_ORDER_TYPES`.
- `WorkOrder.plannedDurationMinutes Int?`.
- `Site.geocodedAt DateTime?`, `Site.geocodeStatus String?` (`ok|not_found|failed|skipped`), `Site.geocodeQuery String?`.
**Zeiterfassung (L12):** keine Schemaänderung durch L13. `services/planning/time-tracking.ts` nutzt `TimeEntry.source = tracked` (Live/Verzug), `TimeEntry.approvalStatus = approved` (tatsächliche Dauer abgeschlossener Aufträge) und `WorkSession.manual = false` – die Filter schalten sich automatisch ein, sobald der generierte Prisma-Client die L12-Felder kennt (vorher wirkungslos, damit die Lane gegen beide Schemata kompiliert). WorkSessions/TimeEntries werden nur gelesen.
## 5. Dateien
**Neu (Lane-Pfade):**
- `src/lib/geo/distance.ts`, `src/lib/planning/{days,capacity,text}.ts`
- `src/server/services/geo/{config,provider,nominatim,rate-limit,normalize,geocode-site,dispatch}.ts`
- `src/server/services/planning/{access,data,board,schedule,recommend,live,watch,summary,team-settings,time-tracking}.ts`
- `src/server/jobs/processors/{geocode-site,planning-watch}.ts`
- `src/server/actions/work_orders/planning.ts` (Kolonnenkapazität, `moduleGuard("work_orders")` + `guard("team:manage")`)
- `src/app/(app)/planning/{layout,page}.tsx`, `src/app/(app)/planning/live/page.tsx`
- `src/app/api/v1/planning/{board,schedule,recommendations,live}/route.ts`
- `src/components/planning/{planning-board,schedule-dialog,recommendations-panel,live-situation,live-map,live-tone,capacity-form,suggestions-section,conflicts-tile}.tsx|ts`
- `messages/{de,en}/planning.json`
- `prisma/migrations/20260915120000_planung/migration.sql`
- `scripts/test-planung-{core,recommend,live,geocode,watch}.ts`, `scripts/geocode-backfill.ts`
**Eingriffe außerhalb planning/geo (minimal, additiv):**
| Datei | Änderung |
|---|---|
| `prisma/schema.prisma` | 7 Spalten (s. §4) |
| `src/lib/work-orders/defaults.ts` | `defaultDurationMinutes` je Standard-Auftragsart |
| `src/server/services/sites/sites.ts` | je 1 Zeile nach Anlage/Adressänderung `requestSiteGeocoding` (+ 2 Importe) |
| `src/server/services/imports/confirm.ts` | 1 Zeile nach Bestätigung mit neuem Objekt (+ 1 Import) |
| `src/server/jobs/queues.ts` | Queue-Namen `geocode-site`, `planning-watch` + Job-Scheduler „alle 5 min“ in `scheduleRecurringJobs` |
| `src/server/jobs/processors/index.ts` | 2 Registrierungen |
| `scripts/craftvia-worker.ts` | Concurrency 1 + Limiter 1/s für `geocode-site` |
| `src/lib/events.ts` | Event-Typen `planning.capacity_freed`, `planning.overrun`, `planning.followup_at_risk` |
| `src/server/services/notifications/recipients.ts` | Regel für die 3 Events (Backoffice + Teamleiter, `mailToUsers: false`) + 3 Facts |
| `src/server/services/notifications/handle-event.ts` | Textvariablen `team`, `minutes`, `percent`, `blocker` |
| `messages/{de,en}/notifications.json` | Typ-Labels + Texte der 3 Events, Audit-Objektart `planning_alert` |
| `next.config.ts` | CSP `img-src` um Kachel-Host(s) aus `MAP_TILE_URL` (Default `https://tile.openstreetmap.org https://*.tile.openstreetmap.org`) |
| `src/lib/nav.ts`, `messages/{de,en}/nav.json` | „Planung“ + Unterpunkte „Plantafel“/„Live-Lage“; `NavItem.sub`/`exact` (optional) |
| `src/components/nav-link.tsx` | optionales `exact` (Unterpunkt nicht auf Unterpfaden aktiv) |
| `src/app/(app)/layout.tsx` | Einrückung für `sub`, eindeutiger `key`, `exact` durchgereicht (Hauptnavigation) |
| `src/app/(app)/dashboard/page.tsx` | Kacheln „Planung heute“ + „Konflikte diese Woche“ (1 Import + 1 Zeile) |
| `src/app/(app)/work-orders/[id]/page.tsx` | Abschnitt „Einsatz-Vorschläge“ auf der Übersicht (2 Importe + 1 Zeile) |
| `src/lib/api/openapi.ts`, `docs/craftvia/API.md` | 4 Planungs-Endpunkte + Tag „Planung“ |
| `scripts/smoke-auth.ts` | Planungsseiten/-API je Rolle |
| `.env.example`, `docs/craftvia/ABNAHME.md` | Env-Block; §3 Kalender/Live-Lage/Verzug/Empfehlungen, §4 Standortdaten |
| `package.json`, `package-lock.json` | neue Abhängigkeiten (s. §7) |
Nicht angefasst: `services/field/**`, `components/field/**`, `app/(field)/m/**`, `TimeEntry`/`WorkSession`-Schema, Zeit-Freigaben, `time.*`-Events (L12), `src/server/rbac.ts` (keine neuen Rechte).
## 6. Tests
| Skript | ✓ | Inhalt |
|---|---|---|
| `test-planung-core` | 92 | Haversine/Formatierung; Tage (Bitmaske, Zeitumstellung, heute + nächste Werktage, Werktage vor/zurück); Dauerregeln inkl. mehrtägig/Mitternacht; **Kolonnenkapazität** (aktive Mitglieder gültig ab/bis/inaktiv/doppelt, Kapazität nicht × Personen, 0 ohne Mitglieder, Wochenende); Auslastung; Konflikte rein + DB (overbooked 10 h bei 225 %, overlap, anschließend ≠ overlap, assignee_double_booked in beiden Zeilen, outside_working_days); **crew_incomplete** als Hinweis (nicht im Konfliktzähler, färbt keine Karte); Vereinigung von Arbeitssegmenten, Fahrzeitschätzung (50 km → 78 min, min. 10), Meldestufen 100/150/200 %; Mitgliedschaft „gültig bis“; Kolonnenkapazität setzen (Wirkung, Audit, Teamleiter forbidden, Mandant B not_found, invalid); Filter; Rollen (Teamleiter nur eigene Kolonne lesend, Monteur forbidden) und **Mandantentrennung**; Zeitraum-Validierung; Dashboard-Zähler; `scheduleWorkOrder`: Zuweisung + Termin + Dauer, Version, Konflikte im Ergebnis, **Event** `work_order.assigned`, **Audit**, Länge beim Verschieben, **Versionskonflikt** mit Klartext, **atomar** (Zuweisung, Statushistorie, Audit zurückgerollt), Rechte/Scope/Mandant B, ungültiges Datum, laufender Auftrag |
| `test-planung-recommend` | 27 | nächste Kolonne mit freier Kolonnenzeit (3 km, 4 h frei, Text, Beginn nach letztem Auftrag), Kolonne ohne Kapazität trotz größerer Nähe ausgeschlossen, außerhalb Radius ausgeschlossen, **nächster Kandidat mit Kapazität gewinnt**, „knapp“, Top 5, englischer Text, Radius-Hinweis; **ohne Koordinaten** → Hinweis, `not_found`, ohne Objekt; nahe ungeplante Aufträge; Rollen; **Mandantentrennung** (identische Koordinaten in B nie in A-Vorschlägen, B → not_found) |
| `test-planung-live` | 26 | Status je Person aus Session, „seit“, beendete Session zählt nicht, Standort = Objekt, ohne Ortsangabe, heute noch geplant, „Heute ohne Beginn“; **ein Eintrag je Kolonne** mit individuellen Mitgliederstatus und abweichendem Auftrag, Kolonne Süd Pause, Monteure gehören zur Kolonne; **keine Geräte-Koordinaten/Geräteinfo** im Ergebnis; Filter; Teamleiter nur eigene Kolonne; Monteur forbidden; Mandant B |
| `test-planung-watch` | 41 | Job/Queue registriert, L12-Adapter je Schema; **Kolonnenzeit vereinigt** (100 statt 140 min), 50 % ok, **83 % „Verzug droht“**, Folgeauftrag rechtzeitig (10:31 < 10:45) bzw. **gefährdet** (10:51 > 10:45, Fahrzeit 31 min für 20 km); Plantafel-Karten (Verzug, Dauer überschritten, gefährdet), Live-Lage Kolonne mit Verzug; **Meldungen**: keine bei 83 %, `planning.overrun` bei 108 %, **Dedupe**, erneut bei 150 %, nicht vor 200 %, `followup_at_risk` einmal je Tag, Ledger-Einträge; **Empfänger** Backoffice + Teamleiter der Kolonne, nicht Monteure/andere Teamleiter, **nur In-App**, Texte; Gefährdung durch **Tageskapazität** (Folgeauftrag ohne Uhrzeit); **früher fertig**: 2 h früher, 90 min ab jetzt, Vorziehen (passend ja, zu lang nein), Umkreis (1 km ja, fern nein), vorbelegter Beginn, **keine automatische Änderung**, Live-Lage-Anzeige, `capacity_freed` einmal + Dedupe + Text; Rollen (Monteur forbidden, anderer Teamleiter sieht nichts); **Mandantentrennung** (B: keine Auswertung/Meldungen/Ledger, Dedupe-Schlüssel in B blockiert A nicht) |
| `test-planung-geocode` | 39 | Normalisierung/Änderungserkennung; Nominatim-URL, User-Agent-Default/Override, Provider none, Kachel-Defaults; Provider mit injiziertem `fetch` (Treffer, Header, Limiter, not_found, HTTP-Fehler); **Drosselung** mit Fake-Uhr; `geocodeSite` mit Fake-Provider (ok + Audit, **Cache**, Neuverortung, **not_found**, failed → Retry, manuelle Koordinaten, none, unvollständig, Mandant B); Processor; Hook nie inline; **Nominatim nie aufgerufen** |
**Gesamt: 225 Prüfungen in 5 neuen Skripten.** Gate/RLS/Smoke: §8.
## 7. Neue Abhängigkeiten (begründet)
| Paket | Lizenz | Größe | Begründung |
|---|---|---|---|
| `leaflet` 1.9.4 | BSD-2-Clause | ~42 KB min+gz JS, 4 KB CSS | Kartenanzeige der Live-Lage (Nutzervorgabe); nur auf `/planning/live` per `next/dynamic` (`ssr: false`); Styles lokal, Marker inline SVG, keine CDN-Assets. |
| `@dnd-kit/core` 6.3.1 | MIT | ~15 KB min+gz | Drag & Drop der Plantafel (Nutzervorgabe, war nicht vorhanden); Pointer- und Keyboard-Sensor, Screenreader-Ansagen; nur auf `/planning`. |
| `@types/leaflet` 1.9.22 (dev) | MIT | – | Typen für tsc. |
## 8. Gate, RLS, Smoke
- **`npm run gate` grün** (Vordergrund): prisma generate, tsc, lint (0 Fehler; 3 bestehende Warnungen in `scripts/test-betrieb-api.ts`, `src/app/(app)/layout.tsx` `CraftviaLogo`, `src/server/services/field/mime.ts`), build inkl. Modul-Guard-Check (33 Action-Dateien), **70/70 Testskripte** (davon 5 neu, 225 Prüfungen).
- **`RLS_ENFORCED=true npm run test` grün: 70/70** (Lane-DB `craftvia_planung`, `RLS_DATABASE_URL` auf dieselbe DB).
- **Smoke** `BASE=http://localhost:3114 npx tsx scripts/smoke-auth.ts` gegen den Produktions-Build (`next start -p 3114`): **OK — 111 Prüfungen** (alle Rollen). Neu: Backoffice `/planning` (Standard 5 Tage: `data-planning-view="days"`, `data-day-count="5"`, „Heute“, Team Nord + Team Süd, Seitenleiste), `?view=today` (Kolonnen als Spalten, „Ganztägig“), `?view=week` (7 Tage), Prefill `?schedule=`, `/planning/live` (Datenschutzhinweis, OSM-Namensnennung, keine Start-Koordinaten), `GET /api/v1/planning/{board,live,recommendations}` (live ohne `startLat/startLng/deviceInfo`), Dashboard „Planung heute“ + „Konflikte diese Woche“; Teamleiter `/planning` + `/planning/live` nur Team Nord, „Nur Lesezugriff“; Monteur `/planning*` → 404; Mandant demo2 ohne Demo-Teams/-Monteure, Empfehlungen für Demo-Auftrag → 404. Renderzeiten im Produktions-Build 30–170 ms.
- Hinweis Dev-Server: `next dev` im Worktree blieb beim zweiten Start beim Kompilieren von `/dashboard` hängen (Turbopack wählt wegen mehrerer Lockfiles das Hauptrepo inkl. aller Worktrees als Workspace-Root); der erste Dev-Lauf renderte alle Planungsseiten fehlerfrei, der finale Smoke lief daher gegen `next start`.
- **Nicht durchgeführt:** visuelle Prüfung im Browser (Drag & Drop, Karte, Tagesansicht mit parallelen Kolonnen, 1024/768/375 px) – erfordert eine angemeldete Browsersitzung (Passwort-/Token-Eingabe durch den Agenten). Bitte manuell mit `backoffice@demo.example` prüfen. Demo-Objekte haben ohne Geocoding keine Koordinaten: `npx tsx scripts/geocode-backfill.ts --tenant=demo` (≈ 1 s je Objekt) oder Koordinaten am Objekt eintragen.
## 9. Bekannte Lücken / offene Punkte
1. **L12-Abgleich beim Merge:** Feldnamen `TimeEntry.source`/`approvalStatus`, `WorkSession.manual` sind gegen die Vorgabe implementiert (Adapter aktiviert sich automatisch); tatsächliche Enum-Werte (`tracked`, `approved`) und „eine Uhr je Nutzer“ beim Merge prüfen. Die Live-Lage liest `WorkSession.status` (`en_route|running|paused|ended`) – ändert L12 die Semantik, `SESSION_STATUS` in `services/planning/live.ts` nachziehen.
2. **Dedupe-Ledger im Audit-Log** (append-only, je Mandant): bei parallel laufenden Worker-Replikas sind vereinzelte Doppelmeldungen möglich (kein Unique-Index). Meldungen erscheinen im Audit-Viewer als „Planungshinweis“.
3. **Früher fertig/Verzug** nur für Aufträge mit Uhrzeit; „Folgeauftrag gefährdet“ betrachtet nur den nächsten Auftrag derselben Kolonne am selben Tag. Die Kolonnen-Marker zeigen den Auftrag des aktivsten Mitglieds; arbeitet eine Kolonne getrennt an zwei Orten, stehen abweichende Aufträge nur im Popup/in der Liste.
4. **Events in Transaktionen** (bekannt aus L10b): `scheduleWorkOrder` ruft `assignWorkOrder`/`updateWorkOrder` innerhalb `inTransaction`; In-App-Benachrichtigungen rollen mit zurück, eine eingereihte E-Mail kann trotzdem versendet werden.
5. **Auftragsart-Dauer** nicht in `/settings/order-types` editierbar (L2-Seite); Kolonnenkapazität nur im Plantafel-Popup, nicht im Teams-Formular (L1).
6. **Nominatim-Limiter je Prozess** (s. §3); Notdienst-Objekte (L8) und Altbestände werden nicht automatisch verortet → Backfill bzw. Adressänderung. Manuelle Koordinaten werden nie ersetzt (zum Neuverorten leeren).
7. **Mehrtägige Aufträge:** Kapazitätsanteil heuristisch; keine Überschneidungsprüfung für mehrtägige/ganztägige Aufträge. Heute-Ansicht: Aufträge vor 6 bzw. nach 20 Uhr am Rand. „5 Tage“ blendet Wochenendtage aus (Aufträge am Wochenende in der Wochenansicht).
8. **Ungeplant** umfasst zusätzlich zugewiesene/angenommene Aufträge ohne Termin. Seitenleiste max. 200, Plantafel max. 3 000 Aufträge je Zeitraum.
9. **Empfehlungen** nur Luftlinie (keine Routen, Spec §35) und nur Kolonnen mit geplantem Auftrag in der Nähe.
10. CSP-Erweiterung wird beim Build ausgewertet (`MAP_TILE_URL` vor `next build`). Dashboard-Kachel „Planung heute“ berechnet Plantafel + Live-Lage je Aufruf (für MVP-Größen unkritisch).
## 10. Routen
| Route | Inhalt |
|---|---|
| `/planning` | Plantafel: Standard 5 Werktage ab heute; `?view=today` (Kolonnen als Spalten), `?view=week`, `&date=YYYY-MM-DD`; Filter `teamId`/`orderTypeId`/`priority`; `?capacity=<teamId>` Popup; `?schedule=<id>&scheduleTeam=&day=&time=` vorbelegtes Einplan-Popover |
| `/planning/live` | Live-Lage (Kolonnen-Marker, Liste, früher fertig, Filter, Polling) |
| `GET /api/v1/planning/board` | Plantafel-Daten |
| `POST /api/v1/planning/schedule` | Einplanen |
| `GET /api/v1/planning/recommendations?workOrderId=` | Empfehlungen + nahe ungeplante Aufträge |
| `GET /api/v1/planning/live` | Live-Lage inkl. Kolonnen und „früher fertig“ |
| Job `planning-watch` | alle 5 min: Verzug/Überschreitung, gefährdete Folgeaufträge, früher fertig → Meldungen |
+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",
+18 -2
View File
@@ -48,7 +48,10 @@
"sync_failed": "Synchronisationsfehler",
"time_approval_requested": "Zeit zur Freigabe",
"time_approved": "Zeit freigegeben",
"time_rejected": "Zeit abgelehnt"
"time_rejected": "Zeit abgelehnt",
"planning_capacity_freed": "Früher fertig",
"planning_overrun": "Geplante Dauer überschritten",
"planning_followup_at_risk": "Folgeauftrag gefährdet"
},
"events": {
"work_order_assigned": {
@@ -130,6 +133,18 @@
"time_rejected": {
"title": "Zeit abgelehnt: {number}",
"message": "{actor} hat Ihre Zeit zu {number} abgelehnt: {reason}"
},
"planning_capacity_freed": {
"title": "{team} früher fertig: {number}",
"message": "{title} – {customer}: {minutes} min vor dem geplanten Ende abgeschlossen. Freie Zeit auf der Plantafel nutzen."
},
"planning_overrun": {
"title": "Geplante Dauer überschritten: {number}",
"message": "{team} bei {customer}: {percent} % der geplanten Dauer erfasst."
},
"planning_followup_at_risk": {
"title": "Folgeauftrag gefährdet: {number}",
"message": "{title} – {customer} ({team}) ist gefährdet durch Verzug {blocker}."
}
},
"fallback": {
@@ -244,7 +259,8 @@
"voice_note": "Sprachnotiz",
"notification": "Benachrichtigung",
"notification_settings": "Benachrichtigungseinstellungen",
"sync_operation": "Synchronisation"
"sync_operation": "Synchronisation",
"planning_alert": "Planungshinweis"
}
}
}
+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",
+18 -2
View File
@@ -48,7 +48,10 @@
"sync_failed": "Sync failed",
"time_approval_requested": "Time to approve",
"time_approved": "Time approved",
"time_rejected": "Time rejected"
"time_rejected": "Time rejected",
"planning_capacity_freed": "Finished early",
"planning_overrun": "Planned duration exceeded",
"planning_followup_at_risk": "Follow-up order at risk"
},
"events": {
"work_order_assigned": {
@@ -130,6 +133,18 @@
"time_rejected": {
"title": "Time rejected: {number}",
"message": "{actor} rejected your time for {number}: {reason}"
},
"planning_capacity_freed": {
"title": "{team} finished early: {number}",
"message": "{title} – {customer}: completed {minutes} min before the planned end. Use the free time on the planning board."
},
"planning_overrun": {
"title": "Planned duration exceeded: {number}",
"message": "{team} at {customer}: {percent} % of the planned duration recorded."
},
"planning_followup_at_risk": {
"title": "Follow-up order at risk: {number}",
"message": "{title} – {customer} ({team}) is at risk due to the delay of {blocker}."
}
},
"fallback": {
@@ -244,7 +259,8 @@
"voice_note": "Voice note",
"notification": "Notification",
"notification_settings": "Notification settings",
"sync_operation": "Sync"
"sync_operation": "Sync",
"planning_alert": "Planning alert"
}
}
}
+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",
@@ -0,0 +1,32 @@
-- L13 Planung: capacity planning + geocoding cache. Columns only (no new table → RLS unchanged,
-- no TENANT_MODELS change). No personal references (no DSGVO pii-fields change).
-- AlterTable
ALTER TABLE "order_types" ADD COLUMN "default_duration_minutes" INTEGER;
-- AlterTable
ALTER TABLE "sites" ADD COLUMN "geocode_query" TEXT,
ADD COLUMN "geocode_status" TEXT,
ADD COLUMN "geocoded_at" TIMESTAMP(3);
-- AlterTable
-- Team = crew (Kolonne): capacity is the crew's working day in minutes, not person-hours.
ALTER TABLE "teams" ADD COLUMN "daily_capacity_minutes" INTEGER NOT NULL DEFAULT 480,
ADD COLUMN "working_days" INTEGER NOT NULL DEFAULT 31;
-- AlterTable
ALTER TABLE "work_orders" ADD COLUMN "planned_duration_minutes" INTEGER;
-- Backfill: default durations of the standard order types (spec §10.2) for existing tenants.
UPDATE "order_types" SET "default_duration_minutes" = CASE "key"
WHEN 'montage' THEN 480
WHEN 'reparatur' THEN 180
WHEN 'wartung' THEN 120
WHEN 'stoerung' THEN 120
WHEN 'notdienst' THEN 120
WHEN 'besichtigung' THEN 60
WHEN 'abnahme' THEN 60
WHEN 'nacharbeit' THEN 120
END
WHERE "default_duration_minutes" IS NULL
AND "key" IN ('montage', 'reparatur', 'wartung', 'stoerung', 'notdienst', 'besichtigung', 'abnahme', 'nacharbeit');
+13
View File
@@ -472,6 +472,8 @@ model OrderType {
active Boolean @default(true)
signatureRequired Boolean @default(true) @map("signature_required")
sortOrder Int @default(0) @map("sort_order")
// L13 Planung: default duration for capacity planning when the order has no own duration
defaultDurationMinutes Int? @map("default_duration_minutes")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
@@ -597,6 +599,11 @@ model Site {
status SiteStatus @default(active)
latitude Float?
longitude Float?
// L13 Planung: geocoding cache (services/geo) — status ok | not_found | failed | skipped,
// geocodeQuery = normalized address the coordinates belong to (change detection)
geocodedAt DateTime? @map("geocoded_at")
geocodeStatus String? @map("geocode_status")
geocodeQuery String? @map("geocode_query")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
@@ -627,6 +634,10 @@ model Team {
vehicle String?
area String?
notes String?
// L13 Planung: a team is a crew (Kolonne) working together — capacity = crew working day in minutes
// (not person-hours); workingDays = bit mask Mon=1 … Sun=64 (31 = Mo–Fr)
dailyCapacityMinutes Int @default(480) @map("daily_capacity_minutes")
workingDays Int @default(31) @map("working_days")
createdAt DateTime @default(now()) @map("created_at")
updatedAt DateTime @updatedAt @map("updated_at")
deletedAt DateTime? @map("deleted_at")
@@ -700,6 +711,8 @@ model WorkOrder {
scope String? // Leistungsumfang
plannedStart DateTime? @map("planned_start")
plannedEnd DateTime? @map("planned_end")
// L13 Planung: explicit duration; fallback plannedStart/End → OrderType.defaultDurationMinutes → 120
plannedDurationMinutes Int? @map("planned_duration_minutes")
assignedTeamId String? @map("assigned_team_id")
teamLeadUserId String? @map("team_lead_user_id")
signatureRequired Boolean @default(true) @map("signature_required")
+3 -2
View File
@@ -18,8 +18,9 @@ async function main() {
continue;
}
const processor = await load();
const concurrency = name === JOB_QUEUES.reportPdf ? 2 : 4;
const w = new Worker<JobPayload>(name, async (job) => processor(job.data), { connection, concurrency });
const geocode = name === JOB_QUEUES.geocodeSite; // L13: OSM Nominatim policy — max. 1 request/s
const concurrency = name === JOB_QUEUES.reportPdf ? 2 : geocode ? 1 : 4;
const w = new Worker<JobPayload>(name, async (job) => processor(job.data), { connection, concurrency, ...(geocode ? { limiter: { max: 1, duration: 1_000 } } : {}) });
w.on("failed", (job, err) => console.error(`[worker] ${name} job ${job?.id} failed:`, err.message));
workers.push(w);
console.info(`[worker] listening on ${name}`);
+81
View File
@@ -0,0 +1,81 @@
/**
* L13 Planung — geocode sites that have no (or outdated) coordinates, respecting the OSM Nominatim
* usage policy: strictly sequential, max. 1 request per second (process-wide limiter in the
* provider), identifying User-Agent, results cached on the site (unchanged addresses are skipped).
* Manually entered coordinates are never overwritten.
*
* Usage:
* npx tsx scripts/geocode-backfill.ts # all tenants
* npx tsx scripts/geocode-backfill.ts --tenant=demo --limit=50
* npx tsx scripts/geocode-backfill.ts --dry-run # only list what would be geocoded
* npx tsx scripts/geocode-backfill.ts --retry-not-found # also retry addresses marked not_found
*
* For larger data sets use a self-hosted or contracted geocoding service (GEOCODING_URL).
*/
import "dotenv/config";
import { isValidLatLng } from "../src/lib/geo/distance";
import { dbForTenant, prisma } from "../src/server/db";
import { geocodingConfig } from "../src/server/services/geo/config";
import { geocodeSite, type GeocodeOutcome } from "../src/server/services/geo/geocode-site";
import { geocodeQueryFor } from "../src/server/services/geo/normalize";
const arg = (name: string) => process.argv.find((a) => a.startsWith(`--${name}=`))?.split("=")[1];
const flag = (name: string) => process.argv.includes(`--${name}`);
async function main() {
const cfg = geocodingConfig();
const dryRun = flag("dry-run");
const retryNotFound = flag("retry-not-found");
const limit = Number(arg("limit") ?? Infinity);
const slug = arg("tenant");
if (cfg.provider === "none" && !dryRun) {
console.log("GEOCODING_PROVIDER=none — nichts zu tun.");
return;
}
console.log(`Provider ${cfg.provider} (${cfg.url}), User-Agent "${cfg.userAgent}", ≤ 1 Anfrage/s${dryRun ? " — Probelauf" : ""}`);
const tenants = await prisma.tenant.findMany({ where: slug ? { slug } : {}, select: { id: true, slug: true }, orderBy: { slug: "asc" } });
const totals: Record<string, number> = {};
let done = 0;
for (const tenant of tenants) {
const db = dbForTenant(tenant.id);
const sites = await db.site.findMany({
where: { deletedAt: null },
select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, country: true, latitude: true, longitude: true, geocodedAt: true, geocodeStatus: true, geocodeQuery: true },
orderBy: { createdAt: "asc" },
});
const pending = sites.filter((s) => {
const query = geocodeQueryFor(s);
if (!query) return false;
if (isValidLatLng(s) && !s.geocodedAt) return false; // manual coordinates
if (s.geocodeQuery !== query) return true;
if (s.geocodeStatus === "ok") return false;
if (s.geocodeStatus === "not_found") return retryNotFound;
return true;
});
if (!pending.length) continue;
console.log(`\n${tenant.slug}: ${pending.length} Objekt(e)`);
for (const site of pending) {
if (done >= limit) break;
done++;
if (dryRun) {
console.log(` · ${site.name} (${[site.postalCode, site.city].filter(Boolean).join(" ")})`);
continue;
}
if (retryNotFound && site.geocodeStatus === "not_found") await db.site.update({ where: { id: site.id }, data: { geocodeStatus: null } });
const outcome: GeocodeOutcome = await geocodeSite(tenant.id, site.id);
totals[outcome] = (totals[outcome] ?? 0) + 1;
console.log(` ${outcome.padEnd(9)} ${site.name}`);
}
}
console.log(`\nFertig: ${done} Objekt(e) ${JSON.stringify(totals)}`);
}
main()
.catch((err) => {
console.error(err);
process.exitCode = 1;
})
.finally(async () => {
await prisma.$disconnect();
});
+19
View File
@@ -102,6 +102,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" },
],
},
{
@@ -120,6 +131,9 @@ function plans(x: Awaited<ReturnType<typeof ids>>): Plan[] {
{ path: "/m/time", mustContain: ["Meine Zeiten", "Zeit nachtragen"] },
{ path: "/m/approvals", mustContain: "Zeiten freigeben" },
{ path: "/m/profile", mustContain: "Zeiten freigeben" },
// 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"] },
],
},
{
@@ -155,6 +169,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] },
],
},
{
@@ -181,6 +197,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] },
],
},
];
+302
View File
@@ -0,0 +1,302 @@
// L13 Planung — Kern: Haversine, Arbeitstage/Kapazität (Mitgliedschaft gültig ab/bis), Auslastung,
// Konflikte (overbooked, overlap, assignee_double_booked, outside_working_days), Plantafel mit Rollen
// und Mandantentrennung, scheduleWorkOrder (Version, Scope, Recht, Event, Audit, atomar).
// Lauf: npx tsx scripts/test-planung-core.ts (eigene zz-Mandanten, räumt auf)
import "dotenv/config";
import { prisma } from "../src/server/db";
import { createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture";
import { formatDistance, haversineKm, isValidLatLng } from "../src/lib/geo/distance";
import { addDays, dayBounds, dayKeyOf, isDayKey, isWorkingDay, startOfWeek, weekdayIndex } from "../src/lib/planning/days";
import {
activeMemberCount,
assigneeDoubleBookings,
computeTeamDay,
effectiveDurationMinutes,
estimateTravelMinutes,
isConflict,
minutesOnDay,
overrunBucket,
teamCapacityMinutes,
unionMinutes,
utilizationPercent,
type PlanningOrder,
} from "../src/lib/planning/capacity";
import { addWorkingDays, nextWorkingDays } from "../src/lib/planning/days";
import { formatDayShort, formatMinutes } from "../src/lib/planning/text";
import { wallTimeToUtc } from "../src/lib/work-orders/time";
import { ServiceError } from "../src/server/services/context";
import { countWeekConflicts, getPlanningBoard } from "../src/server/services/planning/board";
import { scheduleWorkOrder, VERSION_CONFLICT_MESSAGE } from "../src/server/services/planning/schedule";
import { updateTeamPlanningSettings } from "../src/server/services/planning/team-settings";
import { assignWorkOrder } from "../src/server/services/work-orders/assign";
import { createWorkOrder } from "../src/server/services/work-orders/create";
import { ensureDefaultOrderTypes } from "../src/server/services/work-orders/settings";
import { transitionWorkOrder } from "../src/server/services/work-orders/transition";
const TZ = "Europe/Berlin";
const SLUG_A = "zz-planung-core-a";
const SLUG_B = "zz-planung-core-b";
const at = (day: string, hm: string) => wallTimeToUtc(`${day}T${hm}`, TZ)!;
const po = (p: Partial<PlanningOrder> & { id: string }): PlanningOrder => ({
teamId: null,
plannedStart: null,
plannedEnd: null,
plannedDurationMinutes: null,
orderTypeDefaultMinutes: null,
assigneeIds: [],
...p,
});
runSuite("L13 Planung – Kern", [SLUG_A, SLUG_B], async () => {
process.env.GEOCODING_PROVIDER = "none";
section("Haversine & Formatierung");
const hh = { latitude: 53.5503, longitude: 9.992 };
const ber = { latitude: 52.5163, longitude: 13.3777 };
const d = haversineKm(hh, ber);
ok(d > 250 && d < 262, `Hamburg–Berlin ≈ 255 km (${d.toFixed(1)} km)`);
ok(haversineKm(hh, hh) === 0, "gleicher Punkt → 0 km");
ok(Math.abs(haversineKm(hh, ber) - haversineKm(ber, hh)) < 1e-9, "symmetrisch");
const eq = haversineKm({ latitude: 0, longitude: 0 }, { latitude: 0, longitude: 1 });
ok(Math.abs(eq - 111.195) < 0.1, `1° Länge am Äquator ≈ 111,2 km (${eq.toFixed(3)})`);
ok(formatDistance(3.24, "de") === "3,2 km" && formatDistance(0.85, "de") === "850 m" && formatDistance(3.24, "en") === "3.2 km", "„3,2 km“ / „850 m“ / en „3.2 km“");
ok(!isValidLatLng({ latitude: null, longitude: 9 }) && !isValidLatLng({ latitude: 91, longitude: 0 }) && isValidLatLng(hh), "Koordinaten-Validierung");
ok(formatMinutes(240, "de") === "4 h" && formatMinutes(270, "de") === "4,5 h" && formatMinutes(45, "de") === "45 min", "Dauer „4 h“ / „4,5 h“ / „45 min“");
ok(formatDayShort("2026-09-15", "de") === "Di 15.09.", `Tag kurz „Di 15.09.“ (${formatDayShort("2026-09-15", "de")})`);
section("Tage, Dauer, Kapazität, Konflikte (reine Regeln)");
const mon = startOfWeek(addDays(dayKeyOf(new Date(), TZ), 14));
const tue = addDays(mon, 1);
const wed = addDays(mon, 2);
const sat = addDays(mon, 5);
ok(weekdayIndex(mon) === 0 && weekdayIndex(addDays(mon, 6)) === 6, "Wochentag Mo = 0 … So = 6");
ok(isWorkingDay(31, mon) && !isWorkingDay(31, sat) && isWorkingDay(31 | 32, sat), "Arbeitstage-Bitmaske (Mo–Fr, mit Sa)");
ok(isDayKey("2026-02-28") && !isDayKey("2026-02-30") && !isDayKey("28.02.2026"), "Tagesschlüssel validiert");
const dst = dayBounds("2026-10-25", TZ);
ok(dst.end.getTime() - dst.start.getTime() === 25 * 3600_000, "Zeitumstellung: 25-Stunden-Tag");
ok(effectiveDurationMinutes(po({ id: "a", plannedStart: at(mon, "08:00"), plannedEnd: at(mon, "09:00"), plannedDurationMinutes: 90, orderTypeDefaultMinutes: 480 }), TZ) === 90, "Dauer: explizit vor Beginn/Ende und Auftragsart");
ok(effectiveDurationMinutes(po({ id: "b", plannedStart: at(mon, "08:00"), plannedEnd: at(mon, "11:30"), orderTypeDefaultMinutes: 480 }), TZ) === 210, "Dauer: aus Beginn/Ende (3,5 h)");
ok(effectiveDurationMinutes(po({ id: "c", plannedStart: at(mon, "08:00"), orderTypeDefaultMinutes: 180 }), TZ) === 180, "Dauer: Standard der Auftragsart");
ok(effectiveDurationMinutes(po({ id: "d", plannedStart: at(mon, "08:00") }), TZ) === 120, "Dauer: Fallback 120 min");
const multi = po({ id: "e", plannedStart: at(mon, "08:00"), plannedEnd: at(tue, "16:00") });
ok(minutesOnDay(multi, mon, TZ) === 480 && minutesOnDay(multi, tue, TZ) === 480 && minutesOnDay(multi, wed, TZ) === 0, "mehrtägig ohne Dauer: je Tag 480 min");
ok(minutesOnDay({ ...multi, plannedDurationMinutes: 600 }, tue, TZ) === 300, "mehrtägig mit Dauer: gleichmäßig verteilt");
const dateOnly = po({ id: "f", plannedStart: at(mon, "00:00"), plannedEnd: at(tue, "00:00") });
ok(minutesOnDay(dateOnly, mon, TZ) === 120 && minutesOnDay(dateOnly, tue, TZ) === 0, "Ende um Mitternacht gehört zum Vortag");
const longAgo = new Date(Date.now() - 400 * 86400_000);
const team = {
id: "t",
dailyCapacityMinutes: 480,
workingDays: 31,
members: [
{ userId: "u1", validFrom: longAgo, validTo: null, active: true },
{ userId: "u2", validFrom: longAgo, validTo: dayBounds(mon, TZ).start, active: true }, // endet vor Montag
{ userId: "u3", validFrom: dayBounds(tue, TZ).start, validTo: null, active: true }, // ab Dienstag
{ userId: "u4", validFrom: longAgo, validTo: null, active: false }, // deaktiviert
{ userId: "u1", validFrom: longAgo, validTo: null, active: true }, // doppelte Zeile zählt einmal
],
};
ok(activeMemberCount(team, mon, TZ) === 1 && activeMemberCount(team, tue, TZ) === 2, "aktive Mitglieder: gültig ab/bis, inaktiv, doppelt (Mo 1, Di 2)");
ok(teamCapacityMinutes(team, mon, TZ) === 480 && teamCapacityMinutes(team, tue, TZ) === 480, "Kolonnenkapazität = Arbeitstag der Kolonne (480), nicht × Personen");
ok(teamCapacityMinutes(team, sat, TZ) === 0, "Kapazität Sa: kein Arbeitstag → 0");
ok(teamCapacityMinutes({ ...team, members: [] }, tue, TZ) === 0, "Kolonne ohne aktive Mitglieder → 0");
ok(utilizationPercent(360, 960) === 38 && utilizationPercent(60, 0) === null, "Auslastung 6 h / 16 h = 38 %; ohne Kapazität null");
const hintMon = computeTeamDay(team, mon, [], TZ).conflicts.find((c) => c.kind === "crew_incomplete");
ok(!!hintMon && hintMon.severity === "hint" && hintMon.memberCount === 1, "crew_incomplete: Hinweis bei weniger als 2 aktiven Personen");
ok(!computeTeamDay(team, tue, [], TZ).conflicts.some((c) => c.kind === "crew_incomplete") && !computeTeamDay(team, sat, [], TZ).conflicts.some((c) => c.kind === "crew_incomplete"), "kein Hinweis bei 2 Personen bzw. an Nicht-Arbeitstagen");
ok(nextWorkingDays("2026-09-18", 5).join(",") === "2026-09-18,2026-09-21,2026-09-22,2026-09-23,2026-09-24" && nextWorkingDays("2026-09-19", 2).join(",") === "2026-09-19,2026-09-21", "Standardansicht: heute + nächste Werktage (Wochenende übersprungen)");
ok(addWorkingDays("2026-09-21", -5) === "2026-09-14" && addWorkingDays("2026-09-18", 1) === "2026-09-21", "Werktage vor/zurück");
const crew = { ...team, dailyCapacityMinutes: 960 };
const orders = [
po({ id: "o1", teamId: "t", plannedStart: at(tue, "08:00"), plannedEnd: at(tue, "12:00") }),
po({ id: "o2", teamId: "t", plannedStart: at(tue, "11:00"), plannedEnd: at(tue, "13:00") }),
po({ id: "o3", teamId: "t", plannedStart: at(tue, "13:00"), plannedDurationMinutes: 600 }),
];
const td = computeTeamDay(crew, tue, orders, TZ);
ok(td.plannedMinutes === 960 && td.capacityMinutes === 960 && td.utilization === 100, "Tageslast 16 h bei 16 h Kolonnenkapazität = 100 %");
ok(!td.conflicts.some((c) => c.kind === "overbooked"), "genau ausgelastet ist nicht überbucht");
const overlaps = td.conflicts.filter((c) => c.kind === "overlap");
ok(overlaps.length === 1 && overlaps[0].orderIds.includes("o1") && overlaps[0].orderIds.includes("o2"), "Überschneidung o1/o2; o3 beginnt genau am Ende von o2 → keine");
const td2 = computeTeamDay(crew, tue, [...orders, po({ id: "o4", teamId: "t", plannedStart: at(tue, "00:00"), plannedDurationMinutes: 30 })], TZ);
ok(td2.conflicts.some((c) => c.kind === "overbooked" && c.minutesOver === 30), "überbucht um 30 min");
ok(td2.conflicts.filter((c) => c.kind === "overlap").length === 1, "Auftrag ohne Uhrzeit erzeugt keine Überschneidung");
const tdSat = computeTeamDay(crew, sat, [po({ id: "o5", teamId: "t", plannedStart: at(sat, "09:00") })], TZ);
ok(tdSat.conflicts.length === 1 && tdSat.conflicts[0].kind === "outside_working_days", "Auftrag am Samstag → outside_working_days (nicht zusätzlich überbucht)");
const dbl = assigneeDoubleBookings(
[
po({ id: "x1", teamId: "t", plannedStart: at(tue, "09:00"), plannedEnd: at(tue, "10:00"), assigneeIds: ["u1"] }),
po({ id: "x2", teamId: "s", plannedStart: at(tue, "09:30"), plannedEnd: at(tue, "11:00"), assigneeIds: ["u1"] }),
po({ id: "x3", teamId: "s", plannedStart: at(tue, "10:00"), plannedEnd: at(tue, "11:00"), assigneeIds: ["u3"] }),
po({ id: "x4", teamId: "s", plannedStart: at(wed, "09:00"), plannedEnd: at(wed, "11:00"), assigneeIds: ["u1"] }),
],
tue,
TZ,
);
ok(dbl.length === 1 && dbl[0].userId === "u1" && dbl[0].teamIds.length === 2, "Person in zwei Kolonnen gleichzeitig → assignee_double_booked (nur überlappende Zeitfenster)");
section("Kolonnenzeit, Fahrzeit, Überschreitungsstufen (rein)");
const hm = (v: string) => at(tue, v).getTime();
ok(unionMinutes([{ start: hm("08:00"), end: hm("10:00") }, { start: hm("09:00"), end: hm("11:00") }, { start: hm("12:00"), end: hm("13:00") }]) === 240, "Arbeitssegmente zeitlich vereinigt (3 h + 1 h), nicht über Personen summiert");
ok(unionMinutes([]) === 0 && unionMinutes([{ start: hm("10:00"), end: hm("09:00") }]) === 0, "leere/ungültige Segmente → 0");
ok(estimateTravelMinutes(null) === 10 && estimateTravelMinutes(1) === 10 && estimateTravelMinutes(50) === 78 && estimateTravelMinutes(20) === 31, "Fahrzeit = Luftlinie × 1,3 / 50 km/h, mind. 10 min (50 km → 78 min)");
ok(overrunBucket(0.99) === -1 && overrunBucket(1) === 0 && overrunBucket(1.49) === 0 && overrunBucket(1.5) === 1 && overrunBucket(2) === 2, "Meldestufen: 100 %, danach je weitere +50 %");
section("Plantafel (Datenbank)");
const A = await createTenant(SLUG_A);
const B = await createTenant(SLUG_B);
const bo = A.ctx.backoffice;
await ensureDefaultOrderTypes(bo.db, A.tenantId);
const montage = await prisma.orderType.findFirstOrThrow({ where: { tenantId: A.tenantId, key: "montage" } });
const besichtigung = await prisma.orderType.findFirstOrThrow({ where: { tenantId: A.tenantId, key: "besichtigung" } });
ok(montage.defaultDurationMinutes === 480 && besichtigung.defaultDurationMinutes === 60, "Standard-Auftragsarten mit Dauer (Montage 480, Besichtigung 60)");
const mk = (title: string, extra: Record<string, unknown> = {}) => createWorkOrder(bo, { title, customerId: A.customerId, siteId: A.siteId, ...extra });
const version = async (id: string) => (await prisma.workOrder.findUniqueOrThrow({ where: { id } })).version;
const place = async (id: string, teamId: string, start: Date, extra: { plannedEnd?: Date | null; plannedDurationMinutes?: number | null } = {}) =>
scheduleWorkOrder(bo, { workOrderId: id, teamId, plannedStart: start, baseVersion: await version(id), ...extra });
const w1 = await mk("Planung 1");
await assignWorkOrder(bo, { workOrderId: w1.id, teamId: A.teamId, userIds: [A.users.tech.id] });
await place(w1.id, A.teamId, at(mon, "08:00"), { plannedEnd: at(mon, "12:00") });
const w2 = await mk("Planung 2");
await place(w2.id, A.teamId, at(mon, "10:00"), { plannedEnd: at(mon, "14:00") });
const w3 = await mk("Planung 3");
await place(w3.id, A.teamId, at(mon, "14:00"), { plannedDurationMinutes: 600 });
const w4 = await mk("Planung Samstag");
await place(w4.id, A.team2Id, at(sat, "09:00"), { plannedEnd: at(sat, "11:00") });
const w5 = await mk("Planung Süd mit Max", { priority: "high" });
await assignWorkOrder(bo, { workOrderId: w5.id, teamId: A.team2Id, userIds: [A.users.tech.id] });
await place(w5.id, A.team2Id, at(mon, "09:00"), { plannedEnd: at(mon, "11:00") });
const draft = await mk("Noch ungeplant");
const board = await getPlanningBoard(bo, { from: mon, to: addDays(mon, 6) });
const nord = board.teams.find((t) => t.id === A.teamId)!;
const sued = board.teams.find((t) => t.id === A.team2Id)!;
const nMon = nord.days.find((x) => x.day === mon)!;
ok(board.days.length === 7 && board.timeZone === TZ, "Woche Mo–So in Mandanten-Zeitzone");
ok(nMon.capacityMinutes === 480 && nMon.plannedMinutes === 1080 && nMon.utilization === 225 && nMon.memberCount === 2, `Kolonne Nord Mo: 18 h / 8 h · 225 % (${nMon.plannedMinutes}/${nMon.capacityMinutes}/${nMon.utilization})`);
ok(nMon.conflicts.some((c) => c.kind === "overbooked" && c.minutesOver === 600), "Kolonne Nord Mo überbucht um 10 h");
ok(
!nMon.conflicts.some((c) => c.kind === "crew_incomplete") && sued.days.find((x) => x.day === mon)!.conflicts.some((c) => c.kind === "crew_incomplete" && c.severity === "hint" && c.memberCount === 1),
"Hinweis „Kolonne unvollständig“ nur bei Team Süd (1 Person)",
);
ok(nMon.conflicts.some((c) => c.kind === "overlap" && c.orderIds.includes(w1.id) && c.orderIds.includes(w2.id)), "Überschneidung zweier Aufträge desselben Teams");
ok(!nMon.conflicts.some((c) => c.kind === "overlap" && c.orderIds.includes(w3.id)), "anschließender Auftrag (14:00) überschneidet nicht");
ok(sued.days.find((x) => x.day === sat)!.conflicts.some((c) => c.kind === "outside_working_days" && c.orderIds.includes(w4.id)), "Samstag bei Team Süd → außerhalb der Arbeitstage");
const dblC = board.conflicts.find((c) => c.kind === "assignee_double_booked");
ok(!!dblC && dblC.userId === A.users.tech.id && dblC.orderIds.includes(w1.id) && dblC.orderIds.includes(w5.id) && dblC.teamIds.length === 2, "Monteur Nord + Einzelzuweisung Süd gleichzeitig → doppelt eingeplant");
ok(
nMon.conflicts.some((c) => c.kind === "assignee_double_booked") && sued.days.find((x) => x.day === mon)!.conflicts.some((c) => c.kind === "assignee_double_booked"),
"Doppelbuchung in beiden Teamzeilen sichtbar",
);
ok(board.orders[w1.id]?.conflictKinds.includes("overlap") && board.orders[w1.id].conflictKinds.includes("assignee_double_booked"), "Auftragskarte trägt ihre Konfliktarten");
ok(nMon.orderIds.length === 3 && board.orders[w3.id].durationMinutes === 600 && board.orders[w1.id].assigneeIds.includes(A.users.tech.id), "Zelle zeigt Aufträge mit Dauer und Einzelzuweisung");
ok(nord.days.find((x) => x.day === tue)!.utilization === 0 && nord.members.length === 2, "Di ohne Aufträge: 0 %; Mitglieder aufklappbar (2)");
ok(board.unplanned.some((o) => o.id === draft.id) && !board.unplanned.some((o) => o.id === w1.id), "Seitenleiste: ungeplanter Entwurf ja, eingeplanter Auftrag nein");
ok(board.conflictCount >= 4 && board.conflictCount === board.conflicts.filter(isConflict).length && board.conflicts.some((c) => c.severity === "hint"), `Konfliktzähler ohne Hinweise (${board.conflictCount})`);
ok(!board.orders[w4.id].conflictKinds.includes("crew_incomplete"), "Hinweise färben keine Auftragskarte rot");
ok(board.access.all && board.access.canSchedule, "Backoffice: alle Teams, darf einplanen");
await prisma.teamMember.updateMany({ where: { teamId: A.teamId, userId: A.users.tech2.id }, data: { validTo: dayBounds(tue, TZ).start } });
const b2 = await getPlanningBoard(bo, { from: mon, to: tue, teamIds: [A.teamId] });
ok(
b2.teams.length === 1 && b2.teams[0].days[0].memberCount === 2 && b2.teams[0].days[1].memberCount === 1 && b2.teams[0].days[1].capacityMinutes === 480 && b2.teams[0].days[1].conflicts.some((c) => c.kind === "crew_incomplete"),
"Mitgliedschaft „gültig bis Di“ → Di 1 Person: Kapazität bleibt, Hinweis Kolonne unvollständig",
);
await prisma.teamMember.updateMany({ where: { teamId: A.teamId, userId: A.users.tech2.id }, data: { validTo: null } });
const settings = await updateTeamPlanningSettings(A.ctx.admin, A.team2Id, { dailyCapacityMinutes: 360, workingDays: 63 });
const b3 = await getPlanningBoard(bo, { from: sat, to: sat, teamIds: [A.team2Id] });
ok(settings.workingDays === 63 && b3.teams[0].days[0].capacityMinutes === 360 && !b3.teams[0].days[0].conflicts.some((c) => c.kind === "outside_working_days"), "Kolonnenkapazität 6 h + Samstag als Arbeitstag wirkt sofort");
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "team", entityId: A.team2Id, action: "update" } })) === 1, "Kolonnenkapazität auditiert");
await expectCode(() => updateTeamPlanningSettings(A.ctx.lead, A.team2Id, { dailyCapacityMinutes: 1, workingDays: 1 }), "forbidden", "Kolonnenkapazität: Teamleiter → forbidden");
await expectCode(() => updateTeamPlanningSettings(B.ctx.admin, A.team2Id, { dailyCapacityMinutes: 1, workingDays: 1 }), "not_found", "Kolonnenkapazität: Mandant B → not_found");
await expectCode(() => updateTeamPlanningSettings(A.ctx.admin, A.team2Id, { dailyCapacityMinutes: 480, workingDays: 200 }), "invalid", "Kolonnenkapazität: ungültige Bitmaske → invalid");
const bf = await getPlanningBoard(bo, { from: mon, to: addDays(mon, 6), priority: "high" });
ok(!!bf.orders[w5.id] && !bf.orders[w1.id] && bf.teams.find((t) => t.id === A.teamId)!.days[0].plannedMinutes === 1080, "Filter Priorität blendet Karten aus, Auslastung bleibt vollständig");
ok(!bf.unplanned.some((o) => o.id === draft.id), "Filter wirkt auch auf die Seitenleiste");
section("Plantafel: Rollen & Mandantentrennung");
const leadBoard = await getPlanningBoard(A.ctx.lead, { from: mon, to: addDays(mon, 6) });
ok(leadBoard.teams.length === 1 && leadBoard.teams[0].id === A.teamId && !leadBoard.access.canSchedule, "Teamleiter: nur eigenes Team, nur lesend");
ok(!!leadBoard.orders[w1.id] && !leadBoard.orders[w5.id] && !leadBoard.unplanned.some((o) => o.id === draft.id), "Teamleiter: keine Aufträge anderer Teams, keine teamlosen Aufträge");
await expectCode(() => getPlanningBoard(A.ctx.tech, { from: mon, to: mon }), "forbidden", "Monteur → forbidden");
const bB = await getPlanningBoard(B.ctx.backoffice, { from: mon, to: addDays(mon, 6) });
ok(
bB.teams.every((t) => t.id !== A.teamId && t.id !== A.team2Id) && Object.keys(bB.orders).length === 0 && !bB.unplanned.some((o) => [w1.id, draft.id].includes(o.id)),
"Mandant B sieht keine Teams und Aufträge von A",
);
const bBforeign = await getPlanningBoard(B.ctx.backoffice, { from: mon, to: mon, teamIds: [A.teamId] });
ok(bBforeign.teams.length === 0, "Mandant B: fremde Team-ID liefert nichts");
await expectCode(() => getPlanningBoard(bo, { from: mon, to: addDays(mon, 60) }), "invalid", "Zeitraum > 42 Tage → invalid");
await expectCode(() => getPlanningBoard(bo, { from: tue, to: mon }), "invalid", "Ende vor Beginn → invalid");
ok((await countWeekConflicts(B.ctx.backoffice))?.count === 0, "Dashboard-Zähler Mandant B: 0 Konflikte diese Woche");
ok((await countWeekConflicts(A.ctx.tech)) === null, "Dashboard-Zähler für Monteur: keine Kachel");
section("scheduleWorkOrder");
const s1 = await mk("Einplanen Entwurf");
const s1before = await prisma.workOrder.findUniqueOrThrow({ where: { id: s1.id } });
const r1 = await scheduleWorkOrder(bo, { workOrderId: s1.id, teamId: A.teamId, plannedStart: at(mon, "15:00"), plannedDurationMinutes: 60, baseVersion: s1before.version });
const s1after = await prisma.workOrder.findUniqueOrThrow({ where: { id: s1.id } });
ok(
s1after.status === "assigned" && s1after.assignedTeamId === A.teamId && s1after.plannedStart?.getTime() === at(mon, "15:00").getTime() && s1after.plannedDurationMinutes === 60,
"Entwurf → zugewiesen mit Team, Beginn und Dauer",
);
ok(r1.version === s1after.version && s1after.version > s1before.version, "neue Version zurückgegeben");
ok(r1.conflicts.some((c) => c.kind === "overbooked") && r1.conflicts.every((c) => c.teamIds.includes(A.teamId)), "Antwort enthält Konflikte des Zielteams am Zieltag");
ok((await prisma.notification.count({ where: { tenantId: A.tenantId, entityId: s1.id, type: "work_order.assigned" } })) > 0, "Event work_order.assigned → Benachrichtigung ans Team");
const audits = await prisma.auditLog.findMany({ where: { tenantId: A.tenantId, entity: "work_order", entityId: s1.id } });
const planningAudit = audits.find((a) => (a.after as { source?: string } | null)?.source === "planning");
ok(!!planningAudit && planningAudit.actorId === A.users.backoffice.id && (planningAudit.before as { assignedTeamId?: string | null }).assignedTeamId === null, "Audit „planning“ mit before/after und Akteur");
const s2 = await mk("Verschieben");
await place(s2.id, A.teamId, at(tue, "08:00"), { plannedEnd: at(tue, "10:30") });
const v2 = await version(s2.id);
await scheduleWorkOrder(bo, { workOrderId: s2.id, teamId: A.team2Id, plannedStart: at(wed, "13:00"), baseVersion: v2 });
const s2after = await prisma.workOrder.findUniqueOrThrow({ where: { id: s2.id } });
ok(s2after.assignedTeamId === A.team2Id && s2after.plannedEnd?.getTime() === at(wed, "15:30").getTime(), "Verschieben in anderes Team/Tag behält die Länge (2,5 h)");
let conflictErr: unknown = null;
try {
await scheduleWorkOrder(bo, { workOrderId: s2.id, teamId: A.teamId, plannedStart: at(tue, "08:00"), baseVersion: v2 });
} catch (err) {
conflictErr = err;
}
ok(conflictErr instanceof ServiceError && conflictErr.code === "conflict" && conflictErr.message === VERSION_CONFLICT_MESSAGE, "veraltete Version → conflict „Auftrag wurde zwischenzeitlich geändert“");
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: s2.id } })).assignedTeamId === A.team2Id, "bei Konflikt nichts überschrieben");
const s3 = await mk("Atomar");
await expectCode(
() => scheduleWorkOrder(bo, { workOrderId: s3.id, teamId: A.teamId, plannedStart: at(tue, "12:00"), plannedEnd: at(tue, "09:00"), baseVersion: s3.version }),
"invalid",
"Ende vor Beginn → invalid (Prüfung im Auftragsservice nach der Zuweisung)",
);
const s3after = await prisma.workOrder.findUniqueOrThrow({ where: { id: s3.id } });
ok(s3after.status === "draft" && s3after.assignedTeamId === null && s3after.version === s3.version, "atomar: Zuweisung zurückgerollt (Entwurf, ohne Team, gleiche Version)");
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "work_order", entityId: s3.id, action: "update" } })) === 0, "atomar: kein Audit aus dem zurückgerollten Versuch");
ok((await prisma.workOrderStatusChange.count({ where: { tenantId: A.tenantId, workOrderId: s3.id, toStatus: "assigned" } })) === 0, "atomar: keine Statushistorie „zugewiesen“");
const s3input = { workOrderId: s3.id, teamId: A.teamId, plannedStart: at(tue, "12:00"), baseVersion: s3.version };
await expectCode(() => scheduleWorkOrder(A.ctx.lead, s3input), "forbidden", "Teamleiter darf nicht einplanen");
await expectCode(() => scheduleWorkOrder(A.ctx.tech, s3input), "forbidden", "Monteur darf nicht einplanen");
await expectCode(() => scheduleWorkOrder(B.ctx.backoffice, s3input), ["not_found", "invalid"], "Mandant B: Auftrag von A → abgewiesen");
const bOrder = await createWorkOrder(B.ctx.backoffice, { title: "B-Auftrag", customerId: B.customerId, siteId: B.siteId });
await expectCode(
() => scheduleWorkOrder(B.ctx.backoffice, { workOrderId: bOrder.id, teamId: A.teamId, plannedStart: at(tue, "08:00"), baseVersion: bOrder.version }),
"invalid",
"Mandant B: Team von A → invalid (team_not_found)",
);
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: s3.id } })).status === "draft", "Auftrag von A unverändert");
await expectCode(() => scheduleWorkOrder(bo, { ...s3input, plannedStart: "kein Datum" }), "invalid", "ungültiges Datum → invalid");
const s4 = await mk("Läuft schon");
await place(s4.id, A.teamId, at(tue, "08:00"));
await transitionWorkOrder(A.ctx.tech, { workOrderId: s4.id, to: "in_progress" });
const v4 = await version(s4.id);
await expectCode(() => scheduleWorkOrder(bo, { workOrderId: s4.id, teamId: A.team2Id, plannedStart: at(wed, "08:00"), baseVersion: v4 }), "invalid", "laufender Auftrag → invalid (not_schedulable)");
});
+178
View File
@@ -0,0 +1,178 @@
// L13 Planung — Geocoding: Normalisierung/Änderungserkennung, Nominatim-Anfrage (URL, User-Agent,
// Accept-Language, Auswertung, Fehler) mit injiziertem fetch, Drosselung 1/s mit Fake-Uhr, Processor mit
// Fake-Provider (Cache bei unveränderter Adresse, not_found, failed → Retry, manuelle Koordinaten,
// Provider none, Mandantentrennung), Hook beim Anlegen ohne Inline-Aufruf. Nominatim wird NIE aufgerufen.
// Lauf: npx tsx scripts/test-planung-geocode.ts
import "dotenv/config";
import { prisma } from "../src/server/db";
import { createTenant, ok, runSuite, section } from "./lib/e2e-fixture";
import { geocodingConfig, mapConfig } from "../src/server/services/geo/config";
import { requestSiteGeocoding } from "../src/server/services/geo/dispatch";
import { geocodeSite } from "../src/server/services/geo/geocode-site";
import { buildNominatimUrl, createNominatimProvider } from "../src/server/services/geo/nominatim";
import { addressChanged, geocodeQueryFor } from "../src/server/services/geo/normalize";
import { GeocodingError, setGeocodingProvider, type GeocodeAddress, type GeocodingProvider } from "../src/server/services/geo/provider";
import { createIntervalLimiter } from "../src/server/services/geo/rate-limit";
import { process as geocodeProcessor } from "../src/server/jobs/processors/geocode-site";
import { PROCESSORS } from "../src/server/jobs/processors";
import { createSite } from "../src/server/services/sites/sites";
const SLUG_A = "zz-planung-geo-a";
const SLUG_B = "zz-planung-geo-b";
// Guard: any request to a Nominatim host fails the suite.
let nominatimHits = 0;
const realFetch = globalThis.fetch;
globalThis.fetch = (async (input: RequestInfo | URL, init?: RequestInit) => {
const url = input instanceof Request ? input.url : String(input);
if (/nominatim|openstreetmap/i.test(url)) {
nominatimHits++;
throw new Error("Nominatim must never be called from tests");
}
return realFetch(input, init);
}) as typeof fetch;
function fakeProvider(answer: (a: GeocodeAddress) => { status: "ok"; latitude: number; longitude: number } | { status: "not_found" } | "throw") {
const calls: GeocodeAddress[] = [];
const provider: GeocodingProvider = {
name: "fake",
async geocode(a) {
calls.push(a);
const r = answer(a);
if (r === "throw") throw new GeocodingError("http 503");
return r;
},
};
return { provider, calls };
}
runSuite("L13 Planung – Geocoding", [SLUG_A, SLUG_B], async () => {
section("Normalisierung & Änderungserkennung");
const addr = { street: "Hafenstraße", houseNumber: "12", postalCode: "20457", city: "Hamburg", country: "DE" };
ok(geocodeQueryFor(addr) === "hafenstraße 12|20457|hamburg|de", `normierte Adresse (${geocodeQueryFor(addr)})`);
ok(geocodeQueryFor({ ...addr, street: " HAFENSTRASSE ".replace("SS", "ß"), city: "hamburg " }) === geocodeQueryFor(addr), "Groß-/Kleinschreibung und Leerzeichen egal");
ok(geocodeQueryFor({ street: "Hafenstraße", houseNumber: "12" }) === null, "ohne PLZ und Ort → nicht verortbar");
ok(!addressChanged(addr, { ...addr, city: "HAMBURG" }) && addressChanged(addr, { ...addr, houseNumber: "14" }), "nur relevante Adressänderungen zählen");
section("Nominatim-Anfrage (ohne Netz)");
const url = buildNominatimUrl("https://nominatim.example/", addr);
ok(
url.pathname === "/search" && url.searchParams.get("format") === "jsonv2" && url.searchParams.get("limit") === "1" && url.searchParams.get("street") === "12 Hafenstraße" && url.searchParams.get("postalcode") === "20457" && url.searchParams.get("city") === "Hamburg" && url.searchParams.get("countrycodes") === "de",
`strukturierte Suche (${url.search})`,
);
const cfg = geocodingConfig({ APP_BASE_URL: "https://app.craftvia.example" });
ok(/^Craftvia\/\d+\.\d+\.\d+ \(\+https:\/\/app\.craftvia\.example\)$/.test(cfg.userAgent) && cfg.url === "https://nominatim.openstreetmap.org" && cfg.provider === "nominatim", `Standard-User-Agent „${cfg.userAgent}“`);
ok(geocodingConfig({ GEOCODING_USER_AGENT: "Betrieb/1 (+mailto:it@example.org)", GEOCODING_PROVIDER: "none", GEOCODING_URL: "https://geo.example/" }).userAgent === "Betrieb/1 (+mailto:it@example.org)", "User-Agent per GEOCODING_USER_AGENT überschreibbar");
ok(geocodingConfig({ GEOCODING_PROVIDER: "none" }).provider === "none" && geocodingConfig({ GEOCODING_URL: "https://geo.example/" }).url === "https://geo.example", "Provider none / eigene GEOCODING_URL");
ok(mapConfig({}).tileUrl === "https://tile.openstreetmap.org/{z}/{x}/{y}.png" && mapConfig({}).attribution.includes("OpenStreetMap"), "Kartenkacheln: OSM-Standard mit Namensnennung");
const seen: { url: string; headers: Headers }[] = [];
let reply: () => Response = () => Response.json([{ lat: "53.5413", lon: "9.9849" }]);
const acquired: number[] = [];
const nominatim = createNominatimProvider(
{ url: "https://nominatim.example", userAgent: "Craftvia/test (+https://app.example)", timeoutMs: 1000 },
{
fetch: (async (input: RequestInfo | URL, init?: RequestInit) => {
seen.push({ url: String(input), headers: new Headers(init?.headers) });
return reply();
}) as typeof fetch,
limiter: { acquire: async () => (acquired.push(Date.now()), Date.now()) },
},
);
const hit = await nominatim.geocode(addr);
ok(hit.status === "ok" && hit.latitude === 53.5413 && hit.longitude === 9.9849, "Treffer ausgewertet");
ok(seen[0]?.headers.get("user-agent") === "Craftvia/test (+https://app.example)" && seen[0].headers.get("accept-language") === "de", "User-Agent und Accept-Language de gesetzt");
ok(acquired.length === 1, "jede Anfrage läuft über den Drosselungs-Limiter");
reply = () => Response.json([]);
ok((await nominatim.geocode(addr)).status === "not_found", "leere Antwort → not_found");
reply = () => new Response("busy", { status: 503 });
let threw = false;
try {
await nominatim.geocode(addr);
} catch (err) {
threw = err instanceof GeocodingError;
}
ok(threw, "HTTP-Fehler → GeocodingError (wiederholbar)");
section("Drosselung 1 Anfrage/s (Fake-Uhr)");
let clock = 10_000;
const sleeps: number[] = [];
const limiter = createIntervalLimiter(1000, { now: () => clock, sleep: async (ms) => void sleeps.push(ms) });
const slots = [await limiter.acquire(), await limiter.acquire(), await limiter.acquire()];
ok(slots[0] === 10_000 && slots[1] === 11_000 && slots[2] === 12_000 && sleeps.join(",") === "1000,2000", `Slots im Sekundenabstand (${slots.join(",")}), Wartezeiten ${sleeps.join(",")}`);
clock = 20_000;
ok((await limiter.acquire()) === 20_000 && sleeps.length === 2, "nach Pause sofort wieder frei");
section("geocodeSite mit Fake-Provider");
const A = await createTenant(SLUG_A);
const B = await createTenant(SLUG_B);
const fake = fakeProvider((a) => (a.city === "Nirgendwo" ? { status: "not_found" } : { status: "ok", latitude: 53.55, longitude: a.houseNumber === "14" ? 10.01 : 10.0 }));
setGeocodingProvider(fake.provider);
const site = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo 1", ...addr } });
ok((await geocodeSite(A.tenantId, site.id)) === "ok" && fake.calls.length === 1, "neue Adresse → ok, 1 Provider-Aufruf");
const s1 = await prisma.site.findUniqueOrThrow({ where: { id: site.id } });
ok(s1.latitude === 53.55 && s1.longitude === 10.0 && s1.geocodeStatus === "ok" && s1.geocodeQuery === geocodeQueryFor(addr) && !!s1.geocodedAt, "Koordinaten, Status, Adresse und Zeitpunkt am Objekt gespeichert");
ok((await prisma.auditLog.count({ where: { tenantId: A.tenantId, entity: "site", entityId: site.id, action: "update" } })) === 1, "Koordinatenänderung auditiert");
ok((await geocodeSite(A.tenantId, site.id)) === "cached" && fake.calls.length === 1, "unveränderte Adresse → Cache, kein weiterer Aufruf");
await prisma.site.update({ where: { id: site.id }, data: { city: "hamburg " } });
ok((await geocodeSite(A.tenantId, site.id)) === "cached" && fake.calls.length === 1, "nur Schreibweise geändert → Cache");
await prisma.site.update({ where: { id: site.id }, data: { houseNumber: "14" } });
ok((await geocodeSite(A.tenantId, site.id)) === "ok" && fake.calls.length === 2 && (await prisma.site.findUniqueOrThrow({ where: { id: site.id } })).longitude === 10.01, "geänderte Adresse → neu verortet");
await prisma.site.update({ where: { id: site.id }, data: { city: "Nirgendwo", postalCode: "99999" } });
ok((await geocodeSite(A.tenantId, site.id)) === "not_found" && fake.calls.length === 3, "unbekannte Adresse → not_found");
const s2 = await prisma.site.findUniqueOrThrow({ where: { id: site.id } });
ok(s2.geocodeStatus === "not_found" && s2.latitude === null && s2.longitude === null, "not_found entfernt veraltete automatische Koordinaten");
ok((await geocodeSite(A.tenantId, site.id)) === "cached" && fake.calls.length === 3, "not_found wird für dieselbe Adresse nicht wiederholt");
const failing = fakeProvider(() => "throw");
const siteFail = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo Fehler", ...addr } });
ok((await geocodeSite(A.tenantId, siteFail.id, { provider: failing.provider })) === "failed" && (await prisma.site.findUniqueOrThrow({ where: { id: siteFail.id } })).geocodeStatus === "failed", "Providerfehler → Status failed");
ok((await geocodeSite(A.tenantId, siteFail.id)) === "ok", "failed wird beim nächsten Lauf erneut versucht");
const manual = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo manuell", ...addr, latitude: 50.1, longitude: 8.6 } });
const callsBefore = fake.calls.length;
ok((await geocodeSite(A.tenantId, manual.id)) === "manual" && fake.calls.length === callsBefore, "manuelle Koordinaten → kein Aufruf");
const m = await prisma.site.findUniqueOrThrow({ where: { id: manual.id } });
ok(m.latitude === 50.1 && m.longitude === 8.6 && m.geocodeStatus === "skipped", "manuelle Koordinaten bleiben unverändert (Status skipped)");
const none = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo none", ...addr } });
ok((await geocodeSite(A.tenantId, none.id, { provider: null })) === "skipped" && (await prisma.site.findUniqueOrThrow({ where: { id: none.id } })).latitude === null, "Provider none → skipped, ohne Ortsangabe, kein Fehler");
const noAddr = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo ohne Adresse", street: "Irgendwo" } });
ok((await geocodeSite(A.tenantId, noAddr.id)) === "skipped" && fake.calls.length === callsBefore, "unvollständige Adresse → skipped ohne Aufruf");
const bCalls = fake.calls.length;
ok((await geocodeSite(B.tenantId, none.id)) === "missing" && fake.calls.length === bCalls, "Mandant B: Objekt von A nicht gefunden, kein Aufruf");
ok((await prisma.site.findUniqueOrThrow({ where: { id: none.id } })).geocodeStatus === "skipped", "Objekt von A unverändert");
section("Processor & Hook");
ok(typeof PROCESSORS["geocode-site"] === "function", "Processor geocode-site registriert");
const viaJob = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo Job", ...addr } });
await geocodeProcessor({ tenantId: A.tenantId, entityId: viaJob.id });
ok((await prisma.site.findUniqueOrThrow({ where: { id: viaJob.id } })).geocodeStatus === "ok", "Processor verortet das Objekt");
setGeocodingProvider(failing.provider);
const viaJobFail = await prisma.site.create({ data: { tenantId: A.tenantId, customerId: A.customerId, name: "Geo Job Fehler", ...addr } });
let jobThrew = false;
try {
await geocodeProcessor({ tenantId: A.tenantId, entityId: viaJobFail.id });
} catch {
jobThrew = true;
}
ok(jobThrew, "Processor wirft bei failed → BullMQ wiederholt");
setGeocodingProvider(fake.provider);
const prev = process.env.GEOCODING_PROVIDER;
process.env.GEOCODING_PROVIDER = "none";
ok((await requestSiteGeocoding(A.tenantId, viaJob.id)) === false, "GEOCODING_PROVIDER=none → kein Job");
process.env.GEOCODING_PROVIDER = "nominatim";
const hookCalls = fake.calls.length;
const created = await createSite(A.ctx.backoffice, { customerId: A.customerId, name: "Geo Hook", street: "Am Kaiserkai", houseNumber: "1", postalCode: "20457", city: "Hamburg" });
await new Promise((resolve) => setTimeout(resolve, 100));
ok(!!created.id && fake.calls.length === hookCalls, "Anlage eines Objekts: Verortung nur per Queue, nie inline im Request");
if (prev === undefined) delete process.env.GEOCODING_PROVIDER;
else process.env.GEOCODING_PROVIDER = prev;
setGeocodingProvider(undefined);
ok(nominatimHits === 0, "Nominatim wurde in keinem Test aufgerufen");
});
+127
View File
@@ -0,0 +1,127 @@
// L13 Planung — Live-Lage ohne GPS: Status aus der aktiven WorkSession (Unterwegs/In Arbeit/Pause/frei),
// „seit“ aus dem offenen Zeitabschnitt, Standort = Objekt des laufenden Auftrags, KEINE Geräte-Koordinaten
// im Ergebnis, Teamleiter nur eigenes Team, Monteur forbidden, Mandantentrennung, heutige Aufträge ohne Beginn.
// Lauf: npx tsx scripts/test-planung-live.ts
import "dotenv/config";
import { prisma } from "../src/server/db";
import { createTenant, expectCode, ok, runSuite, section } from "./lib/e2e-fixture";
import { dayBounds, dayKeyOf } from "../src/lib/planning/days";
import { getLiveSituation } from "../src/server/services/planning/live";
import { createWorkOrder } from "../src/server/services/work-orders/create";
const TZ = "Europe/Berlin";
const SLUG_A = "zz-planung-live-a";
const SLUG_B = "zz-planung-live-b";
const DEVICE_LAT = 12.3456789;
const DEVICE_LNG = 98.7654321;
const MIN = 60_000;
runSuite("L13 Planung – Live-Lage", [SLUG_A, SLUG_B], async () => {
process.env.GEOCODING_PROVIDER = "none";
const A = await createTenant(SLUG_A);
const B = await createTenant(SLUG_B);
const now = new Date();
const t = A.tenantId;
await prisma.site.update({ where: { id: A.siteId }, data: { latitude: 53.5413, longitude: 9.9849, geocodeStatus: "ok", geocodedAt: now } });
const noCoordSite = await prisma.site.create({ data: { tenantId: t, customerId: A.customerId, name: "Baustelle ohne Ortsangabe", city: "Hamburg" } });
const mk = async (title: string, data: Record<string, unknown>, siteId = A.siteId) => {
const wo = await createWorkOrder(A.ctx.backoffice, { title, customerId: A.customerId, siteId });
await prisma.workOrder.update({ where: { id: wo.id }, data });
return wo;
};
// tech: running on the site with coordinates; the session carries (test) device coordinates that must never leak.
const oRun = await mk("Laufender Einsatz", { status: "in_progress", assignedTeamId: A.teamId, plannedStart: new Date(now.getTime() - 120 * MIN) });
const sRun = await prisma.workSession.create({
data: { tenantId: t, workOrderId: oRun.id, userId: A.users.tech.id, teamId: A.teamId, status: "running", startedAt: new Date(now.getTime() - 120 * MIN), startLat: DEVICE_LAT, startLng: DEVICE_LNG, deviceInfo: "ZZ-Geraet-4711" },
});
await prisma.timeEntry.create({ data: { tenantId: t, workSessionId: sRun.id, userId: A.users.tech.id, type: "travel", startedAt: new Date(now.getTime() - 120 * MIN), endedAt: new Date(now.getTime() - 30 * MIN) } });
const workSince = new Date(now.getTime() - 30 * MIN);
await prisma.timeEntry.create({ data: { tenantId: t, workSessionId: sRun.id, userId: A.users.tech.id, type: "work", startedAt: workSince } });
// tech2: en route to a site without coordinates.
const oRoute = await mk("Anfahrt", { status: "en_route", assignedTeamId: A.teamId }, noCoordSite.id);
await prisma.workSession.create({ data: { tenantId: t, workOrderId: oRoute.id, userId: A.users.tech2.id, teamId: A.teamId, status: "en_route", startedAt: new Date(now.getTime() - 10 * MIN), startLat: DEVICE_LAT, startLng: DEVICE_LNG } });
// outsider (Team Süd): paused.
const oPause = await mk("Pausiert", { status: "paused", assignedTeamId: A.team2Id });
const sPause = await prisma.workSession.create({ data: { tenantId: t, workOrderId: oPause.id, userId: A.users.outsider.id, teamId: A.team2Id, status: "paused", startedAt: new Date(now.getTime() - 60 * MIN) } });
const pauseSince = new Date(now.getTime() - 5 * MIN);
await prisma.timeEntry.create({ data: { tenantId: t, workSessionId: sPause.id, userId: A.users.outsider.id, type: "break", startedAt: pauseSince } });
// an ended session must not count
await prisma.workSession.create({ data: { tenantId: t, workOrderId: oRun.id, userId: A.users.lead.id, teamId: A.teamId, status: "ended", startedAt: new Date(now.getTime() - 300 * MIN), endedAt: new Date(now.getTime() - 200 * MIN) } });
// today's orders without start
const today = dayBounds(dayKeyOf(now, TZ), TZ);
const early = new Date(Math.max(today.start.getTime() + MIN, now.getTime() - 60 * MIN));
const later = new Date(now.getTime() + 120 * MIN);
const canEarly = early < now;
const canLater = later < today.end;
const oNotAccepted = canEarly ? await mk("Nicht angenommen", { status: "assigned", assignedTeamId: A.teamId, plannedStart: early }) : null;
const oOverdue = canEarly ? await mk("Überfällig", { status: "accepted", assignedTeamId: A.teamId, plannedStart: early }) : null;
const oNext = canLater ? await mk("Heute später", { status: "assigned", assignedTeamId: A.teamId, plannedStart: later }) : null;
section("Status aus der WorkSession");
const live = await getLiveSituation(A.ctx.backoffice);
const byUser = (id: string) => live.technicians.find((x) => x.userId === id);
const tech = byUser(A.users.tech.id);
ok(tech?.status === "working" && tech.since === workSince.toISOString(), "Monteur mit laufender Session → In Arbeit, seit Beginn des offenen Abschnitts");
ok(tech?.current?.visible === true && tech.current.id === oRun.id && tech.current.latitude === 53.5413 && tech.current.longitude === 9.9849, "Standort = Koordinaten des Objekts des laufenden Auftrags");
ok(tech?.current?.visible === true && tech.current.number === oRun.number && tech.current.customerName.includes("Hausverwaltung") && !!tech.current.address, "aktueller Auftrag mit Nummer, Kunde und Adresse");
const tech2 = byUser(A.users.tech2.id);
ok(tech2?.status === "en_route" && tech2.current?.visible === true && tech2.current.latitude === null, "Unterwegs; Objekt ohne Koordinaten → ohne Ortsangabe");
ok(live.withoutLocation === 1, "Zähler „ohne Ortsangabe“ = 1");
const outsider = byUser(A.users.outsider.id);
ok(outsider?.status === "paused" && outsider.since === pauseSince.toISOString(), "Pause, seit Beginn der Pause");
const lead = byUser(A.users.lead.id);
ok(lead?.status === "free" && lead.current === null && lead.since === null, "ohne aktive Session (beendete zählt nicht) → frei");
ok(live.counts.working === 1 && live.counts.en_route === 1 && live.counts.paused === 1 && live.counts.free >= 2, `Zähler je Status ${JSON.stringify(live.counts)}`);
if (oNext) ok(!!tech?.next.some((n) => n.id === oNext.id) && !tech?.next.some((n) => n.id === oRun.id), "heute noch geplante Aufträge je Monteur");
if (oNotAccepted && oOverdue) {
ok(live.attention.some((a) => a.id === oNotAccepted.id && a.reason === "not_accepted") && live.attention.some((a) => a.id === oOverdue.id && a.reason === "overdue"), "heute ohne Beginn: nicht angenommen / Beginn überschritten");
ok(!live.attention.some((a) => a.id === oRun.id) && (!oNext || !live.attention.some((a) => a.id === oNext.id)), "laufende und spätere Aufträge nicht in „ohne Beginn“");
}
section("Kolonnen: ein Eintrag je Team");
const nordCrew = live.crews.find((c) => c.teamId === A.teamId);
const suedCrew = live.crews.find((c) => c.teamId === A.team2Id);
ok(
live.crews.filter((c) => c.teamId === A.teamId).length === 1 && nordCrew?.status === "working" && nordCrew.current?.id === oRun.id && nordCrew.current.latitude === 53.5413,
"Kolonne Nord: ein Eintrag am Einsatzort des laufenden Auftrags, Status In Arbeit",
);
const nordMembers = new Map(nordCrew?.members.map((m) => [m.userId, m]) ?? []);
ok(
nordMembers.get(A.users.tech.id)?.status === "working" && nordMembers.get(A.users.tech2.id)?.status === "en_route" && nordMembers.get(A.users.lead.id)?.status === "free" && nordMembers.get(A.users.tech2.id)?.orderNumber === oRoute.number,
"Mitglieder mit individuellem Status aus der eigenen Session",
);
ok(suedCrew?.status === "paused" && suedCrew.members.some((m) => m.userId === A.users.outsider.id && m.status === "paused"), "Kolonne Süd: Pause");
ok(live.technicians.find((x) => x.userId === A.users.tech.id)?.crewId === A.teamId, "Monteure mit Team gehören zu ihrer Kolonne (keine Einzelmarker)");
ok(nordCrew?.delay === null, "kein Verzug bei 30 min von 2 h");
section("Datenschutz: keine Geräte-Koordinaten");
for (const [label, result] of [
["Backoffice", live],
["Filter Status", await getLiveSituation(A.ctx.backoffice, { status: "en_route" })],
["Teamleiter", await getLiveSituation(A.ctx.lead)],
] as const) {
const json = JSON.stringify(result);
ok(!json.includes(String(DEVICE_LAT)) && !json.includes(String(DEVICE_LNG)) && !/startLat|startLng|deviceInfo|ZZ-Geraet/.test(json), `${label}: keine Start-Koordinaten/Geräteinfo im Ergebnis`);
}
section("Filter, Rollen, Mandantentrennung");
const onlyWorking = await getLiveSituation(A.ctx.backoffice, { status: "working" });
ok(onlyWorking.technicians.length === 1 && onlyWorking.technicians[0].userId === A.users.tech.id, "Filter Status „In Arbeit“");
const onlySued = await getLiveSituation(A.ctx.backoffice, { teamId: A.team2Id });
ok(onlySued.technicians.every((x) => x.teams.some((tm) => tm.id === A.team2Id)) && onlySued.technicians.some((x) => x.userId === A.users.outsider.id) && !onlySued.technicians.some((x) => x.userId === A.users.tech.id), "Filter Team Süd");
const leadView = await getLiveSituation(A.ctx.lead);
const leadUsers = leadView.technicians.map((x) => x.userId);
ok(leadUsers.includes(A.users.tech.id) && leadUsers.includes(A.users.tech2.id) && !leadUsers.includes(A.users.outsider.id) && !leadUsers.includes(A.users.lead2.id), "Teamleiter sieht nur sein Team");
ok(leadView.teams.length === 1 && leadView.teams[0].id === A.teamId && !leadView.attention.some((a) => a.teamName === "Team Süd"), "Teamleiter: nur eigenes Team in Auswahl und Hinweisen");
ok(leadView.crews.length === 1 && leadView.crews[0].teamId === A.teamId, "Teamleiter: nur die eigene Kolonne");
await expectCode(() => getLiveSituation(A.ctx.tech), "forbidden", "Monteur → forbidden");
const liveB = await getLiveSituation(B.ctx.backoffice);
const aUsers = Object.values(A.users).map((u) => u.id);
const jsonB = JSON.stringify(liveB);
ok(liveB.technicians.every((x) => !aUsers.includes(x.userId)) && !jsonB.includes(oRun.number) && !jsonB.includes(A.teamId), "Mandant B sieht keine Monteure, Aufträge oder Teams von A");
});
+134
View File
@@ -0,0 +1,134 @@
// L13 Planung — Einsatz-Empfehlungen (Luftlinie + Terminlage) und nahe ungeplante Aufträge:
// nächster Kandidat mit Kapazität gewinnt, Team ohne Kapazität ausgeschlossen, außerhalb des Radius
// ausgeschlossen, „knapp“ markiert, ohne Koordinaten → Hinweis, Rollen, Mandantentrennung.
// Lauf: npx tsx scripts/test-planung-recommend.ts
import "dotenv/config";
import { prisma } from "../src/server/db";
import { createTenant, expectCode, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture";
import { addDays, dayKeyOf, startOfWeek } from "../src/lib/planning/days";
import { wallTimeToUtc } from "../src/lib/work-orders/time";
import { findNearbyUnplanned, recommendAssignments } from "../src/server/services/planning/recommend";
import { scheduleWorkOrder } from "../src/server/services/planning/schedule";
import { createWorkOrder } from "../src/server/services/work-orders/create";
const TZ = "Europe/Berlin";
const SLUG_A = "zz-planung-rec-a";
const SLUG_B = "zz-planung-rec-b";
const at = (day: string, hm: string) => wallTimeToUtc(`${day}T${hm}`, TZ)!;
const TARGET = { latitude: 53.56, longitude: 10.0 };
const NEAR3 = { latitude: 53.587, longitude: 10.0 }; // ≈ 3,0 km
const VERY_NEAR = { latitude: 53.565, longitude: 10.005 }; // ≈ 0,65 km
const FAR = { latitude: 53.87, longitude: 10.69 }; // Lübeck, ≈ 57 km
async function site(f: TenantFixture, name: string, coords: { latitude: number; longitude: number } | null, geocodeStatus: string | null = coords ? "ok" : null) {
return prisma.site.create({
data: { tenantId: f.tenantId, customerId: f.customerId, name, street: "Teststraße", houseNumber: "1", postalCode: "20457", city: "Hamburg", ...(coords ?? {}), geocodeStatus, geocodedAt: coords ? new Date() : null },
});
}
async function order(f: TenantFixture, siteId: string, title: string, extra: Record<string, unknown> = {}) {
return createWorkOrder(f.ctx.backoffice, { title, customerId: f.customerId, siteId, ...extra });
}
async function place(f: TenantFixture, id: string, teamId: string, start: Date, plannedDurationMinutes: number) {
const { version } = await prisma.workOrder.findUniqueOrThrow({ where: { id } });
return scheduleWorkOrder(f.ctx.backoffice, { workOrderId: id, teamId, plannedStart: start, plannedDurationMinutes, baseVersion: version });
}
runSuite("L13 Planung – Empfehlungen", [SLUG_A, SLUG_B], async () => {
process.env.GEOCODING_PROVIDER = "none"; // never contact Nominatim from tests
const A = await createTenant(SLUG_A);
const B = await createTenant(SLUG_B);
const bo = A.ctx.backoffice;
const D1 = startOfWeek(addDays(dayKeyOf(new Date(), TZ), 14));
const D2 = addDays(D1, 1);
const D3 = addDays(D1, 2);
// Third team "Ost" (1 member) far away.
const ost = await prisma.team.create({ data: { tenantId: A.tenantId, name: "Team Ost", leaderUserId: A.users.admin.id } });
await prisma.teamMember.create({ data: { tenantId: A.tenantId, teamId: ost.id, userId: A.users.admin.id, validFrom: new Date(Date.now() - 30 * 86400_000) } });
const sTarget = await site(A, "Ziel", TARGET);
const sNear3 = await site(A, "Nachbar 3 km", NEAR3);
const sVeryNear = await site(A, "Nachbar 650 m", VERY_NEAR);
const sFar = await site(A, "Lübeck", FAR);
section("Kandidaten");
const nordD1 = await order(A, sNear3.id, "Nord D1 3 km");
await place(A, nordD1.id, A.teamId, at(D1, "08:00"), 240); // Kolonne Nord: 480 − 240 = 240 frei
const suedD1 = await order(A, sVeryNear.id, "Süd D1 voll");
await place(A, suedD1.id, A.team2Id, at(D1, "08:00"), 480); // Süd: 480 − 480 = 0 frei
const ostD1 = await order(A, sFar.id, "Ost D1 Lübeck");
await place(A, ostD1.id, ost.id, at(D1, "08:00"), 120);
const target = await order(A, sTarget.id, "Zielauftrag", { plannedDurationMinutes: undefined });
const r1 = await recommendAssignments(bo, { workOrderId: target.id, from: D1, days: 3 });
ok(r1.status === "ok" && r1.requiredMinutes === 120, "Status ok, benötigte Dauer 2 h (Fallback)");
const top = r1.recommendations[0];
ok(!!top && top.teamId === A.teamId && top.day === D1 && top.nearOrder.id === nordD1.id, "Team Nord am D1 (Auftrag in 3 km) wird vorgeschlagen");
ok(!!top && top.distanceKm > 2.5 && top.distanceKm < 3.5 && top.freeMinutes === 240 && !top.tight, `Begründung strukturiert: ${top?.distanceKm} km, 4 h freie Kolonnenzeit`);
ok(!!top && top.text.includes("km von") && top.text.includes(nordD1.number) && top.text.includes("Team Nord") && top.text.includes("noch 4 h frei"), `Begründungstext: „${top?.text}“`);
ok(!r1.recommendations.some((r) => r.teamId === A.team2Id), "Kolonne Süd ohne freie Kapazität ausgeschlossen (obwohl näher)");
ok(!r1.recommendations.some((r) => r.teamId === ost.id), "Team Ost außerhalb des Radius ausgeschlossen");
ok(!!top && new Date(top.suggestedStart).getTime() === at(D1, "12:00").getTime(), "Vorgeschlagener Beginn direkt nach dem letzten Auftrag des Tages (12:00)");
const nordD2 = await order(A, sVeryNear.id, "Nord D2 650 m");
await place(A, nordD2.id, A.teamId, at(D2, "08:00"), 120);
const nordD3 = await order(A, sNear3.id, "Nord D3 fast voll");
await place(A, nordD3.id, A.teamId, at(D3, "07:00"), 420); // 480 − 420 = 60 min frei < 120 → knapp
const r2 = await recommendAssignments(bo, { workOrderId: target.id, from: D1, days: 3 });
ok(r2.recommendations[0]?.teamId === A.teamId && r2.recommendations[0]?.day === D2 && r2.recommendations[0].nearOrder.id === nordD2.id, "nächster Kandidat mit Kapazität gewinnt (650 m am D2 vor 3 km am D1)");
const tight = r2.recommendations.find((r) => r.day === D3);
ok(!!tight && tight.tight && tight.text.includes("knapp") && r2.recommendations.indexOf(tight) > 0, "knappe Kapazität markiert und nachrangig");
ok(r2.recommendations.length <= 5 && r2.recommendations.every((r, i, arr) => i === 0 || arr[i - 1].score >= r.score), "höchstens 5, nach Score sortiert");
const r2en = await recommendAssignments(bo, { workOrderId: target.id, from: D1, days: 3, locale: "en" });
ok(r2en.recommendations[0]?.text.includes("from") && r2en.recommendations[0].text.includes("free"), `englischer Text: „${r2en.recommendations[0]?.text}“`);
const r3 = await recommendAssignments(bo, { workOrderId: target.id, from: D1, days: 3, radiusKm: 0.5 });
ok(r3.recommendations.length === 0 && !!r3.hint && r3.hint.includes("0.5 km"), `kleiner Radius → keine Vorschläge + Hinweis („${r3.hint}“)`);
section("Ohne Koordinaten");
const sNone = await site(A, "Ohne Koordinaten", null);
const noCoords = await order(A, sNone.id, "Auftrag ohne Verortung");
const r4 = await recommendAssignments(bo, { workOrderId: noCoords.id, from: D1, days: 3 });
ok(r4.status === "no_coordinates" && r4.recommendations.length === 0 && !!r4.hint?.includes("nicht verortet"), `ohne Koordinaten → Hinweis „${r4.hint}“`);
const sNotFound = await site(A, "Unbekannte Adresse", null, "not_found");
const notFound = await order(A, sNotFound.id, "Adresse nicht gefunden");
const r5 = await recommendAssignments(bo, { workOrderId: notFound.id, from: D1, days: 3 });
ok(r5.status === "no_coordinates" && !!r5.hint?.includes("nicht gefunden"), "Adresse nicht gefunden → eigener Hinweis");
const withoutSite = await createWorkOrder(bo, { title: "Ohne Objekt", customerId: A.customerId });
ok((await recommendAssignments(bo, { workOrderId: withoutSite.id })).status === "no_coordinates", "Auftrag ohne Objekt → no_coordinates");
ok((await findNearbyUnplanned(bo, { workOrderId: noCoords.id })).status === "no_coordinates", "Umkreis ohne Koordinaten → no_coordinates");
section("Nahe ungeplante Aufträge");
const n1 = await order(A, sVeryNear.id, "Ungeplant nah");
const n2 = await order(A, sFar.id, "Ungeplant fern");
const nearby = await findNearbyUnplanned(bo, { workOrderId: target.id, radiusKm: 5 });
ok(nearby.items.some((i) => i.id === n1.id) && !nearby.items.some((i) => i.id === n2.id) && !nearby.items.some((i) => i.id === target.id), "Umkreis 5 km: nahen Auftrag ja, fernen und sich selbst nein");
ok(nearby.items.every((i, idx, arr) => idx === 0 || arr[idx - 1].distanceKm <= i.distanceKm) && (nearby.items.find((i) => i.id === n1.id)?.distanceKm ?? 99) < 1, "nach Entfernung sortiert (650 m)");
section("Rollen & Mandantentrennung");
await expectCode(() => recommendAssignments(A.ctx.lead, { workOrderId: target.id }), "forbidden", "Teamleiter → forbidden");
await expectCode(() => recommendAssignments(A.ctx.tech, { workOrderId: target.id }), "forbidden", "Monteur → forbidden");
await expectCode(() => findNearbyUnplanned(A.ctx.tech, { workOrderId: target.id }), "forbidden", "Umkreis: Monteur → forbidden");
await expectCode(() => recommendAssignments(B.ctx.backoffice, { workOrderId: target.id }), "not_found", "Mandant B: Auftrag von A → not_found");
await expectCode(() => findNearbyUnplanned(B.ctx.backoffice, { workOrderId: target.id }), "not_found", "Mandant B: Umkreis für Auftrag von A → not_found");
// B has a team with free capacity and an order at identical coordinates + an unplanned order nearby.
const bVeryNear = await site(B, "B nah", VERY_NEAR);
const bTargetSite = await site(B, "B Ziel", TARGET);
const bPlanned = await order(B, bVeryNear.id, "B geplant");
await place(B, bPlanned.id, B.teamId, at(D1, "08:00"), 60);
const bUnplanned = await order(B, bVeryNear.id, "B ungeplant");
const aAgain = await recommendAssignments(bo, { workOrderId: target.id, from: D1, days: 3 });
const aNearby = await findNearbyUnplanned(bo, { workOrderId: target.id, radiusKm: 5 });
ok(aAgain.recommendations.every((r) => r.teamId !== B.teamId && r.nearOrder.id !== bPlanned.id), "A: nie Teams/Aufträge von Mandant B in Vorschlägen");
ok(!aNearby.items.some((i) => i.id === bUnplanned.id), "A: nie ungeplante Aufträge von Mandant B im Umkreis");
const bTarget = await order(B, bTargetSite.id, "B Zielauftrag");
const bRec = await recommendAssignments(B.ctx.backoffice, { workOrderId: bTarget.id, from: D1, days: 3 });
const aTeamIds = [A.teamId, A.team2Id, ost.id];
ok(bRec.recommendations.length > 0 && bRec.recommendations.every((r) => !aTeamIds.includes(r.teamId) && r.nearOrder.id === bPlanned.id), "B: Vorschläge nur aus eigenen Teams/Aufträgen");
const bNear = await findNearbyUnplanned(B.ctx.backoffice, { workOrderId: bTarget.id, radiusKm: 5 });
ok(bNear.items.length === 1 && bNear.items[0].id === bUnplanned.id, "B: Umkreis nur eigene ungeplante Aufträge");
});
+176
View File
@@ -0,0 +1,176 @@
// L13 Planung — Verzug und „früher fertig“: erfasste Kolonnenzeit (vereinigt), Warnung ab 80 %, Meldung ab
// 100 % mit Dedupe und erneuter Meldung je weitere +50 %, gefährdeter Folgeauftrag (Beginn inkl. Fahrzeit,
// Tageskapazität) mit Dedupe, freie Kolonnenzeit bei früherem Abschluss + Vorzieh-/Umkreis-Vorschläge,
// Event planning.capacity_freed, Empfänger (Backoffice + Teamleiter, nur In-App), Plantafel/Live-Lage,
// Rollen, Mandantentrennung, L12-Adapter. Alle Zeiten relativ zu „heute 12:00“ (unabhängig von der Uhrzeit).
// Lauf: npx tsx scripts/test-planung-watch.ts
import "dotenv/config";
import { prisma } from "../src/server/db";
import { berlinAt, createTenant, expectCode, ok, runSuite, section, type TenantFixture } from "./lib/e2e-fixture";
import { dayKeyOf } from "../src/lib/planning/days";
import { getPlanningBoard } from "../src/server/services/planning/board";
import { getLiveSituation } from "../src/server/services/planning/live";
import { approvedEntryWhere, TIME_TRACKING_FIELDS, trackedEntryWhere, trackedSessionWhere } from "../src/server/services/planning/time-tracking";
import { auditAlertLedger, evaluateDelays, getFreedCapacity, runPlanningWatch, watchContext } from "../src/server/services/planning/watch";
import { PROCESSORS } from "../src/server/jobs/processors";
import { JOB_QUEUES } from "../src/server/jobs/queues";
import { createWorkOrder } from "../src/server/services/work-orders/create";
const TZ = "Europe/Berlin";
const SLUG_A = "zz-planung-watch-a";
const SLUG_B = "zz-planung-watch-b";
const T = (hh: number, mm = 0) => berlinAt(0, hh, mm);
const S1 = { latitude: 53.55, longitude: 10.0 };
const S2 = { latitude: 53.73, longitude: 10.0 }; // ≈ 20 km nördlich
const S3 = { latitude: 53.559, longitude: 10.0 }; // ≈ 1 km
const FAR = { latitude: 53.87, longitude: 10.69 };
async function site(f: TenantFixture, name: string, c: { latitude: number; longitude: number } | null) {
return prisma.site.create({ data: { tenantId: f.tenantId, customerId: f.customerId, name, city: "Hamburg", postalCode: "20457", ...(c ?? {}), geocodeStatus: c ? "ok" : null, geocodedAt: c ? new Date() : null } });
}
async function order(f: TenantFixture, siteId: string | null, title: string, data: Record<string, unknown>) {
const wo = await createWorkOrder(f.ctx.backoffice, { title, customerId: f.customerId, ...(siteId ? { siteId } : {}) });
await prisma.workOrder.update({ where: { id: wo.id }, data });
return wo;
}
async function session(f: TenantFixture, workOrderId: string, userId: string, teamId: string, status: "running" | "paused" | "en_route" | "ended", startedAt: Date, endedAt: Date | null = null) {
return prisma.workSession.create({ data: { tenantId: f.tenantId, workOrderId, userId, teamId, status, startedAt, endedAt } });
}
async function entry(f: TenantFixture, workSessionId: string, userId: string, startedAt: Date, endedAt: Date | null, type: "work" | "break" | "travel" = "work") {
return prisma.timeEntry.create({ data: { tenantId: f.tenantId, workSessionId, userId, type, startedAt, endedAt } });
}
const alerts = (f: TenantFixture) => prisma.auditLog.findMany({ where: { tenantId: f.tenantId, entity: "planning_alert" }, select: { entityId: true } });
runSuite("L13 Planung – Verzug & früher fertig", [SLUG_A, SLUG_B], async () => {
process.env.GEOCODING_PROVIDER = "none";
const A = await createTenant(SLUG_A);
const B = await createTenant(SLUG_B);
const today = dayKeyOf(T(12), TZ);
section("Job & Adapter");
ok(JOB_QUEUES.planningWatch === "planning-watch" && typeof PROCESSORS["planning-watch"] === "function", "Queue planning-watch + Processor registriert");
ok(
Object.keys(trackedEntryWhere).length === (TIME_TRACKING_FIELDS.entrySource ? 1 : 0) &&
Object.keys(approvedEntryWhere).length === (TIME_TRACKING_FIELDS.entryApproval ? 1 : 0) &&
Object.keys(trackedSessionWhere).length === (TIME_TRACKING_FIELDS.sessionManual ? 1 : 0),
`L12-Filter aktiv je nach Schema (source=${TIME_TRACKING_FIELDS.entrySource}, approvalStatus=${TIME_TRACKING_FIELDS.entryApproval}, manual=${TIME_TRACKING_FIELDS.sessionManual})`,
);
// Kolonne Nord (tech + tech2): laufender Auftrag, 2 h geplant.
const s1 = await site(A, "Baustelle 1", S1);
const s2 = await site(A, "Baustelle 20 km", S2);
const oRun = await order(A, s1.id, "Laufend Nord", { status: "in_progress", assignedTeamId: A.teamId, plannedStart: T(8), plannedDurationMinutes: 120 });
const sessTech = await session(A, oRun.id, A.users.tech.id, A.teamId, "running", T(8));
await entry(A, sessTech.id, A.users.tech.id, T(8), T(9));
await entry(A, sessTech.id, A.users.tech.id, T(9), T(9, 30), "break");
await entry(A, sessTech.id, A.users.tech.id, T(9, 30), null);
const sessTech2 = await session(A, oRun.id, A.users.tech2.id, A.teamId, "running", T(8, 30));
await entry(A, sessTech2.id, A.users.tech2.id, T(8, 30), null);
const oNext = await order(A, s2.id, "Folgeauftrag Nord", { status: "assigned", assignedTeamId: A.teamId, plannedStart: T(10, 45), plannedDurationMinutes: 60 });
section("Verzug: Kolonnenzeit und 80 %");
const sys = watchContext(A.tenantId);
const e900 = await evaluateDelays(sys, { now: T(9, 0) });
ok(e900.delays.find((d) => d.workOrderId === oRun.id)?.level === "ok" && e900.delays.find((d) => d.workOrderId === oRun.id)?.workedMinutes === 60, "09:00: 60 min (50 %) → kein Verzug");
const e940 = await evaluateDelays(sys, { now: T(9, 40) });
const d940 = e940.delays.find((d) => d.workOrderId === oRun.id);
ok(d940?.workedMinutes === 100, `erfasste Kolonnenzeit vereinigt 08:00–09:40 = 100 min (summiert wären 140) — ${d940?.workedMinutes}`);
ok(d940?.level === "risk" && d940.percent === 83 && d940.remainingMinutes === 20, "83 % → Verzug droht, Restzeit 20 min");
ok(!e940.followups.some((f) => f.workOrderId === oNext.id), "09:40 + 20 min Rest + 31 min Fahrt (20 km) = 10:31 < Beginn 10:45 → Folgeauftrag nicht gefährdet");
const e1020 = await evaluateDelays(sys, { now: T(10, 20) });
const f1020 = e1020.followups.find((f) => f.workOrderId === oNext.id);
ok(!!f1020 && f1020.reason === "start" && f1020.travelMinutes === 31 && f1020.blockedByNumber === oRun.number, "10:20: Rest 0 + 31 min Fahrt = 10:51 > 10:45 → Folgeauftrag gefährdet (Beginn)");
const board = await getPlanningBoard(A.ctx.backoffice, { from: today, to: today }, { now: T(9, 40) });
ok(board.orders[oRun.id]?.delay?.level === "risk" && board.orders[oRun.id].delay?.percent === 83 && !board.orders[oNext.id]?.atRisk, "Plantafel 09:40: Karte „Verzug droht · 83 %“, Folgeauftrag noch nicht gefährdet");
const board1020 = await getPlanningBoard(A.ctx.backoffice, { from: today, to: today }, { now: T(10, 20) });
ok(board1020.orders[oRun.id]?.delay?.level === "overrun" && board1020.orders[oNext.id]?.atRisk?.byNumber === oRun.number, "Plantafel 10:20: „Dauer überschritten“ + Folgeauftrag „gefährdet durch Verzug …“");
const live940 = await getLiveSituation(A.ctx.backoffice, {}, { now: T(9, 40) });
ok(live940.crews.find((c) => c.teamId === A.teamId)?.delay?.level === "risk", "Live-Lage: Kolonne Nord mit Verzugswarnung");
section("Meldungen: 100 %, Dedupe, +50 %");
const r940 = await runPlanningWatch(A.tenantId, { now: T(9, 40) });
ok(r940.overrun === 0 && r940.followupAtRisk === 0, `09:40: keine Meldung bei 83 % (${JSON.stringify(r940)})`);
const r1010 = await runPlanningWatch(A.tenantId, { now: T(10, 10) });
ok(r1010.overrun === 1 && r1010.followupAtRisk === 0, "10:10: 130 min = 108 % → planning.overrun");
const r1020 = await runPlanningWatch(A.tenantId, { now: T(10, 20) });
ok(r1020.overrun === 0 && r1020.followupAtRisk === 1, "10:20: keine erneute Überschreitungsmeldung, 1 × Folgeauftrag gefährdet");
ok((await runPlanningWatch(A.tenantId, { now: T(10, 25) })).followupAtRisk === 0, "Folgeauftrag gefährdet wird je Auftrag/Tag nur einmal gemeldet");
ok((await runPlanningWatch(A.tenantId, { now: T(10, 40) })).overrun === 0, "10:40 (133 %): keine erneute Meldung");
ok((await runPlanningWatch(A.tenantId, { now: T(11, 0) })).overrun === 1, "11:00: 180 min = 150 % → erneute Meldung (+50 %)");
ok((await runPlanningWatch(A.tenantId, { now: T(11, 10) })).overrun === 0, "11:10: keine weitere Meldung vor 200 %");
const keys = (await alerts(A)).map((a) => a.entityId);
ok(keys.includes(`planning.overrun:${oRun.id}:0`) && keys.includes(`planning.overrun:${oRun.id}:1`) && keys.includes(`planning.followup_at_risk:${oNext.id}:${today}`), "Dedupe-Einträge im Audit-Protokoll (planning_alert)");
const overrunNotes = await prisma.notification.findMany({ where: { tenantId: A.tenantId, type: "planning.overrun", entityId: oRun.id }, select: { userId: true, emailedAt: true, title: true, message: true } });
const noteUsers = new Set(overrunNotes.map((n) => n.userId));
ok(noteUsers.has(A.users.backoffice.id) && noteUsers.has(A.users.admin.id) && noteUsers.has(A.users.lead.id), "Empfänger: Backoffice + Teamleiter der Kolonne");
ok(!noteUsers.has(A.users.tech.id) && !noteUsers.has(A.users.lead2.id), "nicht: Monteure, Teamleiter anderer Kolonnen");
ok(overrunNotes.every((n) => n.emailedAt === null) && overrunNotes.some((n) => n.message.includes("150 %") && n.title.includes(oRun.number)), "nur In-App (keine E-Mail), Text mit Prozent und Nummer");
const followNotes = await prisma.notification.findMany({ where: { tenantId: A.tenantId, type: "planning.followup_at_risk", entityId: oNext.id }, select: { message: true } });
ok(followNotes.length > 0 && followNotes.every((n) => n.message.includes(oRun.number)), "Folgeauftrag-Meldung nennt den verursachenden Auftrag");
section("Folgeauftrag gefährdet durch Tageskapazität");
await prisma.team.update({ where: { id: A.team2Id }, data: { dailyCapacityMinutes: 180 } });
const oSud = await order(A, null, "Laufend Süd", { status: "in_progress", assignedTeamId: A.team2Id, plannedStart: T(7), plannedDurationMinutes: 120 });
const sessSud = await session(A, oSud.id, A.users.outsider.id, A.team2Id, "running", T(7));
await entry(A, sessSud.id, A.users.outsider.id, T(7), null);
const oSudNext = await order(A, null, "Folgeauftrag Süd ohne Uhrzeit", { status: "assigned", assignedTeamId: A.team2Id, plannedStart: berlinAt(0, 0), plannedDurationMinutes: 60 });
const eSud = await evaluateDelays(sys, { now: T(9, 40) });
const fSud = eSud.followups.find((f) => f.workOrderId === oSudNext.id);
ok(!!fSud && fSud.reason === "capacity" && fSud.travelMinutes === 10, "160 min statt 120 + Folgeauftrag 60 + 10 min Fahrt > 180 min Kolonnentag → gefährdet (Kapazität)");
section("Früher fertig: freie Kolonnenzeit und Vorschläge");
const ost = await prisma.team.create({ data: { tenantId: A.tenantId, name: "Team Ost", leaderUserId: A.users.lead.id } });
await prisma.teamMember.createMany({ data: [A.users.admin.id, A.users.backoffice.id].map((userId) => ({ tenantId: A.tenantId, teamId: ost.id, userId, validFrom: new Date(Date.now() - 30 * 86400_000) })) });
const s3 = await site(A, "Nachbar 1 km", S3);
const sFar = await site(A, "Lübeck", FAR);
const oDone = await order(A, s1.id, "Früher fertig Ost", { status: "technically_completed", assignedTeamId: ost.id, plannedStart: T(8), plannedDurationMinutes: 240 });
await session(A, oDone.id, A.users.admin.id, ost.id, "ended", T(8), T(10));
const oLater = await order(A, s2.id, "Ost später heute", { status: "assigned", assignedTeamId: ost.id, plannedStart: T(14), plannedDurationMinutes: 60 });
const oTooLong = await order(A, s2.id, "Ost zu lang", { status: "assigned", assignedTeamId: ost.id, plannedStart: T(15), plannedDurationMinutes: 180 });
const oNear = await order(A, s3.id, "Ungeplant nah", { status: "draft", plannedDurationMinutes: 60 });
const oFar = await order(A, sFar.id, "Ungeplant fern", { status: "draft", plannedDurationMinutes: 60 });
const freed = await getFreedCapacity(A.ctx.backoffice, { now: T(10, 30) });
const fo = freed.teams.find((x) => x.teamId === ost.id);
ok(!!fo && fo.earlyMinutes === 120 && fo.availableMinutes === 90 && fo.availableFrom === T(10, 30).toISOString(), "Ost: 2 h früher fertig (Ende 10:00 statt 12:00), 90 min ab jetzt frei");
ok(!freed.teams.some((x) => x.teamId === A.teamId), "laufende Kolonne Nord nicht als früher fertig");
const kinds = new Map(fo?.suggestions.map((s) => [s.order.id, s]) ?? []);
ok(kinds.get(oLater.id)?.kind === "pull_forward" && !kinds.has(oTooLong.id), "Vorziehen: späterer Auftrag der Kolonne, der in die freie Zeit passt (nicht der zu lange)");
ok(kinds.get(oNear.id)?.kind === "nearby_unplanned" && (kinds.get(oNear.id)?.distanceKm ?? 99) < 2 && !kinds.has(oFar.id), "ungeplanter Auftrag im Umkreis (1 km) ja, ferner nein");
ok(kinds.get(oLater.id)?.suggestedStart === T(10, 30).toISOString() && kinds.get(oLater.id)?.day === today, "Vorschlag vorbelegt ab 10:30 heute — keine automatische Änderung");
ok((await prisma.workOrder.findUniqueOrThrow({ where: { id: oLater.id } })).plannedStart?.getTime() === T(14).getTime(), "geplanter Auftrag bleibt unverändert");
const live1030 = await getLiveSituation(A.ctx.backoffice, {}, { now: T(10, 30) });
ok(live1030.freed.some((x) => x.teamId === ost.id && x.suggestions.length >= 2), "Live-Lage zeigt „früher fertig“ mit Vorschlägen");
const rFreed = await runPlanningWatch(A.tenantId, { now: T(10, 30) });
ok(rFreed.capacityFreed === 1, "planning.capacity_freed einmal gemeldet");
ok((await runPlanningWatch(A.tenantId, { now: T(10, 50) })).capacityFreed === 0, "Dedupe je Kolonne/Tag/Auftrag");
const freedNotes = await prisma.notification.findMany({ where: { tenantId: A.tenantId, type: "planning.capacity_freed", entityId: oDone.id }, select: { userId: true, message: true } });
ok(freedNotes.some((n) => n.userId === A.users.lead.id) && freedNotes.some((n) => n.message.includes("120 min")), "Meldung an Teamleiter der Kolonne mit 120 min");
section("Rollen & Mandantentrennung");
await expectCode(() => getFreedCapacity(A.ctx.tech, { now: T(10, 30) }), "forbidden", "Monteur: früher fertig → forbidden");
const leadFreed = await getFreedCapacity(A.ctx.lead2, { now: T(10, 30) });
ok(leadFreed.teams.length === 0, "Teamleiter Süd sieht die Kolonne Ost nicht");
const bSys = watchContext(B.tenantId);
const bEval = await evaluateDelays(bSys, { now: T(11, 0) });
ok(bEval.delays.length === 0 && bEval.followups.length === 0, "Mandant B: keine Aufträge/Sessions von A in der Auswertung");
ok((await getFreedCapacity(B.ctx.backoffice, { now: T(10, 30) })).teams.length === 0, "Mandant B: kein „früher fertig“ von A");
const rB = await runPlanningWatch(B.tenantId, { now: T(11, 0) });
ok(rB.overrun + rB.followupAtRisk + rB.capacityFreed === 0 && (await alerts(B)).length === 0, "Mandant B: keine Meldungen, keine Dedupe-Einträge");
ok((await prisma.notification.count({ where: { tenantId: B.tenantId, type: { startsWith: "planning." } } })) === 0, "Mandant B: keine Planungs-Benachrichtigungen");
// Ein Dedupe-Schlüssel in B blockiert A nicht (Ledger je Mandant).
const oRun2 = await order(A, s1.id, "Laufend Nord 2", { status: "in_progress", assignedTeamId: A.teamId, plannedStart: T(6), plannedDurationMinutes: 60 });
const sess2 = await session(A, oRun2.id, A.users.lead.id, A.teamId, "running", T(6));
await entry(A, sess2.id, A.users.lead.id, T(6), null);
ok(await auditAlertLedger.claim(bSys, `planning.overrun:${oRun2.id}:0`, {}), "B beansprucht (künstlich) den Schlüssel eines A-Auftrags");
ok((await runPlanningWatch(A.tenantId, { now: T(11, 20) })).overrun >= 1, "A meldet trotzdem (Dedupe mandantengetrennt)");
});
+2
View File
@@ -17,6 +17,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";
@@ -164,6 +165,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")}
+24
View File
@@ -0,0 +1,24 @@
import { requireApiContext } from "@/server/api/context";
import { json, withApi } from "@/server/api/respond";
import { getPlanningBoard, weekRange } from "@/server/services/planning/board";
/**
* GET /api/v1/planning/board?from=YYYY-MM-DD&to=YYYY-MM-DD&teamId=…&orderTypeId=…&priority=…
* Default range: current week (Mon–Sun, tenant timezone), max. 42 days. Backoffice: all teams;
* team leads: own teams (read-only). Access is checked in the service.
*/
export const GET = withApi(async (req: Request) => {
const ctx = await requireApiContext("work_orders");
const sp = new URL(req.url).searchParams;
const week = await weekRange(ctx, sp.get("from"));
const teamIds = sp.getAll("teamId").filter(Boolean);
return json(
await getPlanningBoard(ctx, {
from: sp.get("from") ?? week.from,
to: sp.get("to") ?? week.to,
teamIds: teamIds.length ? teamIds : undefined,
orderTypeId: sp.get("orderTypeId") || undefined,
priority: (sp.get("priority") || undefined) as "low" | "normal" | "high" | "urgent" | undefined,
}),
);
});
+19
View File
@@ -0,0 +1,19 @@
import { requireApiContext } from "@/server/api/context";
import { json, withApi } from "@/server/api/respond";
import { getLiveSituation, LIVE_STATUSES, type LiveStatus } from "@/server/services/planning/live";
/**
* GET /api/v1/planning/live?teamId=…&status=en_route|working|paused|free — polled every 30 s by
* /planning/live. Location = site of the running order; never device coordinates.
*/
export const GET = withApi(async (req: Request) => {
const ctx = await requireApiContext("work_orders");
const sp = new URL(req.url).searchParams;
const status = sp.get("status");
return json(
await getLiveSituation(ctx, {
teamId: sp.get("teamId") || undefined,
status: status && (LIVE_STATUSES as readonly string[]).includes(status) ? (status as LiveStatus) : undefined,
}),
);
});
@@ -0,0 +1,25 @@
import { requireApiContext } from "@/server/api/context";
import { ApiError, json, withApi } from "@/server/api/respond";
import { findNearbyUnplanned, recommendAssignments } from "@/server/services/planning/recommend";
/**
* GET /api/v1/planning/recommendations?workOrderId=…&days=10&radiusKm=25&nearbyRadiusKm=5&locale=de
* Suggestions only (straight-line distance + free capacity); nothing is saved.
*/
export const GET = withApi(async (req: Request) => {
const ctx = await requireApiContext("work_orders", "work_order:assign");
const sp = new URL(req.url).searchParams;
const workOrderId = sp.get("workOrderId");
if (!workOrderId) throw new ApiError("invalid", "workOrderId required");
const locale = sp.get("locale") === "en" ? "en" : "de";
const [recommendations, nearby] = await Promise.all([
recommendAssignments(ctx, {
workOrderId,
days: sp.get("days") ?? undefined,
radiusKm: sp.get("radiusKm") ?? undefined,
locale,
}),
findNearbyUnplanned(ctx, { workOrderId, radiusKm: sp.get("nearbyRadiusKm") ?? undefined }),
]);
return json({ ...recommendations, nearby });
});
+15
View File
@@ -0,0 +1,15 @@
import { requireApiContext } from "@/server/api/context";
import { json, readJsonObject, withApi } from "@/server/api/respond";
import { scheduleWorkOrder, type ScheduleInput } from "@/server/services/planning/schedule";
/**
* POST /api/v1/planning/schedule — body { workOrderId, teamId, plannedStart, plannedEnd?, plannedDurationMinutes?, baseVersion }.
* Assign + dates in one transaction (L2 services, event work_order.assigned, audit). 409 conflict when
* the order changed meanwhile ("Auftrag wurde zwischenzeitlich geändert"). Response contains the
* conflicts of the target team on the target day(s).
*/
export const POST = withApi(async (req: Request) => {
const ctx = await requireApiContext("work_orders", "work_order:assign");
const body = await readJsonObject(req);
return json(await scheduleWorkOrder(ctx, body as ScheduleInput));
});
+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>
);
}
+134
View File
@@ -1106,6 +1106,139 @@ const paths: Record<string, Record<string, Schema>> = {
responses: { "200": binaryResponse("Datei"), ...errors("not_found") },
}),
},
// ---------- L13 Planung ----------
"/planning/board": {
get: op({
tag: "Planung",
operationId: "getPlanningBoard",
summary: "Plantafel: Aufträge je Team und Tag mit Kapazität, Auslastung und Konflikten",
description:
"Standard-Zeitraum: aktuelle Woche (Mo–So, Mandanten-Zeitzone), maximal 42 Tage. Teams sind Kolonnen: Kapazität = `dailyCapacityMinutes` je Arbeitstag (nicht je Person). `work_order:read_all` sieht alle Teams (Einplanen mit `work_order:assign` + `work_order:write`); Teamleiter (`work_order:read_team` + Leitung eines Teams) nur eigene Teams, lesend; sonst 403. Konflikte (`severity: conflict`): `overbooked`, `overlap`, `assignee_double_booked`, `outside_working_days`; Hinweis (`severity: hint`, nicht in `conflictCount`): `crew_incomplete` (< 2 aktive Personen). Liegt heute im Zeitraum, tragen Karten `delay` (≥ 80 % erfasste Kolonnenzeit) bzw. `atRisk` (Folgeauftrag gefährdet). Filter `orderTypeId`/`priority` wirken auf die angezeigten Karten, nicht auf die Auslastung.",
module: "work_orders",
permissions: [],
parameters: [
query("from", str({ format: "date" }), "Erster Tag (YYYY-MM-DD)"),
query("to", str({ format: "date" }), "Letzter Tag (YYYY-MM-DD)"),
{ name: "teamId", in: "query", required: false, schema: arr(str()), style: "form", explode: true, description: "mehrfach möglich" },
query("orderTypeId", str()),
query("priority", str({ enum: ["low", "normal", "high", "urgent"] })),
],
responses: {
"200": jsonResponse(
"Plantafel",
open(
{
from: str({ format: "date" }),
to: str({ format: "date" }),
timeZone: str(),
days: arr(obj({ key: str({ format: "date" }), weekday: int({ minimum: 0, maximum: 6 }) })),
teams: arr(open({ id: str(), name: str(), dailyCapacityMinutes: int({ description: "Kolonnen-Arbeitstag in Minuten" }), workingDays: int({ description: "Bitmaske Mo=1 … So=64" }), days: arr(open({ day: str(), memberCount: int(), capacityMinutes: int(), plannedMinutes: int(), utilization: { type: ["integer", "null"] }, conflicts: arr({ type: "object" }) })) })),
orders: { type: "object", additionalProperties: { type: "object" } },
unplanned: arr({ type: "object" }),
conflictCount: int(),
},
["from", "to", "teams", "orders", "unplanned"],
),
),
...errors("unprocessable"),
},
}),
},
"/planning/schedule": {
post: op({
tag: "Planung",
operationId: "scheduleWorkOrder",
summary: "Auftrag einplanen (Team + Termin) – atomar über Zuweisung und Auftragsänderung",
description:
"Nutzt `assignWorkOrder` (Event `work_order.assigned`) und `updateWorkOrder` in einer Transaktion; Audit. Nur Status draft/review_required/planned/assigned/accepted. Veraltete `baseVersion` → 409 („Auftrag wurde zwischenzeitlich geändert“). Antwort enthält die Konflikte des Zielteams am Zieltag.",
module: "work_orders",
permissions: ["work_order:assign", "work_order:write"],
requestBody: jsonBody(
obj(
{
workOrderId: str(),
teamId: str(),
plannedStart: dateTime(),
plannedEnd: nDateTime({ description: "weggelassen: bisherige Länge bleibt erhalten" }),
plannedDurationMinutes: { type: ["integer", "null"], minimum: 15, maximum: 20160 },
baseVersion: int({ minimum: 1 }),
},
["workOrderId", "teamId", "plannedStart", "baseVersion"],
),
),
responses: {
"200": jsonResponse(
"Eingeplant",
open({ id: str(), number: str(), version: int(), status: str({ enum: WORK_ORDER_STATUSES }), teamId: str(), plannedStart: dateTime(), plannedEnd: nDateTime(), plannedDurationMinutes: { type: ["integer", "null"] }, conflicts: arr({ type: "object" }) }, ["id", "version", "conflicts"]),
),
...errors("not_found", "conflict", "unprocessable"),
},
}),
},
"/planning/recommendations": {
get: op({
tag: "Planung",
operationId: "getPlanningRecommendations",
summary: "Einsatz-Empfehlungen (Luftlinie + Terminlage) und nahe ungeplante Aufträge",
description:
"Nur Vorschläge, nichts wird gespeichert. Ohne Koordinaten am Objekt: `status: no_coordinates` + Hinweis; Verortung wird im Hintergrund angestoßen. Top 5 nach Score (Distanz dominant, freie Kapazität, frühes Datum gewichtet nach Priorität).",
module: "work_orders",
permissions: ["work_order:assign"],
parameters: [
{ name: "workOrderId", in: "query", required: true, schema: str() },
query("days", int({ minimum: 1, maximum: 31, default: 10 })),
query("radiusKm", num({ minimum: 0.5, maximum: 200, default: 25 })),
query("nearbyRadiusKm", num({ minimum: 0.5, maximum: 100, default: 5 })),
query("locale", str({ enum: ["de", "en"], default: "de" })),
],
responses: {
"200": jsonResponse(
"Empfehlungen",
open(
{
status: str({ enum: ["ok", "no_coordinates", "not_schedulable"] }),
hint: nstr(),
requiredMinutes: int(),
recommendations: arr(open({ teamId: str(), teamName: str(), day: str({ format: "date" }), distanceKm: num(), nearOrder: obj({ id: str(), number: str() }), freeMinutes: int(), tight: bool(), score: num(), suggestedStart: dateTime(), text: str() })),
nearby: open({ status: str(), radiusKm: num(), items: arr({ type: "object" }) }),
},
["status", "recommendations", "nearby"],
),
),
...errors("not_found", "unprocessable"),
},
}),
},
"/planning/live": {
get: op({
tag: "Planung",
operationId: "getLiveSituation",
summary: "Live-Lage der Monteure (ohne GPS)",
description:
"Standort = Einsatzort (Objekt) des Auftrags der aktiven WorkSession; Status aus der Session (`en_route`, `working`, `paused`, sonst `free`). Es werden keine Geräte-Koordinaten gelesen oder ausgeliefert. `crews`: ein Eintrag je Team/Kolonne mit individuellem Status der Mitglieder und Verzugswarnung (`delay`); `freed`: Kolonnen, die heute früher fertig wurden, mit Vorzieh-/Umkreis-Vorschlägen (nur Vorschlag). Teamleiter: nur eigene Teams.",
module: "work_orders",
permissions: [],
parameters: [query("teamId", str()), query("status", str({ enum: ["en_route", "working", "paused", "free"] }))],
responses: {
"200": jsonResponse(
"Live-Lage",
open(
{
generatedAt: dateTime(),
crews: arr(open({ teamId: str(), teamName: str(), status: str(), current: { type: ["object", "null"] }, delay: { type: ["object", "null"] }, members: arr({ type: "object" }), freedMinutes: { type: ["integer", "null"] } })),
technicians: arr(open({ userId: str(), name: str(), crewId: nstr(), status: str(), since: nDateTime(), current: { type: ["object", "null"] }, delay: { type: ["object", "null"] }, next: arr({ type: "object" }) })),
freed: arr({ type: "object" }),
counts: { type: "object" },
attention: arr({ type: "object" }),
withoutLocation: int(),
},
["generatedAt", "technicians"],
),
),
...errors("unprocessable"),
},
}),
},
"/openapi.json": {
get: op({
tag: "Meta",
@@ -1137,6 +1270,7 @@ export const openApiDocument = {
{ name: "Import", description: "Dokumentenimport mit KI-Extraktion" },
{ name: "Berichte", description: "Tages-/Abschlussberichte" },
{ name: "Einsatz", description: "Mobile/Offline: Sync, Uploads, Bundle" },
{ name: "Planung", description: "Plantafel, Live-Lage, Einsatz-Empfehlungen" },
{ name: "Meta" },
],
paths,
+4
View File
@@ -23,6 +23,10 @@ export const EVENT_TYPES = [
"time.approval_requested",
"time.approved",
"time.rejected",
// L13 Planung (emitted only by the planning-watch job; in-app to back office + team leads)
"planning.capacity_freed",
"planning.overrun",
"planning.followup_at_risk",
] as const;
export type EventType = (typeof EVENT_TYPES)[number];
+37
View File
@@ -0,0 +1,37 @@
/**
* Geo helpers (client-safe, no server imports). Straight-line ("Luftlinie") distances only —
* no routing, no device positions (L13 Planung).
*/
export type LatLng = { latitude: number; longitude: number };
const EARTH_RADIUS_KM = 6371.0088;
export function isValidLatLng(p: { latitude?: number | null; longitude?: number | null } | null | undefined): p is LatLng {
return (
!!p &&
typeof p.latitude === "number" &&
typeof p.longitude === "number" &&
Number.isFinite(p.latitude) &&
Number.isFinite(p.longitude) &&
Math.abs(p.latitude) <= 90 &&
Math.abs(p.longitude) <= 180
);
}
const rad = (deg: number) => (deg * Math.PI) / 180;
/** Great-circle distance in kilometres (haversine formula). */
export function haversineKm(a: LatLng, b: LatLng): number {
const dLat = rad(b.latitude - a.latitude);
const dLng = rad(b.longitude - a.longitude);
const h = Math.sin(dLat / 2) ** 2 + Math.cos(rad(a.latitude)) * Math.cos(rad(b.latitude)) * Math.sin(dLng / 2) ** 2;
return 2 * EARTH_RADIUS_KM * Math.asin(Math.min(1, Math.sqrt(h)));
}
/** "3,2 km" / "850 m" in the given locale. */
export function formatDistance(km: number, locale: string): string {
const tag = locale === "en" ? "en-GB" : "de-DE";
if (km < 1) return `${new Intl.NumberFormat(tag, { maximumFractionDigits: 0 }).format(Math.round(km * 1000))} m`;
return `${new Intl.NumberFormat(tag, { minimumFractionDigits: 1, maximumFractionDigits: 1 }).format(km)} km`;
}
+11
View File
@@ -15,6 +15,9 @@ import {
Siren,
Compass,
Timer,
CalendarRange,
LayoutGrid,
MapPinned,
type LucideIcon,
} from "lucide-react";
import type { ModuleKey } from "@/lib/modules";
@@ -38,10 +41,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",
+216
View File
@@ -0,0 +1,216 @@
import { dayBounds, dayKeyOf, dayRange, isWorkingDay, minutesOfDay } from "@/lib/planning/days";
import type { WorkOrderStatus } from "@/lib/work-orders/status";
/**
* Capacity, utilization and conflict rules of the planning board (client-safe, pure functions,
* unit-tested in scripts/test-planung-core.ts).
*
* A team is a crew ("Kolonne", 2+ people travelling and working together). Capacity is the crew's
* working day in minutes (`Team.dailyCapacityMinutes`, default 480) on its working days — NOT
* person-hours. Order durations are crew time as well.
*
* Duration of an order (minutes): plannedDurationMinutes → plannedStart/End on the same day with a
* time of day → OrderType.defaultDurationMinutes → 120.
* Multi-day orders (plannedEnd on a later day) occupy every day of the range: an explicit duration is
* split evenly, otherwise each day counts with the order type default (or a full day of 480 min).
*/
/** Statuses the planning board may (re)schedule — running or finished orders are never moved. */
export const SCHEDULABLE_STATUSES: readonly WorkOrderStatus[] = ["draft", "review_required", "planned", "assigned", "accepted"];
export const FALLBACK_DURATION_MINUTES = 120;
export const FULL_DAY_MINUTES = 480;
/** Below this number of active members a crew is flagged as incomplete (hint, not a conflict). */
export const MIN_CREW_SIZE = 2;
/** Travel time estimate: straight line × 1.3 detour at 50 km/h, at least 10 min. */
export const TRAVEL_DETOUR_FACTOR = 1.3;
export const TRAVEL_SPEED_KMH = 50;
export const MIN_TRAVEL_MINUTES = 10;
export type PlanningOrder = {
id: string;
teamId: string | null;
plannedStart: Date | null;
plannedEnd: Date | null;
plannedDurationMinutes: number | null;
orderTypeDefaultMinutes: number | null;
assigneeIds: string[];
};
export type PlanningTeam = {
id: string;
dailyCapacityMinutes: number;
workingDays: number;
members: { userId: string; validFrom: Date; validTo: Date | null; active: boolean }[];
};
export const CONFLICT_KINDS = ["overbooked", "overlap", "assignee_double_booked", "outside_working_days", "crew_incomplete"] as const;
export type ConflictKind = (typeof CONFLICT_KINDS)[number];
export type PlanningConflict = {
kind: ConflictKind;
/** "hint" (crew_incomplete) is shown but not counted as a conflict. */
severity: "conflict" | "hint";
day: string;
teamIds: string[];
orderIds: string[];
userId?: string;
minutesOver?: number;
memberCount?: number;
};
export const isConflict = (c: Pick<PlanningConflict, "severity">) => c.severity === "conflict";
/** An instant at exactly local midnight counts as "date only" (no time of day entered). */
export function hasTimeOfDay(date: Date, timeZone: string): boolean {
return minutesOfDay(date, timeZone) !== 0;
}
/** Days (keys) the order occupies; an end at exactly local midnight belongs to the previous day. */
export function orderDays(o: Pick<PlanningOrder, "plannedStart" | "plannedEnd">, timeZone: string): string[] {
if (!o.plannedStart) return [];
const first = dayKeyOf(o.plannedStart, timeZone);
if (!o.plannedEnd || o.plannedEnd <= o.plannedStart) return [first];
const endMinusOne = new Date(o.plannedEnd.getTime() - (hasTimeOfDay(o.plannedEnd, timeZone) ? 0 : 1));
const last = dayKeyOf(endMinusOne, timeZone);
return last < first ? [first] : dayRange(first, last);
}
export function effectiveDurationMinutes(o: PlanningOrder, timeZone: string): number {
if (o.plannedDurationMinutes && o.plannedDurationMinutes > 0) return o.plannedDurationMinutes;
if (o.plannedStart && o.plannedEnd && o.plannedEnd > o.plannedStart && hasTimeOfDay(o.plannedStart, timeZone) && orderDays(o, timeZone).length === 1) {
return Math.round((o.plannedEnd.getTime() - o.plannedStart.getTime()) / 60_000);
}
if (o.orderTypeDefaultMinutes && o.orderTypeDefaultMinutes > 0) return o.orderTypeDefaultMinutes;
return FALLBACK_DURATION_MINUTES;
}
/** Minutes the order occupies on `day` (0 if it is not planned on that day). */
export function minutesOnDay(o: PlanningOrder, day: string, timeZone: string): number {
const days = orderDays(o, timeZone);
if (!days.includes(day)) return 0;
if (days.length === 1) return effectiveDurationMinutes(o, timeZone);
if (o.plannedDurationMinutes && o.plannedDurationMinutes > 0) return Math.ceil(o.plannedDurationMinutes / days.length);
return o.orderTypeDefaultMinutes && o.orderTypeDefaultMinutes > 0 ? o.orderTypeDefaultMinutes : FULL_DAY_MINUTES;
}
/** Time window in ms for single-day orders with a time of day, otherwise null (no overlap check). */
export function timeWindow(o: PlanningOrder, timeZone: string): { start: number; end: number } | null {
if (!o.plannedStart || !hasTimeOfDay(o.plannedStart, timeZone) || orderDays(o, timeZone).length !== 1) return null;
const start = o.plannedStart.getTime();
const end = o.plannedEnd && o.plannedEnd > o.plannedStart ? o.plannedEnd.getTime() : start + effectiveDurationMinutes(o, timeZone) * 60_000;
return { start, end };
}
const overlaps = (a: { start: number; end: number }, b: { start: number; end: number }) => a.start < b.end && b.start < a.end;
/** Distinct active members (user active, membership valid on the day). */
export function activeMemberCount(team: PlanningTeam, day: string, timeZone: string): number {
const { start, end } = dayBounds(day, timeZone);
return new Set(team.members.filter((m) => m.active && m.validFrom < end && (!m.validTo || m.validTo > start)).map((m) => m.userId)).size;
}
/** Crew capacity: the crew working day on working days if anyone is active, otherwise 0. */
export function teamCapacityMinutes(team: PlanningTeam, day: string, timeZone: string): number {
if (!isWorkingDay(team.workingDays, day)) return 0;
return activeMemberCount(team, day, timeZone) > 0 ? team.dailyCapacityMinutes : 0;
}
export function utilizationPercent(plannedMinutes: number, capacityMinutes: number): number | null {
return capacityMinutes > 0 ? Math.round((plannedMinutes / capacityMinutes) * 100) : null;
}
export type TeamDay = {
day: string;
workingDay: boolean;
memberCount: number;
capacityMinutes: number;
plannedMinutes: number;
utilization: number | null;
orderIds: string[];
conflicts: PlanningConflict[];
};
/** Load + team-local conflicts (overbooked, overlap, outside_working_days) + crew_incomplete hint of one team on one day. */
export function computeTeamDay(team: PlanningTeam, day: string, teamOrders: PlanningOrder[], timeZone: string): TeamDay {
const onDay = teamOrders.filter((o) => o.teamId === team.id && minutesOnDay(o, day, timeZone) > 0);
const workingDay = isWorkingDay(team.workingDays, day);
const memberCount = activeMemberCount(team, day, timeZone);
const capacityMinutes = teamCapacityMinutes(team, day, timeZone);
const plannedMinutes = onDay.reduce((sum, o) => sum + minutesOnDay(o, day, timeZone), 0);
const ids = onDay.map((o) => o.id);
const conflicts: PlanningConflict[] = [];
if (!workingDay && onDay.length > 0) {
conflicts.push({ kind: "outside_working_days", severity: "conflict", day, teamIds: [team.id], orderIds: ids });
} else if (workingDay && plannedMinutes > capacityMinutes && onDay.length > 0) {
conflicts.push({ kind: "overbooked", severity: "conflict", day, teamIds: [team.id], orderIds: ids, minutesOver: plannedMinutes - capacityMinutes });
}
const timed = onDay.map((o) => ({ o, w: timeWindow(o, timeZone) })).filter((x): x is { o: PlanningOrder; w: { start: number; end: number } } => !!x.w);
for (let i = 0; i < timed.length; i++) {
for (let j = i + 1; j < timed.length; j++) {
if (overlaps(timed[i].w, timed[j].w)) conflicts.push({ kind: "overlap", severity: "conflict", day, teamIds: [team.id], orderIds: [timed[i].o.id, timed[j].o.id] });
}
}
if (workingDay && memberCount < MIN_CREW_SIZE) {
conflicts.push({ kind: "crew_incomplete", severity: "hint", day, teamIds: [team.id], orderIds: ids, memberCount });
}
return { day, workingDay, memberCount, capacityMinutes, plannedMinutes, utilization: utilizationPercent(plannedMinutes, capacityMinutes), orderIds: ids, conflicts };
}
/** A technician individually assigned to two orders (any team) whose time windows overlap on `day`. */
export function assigneeDoubleBookings(orders: PlanningOrder[], day: string, timeZone: string): PlanningConflict[] {
const byUser = new Map<string, { o: PlanningOrder; w: { start: number; end: number } }[]>();
for (const o of orders) {
if (!orderDays(o, timeZone).includes(day)) continue;
const w = timeWindow(o, timeZone);
if (!w) continue;
for (const userId of new Set(o.assigneeIds)) {
const list = byUser.get(userId) ?? [];
list.push({ o, w });
byUser.set(userId, list);
}
}
const out: PlanningConflict[] = [];
for (const [userId, list] of byUser) {
for (let i = 0; i < list.length; i++) {
for (let j = i + 1; j < list.length; j++) {
if (overlaps(list[i].w, list[j].w)) {
const teamIds = [...new Set([list[i].o.teamId, list[j].o.teamId].filter((t): t is string => !!t))];
out.push({ kind: "assignee_double_booked", severity: "conflict", day, teamIds, orderIds: [list[i].o.id, list[j].o.id], userId });
}
}
}
}
return out;
}
/** Length of the union of time intervals in minutes (crew time: parallel segments count once). */
export function unionMinutes(intervals: { start: number; end: number }[]): number {
const sorted = intervals.filter((i) => i.end > i.start).sort((a, b) => a.start - b.start);
let total = 0;
let curStart = -Infinity;
let curEnd = -Infinity;
for (const i of sorted) {
if (i.start > curEnd) {
if (curEnd > curStart) total += curEnd - curStart;
curStart = i.start;
curEnd = i.end;
} else if (i.end > curEnd) {
curEnd = i.end;
}
}
if (curEnd > curStart) total += curEnd - curStart;
return Math.round(total / 60_000);
}
/** Estimated travel minutes for a straight-line distance (null = unknown → minimum). */
export function estimateTravelMinutes(km: number | null): number {
if (km === null || !Number.isFinite(km)) return MIN_TRAVEL_MINUTES;
return Math.max(MIN_TRAVEL_MINUTES, Math.round(((km * TRAVEL_DETOUR_FACTOR) / TRAVEL_SPEED_KMH) * 60));
}
/** Overrun notification bucket: 0 at ≥ 100 %, 1 at ≥ 150 %, 2 at ≥ 200 % … (-1 below 100 %). */
export function overrunBucket(ratio: number): number {
return ratio < 1 ? -1 : Math.floor((ratio - 1) / 0.5 + 1e-9);
}
+91
View File
@@ -0,0 +1,91 @@
import { safeTimeZone, wallTimeToUtc } from "@/lib/work-orders/time";
/**
* Calendar-day helpers for the planning board (client-safe). A "day key" is `YYYY-MM-DD` in the
* tenant timezone; weekday index 0 = Monday … 6 = Sunday. Team working days are a bit mask
* (Mon = 1, Tue = 2, … Sun = 64; default 31 = Mon–Fri).
*/
export const DEFAULT_WORKING_DAYS = 31;
export const WEEKDAY_BITS = [1, 2, 4, 8, 16, 32, 64] as const;
const DAY_KEY_RE = /^(\d{4})-(\d{2})-(\d{2})$/;
export function isDayKey(value: unknown): value is string {
if (typeof value !== "string") return false;
const m = DAY_KEY_RE.exec(value);
if (!m) return false;
const d = new Date(Date.UTC(+m[1], +m[2] - 1, +m[3]));
return d.getUTCFullYear() === +m[1] && d.getUTCMonth() === +m[2] - 1 && d.getUTCDate() === +m[3];
}
/** Day key of an instant in the given timezone. */
export function dayKeyOf(date: Date, timeZone: string): string {
return new Intl.DateTimeFormat("en-CA", { timeZone: safeTimeZone(timeZone), year: "numeric", month: "2-digit", day: "2-digit" }).format(date);
}
/** Pure calendar arithmetic on day keys (no timezone involved). */
export function addDays(dayKey: string, days: number): string {
const [y, m, d] = dayKey.split("-").map(Number);
return new Date(Date.UTC(y, m - 1, d + days)).toISOString().slice(0, 10);
}
/** 0 = Monday … 6 = Sunday. */
export function weekdayIndex(dayKey: string): number {
const [y, m, d] = dayKey.split("-").map(Number);
return (new Date(Date.UTC(y, m - 1, d)).getUTCDay() + 6) % 7;
}
export function startOfWeek(dayKey: string): string {
return addDays(dayKey, -weekdayIndex(dayKey));
}
/** Inclusive list of day keys. */
export function dayRange(from: string, to: string): string[] {
const out: string[] = [];
for (let k = from; k <= to && out.length < 400; k = addDays(k, 1)) out.push(k);
return out;
}
/** [start, end) instants of a calendar day in the timezone (DST-safe). */
export function dayBounds(dayKey: string, timeZone: string): { start: Date; end: Date } {
const start = wallTimeToUtc(dayKey, timeZone);
const end = wallTimeToUtc(addDays(dayKey, 1), timeZone);
if (!start || !end) throw new Error(`invalid day key ${dayKey}`);
return { start, end };
}
export function isWorkingDay(mask: number, dayKey: string): boolean {
return (mask & WEEKDAY_BITS[weekdayIndex(dayKey)]) !== 0;
}
/** `start` (always, even on a weekend) followed by the next working days — `count` keys in total. */
export function nextWorkingDays(start: string, count: number, mask = DEFAULT_WORKING_DAYS): string[] {
const out = [start];
for (let k = addDays(start, 1); out.length < count && out.length < 400; k = addDays(k, 1)) if (isWorkingDay(mask, k)) out.push(k);
return out;
}
/** Move by `n` working days (negative = backwards); non-working start days count from the next one. */
export function addWorkingDays(day: string, n: number, mask = DEFAULT_WORKING_DAYS): string {
let k = day;
const step = n < 0 ? -1 : 1;
for (let left = Math.abs(n); left > 0; ) {
k = addDays(k, step);
if (isWorkingDay(mask, k)) left--;
}
return k;
}
/** Minutes since local midnight of an instant in the timezone. */
export function minutesOfDay(date: Date, timeZone: string): number {
const parts = new Intl.DateTimeFormat("en-GB", { timeZone: safeTimeZone(timeZone), hourCycle: "h23", hour: "2-digit", minute: "2-digit" }).formatToParts(date);
const n = (t: string) => Number(parts.find((p) => p.type === t)?.value ?? 0);
return n("hour") * 60 + n("minute");
}
/** Local wall time "HH:mm" of an instant. */
export function wallClock(date: Date, timeZone: string): string {
const m = minutesOfDay(date, timeZone);
return `${String(Math.floor(m / 60)).padStart(2, "0")}:${String(m % 60).padStart(2, "0")}`;
}
+24
View File
@@ -0,0 +1,24 @@
/** Small client-safe text helpers of the planning module (no ICU runtime needed on the server). */
const tag = (locale: string) => (locale === "en" ? "en-GB" : "de-DE");
/** Replaces `{name}` placeholders (simple message templates from messages/<locale>/planning.json). */
export function fillTemplate(template: string, vars: Record<string, string | number>): string {
return template.replace(/\{(\w+)\}/g, (match, key: string) => (key in vars ? String(vars[key]) : match));
}
/** "45 min", "4 h", "4,5 h". */
export function formatMinutes(minutes: number, locale: string): string {
const m = Math.round(minutes);
if (Math.abs(m) < 60) return `${m} min`;
return `${new Intl.NumberFormat(tag(locale), { maximumFractionDigits: 1 }).format(m / 60)} h`;
}
/** "Di 16.09." (de) / "Tue 16 Sep" (en) for a day key. */
export function formatDayShort(dayKey: string, locale: string): string {
const [y, m, d] = dayKey.split("-").map(Number);
const date = new Date(Date.UTC(y, m - 1, d, 12));
const weekday = new Intl.DateTimeFormat(tag(locale), { weekday: "short", timeZone: "UTC" }).format(date).replace(/\.$/, "");
if (locale === "en") return `${weekday} ${new Intl.DateTimeFormat("en-GB", { day: "numeric", month: "short", timeZone: "UTC" }).format(date)}`;
return `${weekday} ${String(d).padStart(2, "0")}.${String(m).padStart(2, "0")}.`;
}
+10 -9
View File
@@ -14,15 +14,16 @@ export const DEFAULT_NUMBER_PREFIX: Record<"customer" | "work_order" | "emergenc
};
/** Spec §10.2 — created by ensureDefaultOrderTypes on first use per tenant. */
export const DEFAULT_ORDER_TYPES: ReadonlyArray<{ key: string; name: string; signatureRequired: boolean; sortOrder: number }> = [
{ key: "montage", name: "Montage", signatureRequired: true, sortOrder: 10 },
{ key: "reparatur", name: "Reparatur", signatureRequired: true, sortOrder: 20 },
{ key: "wartung", name: "Wartung", signatureRequired: true, sortOrder: 30 },
{ key: "stoerung", name: "Störung", signatureRequired: true, sortOrder: 40 },
{ key: "notdienst", name: "Notdienst", signatureRequired: true, sortOrder: 50 },
{ key: "besichtigung", name: "Besichtigung", signatureRequired: false, sortOrder: 60 },
{ key: "abnahme", name: "Abnahme", signatureRequired: true, sortOrder: 70 },
{ key: "nacharbeit", name: "Nacharbeit", signatureRequired: true, sortOrder: 80 },
// defaultDurationMinutes: L13 Planung (capacity planning default per order type)
export const DEFAULT_ORDER_TYPES: ReadonlyArray<{ key: string; name: string; signatureRequired: boolean; sortOrder: number; defaultDurationMinutes: number }> = [
{ key: "montage", name: "Montage", signatureRequired: true, sortOrder: 10, defaultDurationMinutes: 480 },
{ key: "reparatur", name: "Reparatur", signatureRequired: true, sortOrder: 20, defaultDurationMinutes: 180 },
{ key: "wartung", name: "Wartung", signatureRequired: true, sortOrder: 30, defaultDurationMinutes: 120 },
{ key: "stoerung", name: "Störung", signatureRequired: true, sortOrder: 40, defaultDurationMinutes: 120 },
{ key: "notdienst", name: "Notdienst", signatureRequired: true, sortOrder: 50, defaultDurationMinutes: 120 },
{ key: "besichtigung", name: "Besichtigung", signatureRequired: false, sortOrder: 60, defaultDurationMinutes: 60 },
{ key: "abnahme", name: "Abnahme", signatureRequired: true, sortOrder: 70, defaultDurationMinutes: 60 },
{ key: "nacharbeit", name: "Nacharbeit", signatureRequired: true, sortOrder: 80, defaultDurationMinutes: 120 },
];
/** Spec §12.4 — suggested checklist items. */
@@ -0,0 +1,27 @@
"use server";
import { revalidatePath } from "next/cache";
import { redirect } from "next/navigation";
import { moduleGuard } from "@/server/action-guard";
import { toActionError, type ActionState } from "@/server/api/action-state";
import { ctxFromGuard } from "@/server/services/context";
import { updateTeamPlanningSettings } from "@/server/services/planning/team-settings";
const guard = moduleGuard("work_orders");
/** L13 Planung: crew capacity (working day in minutes) + working days (popup on the planning board). */
export async function saveTeamCapacityAction(teamId: string, returnTo: string, _prev: ActionState, fd: FormData): Promise<ActionState> {
try {
const ctx = ctxFromGuard(await guard("team:manage"));
const workingDays = fd
.getAll("day")
.map(Number)
.filter((n) => [1, 2, 4, 8, 16, 32, 64].includes(n))
.reduce((mask, bit) => mask | bit, 0);
await updateTeamPlanningSettings(ctx, teamId, { dailyCapacityMinutes: String(fd.get("minutes") ?? ""), workingDays });
} catch (err) {
return toActionError(err);
}
revalidatePath("/planning");
redirect(returnTo.startsWith("/planning") && !returnTo.startsWith("//") ? returnTo : "/planning");
}
@@ -0,0 +1,12 @@
import { geocodeSite } from "@/server/services/geo/geocode-site";
import type { JobPayload } from "../queues";
/**
* Queue "geocode-site" (L13 Planung): address → coordinates for one site, cached on the row.
* The worker runs this queue with concurrency 1 + BullMQ limiter 1/s; the Nominatim provider
* additionally throttles process-wide. `failed` is rethrown so BullMQ retries with backoff.
*/
export async function process(payload: JobPayload): Promise<void> {
const outcome = await geocodeSite(payload.tenantId, payload.entityId);
if (outcome === "failed") throw new Error("geocoding failed (retry scheduled)");
}
+2
View File
@@ -13,6 +13,8 @@ export const PROCESSORS: Partial<Record<JobQueueName, () => Promise<JobProcessor
"report-pdf": () => import(/* turbopackIgnore: true */ "./report-pdf").then((m) => m.process), // worker-only (react-dom/server + Chromium), kept out of the app bundle
"image-derivatives": () => import("./image-derivatives").then((m) => m.process),
"ai-retention": () => import("./ai-retention").then((m) => m.process), // L10b: daily AI log retention (scheduled by the worker)
"geocode-site": () => import("./geocode-site").then((m) => m.process), // L13: geocoding of sites (OSM Nominatim)
"planning-watch": () => import("./planning-watch").then((m) => m.process), // L13: delay/overrun/freed-capacity alerts (every 5 min)
};
/** Inline fallback when no Redis is available (dev/demo). */
@@ -0,0 +1,17 @@
import { runPlanningWatch, runPlanningWatchAllTenants } from "@/server/services/planning/watch";
import type { JobPayload } from "../queues";
/**
* Queue "planning-watch" (L13 Planung): every 5 min (job scheduler registered by the craftvia worker)
* evaluates running orders (delay ≥ 100 % → planning.overrun, follow-up order at risk →
* planning.followup_at_risk) and early completions (planning.capacity_freed). Deduplicated via the
* audit-log ledger; payload tenantId "*" = all tenants.
*/
export async function process(payload: JobPayload): Promise<void> {
if (!payload?.tenantId || payload.tenantId === "*") {
const r = await runPlanningWatchAllTenants();
if (r.overrun || r.followupAtRisk || r.capacityFreed || r.failed) console.info(`[planning-watch] ${JSON.stringify(r)}`);
return;
}
await runPlanningWatch(payload.tenantId);
}
+13
View File
@@ -13,6 +13,8 @@ export const JOB_QUEUES = {
reportPdf: "report-pdf",
imageDerivatives: "image-derivatives",
aiRetention: "ai-retention",
geocodeSite: "geocode-site", // L13 Planung: site address → coordinates (OSM Nominatim, 1 req/s)
planningWatch: "planning-watch", // L13 Planung: delay/overrun/freed-capacity alerts every 5 min
} as const;
export type JobQueueName = (typeof JOB_QUEUES)[keyof typeof JOB_QUEUES];
@@ -97,6 +99,17 @@ export async function scheduleRecurringJobs(connection: Redis): Promise<void> {
} finally {
await q.close();
}
// L13 Planung: planning watch every 5 minutes (all tenants)
const watch = new Queue<JobPayload>(JOB_QUEUES.planningWatch, { connection });
try {
await watch.upsertJobScheduler(
"planning-watch-5min",
{ every: 5 * 60 * 1000 },
{ name: JOB_QUEUES.planningWatch, data: { tenantId: "*", entityId: "watch" }, opts: { removeOnComplete: { count: 50 }, removeOnFail: { count: 50 } } },
);
} finally {
await watch.close();
}
}
export async function closeJobQueues(): Promise<void> {
+42
View File
@@ -0,0 +1,42 @@
import packageJson from "../../../../package.json";
/**
* Geocoding + map configuration (L13 Planung). Defaults follow the OpenStreetMap usage policies:
* Nominatim only server side, max. 1 request/s, identifying User-Agent, results cached per site.
* For production with more than a handful of addresses a self-hosted / contracted geocoding and
* tile service is recommended (see docs/craftvia/lanes/planung.md).
*/
export const GEOCODING_PROVIDERS = ["nominatim", "none"] as const;
export type GeocodingProviderName = (typeof GEOCODING_PROVIDERS)[number];
export const DEFAULT_NOMINATIM_URL = "https://nominatim.openstreetmap.org";
export const DEFAULT_MAP_TILE_URL = "https://tile.openstreetmap.org/{z}/{x}/{y}.png";
export const DEFAULT_MAP_ATTRIBUTION = "© OpenStreetMap-Mitwirkende";
type Env = Record<string, string | undefined>;
const val = (v: string | undefined) => (v && v.trim() ? v.trim() : undefined);
export function geocodingProviderName(env: Env = process.env): GeocodingProviderName {
const v = val(env.GEOCODING_PROVIDER)?.toLowerCase();
return v === "none" ? "none" : "nominatim";
}
export function geocodingConfig(env: Env = process.env) {
const baseUrl = val(env.APP_BASE_URL) ?? val(env.AUTH_URL) ?? "http://localhost:3000";
return {
provider: geocodingProviderName(env),
url: (val(env.GEOCODING_URL) ?? DEFAULT_NOMINATIM_URL).replace(/\/+$/, ""),
userAgent: val(env.GEOCODING_USER_AGENT) ?? `Craftvia/${packageJson.version} (+${baseUrl})`,
timeoutMs: 8_000,
minIntervalMs: 1_000,
};
}
/** Tile URL + attribution for the live map (passed from server pages to the client). */
export function mapConfig(env: Env = process.env) {
return {
tileUrl: val(env.MAP_TILE_URL) ?? DEFAULT_MAP_TILE_URL,
attribution: val(env.MAP_ATTRIBUTION) ?? DEFAULT_MAP_ATTRIBUTION,
};
}
+24
View File
@@ -0,0 +1,24 @@
import { enqueueJob } from "@/server/jobs/queues";
import { geocodingProviderName } from "@/server/services/geo/config";
/**
* Ask the worker to geocode a site (after create / address change / import confirmation).
* Never runs inline: no external request inside a user request (OSM policy: no bulk geocoding in
* requests). If the queue is not reachable yet (lazy Redis connection) one delayed retry is made;
* otherwise the site stays without coordinates ("ohne Ortsangabe") until the next change or
* `scripts/geocode-backfill.ts`. Never throws.
*/
export async function requestSiteGeocoding(tenantId: string, siteId: string): Promise<boolean> {
if (geocodingProviderName() === "none") return false;
const payload = { tenantId, entityId: siteId };
try {
if (await enqueueJob("geocode-site", payload)) return true;
} catch (err) {
console.error("[geo] enqueue geocode-site failed:", (err as Error).message);
}
const timer = setTimeout(() => {
enqueueJob("geocode-site", payload).catch(() => undefined);
}, 1_500);
timer.unref?.();
return false;
}
+92
View File
@@ -0,0 +1,92 @@
import { isValidLatLng } from "@/lib/geo/distance";
import { writeAuditLog } from "@/server/audit";
import { dbForTenant } from "@/server/db";
import { geocodeQueryFor } from "@/server/services/geo/normalize";
import { getGeocodingProvider, type GeocodingProvider } from "@/server/services/geo/provider";
/**
* Geocode one site and cache the result on the row (job `geocode-site`, backfill script).
*
* Rules:
* - Manually entered coordinates (coordinates without `geocodedAt`) are never overwritten.
* - Unchanged address (`geocodeQuery`) with status ok/not_found → no provider call ("cached").
* - No usable address or provider `none` → status `skipped` (retried once enabled/complete).
* - Transport/HTTP error → status `failed` (the job rethrows so BullMQ retries).
* - Address changed and not found → previous automatic coordinates are removed (they would be wrong).
*/
export type GeocodeOutcome = "ok" | "not_found" | "failed" | "skipped" | "cached" | "manual" | "missing";
const SITE_SELECT = {
id: true,
street: true,
houseNumber: true,
postalCode: true,
city: true,
country: true,
latitude: true,
longitude: true,
geocodedAt: true,
geocodeStatus: true,
geocodeQuery: true,
} as const;
export async function geocodeSite(
tenantId: string,
siteId: string,
opts: { provider?: GeocodingProvider | null; now?: Date } = {},
): Promise<GeocodeOutcome> {
const db = dbForTenant(tenantId);
const site = await db.site.findFirst({ where: { id: siteId, deletedAt: null }, select: SITE_SELECT });
if (!site) return "missing";
const now = opts.now ?? new Date();
const query = geocodeQueryFor(site);
if (isValidLatLng(site) && !site.geocodedAt) {
if (site.geocodeStatus !== "skipped" || site.geocodeQuery !== query) {
await db.site.update({ where: { id: site.id }, data: { geocodeStatus: "skipped", geocodeQuery: query } });
}
return "manual";
}
if (!query) {
const clearAuto = site.geocodedAt !== null && (site.latitude !== null || site.longitude !== null);
await db.site.update({
where: { id: site.id },
data: { geocodeStatus: "skipped", geocodeQuery: null, ...(clearAuto ? { latitude: null, longitude: null } : {}) },
});
return "skipped";
}
if (site.geocodeQuery === query && (site.geocodeStatus === "ok" || site.geocodeStatus === "not_found")) return "cached";
const provider = opts.provider === undefined ? await getGeocodingProvider() : opts.provider;
if (!provider) {
if (site.geocodeStatus !== "skipped") await db.site.update({ where: { id: site.id }, data: { geocodeStatus: "skipped" } });
return "skipped";
}
let result;
try {
result = await provider.geocode({ street: site.street, houseNumber: site.houseNumber, postalCode: site.postalCode, city: site.city, country: site.country });
} catch (err) {
console.error(`[geo] geocoding site ${site.id} failed:`, (err as Error).message);
await db.site.update({ where: { id: site.id }, data: { geocodeStatus: "failed", geocodeQuery: query, geocodedAt: now } });
return "failed";
}
const before = { latitude: site.latitude, longitude: site.longitude, geocodeStatus: site.geocodeStatus };
const coords = result.status === "ok" ? { latitude: result.latitude, longitude: result.longitude } : { latitude: null, longitude: null };
await db.site.update({ where: { id: site.id }, data: { ...coords, geocodeStatus: result.status, geocodeQuery: query, geocodedAt: now } });
if (before.latitude !== coords.latitude || before.longitude !== coords.longitude) {
await writeAuditLog({
tenantId,
action: "update",
entity: "site",
entityId: site.id,
before,
after: { ...coords, geocodeStatus: result.status, source: "geocoding", provider: provider.name },
});
}
return result.status;
}
+58
View File
@@ -0,0 +1,58 @@
import { GeocodingError, type GeocodeAddress, type GeocodeResult, type GeocodingProvider } from "@/server/services/geo/provider";
import { nominatimLimiter } from "@/server/services/geo/rate-limit";
/**
* Nominatim (OpenStreetMap) provider. Usage policy: server side only, max. 1 request/s process-wide,
* identifying User-Agent, results are cached in Site.latitude/longitude by the caller, no bulk
* geocoding inside requests (only the geocode-site job and scripts/geocode-backfill.ts call this).
*/
type Config = { url: string; userAgent: string; timeoutMs: number };
type Deps = { fetch: typeof fetch; limiter: { acquire(): Promise<number> } };
/** Structured search URL (no free text → fewer ambiguous hits). */
export function buildNominatimUrl(baseUrl: string, address: GeocodeAddress): URL {
const url = new URL(`${baseUrl.replace(/\/+$/, "")}/search`);
url.searchParams.set("format", "jsonv2");
url.searchParams.set("limit", "1");
url.searchParams.set("addressdetails", "0");
const street = [address.houseNumber, address.street].map((s) => s?.trim()).filter(Boolean).join(" ");
if (street) url.searchParams.set("street", street);
if (address.postalCode?.trim()) url.searchParams.set("postalcode", address.postalCode.trim());
if (address.city?.trim()) url.searchParams.set("city", address.city.trim());
const country = (address.country ?? "DE").trim().toLowerCase();
if (/^[a-z]{2}$/.test(country)) url.searchParams.set("countrycodes", country);
return url;
}
export function createNominatimProvider(config: Config, deps: Deps = { fetch: globalThis.fetch, limiter: nominatimLimiter }): GeocodingProvider {
return {
name: "nominatim",
async geocode(address: GeocodeAddress): Promise<GeocodeResult> {
await deps.limiter.acquire();
let res: Response;
try {
res = await deps.fetch(buildNominatimUrl(config.url, address), {
headers: { "User-Agent": config.userAgent, "Accept-Language": "de", Accept: "application/json" },
signal: AbortSignal.timeout(config.timeoutMs),
});
} catch (err) {
throw new GeocodingError(`request failed: ${(err as Error).name}`);
}
if (!res.ok) throw new GeocodingError(`http ${res.status}`);
let body: unknown;
try {
body = await res.json();
} catch {
throw new GeocodingError("invalid response");
}
const hit = Array.isArray(body) ? (body[0] as { lat?: unknown; lon?: unknown } | undefined) : undefined;
const latitude = Number(hit?.lat);
const longitude = Number(hit?.lon);
if (!hit || !Number.isFinite(latitude) || !Number.isFinite(longitude) || Math.abs(latitude) > 90 || Math.abs(longitude) > 180) {
return { status: "not_found" };
}
return { status: "ok", latitude, longitude };
},
};
}
+27
View File
@@ -0,0 +1,27 @@
/**
* Normalized address string of a site ("hafenstraße 12|20457|hamburg|de"). Stored in
* Site.geocodeQuery next to the coordinates: an unchanged address is never geocoded twice.
* Returns null when the address is too incomplete to geocode (neither postal code nor city).
*/
export type SiteAddress = {
street?: string | null;
houseNumber?: string | null;
postalCode?: string | null;
city?: string | null;
country?: string | null;
};
const clean = (v: string | null | undefined) => (v ?? "").normalize("NFC").trim().replace(/\s+/g, " ").toLowerCase();
export function geocodeQueryFor(site: SiteAddress): string | null {
const postal = clean(site.postalCode);
const city = clean(site.city);
if (!postal && !city) return null;
const street = [clean(site.street), clean(site.houseNumber)].filter(Boolean).join(" ");
return [street, postal, city, clean(site.country) || "de"].join("|");
}
/** True when the geocoding-relevant part of the address changed. */
export function addressChanged(before: SiteAddress, after: SiteAddress): boolean {
return geocodeQueryFor(before) !== geocodeQueryFor(after);
}
+41
View File
@@ -0,0 +1,41 @@
import { geocodingConfig, geocodingProviderName } from "@/server/services/geo/config";
/** Provider-neutral geocoding contract (L13 Planung) — swap Nominatim for another service via env. */
export type GeocodeAddress = {
street: string | null;
houseNumber: string | null;
postalCode: string | null;
city: string | null;
country: string | null;
};
export type GeocodeResult = { status: "ok"; latitude: number; longitude: number } | { status: "not_found" };
export interface GeocodingProvider {
readonly name: string;
/** Resolves ok/not_found; throws GeocodingError for transport/HTTP failures (retryable). */
geocode(address: GeocodeAddress): Promise<GeocodeResult>;
}
export class GeocodingError extends Error {
constructor(message: string) {
super(message);
this.name = "GeocodingError";
}
}
let override: GeocodingProvider | null | undefined;
/** Tests inject a fake provider (never Nominatim); `undefined` restores the env-based provider. */
export function setGeocodingProvider(provider: GeocodingProvider | null | undefined): void {
override = provider;
}
/** Configured provider, or null when geocoding is disabled (`GEOCODING_PROVIDER=none`). */
export async function getGeocodingProvider(): Promise<GeocodingProvider | null> {
if (override !== undefined) return override;
if (geocodingProviderName() === "none") return null;
const { createNominatimProvider } = await import("@/server/services/geo/nominatim");
return createNominatimProvider(geocodingConfig());
}
+24
View File
@@ -0,0 +1,24 @@
/**
* Minimum-interval limiter: callers are released one after another with at least `intervalMs`
* between two slots (Nominatim policy: max. 1 request per second). Clock is injectable for tests.
*/
export type Clock = { now: () => number; sleep: (ms: number) => Promise<void> };
const realClock: Clock = { now: () => Date.now(), sleep: (ms) => new Promise((resolve) => setTimeout(resolve, ms)) };
export function createIntervalLimiter(intervalMs: number, clock: Clock = realClock) {
let next = 0;
return {
/** Waits for the next free slot and returns its timestamp. */
async acquire(): Promise<number> {
const now = clock.now();
const slot = Math.max(now, next);
next = slot + intervalMs;
if (slot > now) await clock.sleep(slot - now);
return slot;
},
};
}
/** Process-wide limiter for all Nominatim requests of this process (worker, backfill script). */
export const nominatimLimiter = createIntervalLimiter(1_000);
+2
View File
@@ -8,6 +8,7 @@ import { computeCorrections, reviewFormSchema, type ReviewForm } from "@/lib/imp
import { createWorkOrder } from "@/server/services/work-orders/create";
import { startExtraction, type Dispatch } from "./upload";
import { dispatchJob } from "@/server/jobs/dispatch";
import { requestSiteGeocoding } from "@/server/services/geo/dispatch";
export type ConfirmResult = { workOrderId: string; workOrderNumber: string; customerId: string; siteId: string | null; contactId: string | null };
@@ -168,6 +169,7 @@ export async function confirmImport(ctx: ServiceCtx, importId: string, rawForm:
if (result.createdCustomer) await writeAuditLog({ ...base, action: "create", entity: "customer", entityId: result.customerId, after: { source: "import", importId: job.id } });
if (result.contactId) await writeAuditLog({ ...base, action: "create", entity: "contact", entityId: result.contactId, after: { customerId: result.customerId, source: "import" } });
if (result.createdSite && result.siteId) await writeAuditLog({ ...base, action: "create", entity: "site", entityId: result.siteId, after: { customerId: result.customerId, source: "import" } });
if (result.createdSite && result.siteId) void requestSiteGeocoding(ctx.tenantId, result.siteId); // L13 Planung: queued geocoding of the new site
// work order creation is audited by services/work-orders/create (entity work_order)
await writeAuditLog({
...base,
@@ -136,6 +136,11 @@ function textVars(locale: Locale, f: EventFacts): Record<string, string | undefi
actor: f.actorName ?? fallbackText(locale, "system"),
fileName: f.fileName ?? fallbackText(locale, "document"),
reason: f.syncErrorCode ?? f.rejectionReason,
// L13 Planung
team: f.team,
minutes: f.planningMinutes,
percent: f.planningPercent,
blocker: f.planningBlocker,
};
}
@@ -49,6 +49,10 @@ export type EventFacts = {
emergencyEnd?: Date | null;
/** L12: reason of a rejected time entry */
rejectionReason?: string;
// L13 Planung
planningMinutes?: string;
planningPercent?: string;
planningBlocker?: string;
};
export type RecipientPlan = {
@@ -331,6 +335,20 @@ export async function resolveRecipients(
rule = { users: [backofficeWhere()], userIds: [op.userId] };
break;
}
case "planning.capacity_freed":
case "planning.overrun":
case "planning.followup_at_risk": {
// L13 Planung: in-app only — back office + team leads of the order's crew.
const wo = await loadWorkOrder(ctx, event.entityId);
if (!wo) return null;
Object.assign(facts, workOrderFacts(wo));
const d = event.data ?? {};
if (typeof d.minutes === "number" || typeof d.minutes === "string") facts.planningMinutes = String(d.minutes);
if (typeof d.percent === "number" || typeof d.percent === "string") facts.planningPercent = String(d.percent);
if (typeof d.blockerNumber === "string") facts.planningBlocker = d.blockerNumber;
rule = { users: [backofficeWhere()], userIds: teamLeadIds(wo), mailToUsers: false };
break;
}
default:
return null;
}
+38
View File
@@ -0,0 +1,38 @@
import { can, ServiceError, type ServiceCtx } from "@/server/services/context";
/**
* Who may use the planning views (L13):
* - `work_order:read_all` (backoffice, admin): all teams; scheduling needs `work_order:assign` + `work_order:write`.
* - `work_order:read_team` + leader of at least one active team (Teamleiter): read-only, own teams only.
* - everyone else (Monteur): forbidden.
*/
export type PlanningAccess = {
all: boolean;
/** null = all teams of the tenant */
teamIds: string[] | null;
canSchedule: boolean;
canManageTeams: boolean;
};
export async function planningAccess(ctx: ServiceCtx): Promise<PlanningAccess> {
if (can(ctx, "work_order:read_all")) {
return {
all: true,
teamIds: null,
canSchedule: can(ctx, "work_order:assign") && can(ctx, "work_order:write"),
canManageTeams: can(ctx, "team:manage"),
};
}
if (can(ctx, "work_order:read_team")) {
const led = await ctx.db.team.findMany({ where: { leaderUserId: ctx.userId, status: "active", deletedAt: null }, select: { id: true } });
if (led.length > 0) return { all: false, teamIds: led.map((t) => t.id), canSchedule: false, canManageTeams: false };
}
throw new ServiceError("forbidden", "planning not allowed");
}
/** Recommendations and scheduling are backoffice functions (all teams + assign right). */
export async function assertCanPlanAll(ctx: ServiceCtx): Promise<PlanningAccess> {
const access = await planningAccess(ctx);
if (!access.all || !can(ctx, "work_order:assign")) throw new ServiceError("forbidden", "missing permission work_order:assign");
return access;
}
+177
View File
@@ -0,0 +1,177 @@
import { z } from "zod";
import {
assigneeDoubleBookings,
computeTeamDay,
isConflict,
type ConflictKind,
type PlanningConflict,
type TeamDay,
} from "@/lib/planning/capacity";
import { addDays, dayBounds, dayKeyOf, dayRange, isDayKey, startOfWeek, weekdayIndex } from "@/lib/planning/days";
import { WORK_ORDER_PRIORITIES } from "@/lib/work-orders/schemas";
import { ServiceError, type ServiceCtx } from "@/server/services/context";
import { planningAccess, type PlanningAccess } from "@/server/services/planning/access";
import {
loadPlannedOrders,
loadTeams,
loadUnplannedOrders,
toBoardOrder,
toPlanningOrder,
toPlanningTeam,
type BoardOrder,
} from "@/server/services/planning/data";
import { evaluateDelays } from "@/server/services/planning/watch";
import { parseInput, tenantTimezone } from "@/server/services/work-orders/_shared";
/**
* Planning board (L13, Plantafel): per crew (team) and day planned orders, crew capacity, utilization,
* conflicts (+ crew_incomplete hints) and — when today is in range — delay/at-risk markers.
*/
export const MAX_BOARD_DAYS = 42;
const dayKey = z.string().refine(isDayKey, "invalid_day");
const boardSchema = z.object({
from: dayKey,
to: dayKey,
teamIds: z.array(z.string().min(1).max(64)).max(100).optional(),
orderTypeId: z.string().min(1).max(64).nullish(),
priority: z.enum(WORK_ORDER_PRIORITIES).nullish(),
});
export type BoardInput = z.input<typeof boardSchema>;
export type BoardTeamDay = TeamDay;
export type BoardTeam = {
id: string;
name: string;
leaderName: string | null;
dailyCapacityMinutes: number;
workingDays: number;
members: { userId: string; name: string }[];
days: BoardTeamDay[];
};
export type PlanningBoard = {
from: string;
to: string;
today: string;
timeZone: string;
days: { key: string; weekday: number }[];
teams: BoardTeam[];
orders: Record<string, BoardOrder>;
unplanned: BoardOrder[];
/** Conflicts and hints (severity); `conflictCount` counts conflicts only. */
conflicts: PlanningConflict[];
conflictCount: number;
access: PlanningAccess;
};
export async function getPlanningBoard(ctx: ServiceCtx, raw: BoardInput, opts: { now?: Date } = {}): Promise<PlanningBoard> {
const input = parseInput(boardSchema, raw);
if (input.to < input.from || dayRange(input.from, input.to).length > MAX_BOARD_DAYS) throw new ServiceError("invalid", "invalid_range");
const access = await planningAccess(ctx);
const timeZone = await tenantTimezone(ctx);
const now = opts.now ?? new Date();
const today = dayKeyOf(now, timeZone);
let teamIds = access.teamIds;
if (input.teamIds?.length) teamIds = teamIds ? teamIds.filter((id) => input.teamIds!.includes(id)) : input.teamIds;
const days = dayRange(input.from, input.to);
const start = dayBounds(input.from, timeZone).start;
const end = dayBounds(input.to, timeZone).end;
const teams = await loadTeams(ctx, teamIds);
const [rows, unplannedRows] = await Promise.all([
loadPlannedOrders(ctx, { teamIds: teams.map((t) => t.id), start, end }),
loadUnplannedOrders(ctx, access.teamIds),
]);
const planningOrders = rows.map(toPlanningOrder);
const visible = (r: { orderTypeId: string | null; priority: string }) =>
(!input.orderTypeId || r.orderTypeId === input.orderTypeId) && (!input.priority || r.priority === input.priority);
const visibleIds = new Set(rows.filter(visible).map((r) => r.id));
const doubleBookings = days.flatMap((day) => assigneeDoubleBookings(planningOrders, day, timeZone));
const conflicts: PlanningConflict[] = [...doubleBookings];
const boardTeams: BoardTeam[] = teams.map((team) => {
const pt = toPlanningTeam(team);
const teamOrders = planningOrders.filter((o) => o.teamId === team.id);
const members = new Map<string, string>();
for (const m of team.members) {
if (m.user.status === "ACTIVE" && m.validFrom < end && (!m.validTo || m.validTo > start)) members.set(m.userId, m.user.name);
}
return {
id: team.id,
name: team.name,
leaderName: team.leader?.name ?? null,
dailyCapacityMinutes: team.dailyCapacityMinutes,
workingDays: team.workingDays,
members: [...members].map(([userId, name]) => ({ userId, name })),
days: days.map((day) => {
const td = computeTeamDay(pt, day, teamOrders, timeZone);
conflicts.push(...td.conflicts);
return {
...td,
orderIds: td.orderIds.filter((id) => visibleIds.has(id)),
conflicts: [...td.conflicts, ...doubleBookings.filter((c) => c.day === day && c.teamIds.includes(team.id))],
};
}),
};
});
const kindsByOrder = new Map<string, Set<ConflictKind>>();
for (const c of conflicts.filter(isConflict)) {
for (const id of c.orderIds) {
const set = kindsByOrder.get(id) ?? new Set<ConflictKind>();
set.add(c.kind);
kindsByOrder.set(id, set);
}
}
const orders: Record<string, BoardOrder> = Object.fromEntries(rows.filter(visible).map((r) => [r.id, toBoardOrder(r, timeZone, [...(kindsByOrder.get(r.id) ?? [])])]));
// Delay (≥ 80 %) and endangered follow-up orders — only meaningful when today is shown.
if (today >= input.from && today <= input.to && teams.length > 0) {
const { delays, followups } = await evaluateDelays(ctx, { now, teamIds: teams.map((t) => t.id) });
for (const d of delays) {
if (d.level !== "ok" && orders[d.workOrderId]) orders[d.workOrderId].delay = { level: d.level, percent: d.percent, workedMinutes: d.workedMinutes };
}
for (const f of followups) {
if (orders[f.workOrderId]) orders[f.workOrderId].atRisk = { byOrderId: f.blockedById, byNumber: f.blockedByNumber, reason: f.reason };
}
}
return {
from: input.from,
to: input.to,
today,
timeZone,
days: days.map((key) => ({ key, weekday: weekdayIndex(key) })),
teams: boardTeams,
orders,
unplanned: unplannedRows.filter(visible).map((r) => toBoardOrder(r, timeZone)),
conflicts,
conflictCount: conflicts.filter(isConflict).length,
access,
};
}
/** Mon–Sun of the week containing `day` (default: today in the tenant timezone). */
export async function weekRange(ctx: ServiceCtx, day?: string | null): Promise<{ from: string; to: string; today: string }> {
const today = dayKeyOf(new Date(), await tenantTimezone(ctx));
const from = startOfWeek(day && isDayKey(day) ? day : today);
return { from, to: addDays(from, 6), today };
}
/** Dashboard tile "Konflikte diese Woche" (Mon–Sun of the current week in the tenant timezone). */
export async function countWeekConflicts(ctx: ServiceCtx): Promise<{ count: number; from: string } | null> {
try {
const today = dayKeyOf(new Date(), await tenantTimezone(ctx));
const from = startOfWeek(today);
const board = await getPlanningBoard(ctx, { from, to: addDays(from, 6) });
return { count: board.conflictCount, from };
} catch (err) {
if (err instanceof ServiceError && err.code === "forbidden") return null;
throw err;
}
}
+184
View File
@@ -0,0 +1,184 @@
import type { Prisma } from "@prisma/client";
import { isValidLatLng } from "@/lib/geo/distance";
import { effectiveDurationMinutes, hasTimeOfDay, orderDays, type PlanningOrder, type PlanningTeam } from "@/lib/planning/capacity";
import { STATUS_GROUP, type StatusGroup, type WorkOrderStatus } from "@/lib/work-orders/status";
import type { ServiceCtx } from "@/server/services/context";
import { customerDisplayName } from "@/server/services/work-orders/options";
import { workOrderScope } from "@/server/services/work-orders/visibility";
/** Shared loaders + DTOs of the planning services (tenant via ctx.db, visibility via workOrderScope). */
/** Unplanned = open planning statuses without team or without planned start (+ assigned/accepted without date). */
export const UNPLANNED_STATUSES: WorkOrderStatus[] = ["draft", "review_required", "planned", "assigned", "accepted"];
const NEEDS_TEAM_STATUSES: WorkOrderStatus[] = ["draft", "review_required", "planned"];
export const ORDER_SELECT = {
id: true,
number: true,
title: true,
status: true,
priority: true,
version: true,
plannedStart: true,
plannedEnd: true,
plannedDurationMinutes: true,
assignedTeamId: true,
orderTypeId: true,
isEmergency: true,
orderType: { select: { name: true, defaultDurationMinutes: true } },
customer: { select: { companyName: true, firstName: true, lastName: true } },
site: { select: { id: true, name: true, street: true, houseNumber: true, postalCode: true, city: true, latitude: true, longitude: true, geocodeStatus: true } },
assignees: { select: { userId: true } },
} as const satisfies Prisma.WorkOrderSelect;
export type OrderRow = Prisma.WorkOrderGetPayload<{ select: typeof ORDER_SELECT }>;
export type BoardOrder = {
id: string;
number: string;
title: string;
status: WorkOrderStatus;
statusGroup: StatusGroup;
priority: "low" | "normal" | "high" | "urgent";
version: number;
isEmergency: boolean;
customerName: string;
siteId: string | null;
siteName: string | null;
siteAddress: string | null;
hasCoordinates: boolean;
orderTypeId: string | null;
orderTypeName: string | null;
teamId: string | null;
plannedStart: string | null;
plannedEnd: string | null;
plannedDurationMinutes: number | null;
durationMinutes: number;
allDay: boolean;
multiDay: boolean;
assigneeIds: string[];
conflictKinds: string[];
/** Running order: recorded crew work time vs. planned duration (≥ 80 % → risk, ≥ 100 % → overrun). */
delay: { level: "risk" | "overrun"; percent: number; workedMinutes: number } | null;
/** Planned follow-up order endangered by the delay of another order of the same crew. */
atRisk: { byOrderId: string; byNumber: string; reason: "start" | "capacity" } | null;
};
export function toPlanningOrder(r: OrderRow): PlanningOrder {
return {
id: r.id,
teamId: r.assignedTeamId,
plannedStart: r.plannedStart,
plannedEnd: r.plannedEnd,
plannedDurationMinutes: r.plannedDurationMinutes,
orderTypeDefaultMinutes: r.orderType?.defaultDurationMinutes ?? null,
assigneeIds: r.assignees.map((a) => a.userId),
};
}
export function siteAddress(s: { street: string | null; houseNumber: string | null; postalCode: string | null; city: string | null } | null): string | null {
if (!s) return null;
const line = [[s.street, s.houseNumber].filter(Boolean).join(" "), [s.postalCode, s.city].filter(Boolean).join(" ")].filter(Boolean).join(", ");
return line || null;
}
export function toBoardOrder(r: OrderRow, timeZone: string, conflictKinds: string[] = []): BoardOrder {
const po = toPlanningOrder(r);
return {
id: r.id,
number: r.number,
title: r.title,
status: r.status as WorkOrderStatus,
statusGroup: STATUS_GROUP[r.status as WorkOrderStatus],
priority: r.priority,
version: r.version,
isEmergency: r.isEmergency,
customerName: customerDisplayName(r.customer),
siteId: r.site?.id ?? null,
siteName: r.site?.name ?? null,
siteAddress: siteAddress(r.site),
hasCoordinates: isValidLatLng(r.site),
orderTypeId: r.orderTypeId,
orderTypeName: r.orderType?.name ?? null,
teamId: r.assignedTeamId,
plannedStart: r.plannedStart?.toISOString() ?? null,
plannedEnd: r.plannedEnd?.toISOString() ?? null,
plannedDurationMinutes: r.plannedDurationMinutes,
durationMinutes: effectiveDurationMinutes(po, timeZone),
allDay: !r.plannedStart || !hasTimeOfDay(r.plannedStart, timeZone),
multiDay: orderDays(po, timeZone).length > 1,
assigneeIds: po.assigneeIds,
conflictKinds,
delay: null,
atRisk: null,
};
}
export async function loadTeams(ctx: ServiceCtx, teamIds: string[] | null) {
return ctx.db.team.findMany({
where: { deletedAt: null, status: "active", ...(teamIds ? { id: { in: teamIds } } : {}) },
select: {
id: true,
name: true,
leaderUserId: true,
dailyCapacityMinutes: true,
workingDays: true,
leader: { select: { id: true, name: true, status: true } },
members: { select: { userId: true, validFrom: true, validTo: true, user: { select: { name: true, status: true } } }, orderBy: { validFrom: "asc" } },
},
orderBy: { name: "asc" },
});
}
export type TeamRow = Awaited<ReturnType<typeof loadTeams>>[number];
export function toPlanningTeam(t: TeamRow): PlanningTeam {
return {
id: t.id,
dailyCapacityMinutes: t.dailyCapacityMinutes,
workingDays: t.workingDays,
members: t.members.map((m) => ({ userId: m.userId, validFrom: m.validFrom, validTo: m.validTo, active: m.user.status === "ACTIVE" })),
};
}
/** Orders of the teams planned in [start, end) incl. multi-day orders overlapping the range. */
export async function loadPlannedOrders(ctx: ServiceCtx, opts: { teamIds: string[]; start: Date; end: Date; excludeId?: string }): Promise<OrderRow[]> {
if (opts.teamIds.length === 0) return [];
return ctx.db.workOrder.findMany({
where: {
AND: [
await workOrderScope(ctx),
{
assignedTeamId: { in: opts.teamIds },
status: { not: "cancelled" },
plannedStart: { lt: opts.end, not: null },
OR: [{ plannedEnd: null, plannedStart: { gte: opts.start } }, { plannedEnd: { gt: opts.start } }],
},
opts.excludeId ? { id: { not: opts.excludeId } } : {},
],
},
select: ORDER_SELECT,
orderBy: [{ plannedStart: "asc" }, { number: "asc" }],
take: 3000,
});
}
export async function loadUnplannedOrders(ctx: ServiceCtx, teamIds: string[] | null, take = 200): Promise<OrderRow[]> {
return ctx.db.workOrder.findMany({
where: {
AND: [
await workOrderScope(ctx),
{ status: { in: UNPLANNED_STATUSES } },
{ OR: [{ plannedStart: null }, { assignedTeamId: null, status: { in: NEEDS_TEAM_STATUSES } }] },
teamIds ? { assignedTeamId: { in: teamIds } } : {},
],
},
select: ORDER_SELECT,
orderBy: [{ priority: "desc" }, { createdAt: "asc" }],
take,
});
}
export async function loadOrder(ctx: ServiceCtx, workOrderId: string): Promise<OrderRow | null> {
return ctx.db.workOrder.findFirst({ where: { AND: [{ id: workOrderId }, await workOrderScope(ctx)] }, select: ORDER_SELECT });
}
+271
View File
@@ -0,0 +1,271 @@
import { z } from "zod";
import { isValidLatLng } from "@/lib/geo/distance";
import { dayBounds, dayKeyOf } from "@/lib/planning/days";
import type { WorkOrderStatus } from "@/lib/work-orders/status";
import type { ServiceCtx } from "@/server/services/context";
import { planningAccess } from "@/server/services/planning/access";
import { loadTeams, siteAddress } from "@/server/services/planning/data";
import { trackedSessionWhere } from "@/server/services/planning/time-tracking";
import { evaluateDelays, getFreedCapacity, type FreedTeam } from "@/server/services/planning/watch";
import { customerDisplayName } from "@/server/services/work-orders/options";
import { parseInput, tenantTimezone } from "@/server/services/work-orders/_shared";
import { workOrderScope } from "@/server/services/work-orders/visibility";
/**
* Live situation (L13, Live-Lage) WITHOUT GPS: a technician is shown at the site of the order of
* his active WorkSession; the status comes from that session (en_route → Unterwegs, running → In
* Arbeit, paused → Pause, none → frei). Teams are crews ("Kolonnen"): ONE entry/marker per crew at
* the site of its running order with the individual status of each member; technicians without a
* team are shown individually. Device positions are never read: WorkSession.startLat/startLng,
* deviceInfo and photo coordinates are not selected and never part of the result (privacy decision).
*/
export const LIVE_STATUSES = ["en_route", "working", "paused", "free"] as const;
export type LiveStatus = (typeof LIVE_STATUSES)[number];
const SESSION_STATUS: Record<string, LiveStatus> = { en_route: "en_route", running: "working", paused: "paused" };
const RANK: Record<LiveStatus, number> = { working: 3, en_route: 2, paused: 1, free: 0 };
const liveSchema = z.object({
teamId: z.string().min(1).max(64).nullish(),
status: z.enum(LIVE_STATUSES).nullish(),
});
export type LiveOrderRef = {
id: string;
number: string;
title: string;
customerName: string;
siteName: string | null;
address: string | null;
latitude: number | null;
longitude: number | null;
};
export type LiveDelay = { level: "risk" | "overrun"; percent: number; workedMinutes: number; plannedMinutes: number };
export type LiveTechnician = {
userId: string;
name: string;
teams: { id: string; name: string }[];
/** Crew the person currently belongs to (team of the running session, else first team). */
crewId: string | null;
status: LiveStatus;
since: string | null;
/** null = no active session; `visible=false` = session on an order outside the caller's scope */
current: (LiveOrderRef & { visible: true }) | { visible: false } | null;
delay: LiveDelay | null;
next: { id: string; number: string; title: string; plannedStart: string; customerName: string; city: string | null }[];
};
export type LiveCrew = {
teamId: string;
teamName: string;
/** Highest member status: In Arbeit > Unterwegs > Pause > frei */
status: LiveStatus;
current: LiveOrderRef | null;
delay: LiveDelay | null;
members: { userId: string; name: string; status: LiveStatus; since: string | null; orderNumber: string | null }[];
freedMinutes: number | null;
};
export type LiveSituation = {
generatedAt: string;
timeZone: string;
teams: { id: string; name: string }[];
crews: LiveCrew[];
technicians: LiveTechnician[];
counts: Record<LiveStatus, number>;
attention: { id: string; number: string; title: string; plannedStart: string; teamName: string | null; customerName: string; reason: "not_accepted" | "overdue" }[];
freed: FreedTeam[];
withoutLocation: number;
};
export async function getLiveSituation(ctx: ServiceCtx, raw: z.input<typeof liveSchema> = {}, opts: { now?: Date } = {}): Promise<LiveSituation> {
const access = await planningAccess(ctx);
const input = parseInput(liveSchema, raw);
const timeZone = await tenantTimezone(ctx);
const now = opts.now ?? new Date();
const today = dayBounds(dayKeyOf(now, timeZone), timeZone);
const allTeams = await loadTeams(ctx, access.teamIds);
const teams = input.teamId ? allTeams.filter((tm) => tm.id === input.teamId) : allTeams;
// People: active members today + team leaders of the visible teams.
const people = new Map<string, { name: string; teams: { id: string; name: string }[] }>();
const addPerson = (userId: string, name: string, team: { id: string; name: string }) => {
const p = people.get(userId) ?? { name, teams: [] };
if (!p.teams.some((x) => x.id === team.id)) p.teams.push(team);
people.set(userId, p);
};
for (const team of teams) {
const ref = { id: team.id, name: team.name };
for (const m of team.members) {
if (m.user.status === "ACTIVE" && m.validFrom <= now && (!m.validTo || m.validTo > now)) addPerson(m.userId, m.user.name, ref);
}
if (team.leader && team.leader.status === "ACTIVE") addPerson(team.leader.id, team.leader.name, ref);
}
// Active clock sessions — explicit select: NEVER startLat/startLng/deviceInfo.
const sessionWhere = { AND: [trackedSessionWhere, { status: { in: ["en_route", "running", "paused"] as ("en_route" | "running" | "paused")[] } }] };
const sessions = await ctx.db.workSession.findMany({
where: access.all && !input.teamId ? sessionWhere : { AND: [sessionWhere, { userId: { in: [...people.keys()] } }] },
select: { id: true, userId: true, teamId: true, status: true, startedAt: true, workOrderId: true },
orderBy: { startedAt: "desc" },
take: 1000,
});
// Backoffice without team filter: also technicians with a session outside any team.
const unknown = [...new Set(sessions.map((s) => s.userId).filter((id) => !people.has(id)))];
if (unknown.length) {
const users = await ctx.db.user.findMany({ where: { id: { in: unknown }, status: "ACTIVE" }, select: { id: true, name: true } });
for (const u of users) people.set(u.id, { name: u.name, teams: [] });
}
const sessionByUser = new Map<string, (typeof sessions)[number]>();
for (const s of sessions) if (!sessionByUser.has(s.userId) && people.has(s.userId)) sessionByUser.set(s.userId, s);
const activeSessions = [...sessionByUser.values()];
const openSegments = activeSessions.length
? await ctx.db.timeEntry.findMany({
where: { workSessionId: { in: activeSessions.map((s) => s.id) }, endedAt: null },
select: { workSessionId: true, startedAt: true },
orderBy: { startedAt: "desc" },
})
: [];
const segmentStart = new Map<string, Date>();
for (const e of openSegments) if (!segmentStart.has(e.workSessionId)) segmentStart.set(e.workSessionId, e.startedAt);
const scope = await workOrderScope(ctx);
const teamIds = teams.map((tm) => tm.id);
const userIds = [...people.keys()];
const [currentOrders, todays, delayEval, freed] = await Promise.all([
activeSessions.length
? ctx.db.workOrder.findMany({
where: { AND: [scope, { id: { in: [...new Set(activeSessions.map((s) => s.workOrderId))] } }] },
select: {
id: true,
number: true,
title: true,
customer: { select: { companyName: true, firstName: true, lastName: true } },
site: { select: { name: true, street: true, houseNumber: true, postalCode: true, city: true, latitude: true, longitude: true } },
},
})
: Promise.resolve([]),
ctx.db.workOrder.findMany({
where: {
AND: [
scope,
{
plannedStart: { gte: today.start, lt: today.end },
status: { in: ["planned", "assigned", "accepted"] },
OR: [...(teamIds.length ? [{ assignedTeamId: { in: teamIds } }] : []), ...(userIds.length ? [{ assignees: { some: { userId: { in: userIds } } } }] : [])],
},
access.all && !input.teamId ? {} : { assignedTeamId: { in: teamIds } },
],
},
select: {
id: true,
number: true,
title: true,
status: true,
plannedStart: true,
assignedTeamId: true,
team: { select: { name: true } },
customer: { select: { companyName: true, firstName: true, lastName: true } },
site: { select: { city: true } },
assignees: { select: { userId: true } },
},
orderBy: { plannedStart: "asc" },
take: 500,
}),
evaluateDelays(ctx, { now, teamIds: access.all && !input.teamId ? null : teamIds }),
getFreedCapacity(ctx, { now }),
]);
const orderById = new Map(currentOrders.map((o) => [o.id, o]));
const delayById = new Map(
delayEval.delays
.filter((d) => d.level !== "ok")
.map((d) => [d.workOrderId, { level: d.level as "risk" | "overrun", percent: d.percent, workedMinutes: d.workedMinutes, plannedMinutes: d.plannedMinutes }]),
);
const technicians: LiveTechnician[] = [...people].map(([userId, p]) => {
const session = sessionByUser.get(userId);
const status: LiveStatus = session ? SESSION_STATUS[session.status] ?? "free" : "free";
const order = session ? orderById.get(session.workOrderId) : undefined;
let current: LiveTechnician["current"] = null;
if (session && order) {
const coords = isValidLatLng(order.site) ? { latitude: order.site!.latitude as number, longitude: order.site!.longitude as number } : { latitude: null, longitude: null };
current = {
visible: true,
id: order.id,
number: order.number,
title: order.title,
customerName: customerDisplayName(order.customer),
siteName: order.site?.name ?? null,
address: siteAddress(order.site),
...coords,
};
} else if (session) {
current = { visible: false };
}
const myTeams = new Set(p.teams.map((x) => x.id));
const crewId = session?.teamId && myTeams.has(session.teamId) ? session.teamId : p.teams[0]?.id ?? null;
const next = todays
.filter((o) => (o.assignees.length ? o.assignees.some((a) => a.userId === userId) : !!o.assignedTeamId && myTeams.has(o.assignedTeamId)))
.slice(0, 3)
.map((o) => ({ id: o.id, number: o.number, title: o.title, plannedStart: o.plannedStart!.toISOString(), customerName: customerDisplayName(o.customer), city: o.site?.city ?? null }));
const since = session ? (segmentStart.get(session.id) ?? session.startedAt).toISOString() : null;
const delay = current?.visible ? delayById.get(current.id) ?? null : null;
return { userId, name: p.name, teams: p.teams, crewId, status, since, current, delay, next };
});
const counts = Object.fromEntries(LIVE_STATUSES.map((s) => [s, technicians.filter((x) => x.status === s).length])) as Record<LiveStatus, number>;
const matches = (x: { status: LiveStatus }) => !input.status || x.status === input.status;
const freedByTeam = new Map(freed.teams.map((f) => [f.teamId, f.earlyMinutes]));
// One entry per crew (team): members with their own status, crew order = order of the most active member.
const crews: LiveCrew[] = teams
.map((team) => {
const members = technicians.filter((x) => x.crewId === team.id).sort((a, b) => RANK[b.status] - RANK[a.status] || a.name.localeCompare(b.name, "de"));
const lead = members.find((m) => m.current?.visible);
const current = lead?.current?.visible ? (({ visible: _v, ...rest }) => (void _v, rest))(lead.current) : null;
return {
teamId: team.id,
teamName: team.name,
status: members[0]?.status ?? ("free" as LiveStatus),
current,
delay: current ? delayById.get(current.id) ?? null : null,
members: members.map((m) => ({ userId: m.userId, name: m.name, status: m.status, since: m.since, orderNumber: m.current?.visible ? m.current.number : null })),
freedMinutes: freedByTeam.get(team.id) ?? null,
};
})
.filter((c) => c.members.length > 0 && matches(c));
const filtered = technicians
.filter(matches)
.sort((a, b) => RANK[b.status] - RANK[a.status] || a.name.localeCompare(b.name, "de"));
const attention = todays
.filter((o) => o.plannedStart! < now)
.map((o) => ({
id: o.id,
number: o.number,
title: o.title,
plannedStart: o.plannedStart!.toISOString(),
teamName: o.team?.name ?? null,
customerName: customerDisplayName(o.customer),
reason: ((o.status as WorkOrderStatus) === "accepted" ? "overdue" : "not_accepted") as "overdue" | "not_accepted",
}));
return {
generatedAt: now.toISOString(),
timeZone,
teams: allTeams.map((tm) => ({ id: tm.id, name: tm.name })),
crews,
technicians: filtered,
counts,
attention,
freed: input.teamId ? freed.teams.filter((f) => f.teamId === input.teamId) : freed.teams,
// running orders (distinct) whose site has no coordinates — shown in the list only
withoutLocation: new Set(filtered.flatMap((x) => (x.current?.visible && x.current.latitude === null ? [x.current.id] : []))).size,
};
}
+222
View File
@@ -0,0 +1,222 @@
import { z } from "zod";
import deMessages from "../../../../messages/de/planning.json";
import enMessages from "../../../../messages/en/planning.json";
import { formatDistance, haversineKm, isValidLatLng } from "@/lib/geo/distance";
import { computeTeamDay, effectiveDurationMinutes, minutesOnDay, timeWindow } from "@/lib/planning/capacity";
import { addDays, dayBounds, dayKeyOf, dayRange, isDayKey, minutesOfDay } from "@/lib/planning/days";
import { fillTemplate, formatDayShort, formatMinutes } from "@/lib/planning/text";
import { wallTimeToUtc } from "@/lib/work-orders/time";
import { ServiceError, type ServiceCtx } from "@/server/services/context";
import { requestSiteGeocoding } from "@/server/services/geo/dispatch";
import { assertCanPlanAll } from "@/server/services/planning/access";
import {
loadOrder,
loadPlannedOrders,
loadTeams,
loadUnplannedOrders,
toBoardOrder,
toPlanningOrder,
toPlanningTeam,
type OrderRow,
} from "@/server/services/planning/data";
import { SCHEDULABLE_STATUSES } from "@/server/services/planning/schedule";
import { parseInput, tenantTimezone } from "@/server/services/work-orders/_shared";
/**
* Assignment suggestions (L13): straight-line distance to orders a team already has on a day +
* free capacity that day. Suggestions only — the human always schedules (no auto-save).
* Score: distance dominates (0.6), then free capacity (0.2), early date (weight by priority), tight −0.25.
*/
const MESSAGES = { de: deMessages, en: enMessages } as const;
type Locale = keyof typeof MESSAGES;
const PRIORITY_DATE_WEIGHT = { low: 0.05, normal: 0.15, high: 0.25, urgent: 0.35 } as const;
const DAY_START_MINUTES = 8 * 60;
const LATEST_START_MINUTES = 18 * 60;
const recommendSchema = z.object({
workOrderId: z.string().min(1).max(64),
days: z.coerce.number().int().min(1).max(31).default(10),
radiusKm: z.coerce.number().min(0.5).max(200).default(25),
from: z.string().refine(isDayKey).optional(),
locale: z.enum(["de", "en"]).default("de"),
});
export type RecommendInput = z.input<typeof recommendSchema>;
export type Recommendation = {
teamId: string;
teamName: string;
day: string;
distanceKm: number;
nearOrder: { id: string; number: string };
freeMinutes: number;
requiredMinutes: number;
tight: boolean;
score: number;
suggestedStart: string;
text: string;
};
export type RecommendationResult = {
workOrderId: string;
number: string;
status: "ok" | "no_coordinates" | "not_schedulable";
geocodeStatus: string | null;
requiredMinutes: number;
radiusKm: number;
recommendations: Recommendation[];
hint: string | null;
};
async function loadTarget(ctx: ServiceCtx, workOrderId: string): Promise<OrderRow> {
const row = await loadOrder(ctx, workOrderId);
if (!row) throw new ServiceError("not_found", "work_order_not_found");
return row;
}
/** Ask for geocoding when the target site has no coordinates (not for confirmed "not found"). */
function nudgeGeocoding(ctx: ServiceCtx, row: OrderRow) {
if (row.site && row.site.geocodeStatus !== "not_found") void requestSiteGeocoding(ctx.tenantId, row.site.id);
}
export async function recommendAssignments(ctx: ServiceCtx, raw: RecommendInput): Promise<RecommendationResult> {
await assertCanPlanAll(ctx);
const input = parseInput(recommendSchema, raw);
const timeZone = await tenantTimezone(ctx);
const t = MESSAGES[input.locale as Locale].recommend;
const row = await loadTarget(ctx, input.workOrderId);
const target = toPlanningOrder(row);
const requiredMinutes = effectiveDurationMinutes({ ...target, plannedEnd: null }, timeZone);
const base = {
workOrderId: row.id,
number: row.number,
geocodeStatus: row.site?.geocodeStatus ?? null,
requiredMinutes,
radiusKm: input.radiusKm,
recommendations: [] as Recommendation[],
};
if (!SCHEDULABLE_STATUSES.includes(row.status)) return { ...base, status: "not_schedulable", hint: t.notSchedulable };
if (!row.site || !isValidLatLng(row.site)) {
nudgeGeocoding(ctx, row);
return { ...base, status: "no_coordinates", hint: row.site?.geocodeStatus === "not_found" ? t.addressNotFound : t.noCoordinates };
}
const origin = { latitude: row.site.latitude as number, longitude: row.site.longitude as number };
const today = dayKeyOf(new Date(), timeZone);
const from = input.from && input.from > today ? input.from : today;
const days = dayRange(from, addDays(from, input.days - 1));
const teams = await loadTeams(ctx, null);
const rows = await loadPlannedOrders(ctx, {
teamIds: teams.map((tm) => tm.id),
start: dayBounds(days[0], timeZone).start,
end: dayBounds(days[days.length - 1], timeZone).end,
excludeId: row.id,
});
const byId = new Map(rows.map((r) => [r.id, r]));
const orders = rows.map(toPlanningOrder);
const candidates: Omit<Recommendation, "text">[] = [];
for (const team of teams) {
const pt = toPlanningTeam(team);
const teamOrders = orders.filter((o) => o.teamId === team.id);
days.forEach((day, idx) => {
const td = computeTeamDay(pt, day, teamOrders, timeZone);
if (!td.workingDay || td.capacityMinutes <= 0) return;
const freeMinutes = td.capacityMinutes - td.plannedMinutes;
if (freeMinutes <= 0) return;
let near: { id: string; number: string; km: number } | null = null;
for (const o of teamOrders) {
if (minutesOnDay(o, day, timeZone) <= 0) continue;
const site = byId.get(o.id)?.site;
if (!site || !isValidLatLng(site)) continue;
const km = haversineKm(origin, { latitude: site.latitude as number, longitude: site.longitude as number });
if (!near || km < near.km) near = { id: o.id, number: byId.get(o.id)!.number, km };
}
if (!near || near.km > input.radiusKm) return;
const tight = freeMinutes < requiredMinutes;
const score =
0.6 * (1 - near.km / input.radiusKm) +
0.2 * (Math.min(freeMinutes / requiredMinutes, 2) / 2) +
PRIORITY_DATE_WEIGHT[row.priority] * (1 - idx / days.length) -
(tight ? 0.25 : 0);
// Suggested start: 08:00 or right after the team's last timed order that day (15-min grid).
const lastEnd = teamOrders
.filter((o) => minutesOnDay(o, day, timeZone) > 0)
.map((o) => timeWindow(o, timeZone)?.end)
.filter((v): v is number => typeof v === "number")
.sort((a, b) => b - a)[0];
let startMinutes = DAY_START_MINUTES;
if (lastEnd !== undefined) {
const m = Math.ceil(minutesOfDay(new Date(lastEnd), timeZone) / 15) * 15;
if (m > startMinutes && m <= LATEST_START_MINUTES) startMinutes = m;
}
const hh = String(Math.floor(startMinutes / 60)).padStart(2, "0");
const mm = String(startMinutes % 60).padStart(2, "0");
const suggestedStart = wallTimeToUtc(`${day}T${hh}:${mm}`, timeZone) ?? dayBounds(day, timeZone).start;
candidates.push({
teamId: team.id,
teamName: team.name,
day,
distanceKm: Math.round(near.km * 10) / 10,
nearOrder: { id: near.id, number: near.number },
freeMinutes,
requiredMinutes,
tight,
score: Math.round(score * 1000) / 1000,
suggestedStart: suggestedStart.toISOString(),
});
});
}
const locale = input.locale;
const recommendations = candidates
.sort((a, b) => b.score - a.score || a.distanceKm - b.distanceKm || a.day.localeCompare(b.day))
.slice(0, 5)
.map((c) => ({
...c,
text: fillTemplate(c.tight ? t.reasonTight : t.reason, {
distance: formatDistance(c.distanceKm, locale),
number: c.nearOrder.number,
team: c.teamName,
day: formatDayShort(c.day, locale),
free: formatMinutes(c.freeMinutes, locale),
required: formatMinutes(c.requiredMinutes, locale),
}),
}));
return { ...base, status: "ok", recommendations, hint: recommendations.length ? null : fillTemplate(t.none, { radius: input.radiusKm, days: input.days }) };
}
const nearbySchema = z.object({
workOrderId: z.string().min(1).max(64),
radiusKm: z.coerce.number().min(0.5).max(100).default(5),
});
export type NearbyOrder = ReturnType<typeof toBoardOrder> & { distanceKm: number };
/** Other unplanned orders around the target ("zusammen einplanen?"). */
export async function findNearbyUnplanned(
ctx: ServiceCtx,
raw: z.input<typeof nearbySchema>,
): Promise<{ status: "ok" | "no_coordinates"; radiusKm: number; items: NearbyOrder[] }> {
await assertCanPlanAll(ctx);
const input = parseInput(nearbySchema, raw);
const timeZone = await tenantTimezone(ctx);
const row = await loadTarget(ctx, input.workOrderId);
if (!row.site || !isValidLatLng(row.site)) {
nudgeGeocoding(ctx, row);
return { status: "no_coordinates", radiusKm: input.radiusKm, items: [] };
}
const origin = { latitude: row.site.latitude as number, longitude: row.site.longitude as number };
const items = (await loadUnplannedOrders(ctx, null, 500))
.filter((r) => r.id !== row.id && r.site && isValidLatLng(r.site))
.map((r) => ({ ...toBoardOrder(r, timeZone), distanceKm: Math.round(haversineKm(origin, { latitude: r.site!.latitude as number, longitude: r.site!.longitude as number }) * 10) / 10 }))
.filter((r) => r.distanceKm <= input.radiusKm)
.sort((a, b) => a.distanceKm - b.distanceKm)
.slice(0, 10);
return { status: "ok", radiusKm: input.radiusKm, items };
}
+148
View File
@@ -0,0 +1,148 @@
import { z } from "zod";
import { orderDays, SCHEDULABLE_STATUSES, type PlanningConflict } from "@/lib/planning/capacity";
import type { WorkOrderStatus } from "@/lib/work-orders/status";
import { writeAuditLog } from "@/server/audit";
import { assertCan, inTransaction, ServiceError, type ServiceCtx } from "@/server/services/context";
import { getPlanningBoard, MAX_BOARD_DAYS } from "@/server/services/planning/board";
import { assignWorkOrder } from "@/server/services/work-orders/assign";
import { parseInput, snapshot, tenantTimezone, writeWithVersion } from "@/server/services/work-orders/_shared";
import { updateWorkOrder } from "@/server/services/work-orders/update";
import { workOrderScope } from "@/server/services/work-orders/visibility";
/**
* Schedule a work order from the planning board: team + planned start/end (+ duration) in ONE
* transaction on top of the L2 services (assignWorkOrder → work_order.assigned, updateWorkOrder →
* work_order.changed). The human always confirms — nothing is scheduled automatically.
*/
export { SCHEDULABLE_STATUSES };
export const VERSION_CONFLICT_MESSAGE = "Auftrag wurde zwischenzeitlich geändert";
const scheduleSchema = z.object({
workOrderId: z.string().min(1).max(64),
teamId: z.string().min(1).max(64),
plannedStart: z.coerce.date(),
plannedEnd: z.coerce.date().nullish(),
plannedDurationMinutes: z.coerce.number().int().min(15).max(14 * 24 * 60).nullish(),
baseVersion: z.coerce.number().int().positive(),
});
export type ScheduleInput = z.input<typeof scheduleSchema>;
export type ScheduleResult = {
id: string;
number: string;
version: number;
status: WorkOrderStatus;
teamId: string;
plannedStart: string;
plannedEnd: string | null;
plannedDurationMinutes: number | null;
/** Conflicts of the target team on the target day(s), computed after the commit. */
conflicts: PlanningConflict[];
};
export async function scheduleWorkOrder(ctx: ServiceCtx, raw: ScheduleInput): Promise<ScheduleResult> {
assertCan(ctx, "work_order:assign");
assertCan(ctx, "work_order:write");
const input = parseInput(scheduleSchema, raw);
if (Number.isNaN(input.plannedStart.getTime())) throw new ServiceError("invalid", "validation_failed", [{ path: "plannedStart" }]);
const saved = await inTransaction(ctx, async (tx) => {
const wo = await tx.db.workOrder.findFirst({
where: { AND: [{ id: input.workOrderId }, await workOrderScope(tx)] },
select: {
id: true,
number: true,
status: true,
version: true,
assignedTeamId: true,
teamLeadUserId: true,
plannedStart: true,
plannedEnd: true,
plannedDurationMinutes: true,
assignees: { select: { userId: true } },
},
});
if (!wo) throw new ServiceError("not_found", "work_order_not_found");
if (wo.version !== input.baseVersion) {
throw new ServiceError("conflict", VERSION_CONFLICT_MESSAGE, { reason: "version_conflict", currentVersion: wo.version, baseVersion: input.baseVersion });
}
if (!SCHEDULABLE_STATUSES.includes(wo.status as WorkOrderStatus)) throw new ServiceError("invalid", "not_schedulable", { status: wo.status });
// Keep the order's length when only the start moves and no new end was given.
const plannedEnd =
input.plannedEnd !== undefined
? input.plannedEnd ?? null
: wo.plannedStart && wo.plannedEnd
? new Date(input.plannedStart.getTime() + (wo.plannedEnd.getTime() - wo.plannedStart.getTime()))
: null;
const duration = input.plannedDurationMinutes !== undefined ? input.plannedDurationMinutes ?? null : wo.plannedDurationMinutes;
let version = wo.version;
let status = wo.status as WorkOrderStatus;
const teamChanged = wo.assignedTeamId !== input.teamId;
if (teamChanged || ["draft", "review_required", "planned"].includes(wo.status)) {
let userIds = wo.assignees.map((a) => a.userId);
if (teamChanged && userIds.length) {
const now = new Date();
const stay = await tx.db.teamMember.findMany({
where: { teamId: input.teamId, userId: { in: userIds }, validFrom: { lte: now }, OR: [{ validTo: null }, { validTo: { gt: now } }] },
select: { userId: true },
});
userIds = [...new Set(stay.map((s) => s.userId))];
}
const res = await assignWorkOrder(tx, {
workOrderId: wo.id,
teamId: input.teamId,
userIds,
teamLeadUserId: teamChanged ? null : wo.teamLeadUserId,
baseVersion: version,
});
version = res.version;
status = res.status;
}
const datesChanged = wo.plannedStart?.getTime() !== input.plannedStart.getTime() || (wo.plannedEnd?.getTime() ?? null) !== (plannedEnd?.getTime() ?? null);
if (datesChanged) {
const res = await updateWorkOrder(tx, wo.id, { plannedStart: input.plannedStart, plannedEnd }, version);
version = res.version;
}
if (duration !== wo.plannedDurationMinutes) {
version = await writeWithVersion(tx, { id: wo.id, version }, { plannedDurationMinutes: duration });
}
if (version !== wo.version) {
await writeAuditLog({
tenantId: tx.tenantId,
actorId: tx.userId,
action: "update",
entity: "work_order",
entityId: wo.id,
before: snapshot({ assignedTeamId: wo.assignedTeamId, plannedStart: wo.plannedStart, plannedEnd: wo.plannedEnd, plannedDurationMinutes: wo.plannedDurationMinutes, status: wo.status }),
after: snapshot({ source: "planning", assignedTeamId: input.teamId, plannedStart: input.plannedStart, plannedEnd, plannedDurationMinutes: duration, status, version }),
});
}
return { id: wo.id, number: wo.number, version, status, plannedEnd, duration };
});
let conflicts: PlanningConflict[] = [];
try {
const days = orderDays({ plannedStart: input.plannedStart, plannedEnd: saved.plannedEnd }, await tenantTimezone(ctx)).slice(0, MAX_BOARD_DAYS);
const board = await getPlanningBoard(ctx, { from: days[0], to: days[days.length - 1], teamIds: [input.teamId] });
conflicts = board.conflicts.filter((c) => c.teamIds.includes(input.teamId));
} catch (err) {
if (!(err instanceof ServiceError)) throw err;
}
return {
id: saved.id,
number: saved.number,
version: saved.version,
status: saved.status,
teamId: input.teamId,
plannedStart: input.plannedStart.toISOString(),
plannedEnd: saved.plannedEnd?.toISOString() ?? null,
plannedDurationMinutes: saved.duration,
conflicts,
};
}
+23
View File
@@ -0,0 +1,23 @@
import { dayKeyOf } from "@/lib/planning/days";
import { ServiceError, type ServiceCtx } from "@/server/services/context";
import { getPlanningBoard } from "@/server/services/planning/board";
import { getLiveSituation } from "@/server/services/planning/live";
import { tenantTimezone } from "@/server/services/work-orders/_shared";
/** Dashboard tile "Planung heute" (L13): crews on the job, conflicts today, unplanned orders. Null without planning access. */
export async function getPlanningToday(ctx: ServiceCtx): Promise<{ day: string; teamsTotal: number; teamsWorking: number; conflicts: number; unplanned: number } | null> {
try {
const day = dayKeyOf(new Date(), await tenantTimezone(ctx));
const [board, live] = await Promise.all([getPlanningBoard(ctx, { from: day, to: day }), getLiveSituation(ctx)]);
return {
day,
teamsTotal: board.teams.length,
teamsWorking: live.crews.filter((c) => c.status !== "free").length,
conflicts: board.conflictCount,
unplanned: board.unplanned.length,
};
} catch (err) {
if (err instanceof ServiceError && err.code === "forbidden") return null;
throw err;
}
}
@@ -0,0 +1,29 @@
import { z } from "zod";
import { writeAuditLog } from "@/server/audit";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
import { parseInput } from "@/server/services/work-orders/_shared";
/** Planning settings of a team = crew (L13): crew working day in minutes + working days bit mask. */
const settingsSchema = z.object({
dailyCapacityMinutes: z.coerce.number().int().min(0).max(24 * 60),
workingDays: z.coerce.number().int().min(0).max(127),
});
export type TeamPlanningSettingsInput = z.input<typeof settingsSchema>;
export async function updateTeamPlanningSettings(ctx: ServiceCtx, teamId: string, raw: TeamPlanningSettingsInput) {
assertCan(ctx, "team:manage");
const input = parseInput(settingsSchema, raw);
const before = await ctx.db.team.findFirst({
where: { id: teamId, deletedAt: null },
select: { id: true, dailyCapacityMinutes: true, workingDays: true },
});
if (!before) throw new ServiceError("not_found", "team_not_found");
const after = await ctx.db.team.update({
where: { id: teamId },
data: input,
select: { id: true, dailyCapacityMinutes: true, workingDays: true },
});
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "team", entityId: teamId, before, after });
return after;
}
@@ -0,0 +1,30 @@
import { Prisma } from "@prisma/client";
/**
* Adapter to the time tracking model of lane L12 (read-only use in planning).
*
* L12 adds `TimeEntry.source` (tracked | manual), `TimeEntry.approvalStatus` (approved | pending |
* rejected) and `WorkSession.manual`. Planning rules:
* - live situation / delay evaluation: only `source = tracked` entries of non-manual sessions,
* - actual duration of completed orders: only `approvalStatus = approved` entries.
* The filters are enabled automatically as soon as the generated Prisma client knows the fields
* (after the L12 merge); before that they are no-ops, so this lane compiles against both schemas.
*/
const entryFields = new Set<string>(Object.values(Prisma.TimeEntryScalarFieldEnum));
const sessionFields = new Set<string>(Object.values(Prisma.WorkSessionScalarFieldEnum));
export const TIME_TRACKING_FIELDS = {
entrySource: entryFields.has("source"),
entryApproval: entryFields.has("approvalStatus"),
sessionManual: sessionFields.has("manual"),
} as const;
/** Time entries recorded by the running clock (no manual additions). */
export const trackedEntryWhere = (TIME_TRACKING_FIELDS.entrySource ? { source: "tracked" } : {}) as unknown as Prisma.TimeEntryWhereInput;
/** Approved time entries (actual duration of completed orders). */
export const approvedEntryWhere = (TIME_TRACKING_FIELDS.entryApproval ? { approvalStatus: "approved" } : {}) as unknown as Prisma.TimeEntryWhereInput;
/** Sessions started by the clock (manual after-the-fact sessions excluded). */
export const trackedSessionWhere = (TIME_TRACKING_FIELDS.sessionManual ? { manual: false } : {}) as unknown as Prisma.WorkSessionWhereInput;
+391
View File
@@ -0,0 +1,391 @@
import { haversineKm, isValidLatLng } from "@/lib/geo/distance";
import {
computeTeamDay,
effectiveDurationMinutes,
estimateTravelMinutes,
hasTimeOfDay,
overrunBucket,
unionMinutes,
} from "@/lib/planning/capacity";
import { addDays, dayBounds, dayKeyOf, isDayKey } from "@/lib/planning/days";
import type { WorkOrderStatus } from "@/lib/work-orders/status";
import { writeAuditLog } from "@/server/audit";
import { dbForTenant, prisma } from "@/server/db";
import { emitEvent } from "@/server/events";
import { PERMISSIONS } from "@/server/rbac";
import type { ServiceCtx } from "@/server/services/context";
import { planningAccess } from "@/server/services/planning/access";
import {
loadPlannedOrders,
loadTeams,
loadUnplannedOrders,
ORDER_SELECT,
toBoardOrder,
toPlanningOrder,
toPlanningTeam,
type BoardOrder,
type OrderRow,
} from "@/server/services/planning/data";
import { approvedEntryWhere, trackedEntryWhere, trackedSessionWhere } from "@/server/services/planning/time-tracking";
import { tenantTimezone } from "@/server/services/work-orders/_shared";
import { workOrderScope } from "@/server/services/work-orders/visibility";
/**
* L13 Planung — watch functions:
* - evaluateDelays: running orders, recorded crew work time (union of tracked `work` segments of all
* sessions of the order — parallel crew members count once) vs. planned duration → ≥ 80 % "risk",
* ≥ 100 % "overrun"; next planned order of the same crew today → endangered when remaining time +
* estimated travel passes its start, or the overrun pushes the crew day over capacity.
* - getFreedCapacity: orders of the day completed before their planned end → free crew minutes +
* suggestions (pull forward later/next orders of the crew, nearby unplanned orders). Never changes data.
* - runPlanningWatch: the only place that emits planning.* events (job `planning-watch`, every 5 min);
* duplicates are prevented by an append-only ledger in the audit log (entity `planning_alert`).
*/
export const DELAY_RISK_RATIO = 0.8;
export const RUNNING_STATUSES: WorkOrderStatus[] = ["en_route", "in_progress", "paused", "waiting_material", "daily_report_created"];
export const COMPLETED_STATUSES: WorkOrderStatus[] = ["technically_completed", "signature_pending", "in_review", "released_for_billing", "billed"];
const UPCOMING_STATUSES: WorkOrderStatus[] = ["planned", "assigned", "accepted"];
const ACTIVE_SESSION_STATUSES: ("en_route" | "running" | "paused")[] = ["en_route", "running", "paused"];
export const MIN_FREED_MINUTES = 15;
const PULL_FORWARD_DAYS = 5;
const MAX_SUGGESTIONS = 3;
const coords = (row: Pick<OrderRow, "site"> | undefined) =>
row?.site && isValidLatLng(row.site) ? { latitude: row.site.latitude as number, longitude: row.site.longitude as number } : null;
const roundUpQuarter = (ms: number) => Math.ceil(ms / (15 * 60_000)) * 15 * 60_000;
// ---------------------------------------------------------------------------------------------
// Delays
// ---------------------------------------------------------------------------------------------
export type OrderDelay = {
workOrderId: string;
number: string;
teamId: string | null;
plannedMinutes: number;
workedMinutes: number;
remainingMinutes: number;
ratio: number;
percent: number;
level: "ok" | "risk" | "overrun";
};
export type FollowupRisk = {
workOrderId: string;
number: string;
teamId: string;
day: string;
blockedById: string;
blockedByNumber: string;
reason: "start" | "capacity";
etaAt: string;
travelMinutes: number;
};
export async function evaluateDelays(ctx: ServiceCtx, opts: { now?: Date; teamIds?: string[] | null } = {}): Promise<{ delays: OrderDelay[]; followups: FollowupRisk[] }> {
const now = opts.now ?? new Date();
const timeZone = await tenantTimezone(ctx);
const scope = await workOrderScope(ctx);
const running = await ctx.db.workOrder.findMany({
where: {
AND: [
scope,
{ status: { in: RUNNING_STATUSES } },
{ workSessions: { some: { AND: [trackedSessionWhere, { status: { in: ACTIVE_SESSION_STATUSES } }] } } },
opts.teamIds ? { assignedTeamId: { in: opts.teamIds } } : {},
],
},
select: ORDER_SELECT,
take: 500,
});
if (running.length === 0) return { delays: [], followups: [] };
const entries = await ctx.db.timeEntry.findMany({
where: { AND: [trackedEntryWhere, { type: "work", startedAt: { lt: now }, workSession: { AND: [trackedSessionWhere, { workOrderId: { in: running.map((r) => r.id) } }] } }] },
select: { startedAt: true, endedAt: true, workSession: { select: { workOrderId: true } } },
});
const segments = new Map<string, { start: number; end: number }[]>();
for (const e of entries) {
const list = segments.get(e.workSession.workOrderId) ?? [];
list.push({ start: e.startedAt.getTime(), end: Math.min((e.endedAt ?? now).getTime(), now.getTime()) });
segments.set(e.workSession.workOrderId, list);
}
const delays: OrderDelay[] = running.map((r) => {
const plannedMinutes = effectiveDurationMinutes(toPlanningOrder(r), timeZone);
const workedMinutes = unionMinutes(segments.get(r.id) ?? []);
const ratio = plannedMinutes > 0 ? workedMinutes / plannedMinutes : 0;
return {
workOrderId: r.id,
number: r.number,
teamId: r.assignedTeamId,
plannedMinutes,
workedMinutes,
remainingMinutes: Math.max(0, plannedMinutes - workedMinutes),
ratio,
percent: Math.round(ratio * 100),
level: ratio >= 1 ? "overrun" : ratio >= DELAY_RISK_RATIO ? "risk" : "ok",
};
});
const today = dayKeyOf(now, timeZone);
const { start, end } = dayBounds(today, timeZone);
const teamIds = [...new Set(delays.map((d) => d.teamId).filter((t): t is string => !!t))];
const [teams, dayOrders] = await Promise.all([loadTeams(ctx, teamIds), loadPlannedOrders(ctx, { teamIds, start, end })]);
const runningById = new Map(running.map((r) => [r.id, r]));
const followups: FollowupRisk[] = [];
for (const d of delays) {
const team = d.teamId ? teams.find((t) => t.id === d.teamId) : undefined;
if (!team) continue;
const next = dayOrders
.filter((o) => o.assignedTeamId === team.id && o.id !== d.workOrderId && UPCOMING_STATUSES.includes(o.status as WorkOrderStatus) && o.plannedStart && o.plannedStart.getTime() >= start.getTime())
.sort((a, b) => a.plannedStart!.getTime() - b.plannedStart!.getTime())[0];
if (!next) continue;
const from = coords(runningById.get(d.workOrderId));
const to = coords(next);
const travelMinutes = estimateTravelMinutes(from && to ? haversineKm(from, to) : null);
const eta = now.getTime() + (d.remainingMinutes + travelMinutes) * 60_000;
const overrunExtra = Math.max(0, d.workedMinutes - d.plannedMinutes);
let reason: FollowupRisk["reason"] | null = null;
if (hasTimeOfDay(next.plannedStart!, timeZone) && eta > next.plannedStart!.getTime()) {
reason = "start";
} else if (overrunExtra > 0) {
const td = computeTeamDay(toPlanningTeam(team), today, dayOrders.filter((o) => o.assignedTeamId === team.id).map(toPlanningOrder), timeZone);
if (td.capacityMinutes > 0 && td.plannedMinutes + overrunExtra + travelMinutes > td.capacityMinutes) reason = "capacity";
}
if (reason) {
followups.push({
workOrderId: next.id,
number: next.number,
teamId: team.id,
day: today,
blockedById: d.workOrderId,
blockedByNumber: d.number,
reason,
etaAt: new Date(eta).toISOString(),
travelMinutes,
});
}
}
return { delays, followups };
}
// ---------------------------------------------------------------------------------------------
// Freed capacity ("früher fertig")
// ---------------------------------------------------------------------------------------------
export type FreedSuggestion = {
kind: "pull_forward" | "nearby_unplanned";
order: BoardOrder;
distanceKm: number | null;
day: string;
suggestedStart: string;
};
export type FreedTeam = {
teamId: string;
teamName: string;
day: string;
earlyMinutes: number;
availableMinutes: number;
availableFrom: string;
orders: { id: string; number: string; earlyMinutes: number; finishedAt: string; actualMinutes: number | null }[];
suggestions: FreedSuggestion[];
};
export async function getFreedCapacity(
ctx: ServiceCtx,
raw: { date?: string | null; now?: Date; radiusKm?: number; withSuggestions?: boolean } = {},
): Promise<{ date: string; teams: FreedTeam[] }> {
const access = await planningAccess(ctx);
const now = raw.now ?? new Date();
const timeZone = await tenantTimezone(ctx);
const today = dayKeyOf(now, timeZone);
const date = raw.date && isDayKey(raw.date) ? raw.date : today;
const radiusKm = raw.radiusKm ?? 15;
const { start, end } = dayBounds(date, timeZone);
const rows = await ctx.db.workOrder.findMany({
where: {
AND: [
await workOrderScope(ctx),
{ status: { in: COMPLETED_STATUSES }, plannedStart: { gte: start, lt: end } },
access.teamIds ? { assignedTeamId: { in: access.teamIds } } : { assignedTeamId: { not: null } },
],
},
select: ORDER_SELECT,
take: 500,
});
if (rows.length === 0) return { date, teams: [] };
const ids = rows.map((r) => r.id);
// Session end (clock sessions only) and approved work time (actual duration after L12 approval).
const [sessions, approved] = await Promise.all([
ctx.db.workSession.findMany({
where: { AND: [trackedSessionWhere, { workOrderId: { in: ids }, status: "ended", endedAt: { not: null } }] },
select: { workOrderId: true, endedAt: true },
}),
ctx.db.timeEntry.findMany({
where: { AND: [approvedEntryWhere, { type: "work", endedAt: { not: null }, workSession: { workOrderId: { in: ids } } }] },
select: { startedAt: true, endedAt: true, workSession: { select: { workOrderId: true } } },
}),
]);
const lastEnd = new Map<string, number>();
for (const s of sessions) lastEnd.set(s.workOrderId, Math.max(lastEnd.get(s.workOrderId) ?? 0, s.endedAt!.getTime()));
const approvedSegments = new Map<string, { start: number; end: number }[]>();
for (const e of approved) {
const list = approvedSegments.get(e.workSession.workOrderId) ?? [];
list.push({ start: e.startedAt.getTime(), end: e.endedAt!.getTime() });
approvedSegments.set(e.workSession.workOrderId, list);
}
type Early = { row: OrderRow; finishedAt: number; plannedEndAt: number; earlyMinutes: number };
const byTeam = new Map<string, Early[]>();
for (const row of rows) {
const finishedAt = lastEnd.get(row.id);
if (!finishedAt || !row.plannedStart || !row.assignedTeamId || !hasTimeOfDay(row.plannedStart, timeZone)) continue;
const plannedEndAt = row.plannedStart.getTime() + effectiveDurationMinutes(toPlanningOrder(row), timeZone) * 60_000;
const earlyMinutes = Math.floor((plannedEndAt - finishedAt) / 60_000);
if (earlyMinutes < MIN_FREED_MINUTES) continue;
const list = byTeam.get(row.assignedTeamId) ?? [];
list.push({ row, finishedAt, plannedEndAt, earlyMinutes });
byTeam.set(row.assignedTeamId, list);
}
if (byTeam.size === 0) return { date, teams: [] };
const teams = await loadTeams(ctx, [...byTeam.keys()]);
const unplanned = raw.withSuggestions === false || date !== today ? [] : await loadUnplannedOrders(ctx, access.teamIds, 300);
const result: FreedTeam[] = [];
for (const team of teams) {
const early = byTeam.get(team.id)!;
const availableFromMs = Math.max(now.getTime(), ...early.map((e) => e.finishedAt));
const availableMinutes = early.reduce((sum, e) => sum + Math.max(0, Math.floor((e.plannedEndAt - Math.max(e.finishedAt, now.getTime())) / 60_000)), 0);
const suggestions: FreedSuggestion[] = [];
if (raw.withSuggestions !== false && date === today && availableMinutes >= MIN_FREED_MINUTES) {
const suggestedStart = new Date(roundUpQuarter(availableFromMs)).toISOString();
const later = await loadPlannedOrders(ctx, { teamIds: [team.id], start: new Date(availableFromMs), end: dayBounds(addDays(date, PULL_FORWARD_DAYS), timeZone).end });
for (const o of later
.filter((o) => UPCOMING_STATUSES.includes(o.status as WorkOrderStatus) && o.plannedStart && o.plannedStart.getTime() > availableFromMs)
.filter((o) => effectiveDurationMinutes(toPlanningOrder(o), timeZone) <= availableMinutes)
.slice(0, MAX_SUGGESTIONS)) {
suggestions.push({ kind: "pull_forward", order: toBoardOrder(o, timeZone), distanceKm: null, day: date, suggestedStart });
}
const latest = [...early].sort((a, b) => b.finishedAt - a.finishedAt)[0];
const origin = coords(latest.row);
if (origin) {
unplanned
.map((o) => ({ o, c: coords(o) }))
.filter((x): x is { o: OrderRow; c: { latitude: number; longitude: number } } => !!x.c)
.map(({ o, c }) => ({ o, km: haversineKm(origin, c) }))
.filter(({ o, km }) => km <= radiusKm && effectiveDurationMinutes(toPlanningOrder(o), timeZone) <= availableMinutes)
.sort((a, b) => a.km - b.km)
.slice(0, MAX_SUGGESTIONS)
.forEach(({ o, km }) => suggestions.push({ kind: "nearby_unplanned", order: toBoardOrder(o, timeZone), distanceKm: Math.round(km * 10) / 10, day: date, suggestedStart }));
}
}
result.push({
teamId: team.id,
teamName: team.name,
day: date,
earlyMinutes: early.reduce((sum, e) => sum + e.earlyMinutes, 0),
availableMinutes,
availableFrom: new Date(availableFromMs).toISOString(),
orders: early.map((e) => ({
id: e.row.id,
number: e.row.number,
earlyMinutes: e.earlyMinutes,
finishedAt: new Date(e.finishedAt).toISOString(),
actualMinutes: approvedSegments.has(e.row.id) ? unionMinutes(approvedSegments.get(e.row.id)!) : null,
})),
suggestions,
});
}
return { date, teams: result };
}
// ---------------------------------------------------------------------------------------------
// Watch job (events)
// ---------------------------------------------------------------------------------------------
export const WATCH_ACTOR_ID = "system:planning-watch";
/** System context of the watch job: all permissions within ONE tenant (dbForTenant + RLS). */
export function watchContext(tenantId: string): ServiceCtx {
return { db: dbForTenant(tenantId), tenantId, userId: WATCH_ACTOR_ID, permissions: new Set<string>(PERMISSIONS) };
}
export type AlertLedger = { claim(ctx: ServiceCtx, key: string, facts: Record<string, unknown>): Promise<boolean> };
/** Dedupe ledger in the append-only audit log (entity `planning_alert`, entityId = dedupe key). */
export const auditAlertLedger: AlertLedger = {
async claim(ctx, key, facts) {
const seen = await ctx.db.auditLog.findFirst({ where: { entity: "planning_alert", entityId: key }, select: { id: true } });
if (seen) return false;
await writeAuditLog({ tenantId: ctx.tenantId, action: "create", entity: "planning_alert", entityId: key, after: facts });
return true;
},
};
export type WatchResult = { overrun: number; followupAtRisk: number; capacityFreed: number };
export async function runPlanningWatch(
tenantId: string,
opts: { now?: Date; emit?: typeof emitEvent; ledger?: AlertLedger } = {},
): Promise<WatchResult> {
const ctx = watchContext(tenantId);
const now = opts.now ?? new Date();
const emit = opts.emit ?? emitEvent;
const ledger = opts.ledger ?? auditAlertLedger;
const result: WatchResult = { overrun: 0, followupAtRisk: 0, capacityFreed: 0 };
const { delays, followups } = await evaluateDelays(ctx, { now });
for (const d of delays) {
const bucket = overrunBucket(d.ratio);
if (bucket < 0) continue;
if (!(await ledger.claim(ctx, `planning.overrun:${d.workOrderId}:${bucket}`, { percent: d.percent, teamId: d.teamId }))) continue;
await emit(ctx, { type: "planning.overrun", entityType: "work_order", entityId: d.workOrderId, data: { percent: d.percent, teamId: d.teamId, occurrenceId: bucket } });
result.overrun++;
}
for (const f of followups) {
if (!(await ledger.claim(ctx, `planning.followup_at_risk:${f.workOrderId}:${f.day}`, { blockedBy: f.blockedByNumber, reason: f.reason }))) continue;
await emit(ctx, {
type: "planning.followup_at_risk",
entityType: "work_order",
entityId: f.workOrderId,
data: { blockerNumber: f.blockedByNumber, teamId: f.teamId, reason: f.reason, occurrenceId: f.day },
});
result.followupAtRisk++;
}
const freed = await getFreedCapacity(ctx, { now, withSuggestions: false });
for (const team of freed.teams) {
for (const o of team.orders) {
if (!(await ledger.claim(ctx, `planning.capacity_freed:${team.teamId}:${freed.date}:${o.id}`, { minutes: o.earlyMinutes }))) continue;
await emit(ctx, { type: "planning.capacity_freed", entityType: "work_order", entityId: o.id, data: { teamId: team.teamId, minutes: o.earlyMinutes, occurrenceId: freed.date } });
result.capacityFreed++;
}
}
return result;
}
/** All tenants (job payload tenantId "*"); a failing tenant never stops the others. */
export async function runPlanningWatchAllTenants(opts: { now?: Date } = {}): Promise<{ tenants: number; failed: number } & WatchResult> {
const tenants = await prisma.tenant.findMany({ select: { id: true } });
const total = { tenants: tenants.length, failed: 0, overrun: 0, followupAtRisk: 0, capacityFreed: 0 };
for (const t of tenants) {
try {
const r = await runPlanningWatch(t.id, opts);
total.overrun += r.overrun;
total.followupAtRisk += r.followupAtRisk;
total.capacityFreed += r.capacityFreed;
} catch (err) {
total.failed++;
console.error(`[planning-watch] tenant ${t.id} failed:`, (err as Error).message);
}
}
return total;
}
+4
View File
@@ -4,6 +4,8 @@ import { writeAuditLog } from "@/server/audit";
import { assertCan, ServiceError, type ServiceCtx } from "@/server/services/context";
import { siteScope } from "@/server/services/work-orders/visibility";
import { optStr } from "@/server/services/customers/schemas";
import { requestSiteGeocoding } from "@/server/services/geo/dispatch";
import { addressChanged } from "@/server/services/geo/normalize";
export const SITE_STATUSES = ["active", "inactive", "provisional"] as const;
@@ -121,6 +123,7 @@ export async function createSite(ctx: ServiceCtx, input: SiteCreateInput) {
await assertCustomerAndContact(ctx, data.customerId, data.contactId);
const site = await ctx.db.site.create({ data: { ...data, tenantId: ctx.tenantId, country: data.country ?? "DE" } });
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "create", entity: "site", entityId: site.id, after: site });
void requestSiteGeocoding(ctx.tenantId, site.id); // L13 Planung: queued, never inline
return site;
}
@@ -134,6 +137,7 @@ export async function updateSite(ctx: ServiceCtx, id: string, patch: SitePatchIn
await assertCustomerAndContact(ctx, customerId, contactId);
const after = await ctx.db.site.update({ where: { id }, data: { ...data, contactId } });
await writeAuditLog({ tenantId: ctx.tenantId, actorId: ctx.userId, action: "update", entity: "site", entityId: id, before, after });
if (addressChanged(before, after)) void requestSiteGeocoding(ctx.tenantId, id); // L13 Planung
return after;
}