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