396 lines
16 KiB
TypeScript
396 lines
16 KiB
TypeScript
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 Kč</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 Kč</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)} Kč</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)} Kč</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)} Kč</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)} Kč</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)} Kč</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)} Kč
|
||
</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(<site_id>, '2024-01-01'::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 Kč</th>
|
||
<th className="px-3 py-2 text-right">Příjem Kč</th>
|
||
<th className="px-3 py-2 text-right">Plán Kč</th>
|
||
<th className="px-3 py-2 text-right">Odchylka Kč</th>
|
||
{hasGreenBonus && <th className="px-3 py-2 text-right">Bonus Kč</th>}
|
||
<th className="px-3 py-2 text-right">Bilance Kč</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>
|
||
)
|
||
}
|