Plantafel: Beispielwoche relativ zu heute; Live-Lage mit Kachelansicht
Neues Skript scripts/planning-demo.ts plant über createWorkOrder + scheduleWorkOrder eine Woche für beide Kolonnen (Auslastung, Überbuchung, Überschneidung, Mehrtagesauftrag, ungeplante Aufträge); die Demo-Termine aus dem Seed liegen relativ zum Seed-Tag und wandern sonst aus dem Standardzeitraum. Live-Lage: Umschalter Karte · Kacheln · Liste, Kachelansicht mit Status, Auftrag und Ort; LiveMap meldet nicht erreichbare Kartenkacheln und die Ansicht wechselt automatisch. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
This commit is contained in:
@@ -79,6 +79,8 @@ für Lesepfade nach erneutem Login; Mutationen prüfen autoritativ gegen die DB
|
|||||||
docker compose up -d postgres redis garage # Infrastruktur (+ --profile dev für mailhog)
|
docker compose up -d postgres redis garage # Infrastruktur (+ --profile dev für mailhog)
|
||||||
GARAGE_ADMIN_URL=http://localhost:3903 npx tsx scripts/garage-provision.ts # Bucket/Key (einmalig)
|
GARAGE_ADMIN_URL=http://localhost:3903 npx tsx scripts/garage-provision.ts # Bucket/Key (einmalig)
|
||||||
npx prisma migrate deploy && npx prisma db seed # Schema + Demo-Daten
|
npx prisma migrate deploy && npx prisma db seed # Schema + Demo-Daten
|
||||||
|
npx tsx scripts/geocode-backfill.ts --tenant=demo # Koordinaten der Objekte (Karte, Empfehlungen)
|
||||||
|
npx tsx scripts/planning-demo.ts # Beispielbelegung der Plantafel (relativ zu heute)
|
||||||
npm run dev # App auf :3000
|
npm run dev # App auf :3000
|
||||||
npm run gate # vollständiges Qualitäts-Gate
|
npm run gate # vollständiges Qualitäts-Gate
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -27,6 +27,8 @@ docker compose up -d postgres redis garage
|
|||||||
GARAGE_ADMIN_URL=http://localhost:3903 npx tsx scripts/garage-provision.ts
|
GARAGE_ADMIN_URL=http://localhost:3903 npx tsx scripts/garage-provision.ts
|
||||||
npx prisma migrate deploy
|
npx prisma migrate deploy
|
||||||
npx prisma db seed
|
npx prisma db seed
|
||||||
|
npx tsx scripts/geocode-backfill.ts --tenant=demo # Koordinaten für Karte/Empfehlungen (1 Anfrage/s)
|
||||||
|
npx tsx scripts/planning-demo.ts # Beispielbelegung der Plantafel (heute + 4 Werktage)
|
||||||
npm run dev # http://localhost:3000
|
npm run dev # http://localhost:3000
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -117,6 +117,7 @@ Nicht angefasst: `services/field/**`, `components/field/**`, `app/(field)/m/**`,
|
|||||||
- **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.
|
- **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`.
|
- 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`.
|
||||||
|
|
||||||
|
- **Beispieldaten für die Plantafel:** `npx tsx scripts/planning-demo.ts` plant relativ zu **heute** eine Woche für beide Kolonnen (hohe Auslastung, Überbuchung, Überschneidung, Mehrtagesauftrag, ungeplante Aufträge). Die Demo-Daten aus `demo-seed.ts` liegen relativ zum Seed-Tag und wandern sonst aus dem Standardzeitraum; `--reset` entfernt die Beispiele wieder.
|
||||||
- **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.
|
- **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
|
## 9. Bekannte Lücken / offene Punkte
|
||||||
|
|||||||
@@ -150,7 +150,9 @@
|
|||||||
"refreshing": "Aktualisiert …",
|
"refreshing": "Aktualisiert …",
|
||||||
"updateFailed": "Aktualisierung fehlgeschlagen – der letzte Stand wird angezeigt.",
|
"updateFailed": "Aktualisierung fehlgeschlagen – der letzte Stand wird angezeigt.",
|
||||||
"tabsLabel": "Darstellung",
|
"tabsLabel": "Darstellung",
|
||||||
"tabs": { "map": "Karte", "list": "Liste" },
|
"tabs": { "map": "Karte", "tiles": "Kacheln", "list": "Liste" },
|
||||||
|
"tileMembers": "{count} Personen",
|
||||||
|
"mapUnavailable": "Kartenkacheln sind gerade nicht erreichbar – die Kachelansicht zeigt denselben Stand.",
|
||||||
"filterTeam": "Team",
|
"filterTeam": "Team",
|
||||||
"filterStatus": "Status",
|
"filterStatus": "Status",
|
||||||
"all": "Alle",
|
"all": "Alle",
|
||||||
|
|||||||
@@ -150,7 +150,9 @@
|
|||||||
"refreshing": "Refreshing …",
|
"refreshing": "Refreshing …",
|
||||||
"updateFailed": "Update failed – showing the last state.",
|
"updateFailed": "Update failed – showing the last state.",
|
||||||
"tabsLabel": "Display",
|
"tabsLabel": "Display",
|
||||||
"tabs": { "map": "Map", "list": "List" },
|
"tabs": { "map": "Map", "tiles": "Tiles", "list": "List" },
|
||||||
|
"tileMembers": "{count} people",
|
||||||
|
"mapUnavailable": "Map tiles are currently unreachable – the tile view shows the same data.",
|
||||||
"filterTeam": "Team",
|
"filterTeam": "Team",
|
||||||
"filterStatus": "Status",
|
"filterStatus": "Status",
|
||||||
"all": "All",
|
"all": "All",
|
||||||
|
|||||||
@@ -0,0 +1,185 @@
|
|||||||
|
/**
|
||||||
|
* Beispielbelegung der Plantafel (L13) für die visuelle Prüfung — relativ zu HEUTE.
|
||||||
|
*
|
||||||
|
* Die Demo-Fachdaten aus `scripts/lib/demo-seed.ts` liegen relativ zum Tag des Seed-Laufs; einen Tag
|
||||||
|
* später ist die Plantafel im Standardzeitraum (heute + 4 Werktage) deshalb fast leer. Dieses Skript
|
||||||
|
* plant eine sprechende Woche über die echten Services (createWorkOrder + scheduleWorkOrder), sodass
|
||||||
|
* Auslastung, Konflikte, Mehrtagesaufträge und die Spalte „Ungeplante Aufträge" sichtbar sind.
|
||||||
|
*
|
||||||
|
* npx tsx scripts/planning-demo.ts # anlegen bzw. auf heute umplanen (idempotent)
|
||||||
|
* npx tsx scripts/planning-demo.ts --tenant=demo # anderer Mandant (Slug)
|
||||||
|
* npx tsx scripts/planning-demo.ts --reset # Beispiele wieder entfernen (Soft Delete)
|
||||||
|
*
|
||||||
|
* Kennzeichen aller Beispiele: externe Auftragsnummer `PLAN-…`.
|
||||||
|
*/
|
||||||
|
import "dotenv/config";
|
||||||
|
import { dbForTenant, prisma } from "../src/server/db";
|
||||||
|
import { ROLE_DEFS } from "../src/server/rbac";
|
||||||
|
import type { ServiceCtx } from "../src/server/services/context";
|
||||||
|
import { scheduleWorkOrder } from "../src/server/services/planning/schedule";
|
||||||
|
import { createWorkOrder } from "../src/server/services/work-orders/create";
|
||||||
|
|
||||||
|
const args = process.argv.slice(2);
|
||||||
|
const RESET = args.includes("--reset");
|
||||||
|
const SLUG = args.find((a) => a.startsWith("--tenant="))?.slice("--tenant=".length) ?? "demo";
|
||||||
|
const PREFIX = "PLAN-";
|
||||||
|
|
||||||
|
/** Lokaler Zeitpunkt am n-ten Werktag ab heute (0 = heute bzw. der nächste Werktag). */
|
||||||
|
function workingDays(count: number): Date[] {
|
||||||
|
const out: Date[] = [];
|
||||||
|
const d = new Date();
|
||||||
|
d.setHours(0, 0, 0, 0);
|
||||||
|
while (out.length < count) {
|
||||||
|
if (d.getDay() >= 1 && d.getDay() <= 5) out.push(new Date(d));
|
||||||
|
d.setDate(d.getDate() + 1);
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}
|
||||||
|
|
||||||
|
function at(day: Date, h: number, m = 0): Date {
|
||||||
|
const d = new Date(day);
|
||||||
|
d.setHours(h, m, 0, 0);
|
||||||
|
return d;
|
||||||
|
}
|
||||||
|
|
||||||
|
type Example = {
|
||||||
|
ext: string;
|
||||||
|
title: string;
|
||||||
|
typeKey: string;
|
||||||
|
crew: "nord" | "sued";
|
||||||
|
/** Index im Werktagsfenster (0 = heute) */
|
||||||
|
dayIndex: number;
|
||||||
|
from: [number, number];
|
||||||
|
to: [number, number];
|
||||||
|
/** Mehrtägig: Ende an einem späteren Werktag */
|
||||||
|
untilDayIndex?: number;
|
||||||
|
priority?: "low" | "normal" | "high" | "urgent";
|
||||||
|
note?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Bewusst gewähltes Bild: hohe Auslastung, eine Überbuchung, eine Überschneidung, ein
|
||||||
|
* Mehrtagesauftrag und ruhige Tage — damit alle Zustände der Plantafel sichtbar werden.
|
||||||
|
*/
|
||||||
|
const EXAMPLES: Example[] = [
|
||||||
|
// Heute: Nord gut ausgelastet (7,5 h / 8 h), Süd halber Tag
|
||||||
|
{ ext: `${PREFIX}01`, title: "Heizungswartung Mehrfamilienhaus", typeKey: "wartung", crew: "nord", dayIndex: 0, from: [8, 0], to: [12, 0] },
|
||||||
|
{ ext: `${PREFIX}02`, title: "Thermostatventile tauschen", typeKey: "reparatur", crew: "nord", dayIndex: 0, from: [13, 0], to: [16, 30] },
|
||||||
|
{ ext: `${PREFIX}03`, title: "Rückstauklappe prüfen", typeKey: "wartung", crew: "sued", dayIndex: 0, from: [9, 0], to: [12, 0] },
|
||||||
|
// Morgen: Nord überbucht (9 h > 8 h Kolonnentag), Süd zwei Termine hintereinander
|
||||||
|
{ ext: `${PREFIX}04`, title: "Badsanierung – Rohinstallation", typeKey: "montage", crew: "nord", dayIndex: 1, from: [7, 30], to: [16, 30], priority: "high" },
|
||||||
|
{ ext: `${PREFIX}05`, title: "Warmwasserspeicher entkalken", typeKey: "wartung", crew: "sued", dayIndex: 1, from: [8, 0], to: [11, 0] },
|
||||||
|
{ ext: `${PREFIX}06`, title: "Waschtisch-Armatur erneuern", typeKey: "reparatur", crew: "sued", dayIndex: 1, from: [11, 30], to: [14, 0] },
|
||||||
|
// Tag 3: Überschneidung bei Nord (zwei Aufträge zur selben Zeit)
|
||||||
|
{ ext: `${PREFIX}07`, title: "Gastherme Störung beheben", typeKey: "stoerung", crew: "nord", dayIndex: 2, from: [8, 0], to: [12, 0], priority: "urgent" },
|
||||||
|
{ ext: `${PREFIX}08`, title: "Sicherheitsventil wechseln", typeKey: "reparatur", crew: "nord", dayIndex: 2, from: [10, 0], to: [14, 0] },
|
||||||
|
// Tag 4/5: Mehrtagesauftrag Nord, Süd mit vollem Tag
|
||||||
|
{ ext: `${PREFIX}09`, title: "Strangsanierung Steigleitung (2 Tage)", typeKey: "montage", crew: "nord", dayIndex: 3, from: [7, 0], to: [16, 0], untilDayIndex: 4 },
|
||||||
|
{ ext: `${PREFIX}10`, title: "Lüftungsanlage Filterwechsel", typeKey: "wartung", crew: "sued", dayIndex: 3, from: [8, 0], to: [15, 0] },
|
||||||
|
{ ext: `${PREFIX}11`, title: "Abnahme Trinkwasserprobe", typeKey: "abnahme", crew: "sued", dayIndex: 4, from: [8, 0], to: [12, 0] },
|
||||||
|
];
|
||||||
|
|
||||||
|
/** Ungeplant: füllt die Spalte „Ungeplante Aufträge" (Drag-&-Drop-Material für die Prüfung). */
|
||||||
|
const UNPLANNED: { ext: string; title: string; typeKey: string; priority?: "low" | "normal" | "high" | "urgent" }[] = [
|
||||||
|
{ ext: `${PREFIX}20`, title: "Neue Duscharmatur montieren", typeKey: "montage" },
|
||||||
|
{ ext: `${PREFIX}21`, title: "Tropfender Außenhahn", typeKey: "reparatur", priority: "low" },
|
||||||
|
{ ext: `${PREFIX}22`, title: "Jahreswartung Wärmepumpe", typeKey: "wartung" },
|
||||||
|
{ ext: `${PREFIX}23`, title: "Heizkörper entlüften – Notdienstnachlauf", typeKey: "nacharbeit", priority: "high" },
|
||||||
|
];
|
||||||
|
|
||||||
|
async function main() {
|
||||||
|
const tenant = await prisma.tenant.findUnique({ where: { slug: SLUG }, select: { id: true, name: true } });
|
||||||
|
if (!tenant) throw new Error(`Mandant „${SLUG}" nicht gefunden.`);
|
||||||
|
const db = dbForTenant(tenant.id);
|
||||||
|
|
||||||
|
if (RESET) {
|
||||||
|
const res = await prisma.workOrder.updateMany({
|
||||||
|
where: { tenantId: tenant.id, externalOrderNumber: { startsWith: PREFIX }, deletedAt: null },
|
||||||
|
data: { deletedAt: new Date() },
|
||||||
|
});
|
||||||
|
console.log(`✔ ${res.count} Beispielauftrag/-aufträge entfernt (${tenant.name}).`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const backoffice = await db.user.findFirst({ where: { email: { contains: "backoffice" }, status: "ACTIVE" }, select: { id: true } });
|
||||||
|
if (!backoffice) throw new Error("Kein Backoffice-Nutzer im Mandanten gefunden.");
|
||||||
|
const ctx: ServiceCtx = { db, tenantId: tenant.id, userId: backoffice.id, permissions: new Set(ROLE_DEFS.backoffice.permissions) };
|
||||||
|
|
||||||
|
const teams = await db.team.findMany({ where: { deletedAt: null, status: "active" }, select: { id: true, name: true, members: { select: { userId: true, validTo: true } } }, orderBy: { name: "asc" } });
|
||||||
|
if (teams.length < 2) throw new Error("Mindestens zwei aktive Teams (Kolonnen) nötig — bitte zuerst `npx prisma db seed` ausführen.");
|
||||||
|
const crews = { nord: teams[0], sued: teams[1] };
|
||||||
|
|
||||||
|
const types = new Map((await db.orderType.findMany({ select: { id: true, key: true } })).map((t) => [t.key, t.id]));
|
||||||
|
// Objekte mit Koordinaten zuerst: so haben Karte und Empfehlungen (Luftlinie) etwas zu zeigen.
|
||||||
|
const sites = await db.site.findMany({
|
||||||
|
where: { deletedAt: null, customer: { deletedAt: null } },
|
||||||
|
select: { id: true, name: true, customerId: true, latitude: true },
|
||||||
|
orderBy: [{ latitude: "desc" }, { name: "asc" }],
|
||||||
|
});
|
||||||
|
if (!sites.length) throw new Error("Keine Objekte im Mandanten — bitte zuerst `npx prisma db seed` ausführen.");
|
||||||
|
const site = (i: number) => sites[i % sites.length];
|
||||||
|
|
||||||
|
const days = workingDays(6);
|
||||||
|
let created = 0;
|
||||||
|
let planned = 0;
|
||||||
|
|
||||||
|
/** Legt den Auftrag an (oder findet den vorhandenen) — immer ohne Termin, geplant wird danach. */
|
||||||
|
async function ensureOrder(ext: string, title: string, typeKey: string, index: number, priority?: Example["priority"], note?: string) {
|
||||||
|
const existing = await db.workOrder.findFirst({ where: { externalOrderNumber: ext, deletedAt: null }, select: { id: true, version: true, status: true } });
|
||||||
|
if (existing) return existing;
|
||||||
|
const s = site(index);
|
||||||
|
const wo = await createWorkOrder(ctx, {
|
||||||
|
title,
|
||||||
|
customerId: s.customerId,
|
||||||
|
siteId: s.id,
|
||||||
|
orderTypeId: types.get(typeKey) ?? null,
|
||||||
|
status: "planned",
|
||||||
|
priority: priority ?? "normal",
|
||||||
|
externalOrderNumber: ext,
|
||||||
|
technicianNotes: note,
|
||||||
|
applyTemplate: false,
|
||||||
|
});
|
||||||
|
created++;
|
||||||
|
return { id: wo.id, version: wo.version, status: wo.status };
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [i, ex] of EXAMPLES.entries()) {
|
||||||
|
const wo = await ensureOrder(ex.ext, ex.title, ex.typeKey, i, ex.priority, ex.note);
|
||||||
|
const startDay = days[ex.dayIndex];
|
||||||
|
const endDay = days[ex.untilDayIndex ?? ex.dayIndex];
|
||||||
|
const crew = crews[ex.crew];
|
||||||
|
const current = await db.workOrder.findFirstOrThrow({ where: { id: wo.id }, select: { version: true, status: true } });
|
||||||
|
// Laufende oder abgeschlossene Aufträge verschiebt die Plantafel nicht — solche Beispiele überspringen.
|
||||||
|
if (!["draft", "review_required", "planned", "assigned", "accepted"].includes(current.status)) continue;
|
||||||
|
await scheduleWorkOrder(ctx, {
|
||||||
|
workOrderId: wo.id,
|
||||||
|
teamId: crew.id,
|
||||||
|
plannedStart: at(startDay, ex.from[0], ex.from[1]),
|
||||||
|
plannedEnd: at(endDay, ex.to[0], ex.to[1]),
|
||||||
|
baseVersion: current.version,
|
||||||
|
});
|
||||||
|
planned++;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const [i, u] of UNPLANNED.entries()) {
|
||||||
|
await ensureOrder(u.ext, u.title, u.typeKey, EXAMPLES.length + i, u.priority);
|
||||||
|
}
|
||||||
|
|
||||||
|
const from = days[0].toLocaleDateString("de-DE");
|
||||||
|
const to = days[4].toLocaleDateString("de-DE");
|
||||||
|
console.log(`✔ ${tenant.name}: ${created} Auftrag/Aufträge neu angelegt, ${planned} eingeplant (${from} – ${to}).`);
|
||||||
|
console.log(` Kolonnen: ${crews.nord.name} · ${crews.sued.name} — Beispiele enthalten Überbuchung, Überschneidung und einen Mehrtagesauftrag.`);
|
||||||
|
console.log(` Ungeplant: ${UNPLANNED.length} Aufträge in der Spalte „Ungeplante Aufträge".`);
|
||||||
|
}
|
||||||
|
|
||||||
|
main()
|
||||||
|
.then(async () => {
|
||||||
|
await prisma.$disconnect();
|
||||||
|
// Ereignisse der Services halten Queue-Verbindungen offen → Prozess bewusst beenden.
|
||||||
|
process.exit(0);
|
||||||
|
})
|
||||||
|
.catch(async (err) => {
|
||||||
|
console.error(err instanceof Error ? err.message : err);
|
||||||
|
await prisma.$disconnect().catch(() => undefined);
|
||||||
|
process.exit(1);
|
||||||
|
});
|
||||||
@@ -45,14 +45,35 @@ function iconHtml(m: LiveMarker): string {
|
|||||||
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>`;
|
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 }) {
|
export function LiveMap({
|
||||||
|
markers,
|
||||||
|
tileUrl,
|
||||||
|
attribution,
|
||||||
|
label,
|
||||||
|
loadingLabel,
|
||||||
|
onTilesUnavailable,
|
||||||
|
}: {
|
||||||
|
markers: LiveMarker[];
|
||||||
|
tileUrl: string;
|
||||||
|
attribution: string;
|
||||||
|
label: string;
|
||||||
|
loadingLabel: string;
|
||||||
|
/** Kacheln nicht erreichbar (offline, gesperrter Host) → Aufrufer zeigt die Kachelansicht. */
|
||||||
|
onTilesUnavailable?: () => void;
|
||||||
|
}) {
|
||||||
const container = useRef<HTMLDivElement>(null);
|
const container = useRef<HTMLDivElement>(null);
|
||||||
const mapRef = useRef<LeafletMap | null>(null);
|
const mapRef = useRef<LeafletMap | null>(null);
|
||||||
const layerRef = useRef<LayerGroup | null>(null);
|
const layerRef = useRef<LayerGroup | null>(null);
|
||||||
const leafletRef = useRef<typeof import("leaflet") | null>(null);
|
const leafletRef = useRef<typeof import("leaflet") | null>(null);
|
||||||
const fittedRef = useRef<string>("");
|
const fittedRef = useRef<string>("");
|
||||||
|
const failedRef = useRef(false);
|
||||||
|
const unavailableRef = useRef(onTilesUnavailable);
|
||||||
const [ready, setReady] = useState(false);
|
const [ready, setReady] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
unavailableRef.current = onTilesUnavailable;
|
||||||
|
}, [onTilesUnavailable]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
import("leaflet").then((mod) => {
|
import("leaflet").then((mod) => {
|
||||||
@@ -60,10 +81,19 @@ export function LiveMap({ markers, tileUrl, attribution, label, loadingLabel }:
|
|||||||
if (cancelled || !container.current || mapRef.current) return;
|
if (cancelled || !container.current || mapRef.current) return;
|
||||||
leafletRef.current = L;
|
leafletRef.current = L;
|
||||||
const map = L.map(container.current, { center: [51.1657, 10.4515], zoom: 6, scrollWheelZoom: true });
|
const map = L.map(container.current, { center: [51.1657, 10.4515], zoom: 6, scrollWheelZoom: true });
|
||||||
L.tileLayer(tileUrl, {
|
const tiles = L.tileLayer(tileUrl, {
|
||||||
maxZoom: 19,
|
maxZoom: 19,
|
||||||
attribution: `${escapeHtml(attribution)} (<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">ODbL</a>)`,
|
attribution: `${escapeHtml(attribution)} (<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer">ODbL</a>)`,
|
||||||
}).addTo(map);
|
});
|
||||||
|
// mehrere fehlgeschlagene Kacheln = kein Kartendienst erreichbar (einmalig melden)
|
||||||
|
let tileErrors = 0;
|
||||||
|
tiles.on("tileerror", () => {
|
||||||
|
tileErrors += 1;
|
||||||
|
if (tileErrors < 3 || failedRef.current) return;
|
||||||
|
failedRef.current = true;
|
||||||
|
unavailableRef.current?.();
|
||||||
|
});
|
||||||
|
tiles.addTo(map);
|
||||||
layerRef.current = L.layerGroup().addTo(map);
|
layerRef.current = L.layerGroup().addTo(map);
|
||||||
mapRef.current = map;
|
mapRef.current = map;
|
||||||
setReady(true);
|
setReady(true);
|
||||||
|
|||||||
@@ -30,7 +30,8 @@ export function LiveSituationView({ initial, tileUrl, attribution, locale }: { i
|
|||||||
const [data, setData] = useState(initial);
|
const [data, setData] = useState(initial);
|
||||||
const [teamId, setTeamId] = useState("");
|
const [teamId, setTeamId] = useState("");
|
||||||
const [status, setStatus] = useState<LiveStatus | "">("");
|
const [status, setStatus] = useState<LiveStatus | "">("");
|
||||||
const [tab, setTab] = useState<"map" | "list">("map");
|
const [tab, setTab] = useState<"map" | "tiles" | "list">("map");
|
||||||
|
const [tilesFallback, setTilesFallback] = useState(false);
|
||||||
const [loading, setLoading] = useState(false);
|
const [loading, setLoading] = useState(false);
|
||||||
const [failed, setFailed] = useState(false);
|
const [failed, setFailed] = useState(false);
|
||||||
const tz = data.timeZone;
|
const tz = data.timeZone;
|
||||||
@@ -100,6 +101,48 @@ export function LiveSituationView({ initial, tileUrl, attribution, locale }: { i
|
|||||||
return out;
|
return out;
|
||||||
}, [data.crews, solo, t, delayText]);
|
}, [data.crews, solo, t, delayText]);
|
||||||
|
|
||||||
|
type LiveTile = {
|
||||||
|
key: string;
|
||||||
|
name: string;
|
||||||
|
status: LiveStatus;
|
||||||
|
memberCount: number | null;
|
||||||
|
delay: LiveDelay | null;
|
||||||
|
freedMinutes: number | null;
|
||||||
|
hiddenOrder: boolean;
|
||||||
|
order: { id: string; number: string; customerName: string; place: string | null; hasLocation: boolean } | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Kacheln = dieselbe Lage wie auf der Karte, aber ohne Kartendienst (auch für Auswertung am Schreibtisch)
|
||||||
|
const tiles = useMemo<LiveTile[]>(() => {
|
||||||
|
const crewTiles: LiveTile[] = data.crews.map((crew) => ({
|
||||||
|
key: `crew-${crew.teamId}`,
|
||||||
|
name: crew.teamName,
|
||||||
|
status: crew.status,
|
||||||
|
memberCount: crew.members.length,
|
||||||
|
delay: crew.delay,
|
||||||
|
freedMinutes: crew.freedMinutes,
|
||||||
|
hiddenOrder: false,
|
||||||
|
order: crew.current
|
||||||
|
? { id: crew.current.id, number: crew.current.number, customerName: crew.current.customerName, place: crew.current.address ?? crew.current.siteName, hasLocation: crew.current.latitude !== null }
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
const soloTiles: LiveTile[] = solo.map((tech) => ({
|
||||||
|
key: `solo-${tech.userId}`,
|
||||||
|
name: tech.name,
|
||||||
|
status: tech.status,
|
||||||
|
memberCount: null,
|
||||||
|
delay: tech.delay,
|
||||||
|
freedMinutes: null,
|
||||||
|
hiddenOrder: !!tech.current && !tech.current.visible,
|
||||||
|
order:
|
||||||
|
tech.current && tech.current.visible
|
||||||
|
? { id: tech.current.id, number: tech.current.number, customerName: tech.current.customerName, place: tech.current.address ?? tech.current.siteName, hasLocation: tech.current.latitude !== null }
|
||||||
|
: null,
|
||||||
|
}));
|
||||||
|
const rank: Record<LiveStatus, number> = { working: 3, en_route: 2, paused: 1, free: 0 };
|
||||||
|
return [...crewTiles, ...soloTiles].sort((a, b) => rank[b.status] - rank[a.status] || a.name.localeCompare(b.name));
|
||||||
|
}, [data.crews, solo]);
|
||||||
|
|
||||||
const statusBadge = (s: LiveStatus) => {
|
const statusBadge = (s: LiveStatus) => {
|
||||||
const Icon = STATUS_ICON[s];
|
const Icon = STATUS_ICON[s];
|
||||||
return (
|
return (
|
||||||
@@ -162,6 +205,46 @@ export function LiveSituationView({ initial, tileUrl, attribution, locale }: { i
|
|||||||
</li>
|
</li>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const tileCard = (tile: LiveTile) => (
|
||||||
|
<li key={tile.key} className="shadow-card rounded-xl border border-l-4 bg-card p-3 text-sm" style={{ borderLeftColor: tile.delay ? (tile.delay.level === "overrun" ? "var(--risk)" : "var(--warn)") : STATUS_TONE[tile.status] }}>
|
||||||
|
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||||
|
<p className="flex items-center gap-1.5 font-semibold">
|
||||||
|
{tile.memberCount === null ? <Wrench className="size-4" aria-hidden /> : <UsersRound className="size-4" aria-hidden />}
|
||||||
|
{tile.name}
|
||||||
|
</p>
|
||||||
|
{statusBadge(tile.status)}
|
||||||
|
</div>
|
||||||
|
{tile.memberCount !== null && <p className="text-xs text-muted-foreground">{t("live.tileMembers", { count: tile.memberCount })}</p>}
|
||||||
|
{delayBadge(tile.delay)}
|
||||||
|
{tile.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: tile.name, minutes: formatMinutes(tile.freedMinutes, locale) })}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
{tile.order ? (
|
||||||
|
<div className="mt-1">
|
||||||
|
<Link href={`/work-orders/${tile.order.id}`} className="font-mono text-xs font-semibold underline-offset-2 hover:underline">
|
||||||
|
{tile.order.number}
|
||||||
|
</Link>{" "}
|
||||||
|
<span className="text-xs">{tile.order.customerName}</span>
|
||||||
|
<p className="flex items-center gap-1 text-xs text-muted-foreground">
|
||||||
|
{!tile.order.hasLocation && <MapPinOff className="size-3.5" aria-hidden />}
|
||||||
|
{tile.order.place ?? ""}
|
||||||
|
{!tile.order.hasLocation && ` · ${t("live.noLocation")}`}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
) : tile.hiddenOrder ? (
|
||||||
|
<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>
|
||||||
|
) : (
|
||||||
|
<p className="mt-1 text-xs text-muted-foreground">{t("live.freeHint")}</p>
|
||||||
|
)}
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<div className="flex flex-wrap items-end gap-3">
|
<div className="flex flex-wrap items-end gap-3">
|
||||||
@@ -249,15 +332,20 @@ export function LiveSituationView({ initial, tileUrl, attribution, locale }: { i
|
|||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div role="tablist" aria-label={t("live.tabsLabel")} className="flex gap-1 border-b lg:hidden">
|
<div role="tablist" aria-label={t("live.tabsLabel")} className="flex gap-1 border-b">
|
||||||
{(["map", "list"] as const).map((k) => (
|
{(["map", "tiles", "list"] as const).map((k) => (
|
||||||
<button
|
<button
|
||||||
key={k}
|
key={k}
|
||||||
type="button"
|
type="button"
|
||||||
role="tab"
|
role="tab"
|
||||||
aria-selected={tab === k}
|
aria-selected={tab === k}
|
||||||
onClick={() => setTab(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")}
|
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",
|
||||||
|
// die Liste steht auf großen Bildschirmen ohnehin daneben
|
||||||
|
k === "list" && "lg:hidden",
|
||||||
|
)}
|
||||||
>
|
>
|
||||||
{t(`live.tabs.${k}`)}
|
{t(`live.tabs.${k}`)}
|
||||||
</button>
|
</button>
|
||||||
@@ -265,14 +353,37 @@ export function LiveSituationView({ initial, tileUrl, attribution, locale }: { i
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
|
<div className="grid gap-4 lg:grid-cols-[minmax(0,2fr)_minmax(320px,1fr)]">
|
||||||
<div className={cn(tab !== "map" && "hidden lg:block")}>
|
<div className={cn(tab === "list" && "hidden lg:block")}>
|
||||||
<LiveMap markers={markers} tileUrl={tileUrl} attribution={attribution} label={t("live.mapLabel")} loadingLabel={t("live.mapLoading")} />
|
{tilesFallback && (
|
||||||
<p className="mt-1 text-[11px] text-muted-foreground">
|
<p role="status" className="mb-2 flex items-center gap-2 rounded-lg border border-[var(--warn)] bg-card px-3 py-2 text-xs">
|
||||||
{attribution} ·{" "}
|
<MapPinOff className="size-4 shrink-0 text-[var(--warn)]" aria-hidden />
|
||||||
<a href="https://www.openstreetmap.org/copyright" target="_blank" rel="noopener noreferrer" className="underline">
|
{t("live.mapUnavailable")}
|
||||||
openstreetmap.org/copyright
|
</p>
|
||||||
</a>
|
)}
|
||||||
</p>
|
{tab === "tiles" || tilesFallback ? (
|
||||||
|
tiles.length === 0 ? (
|
||||||
|
<p className="rounded-lg border border-dashed p-4 text-center text-sm text-muted-foreground">{t("live.empty")}</p>
|
||||||
|
) : (
|
||||||
|
<ul className="grid gap-3 sm:grid-cols-2">{tiles.map(tileCard)}</ul>
|
||||||
|
)
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<LiveMap
|
||||||
|
markers={markers}
|
||||||
|
tileUrl={tileUrl}
|
||||||
|
attribution={attribution}
|
||||||
|
label={t("live.mapLabel")}
|
||||||
|
loadingLabel={t("live.mapLoading")}
|
||||||
|
onTilesUnavailable={() => setTilesFallback(true)}
|
||||||
|
/>
|
||||||
|
<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 && (
|
{data.withoutLocation > 0 && (
|
||||||
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||||
<MapPinOff className="size-3.5" aria-hidden />
|
<MapPinOff className="size-3.5" aria-hidden />
|
||||||
|
|||||||
Reference in New Issue
Block a user