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:
@@ -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>`;
|
||||
}
|
||||
|
||||
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 mapRef = useRef<LeafletMap | null>(null);
|
||||
const layerRef = useRef<LayerGroup | null>(null);
|
||||
const leafletRef = useRef<typeof import("leaflet") | null>(null);
|
||||
const fittedRef = useRef<string>("");
|
||||
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)} (<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);
|
||||
mapRef.current = map;
|
||||
setReady(true);
|
||||
|
||||
@@ -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<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 [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<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 Icon = STATUS_ICON[s];
|
||||
return (
|
||||
@@ -162,6 +205,46 @@ export function LiveSituationView({ initial, tileUrl, attribution, locale }: { i
|
||||
</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 (
|
||||
<div className="space-y-3">
|
||||
<div className="flex flex-wrap items-end gap-3">
|
||||
@@ -249,15 +332,20 @@ export function LiveSituationView({ initial, tileUrl, attribution, locale }: { i
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div role="tablist" aria-label={t("live.tabsLabel")} className="flex gap-1 border-b lg:hidden">
|
||||
{(["map", "list"] as const).map((k) => (
|
||||
<div role="tablist" aria-label={t("live.tabsLabel")} className="flex gap-1 border-b">
|
||||
{(["map", "tiles", "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")}
|
||||
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}`)}
|
||||
</button>
|
||||
@@ -265,14 +353,37 @@ export function LiveSituationView({ initial, tileUrl, attribution, locale }: { i
|
||||
</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>
|
||||
<div className={cn(tab === "list" && "hidden lg:block")}>
|
||||
{tilesFallback && (
|
||||
<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">
|
||||
<MapPinOff className="size-4 shrink-0 text-[var(--warn)]" aria-hidden />
|
||||
{t("live.mapUnavailable")}
|
||||
</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 && (
|
||||
<p className="mt-1 flex items-center gap-1.5 text-xs text-muted-foreground">
|
||||
<MapPinOff className="size-3.5" aria-hidden />
|
||||
|
||||
Reference in New Issue
Block a user