BMS/frontend/app/(dashboard)/fire/page.tsx
2026-03-19 11:32:17 +00:00

285 lines
12 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"use client";
import { useEffect, useState, useCallback } from "react";
import { toast } from "sonner";
import { fetchFireStatus, type FireZoneStatus } from "@/lib/api";
import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card";
import { Skeleton } from "@/components/ui/skeleton";
import { Flame, RefreshCw, CheckCircle2, AlertTriangle, Zap, Wind, Activity } from "lucide-react";
import { cn } from "@/lib/utils";
const SITE_ID = "sg-01";
const LEVEL_CONFIG: Record<string, {
label: string; bg: string; border: string; text: string; icon: React.ElementType; pulsing: boolean;
}> = {
normal: {
label: "Normal",
bg: "bg-green-500/10", border: "border-green-500/20", text: "text-green-400",
icon: CheckCircle2, pulsing: false,
},
alert: {
label: "Alert",
bg: "bg-amber-500/10", border: "border-amber-500/40", text: "text-amber-400",
icon: AlertTriangle, pulsing: false,
},
action: {
label: "Action",
bg: "bg-orange-500/10", border: "border-orange-500/40", text: "text-orange-400",
icon: AlertTriangle, pulsing: true,
},
fire: {
label: "FIRE",
bg: "bg-destructive/10", border: "border-destructive/60", text: "text-destructive",
icon: Flame, pulsing: true,
},
};
function ObscurationBar({ value }: { value: number | null }) {
if (value == null) return null;
const pct = Math.min(100, value * 20); // 05 %/m mapped to 0100%
const color = value > 3 ? "#ef4444" : value > 1.5 ? "#f59e0b" : "#94a3b8";
return (
<div>
<div className="flex justify-between text-[10px] mb-1">
<span className="text-muted-foreground">Obscuration</span>
<span className="font-mono font-semibold text-xs" style={{ color }}>{value.toFixed(2)} %/m</span>
</div>
<div className="rounded-full bg-muted overflow-hidden h-1.5">
<div className="h-full rounded-full transition-all duration-500" style={{ width: `${pct}%`, backgroundColor: color }} />
</div>
</div>
);
}
function StatusIndicator({ label, ok, icon: Icon }: {
label: string; ok: boolean; icon: React.ElementType;
}) {
return (
<div className={cn(
"rounded-lg px-2.5 py-2 flex items-center gap-2 text-xs",
ok ? "bg-green-500/10" : "bg-destructive/10",
)}>
<Icon className={cn("w-3.5 h-3.5 shrink-0", ok ? "text-green-400" : "text-destructive")} />
<div>
<p className="text-muted-foreground text-[10px]">{label}</p>
<p className={cn("font-semibold", ok ? "text-green-400" : "text-destructive")}>
{ok ? "OK" : "Fault"}
</p>
</div>
</div>
);
}
function VesdaCard({ zone }: { zone: FireZoneStatus }) {
const level = zone.level;
const cfg = LEVEL_CONFIG[level] ?? LEVEL_CONFIG.normal;
const Icon = cfg.icon;
const isAlarm = level !== "normal";
return (
<Card className={cn(isAlarm ? "border-2" : "border", cfg.border, isAlarm && cfg.bg, level === "fire" && "bg-red-950/30")}>
<CardHeader className="pb-2">
<div className="flex items-center justify-between gap-2">
<CardTitle className="text-sm font-semibold flex items-center gap-2">
<Icon className={cn("w-4 h-4", cfg.text, cfg.pulsing && "animate-pulse")} />
{zone.zone_id.toUpperCase()}
</CardTitle>
<span className={cn(
"flex items-center gap-1 text-[10px] font-bold px-2.5 py-0.5 rounded-full uppercase tracking-wide border",
cfg.bg, cfg.border, cfg.text,
)}>
<Icon className={cn("w-3 h-3", cfg.pulsing && "animate-pulse")} />
{cfg.label}
</span>
</div>
</CardHeader>
<CardContent className="space-y-4">
{level === "fire" && (
<div className="rounded-lg border border-destructive/60 bg-destructive/15 px-3 py-3 text-xs text-destructive font-semibold animate-pulse">
FIRE ALARM Initiate evacuation and contact emergency services immediately
</div>
)}
{level === "action" && (
<div className="rounded-lg border border-orange-500/40 bg-orange-500/10 px-3 py-2.5 text-xs text-orange-400 font-medium">
Action threshold reached investigate smoke source immediately
</div>
)}
{level === "alert" && (
<div className="rounded-lg border border-amber-500/30 bg-amber-500/10 px-3 py-2.5 text-xs text-amber-400">
Alert level elevated smoke particles detected, monitor closely
</div>
)}
<ObscurationBar value={zone.obscuration_pct_m} />
{/* Detector status */}
<div className="space-y-1.5">
{[
{ label: "Detector 1", ok: zone.detector_1_ok },
{ label: "Detector 2", ok: zone.detector_2_ok },
].map(({ label, ok }) => (
<div key={label} className="flex items-center justify-between text-xs">
<span className="flex items-center gap-1.5 text-muted-foreground">
{ok ? <CheckCircle2 className="w-3.5 h-3.5 text-green-400" /> : <AlertTriangle className="w-3.5 h-3.5 text-destructive" />}
{label}
</span>
<span className={cn("font-semibold", ok ? "text-green-400" : "text-destructive")}>
{ok ? "Online" : "Fault"}
</span>
</div>
))}
</div>
{/* System status */}
<div className="grid grid-cols-2 gap-2">
<StatusIndicator label="Power supply" ok={zone.power_ok} icon={Zap} />
<StatusIndicator label="Airflow" ok={zone.flow_ok} icon={Wind} />
</div>
</CardContent>
</Card>
);
}
export default function FireSafetyPage() {
const [zones, setZones] = useState<FireZoneStatus[]>([]);
const [loading, setLoading] = useState(true);
const load = useCallback(async () => {
try {
setZones(await fetchFireStatus(SITE_ID));
} catch { toast.error("Failed to load fire safety data"); }
finally { setLoading(false); }
}, []);
useEffect(() => {
load();
const id = setInterval(load, 10_000);
return () => clearInterval(id);
}, [load]);
const fireZones = zones.filter((z) => z.level === "fire");
const actionZones = zones.filter((z) => z.level === "action");
const alertZones = zones.filter((z) => z.level === "alert");
const normalZones = zones.filter((z) => z.level === "normal");
const anyAlarm = fireZones.length + actionZones.length + alertZones.length > 0;
const worstLevel =
fireZones.length > 0 ? "fire" :
actionZones.length > 0 ? "action" :
alertZones.length > 0 ? "alert" : "normal";
const worstCfg = LEVEL_CONFIG[worstLevel];
const WIcon = worstCfg.icon;
return (
<div className="p-6 space-y-6">
{/* Header */}
<div className="flex items-start justify-between gap-4 flex-wrap">
<div>
<h1 className="text-xl font-semibold">Fire &amp; Life Safety</h1>
<p className="text-sm text-muted-foreground">Singapore DC01 VESDA aspirating detector network · refreshes every 10s</p>
</div>
<div className="flex items-center gap-3">
{!loading && (
<span className={cn(
"flex items-center gap-1.5 text-xs font-semibold px-3 py-1.5 rounded-full border",
worstCfg.bg, worstCfg.border, worstCfg.text,
anyAlarm && "animate-pulse",
)}>
<WIcon className="w-3.5 h-3.5" />
{anyAlarm
? `${fireZones.length + actionZones.length + alertZones.length} zone${fireZones.length + actionZones.length + alertZones.length > 1 ? "s" : ""} in alarm`
: `All ${zones.length} zones normal`}
</span>
)}
<button
onClick={load}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground transition-colors"
>
<RefreshCw className="w-3.5 h-3.5" /> Refresh
</button>
</div>
</div>
{/* System summary bar */}
{!loading && (
<div className="rounded-xl border border-border bg-muted/10 px-5 py-3 flex items-center gap-8 text-sm flex-wrap">
<div className="flex items-center gap-2">
<Activity className="w-4 h-4 text-primary" />
<span className="text-muted-foreground">VESDA zones monitored:</span>
<strong>{zones.length}</strong>
</div>
{[
{ label: "Fire", count: fireZones.length, cls: "text-destructive" },
{ label: "Action", count: actionZones.length, cls: "text-orange-400" },
{ label: "Alert", count: alertZones.length, cls: "text-amber-400" },
{ label: "Normal", count: normalZones.length, cls: "text-green-400" },
].map(({ label, count, cls }) => (
<div key={label} className="flex items-center gap-1.5">
<span className="text-muted-foreground">{label}:</span>
<strong className={cls}>{count}</strong>
</div>
))}
<div className="ml-auto text-xs text-muted-foreground">
All detectors use VESDA aspirating smoke detection technology
</div>
</div>
)}
{/* Fire alarm banner */}
{!loading && fireZones.length > 0 && (
<div className="rounded-xl border-2 border-destructive bg-destructive/10 px-5 py-4 animate-pulse">
<div className="flex items-center gap-3 mb-2">
<Flame className="w-6 h-6 text-destructive shrink-0" />
<p className="text-base font-bold text-destructive">
FIRE ALARM ACTIVE {fireZones.length} zone{fireZones.length > 1 ? "s" : ""}
</p>
</div>
<p className="text-sm text-destructive/80">
Initiate building evacuation. Contact SCDF (995). Do not re-enter until cleared by fire services.
</p>
</div>
)}
{/* Zone cards — alarms first */}
{loading ? (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{Array.from({ length: 4 }).map((_, i) => <Skeleton key={i} className="h-64" />)}
</div>
) : zones.length === 0 ? (
<div className="text-sm text-muted-foreground">No VESDA zone data available</div>
) : (
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6">
{[...fireZones, ...actionZones, ...alertZones, ...normalZones].map((zone) => (
<VesdaCard key={zone.zone_id} zone={zone} />
))}
</div>
)}
{/* Legend */}
<div className="rounded-xl border border-border bg-muted/5 px-5 py-4">
<p className="text-xs font-semibold text-muted-foreground uppercase tracking-widest mb-3">VESDA Alert Levels</p>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 text-xs">
{Object.entries(LEVEL_CONFIG).map(([key, cfg]) => {
const Icon = cfg.icon;
return (
<div key={key} className={cn("rounded-lg border px-3 py-2.5", cfg.bg, cfg.border)}>
<div className="flex items-center gap-1.5 mb-1">
<Icon className={cn("w-3.5 h-3.5", cfg.text)} />
<span className={cn("font-bold uppercase", cfg.text)}>{cfg.label}</span>
</div>
<p className="text-muted-foreground text-[10px]">
{key === "normal" ? "No smoke detected, system clear" :
key === "alert" ? "Trace smoke particles, monitor" :
key === "action" ? "Significant smoke, investigate now" :
"Confirmed fire, evacuate immediately"}
</p>
</div>
);
})}
</div>
</div>
</div>
);
}