feat: overhaul all 12 sector extensions with full CRUD, validation, and tests

Add shared components (ConfirmDeleteDialog, EditEntryDialog, validation utils),
enhance all 12 extension workspaces with edit/delete dialogs, input validation,
period comparisons, and new analytics features. Fix critical bugs in
ProjectBilling margin calculation and EarningsPerLiter revenue allocation.
Add pure calculation modules with 183 new tests across all extensions.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Emil
2026-02-21 15:21:49 +01:00
co-authored by Claude Opus 4.6
parent 026497ed75
commit 6a5b2b7792
52 changed files with 14130 additions and 222 deletions
+86
View File
@@ -0,0 +1,86 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
interface ExtensionDataRecord {
id: string
key: string
value: Record<string, unknown>
created_at: string
updated_at: string
}
export function useExtensionData(sector: string, slug: string) {
const [data, setData] = useState<ExtensionDataRecord[]>([])
const [isLoading, setIsLoading] = useState(true)
const basePath = `/api/extensions/${sector}/${slug}/data`
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
return () => { mountedRef.current = false }
}, [])
const refresh = useCallback(async () => {
setIsLoading(true)
try {
const res = await fetch(basePath)
if (res.ok) {
const json = await res.json()
if (mountedRef.current) setData(json.data ?? [])
}
} finally {
if (mountedRef.current) setIsLoading(false)
}
}, [basePath])
useEffect(() => {
refresh()
}, [refresh])
const save = useCallback(async (key: string, value: Record<string, unknown>) => {
const res = await fetch(basePath, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ key, value }),
})
if (res.ok) {
const json = await res.json()
setData(prev => {
const idx = prev.findIndex(d => d.key === key)
if (idx >= 0) {
const updated = [...prev]
updated[idx] = json.data
return updated
}
return [...prev, json.data]
})
return json.data
}
return null
}, [basePath])
const remove = useCallback(async (key: string) => {
const res = await fetch(`${basePath}?key=${encodeURIComponent(key)}`, {
method: 'DELETE',
})
if (res.ok) {
setData(prev => prev.filter(d => d.key !== key))
}
}, [basePath])
const getByPrefix = useCallback(async (prefix: string): Promise<ExtensionDataRecord[]> => {
const res = await fetch(`${basePath}?prefix=${encodeURIComponent(prefix)}`)
if (res.ok) {
const json = await res.json()
return json.data ?? []
}
return []
}, [basePath])
const getByKey = useCallback((key: string) => {
return data.find(d => d.key === key) ?? null
}, [data])
return { data, isLoading, save, remove, getByPrefix, getByKey, refresh }
}