Files
ems/frontend/src/pages/Economics.tsx
Dusan Vojacek 806274cf59
Some checks failed
deploy / deploy (push) Failing after 1m15s
test / smoke-test (push) Successful in 2s
uprava adutiu - nacitani dalsich registru, uprava ekonomiky
2026-04-10 21:53:32 +02:00

396 lines
16 KiB
TypeScript
Raw 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.
import { ChevronDown, ChevronLeft, ChevronRight, ChevronUp, Lock, Unlock } from 'lucide-react'
import { useCallback, useMemo, useState } from 'react'
import { toast } from 'sonner'
import { EconomicsChart } from '../components/charts/EconomicsChart'
import {
lockDay,
unlockDay,
useEconomicsChart,
useEconomicsDaily,
useEconomicsIntervals,
} from '../hooks/useEconomics'
import { useSiteStatus } from '../hooks/useSiteStatus'
import { pragueCalendarDay } from '../lib/pragueDate'
import type { DailyEconomics } from '../types/economics'
function currentMonth(): string {
const today = pragueCalendarDay()
return today.slice(0, 7)
}
function monthLabel(ym: string): string {
const [y, m] = ym.split('-').map(Number)
const names = [
'Leden', 'Únor', 'Březen', 'Duben', 'Květen', 'Červen',
'Červenec', 'Srpen', 'Září', 'Říjen', 'Listopad', 'Prosinec',
]
return `${names[m - 1]} ${y}`
}
function shiftMonth(ym: string, delta: number): string {
const [y, m] = ym.split('-').map(Number)
const d = new Date(y, m - 1 + delta, 1)
return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}`
}
function fmtDay(iso: string): string {
const d = new Date(iso + 'T00:00:00')
return d.toLocaleDateString('cs-CZ', { weekday: 'short', day: 'numeric', month: 'numeric' })
}
function fmtTime(iso: string): string {
const d = new Date(iso)
return d.toLocaleTimeString('cs-CZ', { hour: '2-digit', minute: '2-digit', timeZone: 'Europe/Prague' })
}
function czk(v: number | null | undefined, decimals = 2): string {
if (v == null) return ''
const sign = v > 0 ? '+' : ''
return `${sign}${v.toFixed(decimals)}`
}
function kwh(v: number | null | undefined): string {
if (v == null) return ''
return v.toFixed(1)
}
function balanceColor(v: number): string {
if (v > 0) return 'text-green-400'
if (v < 0) return 'text-red-400'
return 'text-slate-400'
}
function IntervalDetail({ siteId, day, hasGreenBonus }: { siteId: number; day: string; hasGreenBonus: boolean }) {
const { intervals, loading } = useEconomicsIntervals(siteId, day)
if (loading) {
return <div className="py-3 text-center text-xs text-slate-500">Načítání intervalů</div>
}
if (intervals.length === 0) {
return <div className="py-3 text-center text-xs text-slate-500">Žádné intervaly</div>
}
return (
<div className="overflow-x-auto">
<table className="w-full text-xs">
<thead>
<tr className="border-b border-slate-700 text-slate-400">
<th className="px-2 py-1 text-left">Čas</th>
<th className="px-2 py-1 text-right">Import kWh</th>
<th className="px-2 py-1 text-right">Export kWh</th>
<th className="px-2 py-1 text-right">Náklad </th>
<th className="px-2 py-1 text-right">Cena nákup</th>
<th className="px-2 py-1 text-right">Cena prodej</th>
{hasGreenBonus && <th className="px-2 py-1 text-right">Bonus </th>}
<th className="px-2 py-1 text-right">Plán grid W</th>
<th className="px-2 py-1 text-right">Skuteč. grid W</th>
<th className="px-2 py-1 text-right">Plán náklad</th>
</tr>
</thead>
<tbody>
{intervals.map((iv) => (
<tr key={iv.interval_start} className="border-b border-slate-800 hover:bg-slate-800/40">
<td className="px-2 py-1 text-slate-300">{fmtTime(iv.interval_start)}</td>
<td className="px-2 py-1 text-right">{kwh(iv.import_kwh)}</td>
<td className="px-2 py-1 text-right">{kwh(iv.export_kwh)}</td>
<td className={`px-2 py-1 text-right font-medium ${iv.dynamic_cost_czk != null ? balanceColor(-iv.dynamic_cost_czk) : ''}`}>
{iv.dynamic_cost_czk != null ? iv.dynamic_cost_czk.toFixed(2) : ''}
</td>
<td className="px-2 py-1 text-right text-slate-400">
{iv.effective_buy_price != null ? iv.effective_buy_price.toFixed(2) : ''}
</td>
<td className="px-2 py-1 text-right text-slate-400">
{iv.effective_sell_price != null ? iv.effective_sell_price.toFixed(2) : ''}
</td>
{hasGreenBonus && (
<td className="px-2 py-1 text-right text-amber-400">
{iv.green_bonus_czk != null && iv.green_bonus_czk > 0
? iv.green_bonus_czk.toFixed(2)
: ''}
</td>
)}
<td className="px-2 py-1 text-right text-slate-400">
{iv.planned_grid_w ?? ''}
</td>
<td className="px-2 py-1 text-right">
{iv.actual_grid_power_w ?? ''}
</td>
<td className="px-2 py-1 text-right text-slate-400">
{iv.planned_cost_czk != null ? iv.planned_cost_czk.toFixed(2) : ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
)
}
function DailyRow({
row,
hasGreenBonus,
siteId,
expanded,
onToggle,
onLockToggle,
}: {
row: DailyEconomics
hasGreenBonus: boolean
siteId: number
expanded: boolean
onToggle: () => void
onLockToggle: () => void
}) {
const colCount = hasGreenBonus ? 13 : 12
return (
<>
<tr
className="cursor-pointer border-b border-slate-800 transition hover:bg-slate-800/50"
onClick={onToggle}
>
<td className="px-3 py-2 text-sm text-slate-200">
<span className="mr-1 inline-block w-4">
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</span>
{fmtDay(row.day)}
</td>
<td className="px-3 py-2 text-right text-sm">{kwh(row.import_kwh)}</td>
<td className="px-3 py-2 text-right text-sm">{kwh(row.export_kwh)}</td>
<td className="px-3 py-2 text-right text-sm text-slate-400">{kwh(row.pv_self_consumption_kwh)}</td>
<td className="px-3 py-2 text-right text-sm text-red-400">{row.grid_import_cashflow_czk.toFixed(2)}</td>
<td className="px-3 py-2 text-right text-sm text-green-400">{row.grid_export_revenue_czk.toFixed(2)}</td>
<td className="px-3 py-2 text-right text-sm text-red-300">{row.import_cost_czk.toFixed(2)}</td>
<td className="px-3 py-2 text-right text-sm text-green-300">{row.export_revenue_czk.toFixed(2)}</td>
<td className="px-3 py-2 text-right text-sm text-slate-400">
{row.planned_balance_czk != null ? czk(row.planned_balance_czk) : ''}
</td>
<td className={`px-3 py-2 text-right text-sm ${row.deviation_cost_czk != null ? balanceColor(-row.deviation_cost_czk) : ''}`}>
{row.deviation_cost_czk != null ? czk(row.deviation_cost_czk) : ''}
</td>
{hasGreenBonus && (
<td className="px-3 py-2 text-right text-sm text-amber-400">
{row.green_bonus_czk > 0 ? row.green_bonus_czk.toFixed(2) : ''}
</td>
)}
<td className={`px-3 py-2 text-right text-sm font-semibold ${balanceColor(row.total_balance_czk)}`}>
{czk(row.total_balance_czk)}
</td>
<td className="px-2 py-2 text-center">
<button
onClick={(e) => {
e.stopPropagation()
onLockToggle()
}}
className="rounded p-1 transition hover:bg-slate-700"
title={row.is_locked ? 'Odemknout den' : 'Zamknout den'}
>
{row.is_locked ? (
<Lock size={14} className="text-amber-400" />
) : (
<Unlock size={14} className="text-slate-500" />
)}
</button>
</td>
</tr>
{expanded && (
<tr>
<td colSpan={colCount} className="bg-slate-900/50 px-4 py-2">
<IntervalDetail siteId={siteId} day={row.day} hasGreenBonus={hasGreenBonus} />
</td>
</tr>
)}
</>
)
}
export default function Economics() {
const { site: siteRow, ready: siteReady, error: siteError } = useSiteStatus()
const siteId = siteRow?.site_id ?? null
const [month, setMonth] = useState(currentMonth)
const [expandedDay, setExpandedDay] = useState<string | null>(null)
const { days, hasGreenBonus, loading, error, reload } = useEconomicsDaily(siteId, month)
const { points } = useEconomicsChart(siteId, month)
const summary = useMemo(() => {
if (days.length === 0) return null
return {
import_cost: days.reduce((s, d) => s + d.import_cost_czk, 0),
export_revenue: days.reduce((s, d) => s + d.export_revenue_czk, 0),
grid_import_cashflow: days.reduce((s, d) => s + d.grid_import_cashflow_czk, 0),
grid_export_revenue: days.reduce((s, d) => s + d.grid_export_revenue_czk, 0),
green_bonus: days.reduce((s, d) => s + d.green_bonus_czk, 0),
total_balance: days.reduce((s, d) => s + d.total_balance_czk, 0),
}
}, [days])
const handleLockToggle = useCallback(
async (row: DailyEconomics) => {
if (siteId == null) return
try {
if (row.is_locked) {
await unlockDay(siteId, row.day)
toast.success(`Den ${row.day} odemčen`)
} else {
await lockDay(siteId, row.day)
toast.success(`Den ${row.day} zamčen`)
}
reload()
} catch {
toast.error('Operace se nezdařila')
}
},
[reload, siteId],
)
return (
<main className="mx-auto max-w-7xl space-y-6 px-4 py-6 md:px-8">
{siteReady && siteRow && (
<p className="text-xs text-slate-500">
Lokalita: <span className="text-slate-400">{siteRow.site_code}</span> (id {siteRow.site_id}) ekonomika vychází z{' '}
<code className="rounded bg-slate-800 px-1">audit_interval</code>, ne z raw telemetrie.
</p>
)}
{siteError && (
<div className="rounded-lg border border-amber-900/50 bg-amber-950/30 px-3 py-2 text-sm text-amber-200">
{siteError} API ekonomiky potřebuje stejnou lokalitu jako Přehled (PostgREST{' '}
<code className="rounded bg-slate-900 px-1">vw_site_status</code>).
</div>
)}
{/* Month selector */}
<div className="flex items-center gap-4">
<button
onClick={() => setMonth((m) => shiftMonth(m, -1))}
className="rounded-lg p-2 text-slate-400 transition hover:bg-slate-800 hover:text-white"
>
<ChevronLeft size={20} />
</button>
<h1 className="min-w-[180px] text-center text-lg font-semibold text-white">
{monthLabel(month)}
</h1>
<button
onClick={() => setMonth((m) => shiftMonth(m, 1))}
className="rounded-lg p-2 text-slate-400 transition hover:bg-slate-800 hover:text-white"
>
<ChevronRight size={20} />
</button>
</div>
{/* Summary cards */}
{summary && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 lg:grid-cols-6">
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<p className="text-xs text-slate-400">Nákup ze sítě</p>
<p className="mt-1 text-lg font-semibold text-red-400">{summary.grid_import_cashflow.toFixed(2)} </p>
</div>
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<p className="text-xs text-slate-400">Prodej do sítě</p>
<p className="mt-1 text-lg font-semibold text-green-400">{summary.grid_export_revenue.toFixed(2)} </p>
</div>
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<p className="text-xs text-slate-400">Náklad celkem</p>
<p className="mt-1 text-lg font-semibold text-red-300">{summary.import_cost.toFixed(2)} </p>
</div>
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<p className="text-xs text-slate-400">Příjem celkem</p>
<p className="mt-1 text-lg font-semibold text-green-300">{summary.export_revenue.toFixed(2)} </p>
</div>
{hasGreenBonus && (
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<p className="text-xs text-slate-400">Zelený bonus</p>
<p className="mt-1 text-lg font-semibold text-amber-400">{summary.green_bonus.toFixed(2)} </p>
</div>
)}
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<p className="text-xs text-slate-400">Bilance měsíce</p>
<p className={`mt-1 text-lg font-semibold ${balanceColor(summary.total_balance)}`}>
{czk(summary.total_balance)}
</p>
</div>
</div>
)}
{/* Chart */}
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<h2 className="mb-3 text-sm font-medium text-slate-300">
Denní bilance (síť + bonus) a kumulativ
</h2>
<EconomicsChart points={points} hasGreenBonus={hasGreenBonus} />
</div>
{/* Daily table */}
<div className="overflow-hidden rounded-xl border border-slate-800 bg-slate-900">
{error && (
<div className="border-b border-red-900/50 bg-red-900/20 px-4 py-2 text-sm text-red-400">
{error}
</div>
)}
{!siteReady ? (
<div className="py-12 text-center text-sm text-slate-500">Načítání lokality</div>
) : siteId == null ? (
<div className="py-12 px-4 text-center text-sm text-slate-500">
Není dostupná lokalita z přehledu. Zkontrolujte PostgREST a tabulku{' '}
<code className="rounded bg-slate-800 px-1">ems.vw_site_status</code>.
</div>
) : loading ? (
<div className="py-12 text-center text-sm text-slate-500">Načítání</div>
) : days.length === 0 ? (
<div className="mx-auto max-w-lg py-10 px-4 text-center text-sm text-slate-400">
<p className="mb-2 font-medium text-slate-300">Žádná data pro tento měsíc</p>
<p className="mb-2">
Záložka čte jen to, co je v <code className="rounded bg-slate-800 px-1">ems.audit_interval</code> (15min agregace po
jobu audit filler). Telemetrie a ceny samy o sobě tabulku audit nenaplní.
</p>
<p className="mb-2">Zkuste jiný měsíc (šipky), pokud máte historii jinde než v aktuálním měsíci.</p>
<p>
Backfill na DB (psql):{' '}
<code className="mt-1 block break-all rounded bg-slate-900 p-2 text-left text-xs text-slate-300">
SELECT ems.fn_fill_audit_range(&lt;site_id&gt;, &apos;2024-01-01&apos;::timestamptz, now());
</code>
</p>
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full">
<thead>
<tr className="border-b border-slate-700 text-xs text-slate-400">
<th className="px-3 py-2 text-left">Den</th>
<th className="px-3 py-2 text-right">Import kWh</th>
<th className="px-3 py-2 text-right">Export kWh</th>
<th className="px-3 py-2 text-right">FVE vl. spot.</th>
<th className="px-3 py-2 text-right">Nákup ze sítě</th>
<th className="px-3 py-2 text-right">Prodej do sítě</th>
<th className="px-3 py-2 text-right">Náklad </th>
<th className="px-3 py-2 text-right">Příjem </th>
<th className="px-3 py-2 text-right">Plán </th>
<th className="px-3 py-2 text-right">Odchylka </th>
{hasGreenBonus && <th className="px-3 py-2 text-right">Bonus </th>}
<th className="px-3 py-2 text-right">Bilance </th>
<th className="w-10 px-2 py-2 text-center" />
</tr>
</thead>
<tbody>
{days.map((row) => (
<DailyRow
key={row.day}
row={row}
hasGreenBonus={hasGreenBonus}
siteId={siteId}
expanded={expandedDay === row.day}
onToggle={() => setExpandedDay((prev) => (prev === row.day ? null : row.day))}
onLockToggle={() => handleLockToggle(row)}
/>
))}
</tbody>
</table>
</div>
)}
</div>
</main>
)
}