Files
accounted/components/salary/OpeningBalancesPanel.tsx
T
314efe8b22 fix(design): stop synthesizing bold on Hedvig display headings (#1555)
* fix(design): stop synthesizing bold on Hedvig display headings

Hedvig Letters Serif ships weight 400 only, but the h1-h3 base rule
forced font-weight 500 and DialogTitle/SheetTitle stacked font-semibold
on top, so every display heading rendered browser-synthesized bold: the
smudged heavy look on dialog titles and page headings. Drop the base
rule to 400, remove the weight utilities from the title primitives, and
sweep the 41 files that hand-set font-medium/semibold/bold on serif
headings (a pattern design.md already forbids). Headings that opt into
font-sans keep their weight.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

* fix(design): drop empty className left by the weight sweep

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-13 11:27:25 +02:00

282 lines
11 KiB
TypeScript

'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<string, number>
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<Record<string, string>>({})
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<string, number> = {}
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 (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('opening_balances_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
)
}
const readOnly = locked || !canWrite
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('opening_balances_title')}</CardTitle>
<p className="text-sm text-muted-foreground">{t('opening_balances_description')}</p>
</CardHeader>
<CardContent className="space-y-6">
{locked && (
<p className="text-sm text-muted-foreground border border-border rounded-lg p-3">
{t('opening_balances_locked_notice')}
</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="ob-cutover">{t('opening_balances_cutover_date')}</Label>
<Input
id="ob-cutover"
type="date"
value={cutoverDate}
onChange={(e) => setCutoverDate(e.target.value)}
disabled={readOnly}
/>
<p className="text-xs text-muted-foreground">{t('opening_balances_cutover_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="ob-karens">{t('opening_balances_karens')}</Label>
<Input
id="ob-karens"
type="number"
min={0}
max={10}
step={1}
value={karens}
onChange={(e) => setKarens(e.target.value)}
disabled={readOnly}
className="tabular-nums"
/>
<p className="text-xs text-muted-foreground">{t('opening_balances_karens_hint')}</p>
</div>
</div>
<div>
<h2 className="text-sm uppercase tracking-wider text-muted-foreground mb-3">
{t('opening_balances_ytd_heading')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="ob-ytd-gross">{t('opening_balances_ytd_gross')}</Label>
<Input id="ob-ytd-gross" type="number" min={0} value={ytdGross}
onChange={(e) => setYtdGross(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
<div className="space-y-2">
<Label htmlFor="ob-ytd-tax">{t('opening_balances_ytd_tax')}</Label>
<Input id="ob-ytd-tax" type="number" min={0} value={ytdTax}
onChange={(e) => setYtdTax(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
<div className="space-y-2">
<Label htmlFor="ob-ytd-net">{t('opening_balances_ytd_net')}</Label>
<Input id="ob-ytd-net" type="number" min={0} value={ytdNet}
onChange={(e) => setYtdNet(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
</div>
</div>
<div>
<h2 className="text-sm uppercase tracking-wider text-muted-foreground mb-3">
{t('opening_balances_vacation_heading')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="ob-days-remaining">{t('opening_balances_days_remaining')}</Label>
<Input id="ob-days-remaining" type="number" min={0} max={40} step={0.5} value={daysRemaining}
onChange={(e) => setDaysRemaining(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
<div className="space-y-2">
<Label htmlFor="ob-days-taken">{t('opening_balances_days_taken')}</Label>
<Input id="ob-days-taken" type="number" min={0} max={40} step={0.5} value={daysTaken}
onChange={(e) => setDaysTaken(e.target.value)} disabled={readOnly} className="tabular-nums" />
<p className="text-xs text-muted-foreground">{t('opening_balances_days_taken_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="ob-liability">{t('opening_balances_liability')}</Label>
<Input id="ob-liability" type="number" min={0} value={liability}
onChange={(e) => setLiability(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
<div className="space-y-2">
<Label htmlFor="ob-liability-avgifter">{t('opening_balances_liability_avgifter')}</Label>
<Input id="ob-liability-avgifter" type="number" min={0} value={liabilityAvgifter}
onChange={(e) => setLiabilityAvgifter(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
</div>
</div>
<div>
<h2 className="text-sm uppercase tracking-wider text-muted-foreground mb-3">
{t('opening_balances_saved_heading')}
</h2>
<p className="text-xs text-muted-foreground mb-3">{t('opening_balances_saved_hint')}</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{SAVED_YEARS.map((year) => (
<div key={year} className="space-y-2">
<Label htmlFor={`ob-saved-${year}`} className="tabular-nums">{year}</Label>
<Input
id={`ob-saved-${year}`}
type="number"
min={0}
max={40}
step={0.5}
value={savedByYear[year] ?? ''}
onChange={(e) => setSavedByYear((prev) => ({ ...prev, [year]: e.target.value }))}
disabled={readOnly}
className="tabular-nums"
/>
</div>
))}
</div>
</div>
{!readOnly && (
<div className="flex justify-end">
<Button onClick={handleSave} disabled={saving}>
<Save className="h-4 w-4 mr-2" />
{saving
? t('opening_balances_saving')
: hasRow
? t('opening_balances_update')
: t('opening_balances_save')}
</Button>
</div>
)}
</CardContent>
</Card>
)
}