'use client' import { useState, useEffect, useMemo, useRef } from 'react' import { useTranslations } from 'next-intl' import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card' import { Input } from '@/components/ui/input' import { Label } from '@/components/ui/label' import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select' import { InfoTooltip } from '@/components/ui/info-tooltip' import MunicipalityCombobox from './MunicipalityCombobox' import { TAX_COLUMN_OPTIONS, deriveTaxColumn } from '@/lib/salary/tax-column' export interface EmployeeTaxValue { f_skatt_status: string is_sidoinkomst: boolean tax_table_number: number | null tax_column: number tax_municipality: string /** * Jämkning (Skatteverket beslut om ändrad beräkning av skatteavdrag). All * three are null when there is no beslut; a null percentage clears it. */ jamkning_percentage: number | null jamkning_valid_from: string | null jamkning_valid_to: string | null /** * True once the user edited any of the three jämkning inputs this session. * Hosts use it (via jamkningPatch) to leave a stored beslut alone on * unrelated edits instead of re-sending or wiping it. */ jamkning_touched: boolean } interface EmployeeTaxCardProps { /** Live personnummer (full or masked): drives the column suggestion. */ personnummer: string initial?: Partial /** Income year the table/column applies to. Defaults to the current year. */ year?: number disabled?: boolean /** * Render the fields without the Card chrome (no border/header), for hosts * that lay their own section dividers around it (e.g. the compact * NewEmployeeDialog). The edit page keeps the default boxed rendering. */ flat?: boolean onChange: (value: EmployeeTaxValue) => void } function RequiredMark() { return * } /** * The "Skatt" card on the employee form. Instead of asking the user to look up * an opaque skattetabell (29-42) and kolumn (1-6), it derives both from data we * already have: the folkbokföringskommun fills the tax table, and the * personnummer fills the column. Manual overrides remain for edge cases. * A jämkning beslut (fixed percentage with a validity period) can be entered * alongside the table: the engine uses the percentage while the beslut is * valid and falls back to the table outside that window. */ export default function EmployeeTaxCard({ personnummer, initial, year, disabled, flat, onChange, }: EmployeeTaxCardProps) { const t = useTranslations('salary_employee') const incomeYear = year ?? new Date().getFullYear() const [fSkatt, setFSkatt] = useState(initial?.f_skatt_status ?? 'a_skatt') const [sido, setSido] = useState(initial?.is_sidoinkomst ?? false) const [municipality, setMunicipality] = useState(initial?.tax_municipality ?? '') const [tableNumber, setTableNumber] = useState(initial?.tax_table_number ?? null) const [rate, setRate] = useState(null) const [tableManual, setTableManual] = useState(false) const [column, setColumn] = useState(initial?.tax_column ?? 1) // Editing an existing employee: respect their saved column. New employee: // let the personnummer drive it until the user picks one. const [columnTouched, setColumnTouched] = useState(initial?.tax_column != null) // Jämkning is kept as raw input strings so a half-typed value ("12.") does // not snap; the parsed number is derived below. Not rounded: decimals like // 12.5 are legal (the API caps at 0..100). const [jamkningPct, setJamkningPct] = useState( initial?.jamkning_percentage != null ? String(initial.jamkning_percentage) : '' ) const [jamkningFrom, setJamkningFrom] = useState(initial?.jamkning_valid_from ?? '') const [jamkningTo, setJamkningTo] = useState(initial?.jamkning_valid_to ?? '') // Set by the three jämkning handlers. Until then the seeded beslut is shown // as-is: a row stored through the API/MCP without an end date (allowed by // the schema) must not block the form's native validation on an unrelated // edit, and must not be re-sent either (see jamkningPatch). const [jamkningTouched, setJamkningTouched] = useState(false) const requiresTable = fSkatt === 'a_skatt' && !sido const jamkningValue = (() => { const n = parseFloat(jamkningPct) return Number.isFinite(n) ? n : null })() // The jämkning inputs are only rendered for A-skatt without sidoinkomst, so // like tax_table_number they are reported as null otherwise. Hosts must not // treat that null as "clear the beslut": the edit page routes the value // through jamkningPatch, which omits the keys whenever the inputs were // hidden or untouched, so a stored beslut survives toggling the status. const hasJamkning = requiresTable && jamkningValue !== null // Seeded beslut missing a date: the engine will not apply it. Shown as a // non-blocking hint until the user edits the fields, at which point the // native `required` on both dates takes over. const jamkningIncomplete = !jamkningTouched && jamkningValue !== null && (!jamkningFrom || !jamkningTo) const derivedColumn = useMemo( () => deriveTaxColumn(personnummer, incomeYear), [personnummer, incomeYear] ) const isSenior = personnummer.replace(/\D/g, '').length >= 8 && derivedColumn === null // The effective column is the user's explicit choice once they've made one, // otherwise the value suggested from the personnummer (falling back to 1). // Derived in render: no setState-in-effect needed. const effectiveColumn = columnTouched ? column : (derivedColumn ?? 1) // Report the current value up. onChange via ref so an unstable parent callback // doesn't retrigger the effect (deps are the primitive values only). const onChangeRef = useRef(onChange) useEffect(() => { onChangeRef.current = onChange }) useEffect(() => { onChangeRef.current({ f_skatt_status: fSkatt, is_sidoinkomst: sido, tax_table_number: requiresTable ? tableNumber : null, tax_column: effectiveColumn, tax_municipality: municipality.trim(), jamkning_percentage: hasJamkning ? jamkningValue : null, jamkning_valid_from: hasJamkning ? jamkningFrom || null : null, jamkning_valid_to: hasJamkning ? jamkningTo || null : null, jamkning_touched: jamkningTouched, }) }, [ fSkatt, sido, tableNumber, effectiveColumn, municipality, requiresTable, hasJamkning, jamkningValue, jamkningFrom, jamkningTo, jamkningTouched, ]) const body = ( <>
{requiresTable ? ( <>
{ setMunicipality(value) // Clearing the field must clear the derived table/rate too: // otherwise we'd report an empty kommun alongside a stale // table number (an inconsistent pair). Manual entry keeps its // own value. if (!value && !tableManual) { setTableNumber(null) setRate(null) } }} onSelect={(kommun, table, totalRate) => { setMunicipality(kommun) setRate(totalRate) if (!tableManual) setTableNumber(table) }} />
{tableManual ? ( setTableNumber(parseInt(e.target.value) || null)} disabled={disabled} /> ) : tableNumber ? (
{tableNumber} {(municipality || rate != null) && ( {municipality} {rate != null ? ` · ${rate.toLocaleString('sv-SE')} %` : ''} )}
) : (

{t('tax_table_pick_municipality')}

)} {!disabled && ( )}
{!columnTouched && derivedColumn != null ? (

{t('tax_column_suggested_under_66')}

) : isSenior && !columnTouched ? (

{t('tax_column_senior_warning')}

) : null}
{/* Jämkning: a Skatteverket beslut overrides the table with a fixed percentage for a bounded period. The engine only applies it when BOTH dates are set, so both are required as soon as the user edits the beslut (native `required`: both hosts render this inside a
). A seeded beslut is never blocked on: the API keeps valid_to optional, so a row stored that way must stay editable elsewhere. The table fields stay visible above: they apply again once the beslut expires. */}
{ setJamkningPct(e.target.value) setJamkningTouched(true) }} disabled={disabled} />

{t('tax_jamkning_hint')}

{jamkningValue !== null && (
{ setJamkningFrom(e.target.value) setJamkningTouched(true) }} required={jamkningTouched} disabled={disabled} />
{ setJamkningTo(e.target.value) setJamkningTouched(true) }} required={jamkningTouched} disabled={disabled} />
{jamkningIncomplete ? (

{t('tax_jamkning_incomplete_hint')}

) : (

{t('tax_jamkning_dates_hint')}

)}
)} ) : (

{sido ? t('tax_no_table_sidoinkomst') : t('tax_no_table_f_skatt')}

)} ) if (flat) { return
{body}
} return ( {t('tax_title')} {body} ) }