From 1e1c154a8a8783292c2b8ec818378a5eeb1af29c Mon Sep 17 00:00:00 2001 From: Martin Date: Wed, 16 Sep 2026 09:44:24 +0200 Subject: [PATCH] Plantafel: Beispielwoche relativ zu heute; Live-Lage mit Kachelansicht MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- AGENTS.md | 2 + README.md | 2 + docs/craftvia/lanes/planung.md | 1 + messages/de/planning.json | 4 +- messages/en/planning.json | 4 +- scripts/planning-demo.ts | 185 +++++++++++++++++++++ src/components/planning/live-map.tsx | 36 +++- src/components/planning/live-situation.tsx | 135 +++++++++++++-- 8 files changed, 352 insertions(+), 17 deletions(-) create mode 100644 scripts/planning-demo.ts diff --git a/AGENTS.md b/AGENTS.md index fc910ff..3e29cd1 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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) 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 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 gate # vollständiges Qualitäts-Gate ``` diff --git a/README.md b/README.md index 62384ea..9a0abe6 100644 --- a/README.md +++ b/README.md @@ -27,6 +27,8 @@ docker compose up -d postgres redis garage GARAGE_ADMIN_URL=http://localhost:3903 npx tsx scripts/garage-provision.ts npx prisma migrate deploy 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 ``` diff --git a/docs/craftvia/lanes/planung.md b/docs/craftvia/lanes/planung.md index 86b4aa0..208e5b9 100644 --- a/docs/craftvia/lanes/planung.md +++ b/docs/craftvia/lanes/planung.md @@ -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. - 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. ## 9. Bekannte Lücken / offene Punkte diff --git a/messages/de/planning.json b/messages/de/planning.json index 62e8dc6..9c558f3 100644 --- a/messages/de/planning.json +++ b/messages/de/planning.json @@ -150,7 +150,9 @@ "refreshing": "Aktualisiert …", "updateFailed": "Aktualisierung fehlgeschlagen – der letzte Stand wird angezeigt.", "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", "filterStatus": "Status", "all": "Alle", diff --git a/messages/en/planning.json b/messages/en/planning.json index aafe3b3..365c09b 100644 --- a/messages/en/planning.json +++ b/messages/en/planning.json @@ -150,7 +150,9 @@ "refreshing": "Refreshing …", "updateFailed": "Update failed – showing the last state.", "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", "filterStatus": "Status", "all": "All", diff --git a/scripts/planning-demo.ts b/scripts/planning-demo.ts new file mode 100644 index 0000000..707e3bf --- /dev/null +++ b/scripts/planning-demo.ts @@ -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); + }); diff --git a/src/components/planning/live-map.tsx b/src/components/planning/live-map.tsx index ca5c6e7..bdf237c 100644 --- a/src/components/planning/live-map.tsx +++ b/src/components/planning/live-map.tsx @@ -45,14 +45,35 @@ function iconHtml(m: LiveMarker): string { return `
${badge}
`; } -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(null); const mapRef = useRef(null); const layerRef = useRef(null); const leafletRef = useRef(null); const fittedRef = useRef(""); + const failedRef = useRef(false); + const unavailableRef = useRef(onTilesUnavailable); const [ready, setReady] = useState(false); + useEffect(() => { + unavailableRef.current = onTilesUnavailable; + }, [onTilesUnavailable]); + useEffect(() => { let cancelled = false; import("leaflet").then((mod) => { @@ -60,10 +81,19 @@ export function LiveMap({ markers, tileUrl, attribution, label, loadingLabel }: 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, { + const tiles = L.tileLayer(tileUrl, { maxZoom: 19, attribution: `${escapeHtml(attribution)} (ODbL)`, - }).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); mapRef.current = map; setReady(true); diff --git a/src/components/planning/live-situation.tsx b/src/components/planning/live-situation.tsx index 5af081f..7b05ae4 100644 --- a/src/components/planning/live-situation.tsx +++ b/src/components/planning/live-situation.tsx @@ -30,7 +30,8 @@ export function LiveSituationView({ initial, tileUrl, attribution, locale }: { i const [data, setData] = useState(initial); const [teamId, setTeamId] = useState(""); const [status, setStatus] = useState(""); - 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 [failed, setFailed] = useState(false); const tz = data.timeZone; @@ -100,6 +101,48 @@ export function LiveSituationView({ initial, tileUrl, attribution, locale }: { i return out; }, [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(() => { + 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 = { 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 Icon = STATUS_ICON[s]; return ( @@ -162,6 +205,46 @@ export function LiveSituationView({ initial, tileUrl, attribution, locale }: { i ); + const tileCard = (tile: LiveTile) => ( +
  • +
    +

    + {tile.memberCount === null ? : } + {tile.name} +

    + {statusBadge(tile.status)} +
    + {tile.memberCount !== null &&

    {t("live.tileMembers", { count: tile.memberCount })}

    } + {delayBadge(tile.delay)} + {tile.freedMinutes !== null && ( +

    + + {t("freed.badge", { team: tile.name, minutes: formatMinutes(tile.freedMinutes, locale) })} +

    + )} + {tile.order ? ( +
    + + {tile.order.number} + {" "} + {tile.order.customerName} +

    + {!tile.order.hasLocation && } + {tile.order.place ?? ""} + {!tile.order.hasLocation && ` · ${t("live.noLocation")}`} +

    +
    + ) : tile.hiddenOrder ? ( +

    + + {t("live.hiddenOrder")} +

    + ) : ( +

    {t("live.freeHint")}

    + )} +
  • + ); + return (
    @@ -249,15 +332,20 @@ export function LiveSituationView({ initial, tileUrl, attribution, locale }: { i )} -
    - {(["map", "list"] as const).map((k) => ( +
    + {(["map", "tiles", "list"] as const).map((k) => ( @@ -265,14 +353,37 @@ export function LiveSituationView({ initial, tileUrl, attribution, locale }: { i
    -
    - -

    - {attribution} ·{" "} - - openstreetmap.org/copyright - -

    +
    + {tilesFallback && ( +

    + + {t("live.mapUnavailable")} +

    + )} + {tab === "tiles" || tilesFallback ? ( + tiles.length === 0 ? ( +

    {t("live.empty")}

    + ) : ( +
      {tiles.map(tileCard)}
    + ) + ) : ( + <> + setTilesFallback(true)} + /> +

    + {attribution} ·{" "} + + openstreetmap.org/copyright + +

    + + )} {data.withoutLocation > 0 && (