'use client' /** * IngÄende saldon (payroll cutover) panel on the employee editor. * * Mid-year switchers from another payroll system enter per-employee state * here: YTD accumulators, vacation balances (incl. sparade dagar per * origin year), opening semesterlöneskuld SEK, and the karens adjustment. * Locked (read-only) once the employee has a booked salary run; the lock * self-releases if that run is corrected. */ import { useEffect, useState } from 'react' import { useTranslations } from 'next-intl' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Skeleton } from '@/components/ui/skeleton' import { Save } from 'lucide-react' import { useToast } from '@/components/ui/use-toast' import { getErrorMessage } from '@/lib/errors/get-error-message' interface OpeningBalancesData { cutover_date: string ytd_gross: number ytd_tax: number ytd_net: number vacation_paid_days_remaining: number vacation_days_taken_this_year: number vacation_saved_days_by_year: Record opening_semester_liability: number opening_semester_liability_avgifter: number karens_periods_adjustment: number locked: boolean locked_by_run_id: string | null } const currentYear = new Date().getFullYear() /** Sparade dagar origin years: Semesterlagen allows saving max 5 years. */ const SAVED_YEARS = Array.from({ length: 5 }, (_, i) => String(currentYear - 1 - i)) export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: string; canWrite: boolean }) { const t = useTranslations('salary_employee') const { toast } = useToast() const [loading, setLoading] = useState(true) const [saving, setSaving] = useState(false) const [locked, setLocked] = useState(false) const [hasRow, setHasRow] = useState(false) const [cutoverDate, setCutoverDate] = useState(`${currentYear}-01-01`) const [ytdGross, setYtdGross] = useState('') const [ytdTax, setYtdTax] = useState('') const [ytdNet, setYtdNet] = useState('') const [daysRemaining, setDaysRemaining] = useState('') const [daysTaken, setDaysTaken] = useState('') const [savedByYear, setSavedByYear] = useState>({}) const [liability, setLiability] = useState('') const [liabilityAvgifter, setLiabilityAvgifter] = useState('') const [karens, setKarens] = useState('') useEffect(() => { async function load() { setLoading(true) const res = await fetch(`/api/salary/employees/${employeeId}/opening-balances`) if (res.ok) { const { data } = (await res.json()) as { data: OpeningBalancesData | null } if (data) { setHasRow(true) setLocked(data.locked) setCutoverDate(data.cutover_date) setYtdGross(String(data.ytd_gross)) setYtdTax(String(data.ytd_tax)) setYtdNet(String(data.ytd_net)) setDaysRemaining(String(data.vacation_paid_days_remaining)) setDaysTaken(String(data.vacation_days_taken_this_year ?? 0)) setSavedByYear( Object.fromEntries( Object.entries(data.vacation_saved_days_by_year ?? {}).map(([y, d]) => [y, String(d)]), ), ) setLiability(String(data.opening_semester_liability)) setLiabilityAvgifter(String(data.opening_semester_liability_avgifter)) setKarens(String(data.karens_periods_adjustment)) } } setLoading(false) } load() }, [employeeId]) async function handleSave() { setSaving(true) const saved: Record = {} for (const [year, value] of Object.entries(savedByYear)) { const days = parseFloat(value) if (Number.isFinite(days) && days > 0) saved[year] = days } const res = await fetch(`/api/salary/employees/${employeeId}/opening-balances`, { method: 'PUT', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ cutover_date: cutoverDate, ytd_gross: parseFloat(ytdGross) || 0, ytd_tax: parseFloat(ytdTax) || 0, ytd_net: parseFloat(ytdNet) || 0, vacation_paid_days_remaining: parseFloat(daysRemaining) || 0, vacation_days_taken_this_year: parseFloat(daysTaken) || 0, vacation_saved_days_by_year: saved, opening_semester_liability: parseFloat(liability) || 0, opening_semester_liability_avgifter: parseFloat(liabilityAvgifter) || 0, karens_periods_adjustment: parseInt(karens, 10) || 0, }), }) if (res.ok) { setHasRow(true) toast({ title: t('opening_balances_saved') }) } else { const result = await res.json() toast({ title: t('opening_balances_save_failed'), description: getErrorMessage(result, { statusCode: res.status }), variant: 'destructive', }) } setSaving(false) } if (loading) { return ( {t('opening_balances_title')} ) } const readOnly = locked || !canWrite return ( {t('opening_balances_title')}

{t('opening_balances_description')}

{locked && (

{t('opening_balances_locked_notice')}

)}
setCutoverDate(e.target.value)} disabled={readOnly} />

{t('opening_balances_cutover_hint')}

setKarens(e.target.value)} disabled={readOnly} className="tabular-nums" />

{t('opening_balances_karens_hint')}

{t('opening_balances_ytd_heading')}

setYtdGross(e.target.value)} disabled={readOnly} className="tabular-nums" />
setYtdTax(e.target.value)} disabled={readOnly} className="tabular-nums" />
setYtdNet(e.target.value)} disabled={readOnly} className="tabular-nums" />

{t('opening_balances_vacation_heading')}

setDaysRemaining(e.target.value)} disabled={readOnly} className="tabular-nums" />
setDaysTaken(e.target.value)} disabled={readOnly} className="tabular-nums" />

{t('opening_balances_days_taken_hint')}

setLiability(e.target.value)} disabled={readOnly} className="tabular-nums" />
setLiabilityAvgifter(e.target.value)} disabled={readOnly} className="tabular-nums" />

{t('opening_balances_saved_heading')}

{t('opening_balances_saved_hint')}

{SAVED_YEARS.map((year) => (
setSavedByYear((prev) => ({ ...prev, [year]: e.target.value }))} disabled={readOnly} className="tabular-nums" />
))}
{!readOnly && (
)}
) }