M6-T10: frontend contract management + price/cost views + Tibber test

- EnergyPage: Mantine Tabs (Devices kept intact + Contracts/Prices/Costs).
- ContractManager/ContractForm: list/activate/add-version + version history;
  form fields rendered dynamically from /api/energy/profiles structure.
- TibberPrices + CostView: Recharts price curve, cost trend/detail/summary,
  recompute; window-bounded, currency/units from API, empty/error/loading states.
- ConfigPage: tri-state Tibber test button (token never shown).
- hooks.ts: typed energy hooks; schema.d.ts regenerated via codegen.
This commit is contained in:
2026-06-23 23:32:39 +02:00
parent 57f2459f60
commit 96e88861d4
15 changed files with 4235 additions and 32 deletions
+229
View File
@@ -191,6 +191,235 @@ export function useMetrics(uuid: string) {
})
}
// ===========================================================================
// Energy / Pricing hooks — typed TanStack Query wrappers for /api/energy/*.
//
// Query-key conventions:
// ['energy-contracts'] — contract list
// ['energy-contract', id] — single contract with versions
// ['energy-profiles'] — pricing profile structures
// ['energy-prices', start, end] — price curve
// ['energy-costs', start, end, limit] — cost periods
// ['energy-costs-summary', start, end] — summary
// ['dsmr-latest'] — DSMR latest
// ===========================================================================
// Re-exported energy types for consumers
export type ContractResponse = components['schemas']['ContractResponse']
export type ContractDetailResponse = components['schemas']['ContractDetailResponse']
export type ContractVersionResponse = components['schemas']['ContractVersionResponse']
export type ContractListResponse = components['schemas']['ContractListResponse']
export type ContractCreate = components['schemas']['ContractCreate']
export type ContractPatch = components['schemas']['ContractPatch']
export type VersionCreate = components['schemas']['VersionCreate']
export type ProfilesResponse = components['schemas']['ProfilesResponse']
export type PricesResponse = components['schemas']['PricesResponse']
export type PricePointSchema = components['schemas']['PricePointSchema']
export type ManualTariffSchema = components['schemas']['ManualTariffSchema']
export type CostsResponse = components['schemas']['CostsResponse']
export type CostPeriodSchema = components['schemas']['CostPeriodSchema']
export type SummaryResponse = components['schemas']['SummaryResponse']
export type DsmrLatestResponse = components['schemas']['DsmrLatestResponse']
export type TibberTestResponse = components['schemas']['TibberTestResponse']
export type TibberTestPriceSchema = components['schemas']['TibberTestPriceSchema']
// ---------------------------------------------------------------------------
// Query: list all energy contracts
// ---------------------------------------------------------------------------
export function useContracts() {
return useQuery({
queryKey: ['energy-contracts'],
queryFn: async () => {
const res = await apiClient.GET('/api/energy/contracts')
return res.data
},
})
}
// ---------------------------------------------------------------------------
// Query: single contract with full version history
// ---------------------------------------------------------------------------
export function useContractDetail(id: number) {
return useQuery({
queryKey: ['energy-contract', id],
queryFn: async () => {
const res = await apiClient.GET('/api/energy/contracts/{contract_id}', {
params: { path: { contract_id: id } },
})
return res.data
},
enabled: !!id,
})
}
// ---------------------------------------------------------------------------
// Query: energy pricing profile structures
// ---------------------------------------------------------------------------
export function useEnergyProfiles() {
return useQuery({
queryKey: ['energy-profiles'],
queryFn: async () => {
const res = await apiClient.GET('/api/energy/profiles')
return res.data
},
staleTime: 5 * 60 * 1000,
})
}
// ---------------------------------------------------------------------------
// Mutation: create energy contract
// ---------------------------------------------------------------------------
export function useCreateContract() {
const qc = useQueryClient()
return useMutation({
mutationFn: (body: ContractCreate) =>
apiClient.POST('/api/energy/contracts', { body }),
onSuccess: () => qc.invalidateQueries({ queryKey: ['energy-contracts'] }),
})
}
// ---------------------------------------------------------------------------
// Mutation: update (PATCH) energy contract
// ---------------------------------------------------------------------------
export function useUpdateContract() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: number; body: ContractPatch }) =>
apiClient.PATCH('/api/energy/contracts/{contract_id}', {
params: { path: { contract_id: id } },
body,
}),
onSuccess: () => qc.invalidateQueries({ queryKey: ['energy-contracts'] }),
})
}
// ---------------------------------------------------------------------------
// Mutation: add a new version to an existing contract
// ---------------------------------------------------------------------------
export function useAddContractVersion() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ id, body }: { id: number; body: VersionCreate }) =>
apiClient.POST('/api/energy/contracts/{contract_id}/versions', {
params: { path: { contract_id: id } },
body,
}),
onSuccess: (_data, vars) => {
void qc.invalidateQueries({ queryKey: ['energy-contracts'] })
void qc.invalidateQueries({ queryKey: ['energy-contract', vars.id] })
},
})
}
// ---------------------------------------------------------------------------
// Query: energy price curve (Tibber 15-min / manual tariff)
// ---------------------------------------------------------------------------
export function useEnergyPrices(start?: string, end?: string) {
return useQuery({
queryKey: ['energy-prices', start, end],
queryFn: async () => {
const res = await apiClient.GET('/api/energy/prices', {
params: {
query: {
...(start ? { start } : {}),
...(end ? { end } : {}),
},
},
})
return res.data
},
})
}
// ---------------------------------------------------------------------------
// Query: cost periods
// ---------------------------------------------------------------------------
export function useEnergyCosts(start?: string, end?: string, limit?: number) {
return useQuery({
queryKey: ['energy-costs', start, end, limit],
queryFn: async () => {
const res = await apiClient.GET('/api/energy/costs', {
params: {
query: {
...(start ? { start } : {}),
...(end ? { end } : {}),
...(limit != null ? { limit } : {}),
},
},
})
return res.data
},
})
}
// ---------------------------------------------------------------------------
// Query: cost summary
// ---------------------------------------------------------------------------
export function useEnergyCostSummary(start?: string, end?: string) {
return useQuery({
queryKey: ['energy-costs-summary', start, end],
queryFn: async () => {
const res = await apiClient.GET('/api/energy/costs/summary', {
params: {
query: {
...(start ? { start } : {}),
...(end ? { end } : {}),
},
},
})
return res.data
},
})
}
// ---------------------------------------------------------------------------
// Query: DSMR latest reading
// ---------------------------------------------------------------------------
export function useDsmrLatest() {
return useQuery({
queryKey: ['dsmr-latest'],
queryFn: async () => {
const res = await apiClient.GET('/api/energy/dsmr/latest')
return res.data
},
})
}
// ---------------------------------------------------------------------------
// Mutation: recompute costs
// ---------------------------------------------------------------------------
export function useRecomputeCosts() {
const qc = useQueryClient()
return useMutation({
mutationFn: ({ start, end }: { start?: string; end?: string } = {}) => {
// The schema marks start/end as required, but the backend accepts them as
// optional query params; we spread only defined values.
const query = {
...(start ? { start } : {}),
...(end ? { end } : {}),
} as { start: string; end: string }
return apiClient.POST('/api/energy/costs/recompute', {
params: { query },
})
},
onSuccess: () => {
void qc.invalidateQueries({ queryKey: ['energy-costs'] })
void qc.invalidateQueries({ queryKey: ['energy-costs-summary'] })
},
})
}
// ---------------------------------------------------------------------------
// Query: time-range readings for a device (window + limit — never full-table)
// ---------------------------------------------------------------------------