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
Načítání intervalů…
}
if (intervals.length === 0) {
return Žádné intervaly
}
return (
| Čas |
Import kWh |
Export kWh |
Náklad Kč |
Cena nákup |
Cena prodej |
{hasGreenBonus && Bonus Kč | }
Plán grid W |
Skuteč. grid W |
Plán náklad |
{intervals.map((iv) => (
| {fmtTime(iv.interval_start)} |
{kwh(iv.import_kwh)} |
{kwh(iv.export_kwh)} |
{iv.dynamic_cost_czk != null ? iv.dynamic_cost_czk.toFixed(2) : '–'}
|
{iv.effective_buy_price != null ? iv.effective_buy_price.toFixed(2) : '–'}
|
{iv.effective_sell_price != null ? iv.effective_sell_price.toFixed(2) : '–'}
|
{hasGreenBonus && (
{iv.green_bonus_czk != null && iv.green_bonus_czk > 0
? iv.green_bonus_czk.toFixed(2)
: '–'}
|
)}
{iv.planned_grid_w ?? '–'}
|
{iv.actual_grid_power_w ?? '–'}
|
{iv.planned_cost_czk != null ? iv.planned_cost_czk.toFixed(2) : '–'}
|
))}
)
}
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 (
<>
|
{expanded ? : }
{fmtDay(row.day)}
|
{kwh(row.import_kwh)} |
{kwh(row.export_kwh)} |
{kwh(row.pv_self_consumption_kwh)} |
{row.grid_import_cashflow_czk.toFixed(2)} |
{row.grid_export_revenue_czk.toFixed(2)} |
{row.import_cost_czk.toFixed(2)} |
{row.export_revenue_czk.toFixed(2)} |
{row.planned_balance_czk != null ? czk(row.planned_balance_czk) : '–'}
|
{row.deviation_cost_czk != null ? czk(row.deviation_cost_czk) : '–'}
|
{hasGreenBonus && (
{row.green_bonus_czk > 0 ? row.green_bonus_czk.toFixed(2) : '–'}
|
)}
{czk(row.total_balance_czk)}
|
|
{expanded && (
|
|
)}
>
)
}
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(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 (
{siteReady && siteRow && (
Lokalita: {siteRow.site_code} (id {siteRow.site_id}) — ekonomika vychází z{' '}
audit_interval, ne z raw telemetrie.
)}
{siteError && (
{siteError} — API ekonomiky potřebuje stejnou lokalitu jako Přehled (PostgREST{' '}
vw_site_status).
)}
{/* Month selector */}
{monthLabel(month)}
{/* Summary cards */}
{summary && (
Nákup ze sítě
{summary.grid_import_cashflow.toFixed(2)} Kč
Prodej do sítě
{summary.grid_export_revenue.toFixed(2)} Kč
Náklad celkem
{summary.import_cost.toFixed(2)} Kč
Příjem celkem
{summary.export_revenue.toFixed(2)} Kč
{hasGreenBonus && (
Zelený bonus
{summary.green_bonus.toFixed(2)} Kč
)}
Bilance měsíce
{czk(summary.total_balance)} Kč
)}
{/* Chart */}
Denní bilance (síť + bonus) a kumulativ
{/* Daily table */}
{error && (
{error}
)}
{!siteReady ? (
Načítání lokality…
) : siteId == null ? (
Není dostupná lokalita z přehledu. Zkontrolujte PostgREST a tabulku{' '}
ems.vw_site_status.
) : loading ? (
Načítání…
) : days.length === 0 ? (
Žádná data pro tento měsíc
Záložka čte jen to, co je v ems.audit_interval (15min agregace po
jobu audit filler). Telemetrie a ceny samy o sobě tabulku audit nenaplní.
Zkuste jiný měsíc (šipky), pokud máte historii jinde než v aktuálním měsíci.
Backfill na DB (psql):{' '}
SELECT ems.fn_fill_audit_range(<site_id>, '2024-01-01'::timestamptz, now());
) : (
| Den |
Import kWh |
Export kWh |
FVE vl. spot. |
Nákup ze sítě |
Prodej do sítě |
Náklad Kč |
Příjem Kč |
Plán Kč |
Odchylka Kč |
{hasGreenBonus && Bonus Kč | }
Bilance Kč |
|
{days.map((row) => (
setExpandedDay((prev) => (prev === row.day ? null : row.day))}
onLockToggle={() => handleLockToggle(row)}
/>
))}
)}
)
}