first commit

This commit is contained in:
mega 2026-03-19 11:32:17 +00:00
commit 4b98219bf7
144 changed files with 31561 additions and 0 deletions

View file

@ -0,0 +1,285 @@
"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>
);
}