first commit
This commit is contained in:
commit
4b98219bf7
144 changed files with 31561 additions and 0 deletions
473
frontend/components/dashboard/crac-detail-sheet.tsx
Normal file
473
frontend/components/dashboard/crac-detail-sheet.tsx
Normal file
|
|
@ -0,0 +1,473 @@
|
|||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { fetchCracStatus, fetchCracHistory, type CracStatus, type CracHistoryPoint } from "@/lib/api";
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle } from "@/components/ui/sheet";
|
||||
import { Skeleton } from "@/components/ui/skeleton";
|
||||
import { Badge } from "@/components/ui/badge";
|
||||
import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
|
||||
import { TimeRangePicker } from "@/components/ui/time-range-picker";
|
||||
import {
|
||||
LineChart, Line, XAxis, YAxis, CartesianGrid, Tooltip,
|
||||
ResponsiveContainer, ReferenceLine,
|
||||
} from "recharts";
|
||||
import { Thermometer, Wind, Zap, Gauge, Settings2, ArrowRight } from "lucide-react";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
interface Props {
|
||||
siteId: string;
|
||||
cracId: string | null;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
function fmt(v: number | null | undefined, dec = 1, unit = "") {
|
||||
if (v == null) return "—";
|
||||
return `${v.toFixed(dec)}${unit}`;
|
||||
}
|
||||
|
||||
function formatTime(iso: string) {
|
||||
try { return new Date(iso).toLocaleTimeString([], { hour: "2-digit", minute: "2-digit" }); }
|
||||
catch { return iso; }
|
||||
}
|
||||
|
||||
function FillBar({
|
||||
value, max, color, warn, crit,
|
||||
}: {
|
||||
value: number | null; max: number; color: string; warn?: number; crit?: number;
|
||||
}) {
|
||||
const pct = value != null ? Math.min(100, (value / max) * 100) : 0;
|
||||
const barColor =
|
||||
crit && value != null && value >= crit ? "#ef4444" :
|
||||
warn && value != null && value >= warn ? "#f59e0b" :
|
||||
color;
|
||||
return (
|
||||
<div className="h-1.5 rounded-full bg-muted overflow-hidden w-full">
|
||||
<div
|
||||
className="h-full rounded-full transition-all duration-500"
|
||||
style={{ width: `${pct}%`, backgroundColor: barColor }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SectionLabel({ icon: Icon, title }: { icon: React.ElementType; title: string }) {
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 mt-5 mb-2">
|
||||
<Icon className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-[10px] font-semibold text-muted-foreground uppercase tracking-wider">
|
||||
{title}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function StatRow({ label, value, highlight }: { label: string; value: string; highlight?: boolean }) {
|
||||
return (
|
||||
<div className="flex justify-between items-center py-1.5 border-b border-border/40 last:border-0">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className={cn("text-sm font-mono font-medium", highlight && "text-amber-400")}>{value}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function RefrigerantRow({ label, value, status }: { label: string; value: string; status: "ok" | "warn" | "crit" }) {
|
||||
return (
|
||||
<div className="flex justify-between items-center py-2 border-b border-border/40 last:border-0">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-mono font-medium">{value}</span>
|
||||
<span className={cn(
|
||||
"text-[10px] font-semibold px-1.5 py-0.5 rounded-full uppercase tracking-wide",
|
||||
status === "ok" ? "bg-green-500/10 text-green-400" :
|
||||
status === "warn" ? "bg-amber-500/10 text-amber-400" :
|
||||
"bg-destructive/10 text-destructive",
|
||||
)}>
|
||||
{status === "ok" ? "normal" : status}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function MiniChart({
|
||||
data, dataKey, color, label, unit, refLine,
|
||||
}: {
|
||||
data: CracHistoryPoint[];
|
||||
dataKey: keyof CracHistoryPoint;
|
||||
color: string;
|
||||
label: string;
|
||||
unit: string;
|
||||
refLine?: number;
|
||||
}) {
|
||||
const vals = data.map(d => d[dataKey] as number | null).filter(v => v != null) as number[];
|
||||
const last = vals[vals.length - 1];
|
||||
return (
|
||||
<div className="mb-5">
|
||||
<div className="flex justify-between items-baseline mb-1">
|
||||
<span className="text-xs text-muted-foreground">{label}</span>
|
||||
<span className="text-sm font-mono font-medium" style={{ color }}>
|
||||
{last != null ? `${last.toFixed(1)}${unit}` : "—"}
|
||||
</span>
|
||||
</div>
|
||||
<ResponsiveContainer width="100%" height={60}>
|
||||
<LineChart data={data} margin={{ top: 2, right: 4, left: -28, bottom: 0 }}>
|
||||
<XAxis
|
||||
dataKey="bucket"
|
||||
tickFormatter={formatTime}
|
||||
tick={{ fontSize: 9 }}
|
||||
interval="preserveStartEnd"
|
||||
/>
|
||||
<YAxis tick={{ fontSize: 9 }} domain={["auto", "auto"]} />
|
||||
<CartesianGrid strokeDasharray="3 3" stroke="rgba(255,255,255,0.05)" />
|
||||
<Tooltip
|
||||
formatter={(v: unknown) => [`${(v as number).toFixed(1)}${unit}`, label]}
|
||||
labelFormatter={(l: unknown) => formatTime(String(l))}
|
||||
contentStyle={{ fontSize: 11, background: "#1e1e2e", border: "1px solid #333" }}
|
||||
/>
|
||||
{refLine != null && (
|
||||
<ReferenceLine y={refLine} stroke="rgba(251,191,36,0.4)" strokeDasharray="4 2" />
|
||||
)}
|
||||
<Line
|
||||
type="monotone"
|
||||
dataKey={dataKey}
|
||||
stroke={color}
|
||||
dot={false}
|
||||
strokeWidth={1.5}
|
||||
connectNulls
|
||||
/>
|
||||
</LineChart>
|
||||
</ResponsiveContainer>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Overview tab ──────────────────────────────────────────────────────────────
|
||||
|
||||
function OverviewTab({ status }: { status: CracStatus }) {
|
||||
const deltaWarn = (status.delta ?? 0) > 11;
|
||||
const deltaCrit = (status.delta ?? 0) > 14;
|
||||
const capWarn = (status.cooling_capacity_pct ?? 0) > 75;
|
||||
const capCrit = (status.cooling_capacity_pct ?? 0) > 90;
|
||||
const copWarn = (status.cop ?? 99) < 1.5;
|
||||
const filterWarn = (status.filter_dp_pa ?? 0) > 80;
|
||||
const filterCrit = (status.filter_dp_pa ?? 0) > 120;
|
||||
const compWarn = (status.compressor_load_pct ?? 0) > 95;
|
||||
|
||||
return (
|
||||
<div>
|
||||
{/* ── Thermal hero ──────────────────────────────────────────── */}
|
||||
<SectionLabel icon={Thermometer} title="Thermal" />
|
||||
<div className="rounded-lg bg-muted/20 px-4 py-3 mb-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Supply</p>
|
||||
<p className="text-2xl font-bold tabular-nums text-blue-400">
|
||||
{fmt(status.supply_temp, 1)}°C
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex-1 flex flex-col items-center gap-1 px-4">
|
||||
<p className={cn(
|
||||
"text-sm font-bold tabular-nums",
|
||||
deltaCrit ? "text-destructive" : deltaWarn ? "text-amber-400" : "text-muted-foreground",
|
||||
)}>
|
||||
ΔT {fmt(status.delta, 1)}°C
|
||||
</p>
|
||||
<div className="flex items-center gap-1 w-full">
|
||||
<div className="flex-1 h-px bg-muted-foreground/30" />
|
||||
<ArrowRight className="w-3 h-3 text-muted-foreground/50 shrink-0" />
|
||||
<div className="flex-1 h-px bg-muted-foreground/30" />
|
||||
</div>
|
||||
</div>
|
||||
<div className="text-center">
|
||||
<p className="text-[10px] text-muted-foreground uppercase tracking-wide mb-0.5">Return</p>
|
||||
<p className={cn(
|
||||
"text-2xl font-bold tabular-nums",
|
||||
deltaCrit ? "text-destructive" : deltaWarn ? "text-amber-400" : "text-orange-400",
|
||||
)}>
|
||||
{fmt(status.return_temp, 1)}°C
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="bg-muted/30 rounded-lg px-3 py-1 mb-3">
|
||||
<StatRow label="Supply Humidity" value={fmt(status.supply_humidity, 0, "%")} />
|
||||
<StatRow label="Return Humidity" value={fmt(status.return_humidity, 0, "%")} />
|
||||
<StatRow label="Airflow" value={status.airflow_cfm != null ? `${Math.round(status.airflow_cfm).toLocaleString()} CFM` : "—"} />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<div className="flex justify-between text-[10px] mb-1">
|
||||
<span className="text-muted-foreground">Filter ΔP</span>
|
||||
<span className={cn(
|
||||
"font-mono",
|
||||
filterCrit ? "text-destructive" : filterWarn ? "text-amber-400" : "text-foreground",
|
||||
)}>
|
||||
{fmt(status.filter_dp_pa, 0)} Pa
|
||||
{!filterWarn && <span className="text-green-400 ml-1.5">✓</span>}
|
||||
</span>
|
||||
</div>
|
||||
<FillBar value={status.filter_dp_pa} max={150} color="#94a3b8" warn={80} crit={120} />
|
||||
</div>
|
||||
|
||||
{/* ── Cooling capacity ──────────────────────────────────────── */}
|
||||
<SectionLabel icon={Gauge} title="Cooling Capacity" />
|
||||
<div className="mb-1.5">
|
||||
<div className="flex justify-between items-baseline mb-1.5">
|
||||
<span className={cn(
|
||||
"text-sm font-bold tabular-nums",
|
||||
capCrit ? "text-destructive" : capWarn ? "text-amber-400" : "text-foreground",
|
||||
)}>
|
||||
{fmt(status.cooling_capacity_kw, 1)} kW
|
||||
</span>
|
||||
<span className="text-xs text-muted-foreground">of {status.rated_capacity_kw} kW rated</span>
|
||||
</div>
|
||||
<FillBar value={status.cooling_capacity_pct} max={100} color="#34d399" warn={75} crit={90} />
|
||||
<p className={cn(
|
||||
"text-[10px] mt-1 text-right",
|
||||
capCrit ? "text-destructive" : capWarn ? "text-amber-400" : "text-muted-foreground",
|
||||
)}>
|
||||
{fmt(status.cooling_capacity_pct, 1)}% utilised
|
||||
</p>
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded-lg px-3 py-1">
|
||||
<StatRow label="COP" value={fmt(status.cop, 2)} highlight={copWarn} />
|
||||
<StatRow label="Sensible Heat Ratio" value={fmt(status.sensible_heat_ratio, 2)} />
|
||||
</div>
|
||||
|
||||
{/* ── Compressor ────────────────────────────────────────────── */}
|
||||
<SectionLabel icon={Settings2} title="Compressor" />
|
||||
<div className="mb-2">
|
||||
<div className="flex justify-between text-[10px] mb-1">
|
||||
<span className="text-muted-foreground">
|
||||
Load
|
||||
<span className={cn(
|
||||
"ml-2 font-semibold px-1.5 py-0.5 rounded-full",
|
||||
status.compressor_state === 1
|
||||
? "bg-green-500/10 text-green-400"
|
||||
: "bg-muted text-muted-foreground",
|
||||
)}>
|
||||
{status.compressor_state === 1 ? "● Running" : "○ Off"}
|
||||
</span>
|
||||
</span>
|
||||
<span className={cn("font-mono", compWarn ? "text-amber-400" : "text-foreground")}>
|
||||
{fmt(status.compressor_load_pct, 1)}%
|
||||
</span>
|
||||
</div>
|
||||
<FillBar value={status.compressor_load_pct} max={100} color="#e879f9" warn={80} crit={95} />
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded-lg px-3 py-1">
|
||||
<StatRow label="Power" value={fmt(status.compressor_power_kw, 2, " kW")} />
|
||||
<StatRow label="Run Hours" value={status.compressor_run_hours != null ? status.compressor_run_hours.toLocaleString() + " h" : "—"} />
|
||||
</div>
|
||||
|
||||
{/* ── Fan ───────────────────────────────────────────────────── */}
|
||||
<SectionLabel icon={Wind} title="Fan" />
|
||||
<div className="mb-2">
|
||||
<div className="flex justify-between text-[10px] mb-1">
|
||||
<span className="text-muted-foreground">Speed</span>
|
||||
<span className="font-mono text-foreground">
|
||||
{fmt(status.fan_pct, 1)}%
|
||||
{status.fan_rpm != null ? ` · ${Math.round(status.fan_rpm).toLocaleString()} rpm` : ""}
|
||||
</span>
|
||||
</div>
|
||||
<FillBar value={status.fan_pct} max={100} color="#60a5fa" />
|
||||
</div>
|
||||
<div className="bg-muted/30 rounded-lg px-3 py-1">
|
||||
<StatRow label="Fan Power" value={fmt(status.fan_power_kw, 2, " kW")} />
|
||||
<StatRow label="Run Hours" value={status.fan_run_hours != null ? status.fan_run_hours.toLocaleString() + " h" : "—"} />
|
||||
</div>
|
||||
|
||||
{/* ── Electrical ────────────────────────────────────────────── */}
|
||||
<SectionLabel icon={Zap} title="Electrical" />
|
||||
<div className="bg-muted/30 rounded-lg px-3 py-1">
|
||||
<StatRow label="Total Unit Power" value={fmt(status.total_unit_power_kw, 2, " kW")} />
|
||||
<StatRow label="Input Voltage" value={fmt(status.input_voltage_v, 1, " V")} />
|
||||
<StatRow label="Input Current" value={fmt(status.input_current_a, 1, " A")} />
|
||||
<StatRow label="Power Factor" value={fmt(status.power_factor, 3)} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Refrigerant tab ───────────────────────────────────────────────────────────
|
||||
|
||||
function RefrigerantTab({ status }: { status: CracStatus }) {
|
||||
const hiP = status.high_pressure_bar ?? 0;
|
||||
const loP = status.low_pressure_bar ?? 99;
|
||||
const sh = status.discharge_superheat_c ?? 0;
|
||||
const sc = status.liquid_subcooling_c ?? 0;
|
||||
const load = status.compressor_load_pct ?? 0;
|
||||
|
||||
// Normal ranges for R410A-style DX unit
|
||||
const hiPStatus: "ok" | "warn" | "crit" = hiP > 22 ? "crit" : hiP > 20 ? "warn" : "ok";
|
||||
const loPStatus: "ok" | "warn" | "crit" = loP < 3 ? "crit" : loP < 4 ? "warn" : "ok";
|
||||
const shStatus: "ok" | "warn" | "crit" = sh > 16 ? "warn" : sh < 4 ? "warn" : "ok";
|
||||
const scStatus: "ok" | "warn" | "crit" = sc < 2 ? "warn" : "ok";
|
||||
const ldStatus: "ok" | "warn" | "crit" = load > 95 ? "warn" : "ok";
|
||||
|
||||
const compRunning = status.compressor_state === 1;
|
||||
|
||||
return (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground mt-4 mb-4 leading-relaxed">
|
||||
Refrigerant circuit data for the DX cooling system. Pressures assume an R410A charge.
|
||||
Values outside normal range are flagged automatically.
|
||||
</p>
|
||||
|
||||
<div className="rounded-lg bg-muted/20 px-3 mb-4">
|
||||
<div className="flex justify-between items-center py-3 border-b border-border/30">
|
||||
<span className="text-xs text-muted-foreground">Compressor State</span>
|
||||
<span className={cn(
|
||||
"text-xs font-semibold px-2 py-0.5 rounded-full",
|
||||
compRunning ? "bg-green-500/10 text-green-400" : "bg-muted text-muted-foreground",
|
||||
)}>
|
||||
{compRunning ? "● Running" : "○ Off"}
|
||||
</span>
|
||||
</div>
|
||||
<RefrigerantRow
|
||||
label="High Side Pressure"
|
||||
value={fmt(status.high_pressure_bar, 2, " bar")}
|
||||
status={hiPStatus}
|
||||
/>
|
||||
<RefrigerantRow
|
||||
label="Low Side Pressure"
|
||||
value={fmt(status.low_pressure_bar, 2, " bar")}
|
||||
status={loPStatus}
|
||||
/>
|
||||
<RefrigerantRow
|
||||
label="Discharge Superheat"
|
||||
value={fmt(status.discharge_superheat_c, 1, "°C")}
|
||||
status={shStatus}
|
||||
/>
|
||||
<RefrigerantRow
|
||||
label="Liquid Subcooling"
|
||||
value={fmt(status.liquid_subcooling_c, 1, "°C")}
|
||||
status={scStatus}
|
||||
/>
|
||||
<RefrigerantRow
|
||||
label="Compressor Load"
|
||||
value={fmt(status.compressor_load_pct, 1, "%")}
|
||||
status={ldStatus}
|
||||
/>
|
||||
<div className="flex justify-between items-center py-2">
|
||||
<span className="text-xs text-muted-foreground">Compressor Power</span>
|
||||
<span className="text-sm font-mono font-medium">{fmt(status.compressor_power_kw, 2, " kW")}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md bg-muted/30 px-3 py-2.5 text-xs text-muted-foreground space-y-1">
|
||||
<p className="font-semibold text-foreground mb-1.5">Normal Ranges (R410A)</p>
|
||||
<p>High side pressure: 15 – 20 bar</p>
|
||||
<p>Low side pressure: 4 – 6 bar</p>
|
||||
<p>Discharge superheat: 5 – 15°C</p>
|
||||
<p>Liquid subcooling: 3 – 8°C</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Trends tab ────────────────────────────────────────────────────────────────
|
||||
|
||||
function TrendsTab({ history }: { history: CracHistoryPoint[] }) {
|
||||
if (history.length < 2) {
|
||||
return (
|
||||
<p className="text-sm text-muted-foreground mt-6 text-center">
|
||||
Not enough history yet — data accumulates every 5 minutes.
|
||||
</p>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="mt-4">
|
||||
<MiniChart data={history} dataKey="supply_temp" color="#60a5fa" label="Supply Temp" unit="°C" />
|
||||
<MiniChart data={history} dataKey="return_temp" color="#f97316" label="Return Temp" unit="°C" refLine={36} />
|
||||
<MiniChart data={history} dataKey="delta_t" color="#a78bfa" label="ΔT" unit="°C" />
|
||||
<MiniChart data={history} dataKey="capacity_kw" color="#34d399" label="Cooling kW" unit=" kW" />
|
||||
<MiniChart data={history} dataKey="capacity_pct"color="#fbbf24" label="Utilisation" unit="%" refLine={90} />
|
||||
<MiniChart data={history} dataKey="cop" color="#38bdf8" label="COP" unit="" refLine={1.5} />
|
||||
<MiniChart data={history} dataKey="comp_load" color="#e879f9" label="Comp Load" unit="%" refLine={95} />
|
||||
<MiniChart data={history} dataKey="filter_dp" color="#fb923c" label="Filter ΔP" unit=" Pa" refLine={80} />
|
||||
<MiniChart data={history} dataKey="fan_pct" color="#94a3b8" label="Fan Speed" unit="%" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Sheet ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
export function CracDetailSheet({ siteId, cracId, onClose }: Props) {
|
||||
const [hours, setHours] = useState(6);
|
||||
const [status, setStatus] = useState<CracStatus | null>(null);
|
||||
const [history, setHistory] = useState<CracHistoryPoint[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!cracId) return;
|
||||
setLoading(true);
|
||||
Promise.all([
|
||||
fetchCracStatus(siteId),
|
||||
fetchCracHistory(siteId, cracId, hours),
|
||||
]).then(([statuses, hist]) => {
|
||||
setStatus(statuses.find(c => c.crac_id === cracId) ?? null);
|
||||
setHistory(hist);
|
||||
}).finally(() => setLoading(false));
|
||||
}, [siteId, cracId, hours]);
|
||||
|
||||
return (
|
||||
<Sheet open={cracId != null} onOpenChange={open => { if (!open) onClose(); }}>
|
||||
<SheetContent className="w-[500px] sm:w-[560px] overflow-y-auto" side="right">
|
||||
<SheetHeader className="mb-2">
|
||||
<SheetTitle className="flex items-center justify-between">
|
||||
<span>{cracId?.toUpperCase() ?? "CRAC Unit"}</span>
|
||||
{status && (
|
||||
<Badge
|
||||
variant={status.state === "online" ? "default" : "destructive"}
|
||||
className="text-xs"
|
||||
>
|
||||
{status.state}
|
||||
</Badge>
|
||||
)}
|
||||
</SheetTitle>
|
||||
{status && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{status.room_id ? `Room: ${status.room_id}` : ""}
|
||||
{status.state === "online" ? " · Mode: Cooling · Setpoint 22°C" : ""}
|
||||
</p>
|
||||
)}
|
||||
</SheetHeader>
|
||||
|
||||
{loading && !status ? (
|
||||
<div className="space-y-3 mt-4">
|
||||
{Array.from({ length: 8 }).map((_, i) => (
|
||||
<Skeleton key={i} className="h-8 w-full" />
|
||||
))}
|
||||
</div>
|
||||
) : status ? (
|
||||
<Tabs defaultValue="overview">
|
||||
<div className="flex items-center justify-between mt-3 mb-1">
|
||||
<TabsList>
|
||||
<TabsTrigger value="overview">Overview</TabsTrigger>
|
||||
<TabsTrigger value="refrigerant">Refrigerant</TabsTrigger>
|
||||
<TabsTrigger value="trends">Trends</TabsTrigger>
|
||||
</TabsList>
|
||||
<TimeRangePicker value={hours} onChange={setHours} />
|
||||
</div>
|
||||
|
||||
<TabsContent value="overview">
|
||||
<OverviewTab status={status} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="refrigerant">
|
||||
<RefrigerantTab status={status} />
|
||||
</TabsContent>
|
||||
|
||||
<TabsContent value="trends">
|
||||
<TrendsTab history={history} />
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
) : (
|
||||
<p className="text-sm text-muted-foreground mt-4">No data available for {cracId}.</p>
|
||||
)}
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
Loading…
Add table
Add a link
Reference in a new issue