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:
@@ -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')
|
||||
})
|
||||
})
|
||||
@@ -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,
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user