285 lines
12 KiB
TypeScript
285 lines
12 KiB
TypeScript
"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); // 0–5 %/m mapped to 0–100%
|
||
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 & 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>
|
||
);
|
||
}
|