implementace Ekonomiky
All checks were successful
test / smoke-test (push) Successful in 5s
deploy / deploy (push) Successful in 11s

This commit is contained in:
Dusan Vojacek
2026-04-05 20:10:43 +02:00
parent caf3f522e2
commit 5fcc47bce2
13 changed files with 1310 additions and 31 deletions

View File

@@ -3,6 +3,7 @@ import { NavLink, Outlet, Route, Routes } from 'react-router-dom'
import { useWsLogErrorCount } from './hooks/useWsLogErrorCount'
import { Dashboard } from './pages/Dashboard'
import Economics from './pages/Economics'
import { Logs } from './pages/Logs'
import Planning from './pages/Planning'
import { Settings } from './pages/Settings'
@@ -25,6 +26,9 @@ function AppLayout() {
<NavLink to="/planning" className={tabClass}>
Plánování
</NavLink>
<NavLink to="/economics" className={tabClass}>
Ekonomika
</NavLink>
<NavLink to="/settings" className={tabClass}>
Nastavení
</NavLink>
@@ -55,6 +59,7 @@ export default function App() {
<Route element={<AppLayout />}>
<Route index element={<Dashboard />} />
<Route path="planning" element={<Planning />} />
<Route path="economics" element={<Economics />} />
<Route path="settings" element={<Settings />} />
</Route>
<Route path="logs" element={<Logs />} />

View File

@@ -0,0 +1,123 @@
import {
Bar,
CartesianGrid,
Cell,
ComposedChart,
Line,
ReferenceLine,
ResponsiveContainer,
Tooltip,
XAxis,
YAxis,
} from 'recharts'
import type { ChartDayPoint } from '../../types/economics'
type Props = {
points: ChartDayPoint[]
}
const GREEN = '#22c55e'
const RED = '#ef4444'
const BLUE = '#3b82f6'
function formatDay(iso: string): string {
const d = new Date(iso + 'T00:00:00')
return `${d.getDate()}.`
}
type PayloadEntry = {
name?: string
value?: number
color?: string
}
function CustomTooltip({
active,
payload,
label,
}: {
active?: boolean
payload?: PayloadEntry[]
label?: string
}) {
if (!active || !payload?.length || !label) return null
const balance = payload.find((p) => p.name === 'daily_balance_czk')
const cumulative = payload.find((p) => p.name === 'cumulative_balance_czk')
return (
<div className="rounded-lg border border-slate-700 bg-slate-800 px-3 py-2 text-xs shadow-lg">
<p className="mb-1 font-medium text-slate-200">{label}</p>
{balance && (
<p style={{ color: (balance.value ?? 0) >= 0 ? GREEN : RED }}>
Den: {(balance.value ?? 0) >= 0 ? '+' : ''}
{(balance.value ?? 0).toFixed(2)}
</p>
)}
{cumulative && (
<p style={{ color: BLUE }}>
Kumulativ: {(cumulative.value ?? 0) >= 0 ? '+' : ''}
{(cumulative.value ?? 0).toFixed(2)}
</p>
)}
</div>
)
}
export function EconomicsChart({ points }: Props) {
if (points.length === 0) {
return (
<div className="flex h-64 items-center justify-center text-sm text-slate-500">
Žádná data pro tento měsíc
</div>
)
}
const data = points.map((p) => ({
...p,
label: formatDay(p.day),
}))
return (
<ResponsiveContainer width="100%" height={320}>
<ComposedChart data={data} margin={{ top: 8, right: 16, bottom: 4, left: 0 }}>
<CartesianGrid strokeDasharray="3 3" stroke="#334155" />
<XAxis dataKey="label" tick={{ fontSize: 11, fill: '#94a3b8' }} />
<YAxis
yAxisId="left"
tick={{ fontSize: 11, fill: '#94a3b8' }}
label={{
value: 'Kč/den',
angle: -90,
position: 'insideLeft',
style: { fontSize: 11, fill: '#94a3b8' },
}}
/>
<YAxis
yAxisId="right"
orientation="right"
tick={{ fontSize: 11, fill: BLUE }}
label={{
value: 'Kumulativ Kč',
angle: 90,
position: 'insideRight',
style: { fontSize: 11, fill: BLUE },
}}
/>
<Tooltip content={<CustomTooltip />} />
<ReferenceLine yAxisId="left" y={0} stroke="#475569" strokeDasharray="2 2" />
<Bar yAxisId="left" dataKey="daily_balance_czk" radius={[3, 3, 0, 0]} maxBarSize={32}>
{data.map((entry, idx) => (
<Cell key={idx} fill={entry.daily_balance_czk >= 0 ? GREEN : RED} fillOpacity={0.8} />
))}
</Bar>
<Line
yAxisId="right"
type="monotone"
dataKey="cumulative_balance_czk"
stroke={BLUE}
strokeWidth={2}
dot={{ r: 3, fill: BLUE }}
/>
</ComposedChart>
</ResponsiveContainer>
)
}

View File

@@ -0,0 +1,112 @@
import { useCallback, useEffect, useState } from 'react'
import { backendClient } from '../api/backend'
import type {
ChartDayPoint,
DailyEconomics,
DailyEconomicsResponse,
IntervalEconomics,
LockResponse,
} from '../types/economics'
export function useEconomicsDaily(siteId: number | null, month: string) {
const [days, setDays] = useState<DailyEconomics[]>([])
const [hasGreenBonus, setHasGreenBonus] = useState(false)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const load = useCallback(async () => {
if (siteId == null || !month) return
setLoading(true)
setError(null)
try {
const { data } = await backendClient.get<DailyEconomicsResponse>(
`/sites/${siteId}/economics/daily`,
{ params: { month }, timeout: 30_000 },
)
setDays(data.days ?? [])
setHasGreenBonus(data.has_green_bonus ?? false)
} catch {
setDays([])
setError('Nepodařilo se načíst ekonomiku')
} finally {
setLoading(false)
}
}, [siteId, month])
useEffect(() => {
void load()
}, [load])
return { days, hasGreenBonus, loading, error, reload: load }
}
export function useEconomicsIntervals(siteId: number | null, day: string | null) {
const [intervals, setIntervals] = useState<IntervalEconomics[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
if (siteId == null || !day) {
setIntervals([])
return
}
setLoading(true)
try {
const { data } = await backendClient.get<IntervalEconomics[]>(
`/sites/${siteId}/economics/daily/${day}/intervals`,
{ timeout: 30_000 },
)
setIntervals(Array.isArray(data) ? data : [])
} catch {
setIntervals([])
} finally {
setLoading(false)
}
}, [siteId, day])
useEffect(() => {
void load()
}, [load])
return { intervals, loading }
}
export function useEconomicsChart(siteId: number | null, month: string) {
const [points, setPoints] = useState<ChartDayPoint[]>([])
const [loading, setLoading] = useState(false)
const load = useCallback(async () => {
if (siteId == null || !month) return
setLoading(true)
try {
const { data } = await backendClient.get<ChartDayPoint[]>(
`/sites/${siteId}/economics/monthly-chart`,
{ params: { month }, timeout: 30_000 },
)
setPoints(Array.isArray(data) ? data : [])
} catch {
setPoints([])
} finally {
setLoading(false)
}
}, [siteId, month])
useEffect(() => {
void load()
}, [load])
return { points, loading }
}
export async function lockDay(siteId: number, day: string): Promise<LockResponse> {
const { data } = await backendClient.post<LockResponse>(
`/sites/${siteId}/economics/daily/${day}/lock`,
)
return data
}
export async function unlockDay(siteId: number, day: string): Promise<LockResponse> {
const { data } = await backendClient.delete<LockResponse>(
`/sites/${siteId}/economics/daily/${day}/lock`,
)
return data
}

View File

@@ -0,0 +1,344 @@
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 { pragueCalendarDay } from '../lib/pragueDate'
import type { DailyEconomics } from '../types/economics'
const SITE_ID = 1
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
}) {
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.self_consumption_kwh)}</td>
<td className="px-3 py-2 text-right text-sm text-red-400">{row.import_cost_czk.toFixed(2)}</td>
<td className="px-3 py-2 text-right text-sm text-green-400">{row.export_revenue_czk.toFixed(2)}</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-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>
<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={hasGreenBonus ? 11 : 10} className="bg-slate-900/50 px-4 py-2">
<IntervalDetail siteId={siteId} day={row.day} hasGreenBonus={hasGreenBonus} />
</td>
</tr>
)}
</>
)
}
export default function Economics() {
const [month, setMonth] = useState(currentMonth)
const [expandedDay, setExpandedDay] = useState<string | null>(null)
const { days, hasGreenBonus, loading, error, reload } = useEconomicsDaily(SITE_ID, month)
const { points } = useEconomicsChart(SITE_ID, 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),
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) => {
try {
if (row.is_locked) {
await unlockDay(SITE_ID, row.day)
toast.success(`Den ${row.day} odemčen`)
} else {
await lockDay(SITE_ID, row.day)
toast.success(`Den ${row.day} zamčen`)
}
reload()
} catch {
toast.error('Operace se nezdařila')
}
},
[reload],
)
return (
<main className="mx-auto max-w-7xl space-y-6 px-4 py-6 md:px-8">
{/* 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-4">
<div className="rounded-xl border border-slate-800 bg-slate-900 p-4">
<p className="text-xs text-slate-400">Nákup celkem</p>
<p className="mt-1 text-lg font-semibold text-red-400">{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">Prodej celkem</p>
<p className="mt-1 text-lg font-semibold text-green-400">{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 + kumulativ od 1. v měsíci
</h2>
<EconomicsChart points={points} />
</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>
)}
{loading ? (
<div className="py-12 text-center text-sm text-slate-500">Načítání</div>
) : days.length === 0 ? (
<div className="py-12 text-center text-sm text-slate-500">Žádná data pro tento měsíc</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">Vl. spotřeba</th>
<th className="px-3 py-2 text-right">Náklad </th>
<th className="px-3 py-2 text-right">Příjem </th>
{hasGreenBonus && <th className="px-3 py-2 text-right">Bonus </th>}
<th className="px-3 py-2 text-right">Bilance </th>
<th className="px-3 py-2 text-right">Plán </th>
<th className="px-3 py-2 text-right">Odchylka </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={SITE_ID}
expanded={expandedDay === row.day}
onToggle={() => setExpandedDay((prev) => (prev === row.day ? null : row.day))}
onLockToggle={() => handleLockToggle(row)}
/>
))}
</tbody>
</table>
</div>
)}
</div>
</main>
)
}

View File

@@ -0,0 +1,55 @@
export type DailyEconomics = {
day: string
interval_count: number
import_kwh: number
export_kwh: number
pv_kwh: number
load_kwh: number
self_consumption_kwh: number
ev_kwh: number
hp_kwh: number
import_cost_czk: number
export_revenue_czk: number
net_cost_czk: number
green_bonus_czk: number
total_balance_czk: number
planned_balance_czk: number | null
deviation_cost_czk: number | null
is_locked: boolean
}
export type DailyEconomicsResponse = {
days: DailyEconomics[]
has_green_bonus: boolean
}
export type IntervalEconomics = {
interval_start: string
import_kwh: number
export_kwh: number
dynamic_cost_czk: number | null
stored_cost_czk: number | null
green_bonus_czk: number | null
planned_cost_czk: number | null
planned_grid_w: number | null
actual_grid_power_w: number | null
effective_buy_price: number | null
effective_sell_price: number | null
planned_buy_price: number | null
planned_sell_price: number | null
actual_pv_power_w: number | null
actual_load_power_w: number | null
actual_battery_power_w: number | null
actual_battery_soc_pct: number | null
}
export type ChartDayPoint = {
day: string
daily_balance_czk: number
cumulative_balance_czk: number
}
export type LockResponse = {
locked: boolean
day: string
}