* fix(payroll): expose jämkning percentage and validity on the employee tax form (#1913) An employee with a Skatteverket jämkning decision could not have the adjusted withholding percentage set anywhere in the app: model, API and engine supported jamkning_percentage / jamkning_valid_from / jamkning_valid_to end to end, but EmployeeTaxCard never exposed them. - EmployeeTaxCard: percentage input plus required from/to dates in the A-skatt branch; null (= clear the beslut) when emptied or when no table applies, mirroring tax_table_number. Both dates are required because isJamkningValid only applies a beslut when both are set. - Edit page: PATCH body sends the three fields as explicit values (guarded on the card having reported), card initial seeded from the employee, read-only Jämkning row in the tax section. - NewEmployeeDialog: initial tax state and POST body carry the fields. - Legacy PATCH /api/salary/employees/[id]: merged-state jämkning check (start date required, dates ordered), same rule and messages as v1 and employee-commands, gated on the PATCH touching a jamkning key. - lib/api/schemas.ts: truthful comment on the engine's both-dates gate. - i18n: salary_employee.tax_jamkning_* in sv and en. - Tests on the legacy PATCH route (400 x4, 200 x3) and the POST route. The engine is deliberately untouched; the API/MCP contract (valid_to optional) stays as is, follow-up filed in the PR body. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * fix(payroll): jämkning keys reach the employee PATCH only when visible and edited (#1913) Review findings on #1988: the card reported null for the three jämkning fields whenever its inputs were hidden (sidoinkomst, F-skatt, FA-skatt, ej verifierad) and the edit page forwarded those nulls, so toggling sidoinkomst or fixing a phone number on an FA-skatt employee silently wiped a stored beslut (which the engine still applies for FA-skatt). The two date inputs were also natively required whenever a percentage was present, so a beslut stored via the API/MCP without valid_to (allowed by the schema) blocked the whole form on unrelated edits. - lib/salary/jamkning-patch.ts (new): isJamkningEditable() and jamkningPatch(); the keys are spread into the PATCH body with explicit values (null = clear) only when the inputs were visible and edited, otherwise omitted like every other sparse field. - EmployeeTaxCard: jamkning_touched flag on EmployeeTaxValue, set by the three handlers; required on both dates gated on it; non-blocking hint (tax_jamkning_incomplete_hint, sv + en) on a seeded beslut missing a date. - Edit page spreads jamkningPatch(tax); NewEmployeeDialog initial state carries the flag. - Tests: lib/salary/__tests__/jamkning-patch.test.ts (keys omitted for sidoinkomst / f_skatt / fa_skatt / not_verified / untouched seeded row, explicit nulls when cleared, spread shape). - DECISIONS.md: one line. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 * test(payroll): type the insert mock's payload so the typecheck ratchet accepts the jamkning tests vi.fn(() => ...) infers an empty parameter tuple, so insert.mock.calls[0][0] failed TS2493 under the new check:types gate (#1980) on CI. Declaring the payload parameter keeps the assertions and makes the tuple indexable. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01FkUfWtuFCUkNtRAgMQCse2 --------- Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
413 lines
16 KiB
TypeScript
413 lines
16 KiB
TypeScript
'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<EmployeeTaxValue>
|
|
/** 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 <span className="text-destructive ml-0.5">*</span>
|
|
}
|
|
|
|
/**
|
|
* 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<number | null>(initial?.tax_table_number ?? null)
|
|
const [rate, setRate] = useState<number | null>(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 = (
|
|
<>
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="f_skatt_status">
|
|
<InfoTooltip content={t('tax_form_tooltip')}>
|
|
{t('tax_form_label')}
|
|
</InfoTooltip>
|
|
</Label>
|
|
<Select value={fSkatt} onValueChange={setFSkatt} disabled={disabled}>
|
|
<SelectTrigger id="f_skatt_status">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="a_skatt">A-skatt</SelectItem>
|
|
<SelectItem value="f_skatt">F-skatt</SelectItem>
|
|
<SelectItem value="fa_skatt">FA-skatt</SelectItem>
|
|
<SelectItem value="not_verified">{t('tax_status_not_verified')}</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-end pb-2">
|
|
<label className="flex items-center gap-2 text-sm">
|
|
<input
|
|
type="checkbox"
|
|
checked={sido}
|
|
onChange={(e) => setSido(e.target.checked)}
|
|
disabled={disabled}
|
|
className="rounded-sm border-border"
|
|
/>
|
|
<InfoTooltip content={t('tax_sidoinkomst_tooltip')}>
|
|
{t('tax_sidoinkomst_label')}
|
|
</InfoTooltip>
|
|
</label>
|
|
</div>
|
|
</div>
|
|
|
|
{requiresTable ? (
|
|
<>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tax_municipality">
|
|
<InfoTooltip content={t('tax_municipality_tooltip')}>
|
|
{t('tax_municipality_label')}
|
|
</InfoTooltip>
|
|
<RequiredMark />
|
|
</Label>
|
|
<MunicipalityCombobox
|
|
id="tax_municipality"
|
|
value={municipality}
|
|
year={incomeYear}
|
|
disabled={disabled}
|
|
onChange={(value) => {
|
|
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)
|
|
}}
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tax_table_number">
|
|
<InfoTooltip content={t('tax_table_tooltip')}>
|
|
{t('tax_table_label')}
|
|
</InfoTooltip>
|
|
<RequiredMark />
|
|
</Label>
|
|
|
|
{tableManual ? (
|
|
<Input
|
|
id="tax_table_number"
|
|
type="number"
|
|
min="29"
|
|
max="42"
|
|
value={tableNumber ?? ''}
|
|
onChange={(e) => setTableNumber(parseInt(e.target.value) || null)}
|
|
disabled={disabled}
|
|
/>
|
|
) : tableNumber ? (
|
|
<div className="flex items-baseline gap-2 rounded-lg border border-input px-3 py-2">
|
|
<span className="font-sans text-xl tabular-nums">{tableNumber}</span>
|
|
{(municipality || rate != null) && (
|
|
<span className="text-xs text-muted-foreground">
|
|
{municipality}
|
|
{rate != null ? ` · ${rate.toLocaleString('sv-SE')} %` : ''}
|
|
</span>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<p className="rounded-lg border border-dashed border-input px-3 py-2 text-sm text-muted-foreground">
|
|
{t('tax_table_pick_municipality')}
|
|
</p>
|
|
)}
|
|
|
|
{!disabled && (
|
|
<button
|
|
type="button"
|
|
onClick={() => setTableManual((v) => !v)}
|
|
className="text-xs text-primary hover:underline underline-offset-4"
|
|
>
|
|
{tableManual ? t('tax_table_use_municipality') : t('tax_table_enter_manually')}
|
|
</button>
|
|
)}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="tax_column">
|
|
<InfoTooltip content={t('tax_column_tooltip')}>
|
|
{t('tax_column_label')}
|
|
</InfoTooltip>
|
|
</Label>
|
|
<Select
|
|
value={String(effectiveColumn)}
|
|
onValueChange={(v) => {
|
|
setColumn(parseInt(v))
|
|
setColumnTouched(true)
|
|
}}
|
|
disabled={disabled}
|
|
>
|
|
<SelectTrigger id="tax_column">
|
|
<SelectValue />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{TAX_COLUMN_OPTIONS.map((opt) => (
|
|
<SelectItem key={opt.value} value={String(opt.value)}>
|
|
{opt.value}. {opt.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
{!columnTouched && derivedColumn != null ? (
|
|
<p className="text-xs text-muted-foreground">
|
|
{t('tax_column_suggested_under_66')}
|
|
</p>
|
|
) : isSenior && !columnTouched ? (
|
|
<p className="text-xs text-attn">
|
|
{t('tax_column_senior_warning')}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 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 <form>). 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. */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="jamkning_percentage">
|
|
<InfoTooltip content={t('tax_jamkning_tooltip')}>
|
|
{t('tax_jamkning_label')}
|
|
</InfoTooltip>
|
|
</Label>
|
|
<Input
|
|
id="jamkning_percentage"
|
|
type="number"
|
|
min="0"
|
|
max="100"
|
|
step="any"
|
|
inputMode="decimal"
|
|
value={jamkningPct}
|
|
onChange={(e) => {
|
|
setJamkningPct(e.target.value)
|
|
setJamkningTouched(true)
|
|
}}
|
|
disabled={disabled}
|
|
/>
|
|
<p className="text-xs text-muted-foreground">{t('tax_jamkning_hint')}</p>
|
|
</div>
|
|
|
|
{jamkningValue !== null && (
|
|
<div className="space-y-2">
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="jamkning_valid_from">
|
|
{t('tax_jamkning_valid_from')}
|
|
<RequiredMark />
|
|
</Label>
|
|
<Input
|
|
id="jamkning_valid_from"
|
|
type="date"
|
|
value={jamkningFrom}
|
|
onChange={(e) => {
|
|
setJamkningFrom(e.target.value)
|
|
setJamkningTouched(true)
|
|
}}
|
|
required={jamkningTouched}
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
<div className="space-y-2">
|
|
<Label htmlFor="jamkning_valid_to">
|
|
{t('tax_jamkning_valid_to')}
|
|
<RequiredMark />
|
|
</Label>
|
|
<Input
|
|
id="jamkning_valid_to"
|
|
type="date"
|
|
value={jamkningTo}
|
|
min={jamkningFrom || undefined}
|
|
onChange={(e) => {
|
|
setJamkningTo(e.target.value)
|
|
setJamkningTouched(true)
|
|
}}
|
|
required={jamkningTouched}
|
|
disabled={disabled}
|
|
/>
|
|
</div>
|
|
</div>
|
|
{jamkningIncomplete ? (
|
|
<p className="text-xs text-attn">{t('tax_jamkning_incomplete_hint')}</p>
|
|
) : (
|
|
<p className="text-xs text-muted-foreground">{t('tax_jamkning_dates_hint')}</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</>
|
|
) : (
|
|
<p className="rounded-lg border border-dashed border-input px-3 py-3 text-sm text-muted-foreground">
|
|
{sido
|
|
? t('tax_no_table_sidoinkomst')
|
|
: t('tax_no_table_f_skatt')}
|
|
</p>
|
|
)}
|
|
</>
|
|
)
|
|
|
|
if (flat) {
|
|
return <div className="space-y-4">{body}</div>
|
|
}
|
|
|
|
return (
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-base">{t('tax_title')}</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-4">{body}</CardContent>
|
|
</Card>
|
|
)
|
|
}
|