177 lines
7.5 KiB
TypeScript
177 lines
7.5 KiB
TypeScript
import { Battery, Sun, Zap } from 'lucide-react'
|
|
import { PowerFlowCard } from '../components/PowerFlowCard'
|
|
import { SocGauge } from '../components/SocGauge'
|
|
import { TelemetryChart } from '../components/TelemetryChart'
|
|
import { useAuditDailyToday } from '../hooks/useAuditDailyToday'
|
|
import { useSiteStatus } from '../hooks/useSiteStatus'
|
|
import { useTelemetryToday } from '../hooks/useTelemetryToday'
|
|
|
|
function fmtEnergy(v: string | number | null | undefined): string {
|
|
const n = typeof v === 'number' ? v : v == null ? NaN : Number(v)
|
|
if (!Number.isFinite(n)) return '—'
|
|
return `${n.toLocaleString('cs-CZ', { maximumFractionDigits: 3 })} kWh`
|
|
}
|
|
|
|
function fmtMoney(v: string | number | null | undefined): string {
|
|
const n = typeof v === 'number' ? v : v == null ? NaN : Number(v)
|
|
if (!Number.isFinite(n)) return '—'
|
|
return `${n.toLocaleString('cs-CZ', { minimumFractionDigits: 2, maximumFractionDigits: 2 })} Kč`
|
|
}
|
|
|
|
function modeBadgeClass(code: string | null): string {
|
|
const c = (code ?? '').toUpperCase()
|
|
if (c.includes('AUTO')) return 'bg-emerald-500/15 text-emerald-300 ring-1 ring-emerald-500/35'
|
|
if (c.includes('SELF')) return 'bg-cyan-500/15 text-cyan-300 ring-1 ring-cyan-500/35'
|
|
if (c.includes('MANUAL') || c.includes('FORCE')) return 'bg-amber-500/15 text-amber-200 ring-1 ring-amber-500/35'
|
|
if (c.includes('OFF') || c.includes('IDLE')) return 'bg-slate-600/40 text-slate-300 ring-1 ring-slate-500/30'
|
|
return 'bg-slate-700/60 text-slate-200 ring-1 ring-slate-600/50'
|
|
}
|
|
|
|
function batteryStyles(powerW: number | null | undefined): { border: string; icon: string } {
|
|
if (powerW == null || Number.isNaN(powerW)) {
|
|
return { border: 'border-l-slate-600', icon: 'text-slate-400' }
|
|
}
|
|
if (powerW >= 0) {
|
|
return { border: 'border-l-emerald-500', icon: 'text-emerald-400' }
|
|
}
|
|
return { border: 'border-l-orange-500', icon: 'text-orange-400' }
|
|
}
|
|
|
|
function gridStyles(powerW: number | null | undefined): { border: string; icon: string } {
|
|
if (powerW == null || Number.isNaN(powerW)) {
|
|
return { border: 'border-l-slate-600', icon: 'text-slate-400' }
|
|
}
|
|
if (powerW >= 0) {
|
|
return { border: 'border-l-red-500', icon: 'text-red-400' }
|
|
}
|
|
return { border: 'border-l-emerald-500', icon: 'text-emerald-400' }
|
|
}
|
|
|
|
function SectionTitle({ kicker, title }: { kicker: string; title: string }) {
|
|
return (
|
|
<div className="mb-4">
|
|
<p className="text-xs font-semibold uppercase tracking-widest text-slate-500">{kicker}</p>
|
|
<h2 className="text-lg font-semibold text-slate-100">{title}</h2>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function CardSkeleton() {
|
|
return <div className="h-[88px] animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
|
|
}
|
|
|
|
function StatBlock({ label, value }: { label: string; value: string }) {
|
|
return (
|
|
<div className="rounded-xl border border-slate-800 bg-slate-900/50 p-4">
|
|
<p className="text-xs font-medium uppercase tracking-wide text-slate-500">{label}</p>
|
|
<p className="mt-1 text-lg font-semibold tabular-nums text-slate-100">{value}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function StatSkeleton() {
|
|
return <div className="h-[76px] animate-pulse rounded-xl border border-slate-800 bg-slate-900/40" />
|
|
}
|
|
|
|
export function Dashboard() {
|
|
const { site, ready: siteReady, hasLiveData } = useSiteStatus()
|
|
const siteId = site?.site_id ?? null
|
|
const { points, ready: telemetryReady, hasChartData } = useTelemetryToday(siteId)
|
|
const { daily, ready: auditReady, hasDaily } = useAuditDailyToday(siteId)
|
|
|
|
const liveSkeleton = !siteReady || !hasLiveData
|
|
const chartSkeleton = !telemetryReady || !hasChartData
|
|
const econSkeleton = !auditReady || !hasDaily
|
|
|
|
const hbOk = site?.ems_heartbeat_status === 'ok'
|
|
const bat = batteryStyles(site?.battery_power_w ?? null)
|
|
const grd = gridStyles(site?.grid_power_w ?? null)
|
|
|
|
return (
|
|
<div className="min-h-screen bg-slate-950 p-4 md:p-8">
|
|
<div className="mx-auto max-w-7xl space-y-10">
|
|
<header className="flex flex-col gap-4 border-b border-slate-800/80 pb-6 md:flex-row md:items-center md:justify-between">
|
|
<div>
|
|
<h1 className="text-2xl font-bold tracking-tight text-white">EMS Platform</h1>
|
|
<p className="mt-1 text-sm text-slate-400">Přehled lokality a auditu</p>
|
|
</div>
|
|
{!siteReady ? (
|
|
<div className="h-10 w-56 animate-pulse rounded-lg bg-slate-800/80" />
|
|
) : site ? (
|
|
<div className="flex flex-wrap items-center gap-3">
|
|
<span className="text-sm text-slate-400">{site.site_name}</span>
|
|
<span
|
|
className={`rounded-md px-2.5 py-1 text-xs font-semibold uppercase tracking-wide ${modeBadgeClass(site.active_mode)}`}
|
|
title={site.mode_description ?? undefined}
|
|
>
|
|
{site.active_mode ?? '—'}
|
|
{site.mode_name ? ` · ${site.mode_name}` : ''}
|
|
</span>
|
|
<span className="flex items-center gap-2 text-xs text-slate-500">
|
|
<span className="relative flex h-2.5 w-2.5">
|
|
<span
|
|
className={`inline-flex h-2.5 w-2.5 rounded-full ${hbOk ? 'bg-emerald-500' : 'bg-red-500'}`}
|
|
title={site.ems_heartbeat_status ?? 'neznámý'}
|
|
/>
|
|
</span>
|
|
EMS
|
|
</span>
|
|
</div>
|
|
) : null}
|
|
</header>
|
|
|
|
<section>
|
|
<SectionTitle kicker="Živě" title="Aktuální stav" />
|
|
{liveSkeleton ? (
|
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
<CardSkeleton />
|
|
<CardSkeleton />
|
|
<div className="flex min-h-[88px] items-center justify-center rounded-xl border border-slate-800 bg-slate-900/40">
|
|
<div className="h-36 w-36 animate-pulse rounded-full bg-slate-800/80" />
|
|
</div>
|
|
<CardSkeleton />
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-4 md:grid-cols-2 xl:grid-cols-4">
|
|
<PowerFlowCard label="FVE" powerW={site?.pv_power_w} icon={Sun} borderClass="border-l-amber-400" iconClass="text-amber-400" />
|
|
<PowerFlowCard
|
|
label="Baterie"
|
|
powerW={site?.battery_power_w}
|
|
icon={Battery}
|
|
borderClass={bat.border}
|
|
iconClass={bat.icon}
|
|
/>
|
|
<SocGauge socPercent={site?.battery_soc_percent} loading={false} />
|
|
<PowerFlowCard label="Síť" powerW={site?.grid_power_w} icon={Zap} borderClass={grd.border} iconClass={grd.icon} />
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
<section>
|
|
<SectionTitle kicker="Dnes" title="Průběh výkonů (hodinový průměr)" />
|
|
<TelemetryChart points={points} loading={chartSkeleton} />
|
|
</section>
|
|
|
|
<section>
|
|
<SectionTitle kicker="Dnes" title="Ekonomika auditu" />
|
|
{econSkeleton ? (
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
<StatSkeleton />
|
|
<StatSkeleton />
|
|
<StatSkeleton />
|
|
<StatSkeleton />
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
<StatBlock label="Import" value={fmtEnergy(daily?.import_kwh)} />
|
|
<StatBlock label="Export" value={fmtEnergy(daily?.export_kwh)} />
|
|
<StatBlock label="FVE výroba" value={fmtEnergy(daily?.pv_kwh)} />
|
|
<StatBlock label="Náklady / příjem (audit)" value={fmtMoney(daily?.actual_cost_czk)} />
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|