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
+177
View File
@@ -0,0 +1,177 @@
import { describe, it, expect } from 'vitest'
import {
validateSwedishPersonalNumber,
validatePositiveNumber,
validateNonNegativeNumber,
validateMaxNumber,
validateRequired,
validateDateNotFuture,
} from '../validation'
describe('validateSwedishPersonalNumber', () => {
it('accepts a valid personal number with dash', () => {
// 811228-9874 is a valid test number (Luhn passes)
expect(validateSwedishPersonalNumber('19811228-9874')).toBeNull()
})
it('accepts a valid personal number without dash', () => {
expect(validateSwedishPersonalNumber('198112289874')).toBeNull()
})
it('rejects empty string', () => {
expect(validateSwedishPersonalNumber('')).toBe('Personnummer kravs')
})
it('rejects too short input', () => {
expect(validateSwedishPersonalNumber('19811228')).toBe('Format: YYYYMMDD-XXXX (12 siffror)')
})
it('rejects too long input', () => {
expect(validateSwedishPersonalNumber('198112289874555')).toBe('Format: YYYYMMDD-XXXX (12 siffror)')
})
it('rejects non-numeric characters', () => {
expect(validateSwedishPersonalNumber('19811228ABCD')).toBe('Format: YYYYMMDD-XXXX (12 siffror)')
})
it('rejects invalid month', () => {
expect(validateSwedishPersonalNumber('199913011234')).toBe('Ogiltig manad')
})
it('rejects month 00', () => {
expect(validateSwedishPersonalNumber('199900011234')).toBe('Ogiltig manad')
})
it('rejects invalid day', () => {
expect(validateSwedishPersonalNumber('199901321234')).toBe('Ogiltig dag')
})
it('rejects day 00', () => {
expect(validateSwedishPersonalNumber('199901001234')).toBe('Ogiltig dag')
})
it('rejects year before 1900', () => {
expect(validateSwedishPersonalNumber('189901011234')).toBe('Ogiltigt ar')
})
it('rejects future year', () => {
const futureYear = new Date().getFullYear() + 1
expect(validateSwedishPersonalNumber(`${futureYear}01011234`)).toBe('Ogiltigt ar')
})
it('rejects invalid Luhn checksum', () => {
// Change last digit to break checksum
expect(validateSwedishPersonalNumber('19811228-9875')).toBe('Ogiltig kontrollsiffra')
})
it('handles spaces in input', () => {
expect(validateSwedishPersonalNumber('1981 1228 9874')).toBeNull()
})
})
describe('validatePositiveNumber', () => {
it('returns null for positive number', () => {
expect(validatePositiveNumber(5)).toBeNull()
})
it('returns null for positive string number', () => {
expect(validatePositiveNumber('42.5')).toBeNull()
})
it('rejects zero', () => {
expect(validatePositiveNumber(0)).toBe('Varde maste vara storre an 0')
})
it('rejects negative number', () => {
expect(validatePositiveNumber(-3)).toBe('Varde maste vara storre an 0')
})
it('rejects NaN string', () => {
expect(validatePositiveNumber('abc')).toBe('Varde maste vara storre an 0')
})
})
describe('validateNonNegativeNumber', () => {
it('returns null for positive number', () => {
expect(validateNonNegativeNumber(5)).toBeNull()
})
it('returns null for zero', () => {
expect(validateNonNegativeNumber(0)).toBeNull()
})
it('rejects negative number', () => {
expect(validateNonNegativeNumber(-1)).toBe('Varde kan inte vara negativt')
})
it('rejects NaN string', () => {
expect(validateNonNegativeNumber('xyz')).toBe('Varde kan inte vara negativt')
})
})
describe('validateMaxNumber', () => {
it('returns null when value is under max', () => {
expect(validateMaxNumber(5, 10)).toBeNull()
})
it('returns null when value equals max', () => {
expect(validateMaxNumber(10, 10)).toBeNull()
})
it('rejects when value exceeds max', () => {
expect(validateMaxNumber(15, 10)).toBe('Varde kan inte overskrida 10')
})
it('rejects NaN input', () => {
expect(validateMaxNumber('abc', 10)).toBe('Ogiltigt varde')
})
it('works with string numbers', () => {
expect(validateMaxNumber('8', 10)).toBeNull()
})
})
describe('validateRequired', () => {
it('returns null for non-empty string', () => {
expect(validateRequired('hello')).toBeNull()
})
it('returns null for number', () => {
expect(validateRequired(42)).toBeNull()
})
it('returns null for zero', () => {
expect(validateRequired(0)).toBeNull()
})
it('rejects empty string', () => {
expect(validateRequired('')).toBe('Obligatoriskt falt')
})
it('rejects undefined', () => {
expect(validateRequired(undefined)).toBe('Obligatoriskt falt')
})
it('rejects null', () => {
expect(validateRequired(null)).toBe('Obligatoriskt falt')
})
})
describe('validateDateNotFuture', () => {
it('returns null for past date', () => {
expect(validateDateNotFuture('2020-01-01')).toBeNull()
})
it('returns null for today', () => {
const today = new Date().toISOString().slice(0, 10)
expect(validateDateNotFuture(today)).toBeNull()
})
it('rejects future date', () => {
expect(validateDateNotFuture('2099-01-01')).toBe('Datum kan inte vara i framtiden')
})
it('rejects empty string', () => {
expect(validateDateNotFuture('')).toBe('Datum kravs')
})
})
+80
View File
@@ -0,0 +1,80 @@
'use client'
import { useState, useEffect, useCallback, useRef } from 'react'
interface AccountTotal {
account_number: string
debit: number
credit: number
net: number
}
interface MonthlyTotal {
month: string
account_number: string
debit: number
credit: number
net: number
}
interface UseAccountTotalsOptions {
from: string
to: string
dateFrom?: string
dateTo?: string
groupBy?: 'month'
}
export function useAccountTotals(options: UseAccountTotalsOptions) {
const [totals, setTotals] = useState<AccountTotal[]>([])
const [monthly, setMonthly] = useState<MonthlyTotal[]>([])
const [isLoading, setIsLoading] = useState(true)
const mountedRef = useRef(true)
useEffect(() => {
mountedRef.current = true
return () => { mountedRef.current = false }
}, [])
const refresh = useCallback(async () => {
setIsLoading(true)
try {
const params = new URLSearchParams({
from: options.from,
to: options.to,
})
if (options.dateFrom) params.set('date_from', options.dateFrom)
if (options.dateTo) params.set('date_to', options.dateTo)
if (options.groupBy) params.set('group_by', options.groupBy)
const res = await fetch(`/api/bookkeeping/account-totals?${params}`)
if (res.ok) {
const json = await res.json()
if (mountedRef.current) {
setTotals(json.totals ?? [])
setMonthly(json.monthly ?? [])
}
}
} finally {
if (mountedRef.current) setIsLoading(false)
}
}, [options.from, options.to, options.dateFrom, options.dateTo, options.groupBy])
useEffect(() => {
refresh()
}, [refresh])
const totalDebit = totals.reduce((sum, t) => sum + t.debit, 0)
const totalCredit = totals.reduce((sum, t) => sum + t.credit, 0)
const totalNet = totals.reduce((sum, t) => sum + t.net, 0)
return {
totals,
monthly,
isLoading,
totalDebit: Math.round(totalDebit * 100) / 100,
totalCredit: Math.round(totalCredit * 100) / 100,
totalNet: Math.round(totalNet * 100) / 100,
refresh,
}
}
+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 }
}
+70
View File
@@ -0,0 +1,70 @@
/**
* Validates a Swedish personal number (YYYYMMDD-XXXX) using Luhn checksum.
* Returns an error message string, or null if valid.
*/
export function validateSwedishPersonalNumber(pnr: string): string | null {
if (!pnr) return 'Personnummer kravs'
// Accept YYYYMMDD-XXXX or YYYYMMDDXXXX
const cleaned = pnr.replace(/[-\s]/g, '')
if (!/^\d{12}$/.test(cleaned)) {
return 'Format: YYYYMMDD-XXXX (12 siffror)'
}
const year = parseInt(cleaned.slice(0, 4))
const month = parseInt(cleaned.slice(4, 6))
const day = parseInt(cleaned.slice(6, 8))
if (month < 1 || month > 12) return 'Ogiltig manad'
if (day < 1 || day > 31) return 'Ogiltig dag'
if (year < 1900 || year > new Date().getFullYear()) return 'Ogiltigt ar'
// Luhn check on the last 10 digits (YYMMDDXXXX)
const luhnDigits = cleaned.slice(2)
let sum = 0
for (let i = 0; i < 10; i++) {
let digit = parseInt(luhnDigits[i])
if (i % 2 === 0) {
digit *= 2
if (digit > 9) digit -= 9
}
sum += digit
}
if (sum % 10 !== 0) return 'Ogiltig kontrollsiffra'
return null
}
export function validatePositiveNumber(value: number | string): string | null {
const num = typeof value === 'string' ? parseFloat(value) : value
if (isNaN(num) || num <= 0) return 'Varde maste vara storre an 0'
return null
}
export function validateNonNegativeNumber(value: number | string): string | null {
const num = typeof value === 'string' ? parseFloat(value) : value
if (isNaN(num) || num < 0) return 'Varde kan inte vara negativt'
return null
}
export function validateMaxNumber(value: number | string, max: number): string | null {
const num = typeof value === 'string' ? parseFloat(value) : value
if (isNaN(num)) return 'Ogiltigt varde'
if (num > max) return `Varde kan inte overskrida ${max}`
return null
}
export function validateRequired(value: string | number | undefined | null): string | null {
if (value === undefined || value === null || value === '') return 'Obligatoriskt falt'
return null
}
export function validateDateNotFuture(dateStr: string): string | null {
if (!dateStr) return 'Datum kravs'
const date = new Date(dateStr)
const today = new Date()
today.setHours(23, 59, 59, 999)
if (date > today) return 'Datum kan inte vara i framtiden'
return null
}