Files
accounted/components/salary/NewEmployeeDialog.tsx
T
Jakob Wennberg ca93ef3fb6 fix(salary): surface employee-save failures and a typed 503 for the missing encryption key (#1996) (#2009)
* fix(salary): surface employee-save failures in the dialog and type the missing encryption key (#1996)

Pressing Spara in "Ny anställd" could fail without any feedback: a thrown
fetch or a non-JSON 5xx body escaped handleSubmit before setSaving(false)
ran, leaving the button stuck on "Sparar..." and the dialog silent. Even
when the toast did fire, the Radix modal aria-hides the root-layout
Toaster, so assistive tech (and the E2E driver that found this) heard
nothing, and the requestId support needs was never shown anywhere.

- NewEmployeeDialog: fetch + parse run in a never-throwing helper, saving
  is released in finally, the body is parsed with json().catch(() => null)
  so an HTML/plain-text error page still maps through the HTTP-status map,
  and the failure is rendered inline (role="alert" in the footer) with
  "Ärende-id: <requestId>" next to the single destructive toast.
- personnummer.ts: the production "key missing" throw now carries the
  registry code PERSONNUMMER_ENCRYPTION_NOT_CONFIGURED, and the SALARY
  registry gains a 503 entry naming PERSONNUMMER_ENCRYPTION_KEY with a
  "contact support" message and a remediation hint. withRouteContext
  emits the typed envelope automatically instead of INTERNAL_ERROR 500,
  which read as transient and invited retries that can never succeed.
- Tests for the route (401, 400, 503 with requestId and no insert), the
  key guard, the registry entry, errorResponse dispatch on a coded Error,
  and getErrorMessage locale handling of the new envelope.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

* fix(salary): address review findings (#1996)

- NewEmployeeDialog: fall back to the X-Request-Id response header when the
  body carries no error.requestId. The route hand-builds its 409 (duplicate
  personnummer) and generic insert-failure 500 bodies as flat strings, so the
  inline "Ärende-id" line was hidden for exactly the DB-failure class the
  issue names; withRouteContext sets the header on every response.
- Route tests pin that the 409 and 500 insert-error arms carry X-Request-Id.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_015nAd8XJ2RPCmG2eKoLBdna

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-28 16:57:54 +02:00

523 lines
22 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { useCompanySettings } from '@/lib/reference-data/hooks'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Save } from 'lucide-react'
import { cn } from '@/lib/utils'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage, type ErrorLocale } from '@/lib/errors/get-error-message'
import {
validateEmployeeBankAccount,
isValidClearing,
isValidAccount,
normalizeBankNumber,
lookupBankByClearing,
checkEmployeeAccountChecksum,
BANK_ISSUE_MESSAGES_SV,
BANK_CHECKSUM_WARNING_SV,
} from '@/lib/salary/payment/bank-account'
import EmployeeTaxCard, { type EmployeeTaxValue } from '@/components/salary/EmployeeTaxCard'
import LineDimensionFields from '@/components/dimensions/LineDimensionFields'
function RequiredMark() {
return <span className="text-destructive ml-0.5">*</span>
}
// Small caps section label; sections are separated by a hairline divider
// instead of being boxed in their own cards (compact, Linear-style layout).
const SECTION_HEADER = 'text-xs font-semibold uppercase tracking-wider text-muted-foreground'
/** Compact label + control stack shared by every field in the dialog. */
function Field({
label,
htmlFor,
required,
className,
children,
}: {
label: string
htmlFor?: string
required?: boolean
className?: string
children: React.ReactNode
}) {
return (
<div className={cn('space-y-1', className)}>
<Label htmlFor={htmlFor}>
{label}
{required && <RequiredMark />}
</Label>
{children}
</div>
)
}
interface Props {
open: boolean
onOpenChange: (open: boolean) => void
/** Fired after a successful create. Hosts close the dialog and refresh their list. */
onCreated: () => void
}
/**
* "Ny anställd" as a modal: mirrors NewSupplierInvoiceDialog. The last
* register entity (after customers/suppliers/articles) to move off a full
* page. Compact layout: borderless sections split by hairline dividers
* (no per-section cards) with a sticky Spara bar, to keep scrolling short.
*/
export default function NewEmployeeDialog({ open, onOpenChange, onCreated }: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
// Override the primitive's own padding + whole-dialog scroll: this
// dialog is a fixed header + scrolling body + solid footer (flex
// column), so the footer never overlaps scrolling content.
className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden"
// A half-typed employee must survive an accidental backdrop click or
// a stray Escape (the municipality combobox and dimension pickers
// portal outside the dialog). Closing is explicit: the header X or
// Avbryt. Same convention as NewJournalEntryDialog.
onEscapeKeyDown={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader className="border-b border-border px-6 pb-4 pt-6">
<DialogTitle>Ny anställd</DialogTitle>
</DialogHeader>
<NewEmployeeForm onCreated={onCreated} onCancel={() => onOpenChange(false)} />
</DialogContent>
</Dialog>
)
}
/**
* Why a failed save is reported twice (toast AND an inline line): the dialog
* is a Radix modal, which aria-hides everything outside DialogContent while
* it is open, including the root-layout Toaster. The toast is visible on
* screen but absent from the accessibility tree, so a screen reader (and the
* E2E driver that filed #1996) hears nothing. The inline role="alert" line
* lives inside the dialog and persists until the next attempt, and it is the
* one place the support reference (requestId) is shown.
*/
interface SubmitError {
message: string
requestId: string | null
}
function readRequestId(body: unknown): string | null {
if (typeof body !== 'object' || body === null) return null
const inner = (body as { error?: unknown }).error
if (typeof inner !== 'object' || inner === null) return null
const id = (inner as { requestId?: unknown }).requestId
return typeof id === 'string' && id.trim() ? id : null
}
/**
* POST the employee and describe the failure, if any, in one sentence.
* Returns null on success. Never throws: every arm (non-2xx envelope, a
* non-JSON body such as Vercel's plain-text FUNCTION_INVOCATION_FAILED or a
* 413 HTML page, a fetch that never completed) ends in a SubmitError so the
* caller has exactly one thing to report.
*/
async function submitEmployee(body: unknown, locale: ErrorLocale): Promise<SubmitError | null> {
try {
const res = await fetch('/api/salary/employees', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
})
if (res.ok) return null
// A body that is not JSON leaves null: getErrorMessage then falls back to
// the HTTP status map, still in the user's language.
const result: unknown = await res.json().catch(() => null)
// The route hand-builds its 409 (duplicate personnummer) and generic
// insert-failure 500 bodies as flat strings, so only the typed envelopes
// carry error.requestId. withRouteContext sets X-Request-Id on every
// response, so fall back to the header: those DB-failure arms are exactly
// the ones support needs a correlation id for.
return {
message: getErrorMessage(result, { context: 'salary', statusCode: res.status, locale }),
requestId: readRequestId(result) ?? res.headers.get('X-Request-Id'),
}
} catch (err) {
return {
message: getErrorMessage(err, { context: 'salary', locale }),
requestId: null,
}
}
}
// Inner component so form state resets whenever the dialog reopens (Radix
// unmounts DialogContent children on close).
function NewEmployeeForm({ onCreated, onCancel }: { onCreated: () => void; onCancel: () => void }) {
const t = useTranslations('employees')
const locale = useLocale() as ErrorLocale
const { toast } = useToast()
const [saving, setSaving] = useState(false)
const [submitError, setSubmitError] = useState<SubmitError | null>(null)
const [employmentType, setEmploymentType] = useState('employee')
const [salaryType, setSalaryType] = useState('monthly')
const [personnummer, setPersonnummer] = useState('')
const [vacationRule, setVacationRule] = useState('procentregeln')
const [clearing, setClearing] = useState('')
const [account, setAccount] = useState('')
// Suppress the (soft) check-digit warning while the user is still typing in
// the bank fields; only surface it once they move focus away.
const [bankFocused, setBankFocused] = useState(false)
// Default dimensions bag ({sie_dim_no: object_code}) proposed on the
// employee's salary-cost lines at booking. The fields render only when
// company_settings.dimensions_enabled: same UI gate as the voucher form.
const { settings: companySettings } = useCompanySettings()
const dimensionsEnabled = companySettings?.dimensions_enabled === true
const [dimensions, setDimensions] = useState<Record<string, string>>({})
const [tax, setTax] = useState<EmployeeTaxValue>({
f_skatt_status: 'a_skatt',
is_sidoinkomst: false,
tax_table_number: null,
tax_column: 1,
tax_municipality: '',
jamkning_percentage: null,
jamkning_valid_from: null,
jamkning_valid_to: null,
jamkning_touched: false,
})
function setDimension(dimNo: string, code: string | null) {
setDimensions((prev) => {
const next = { ...prev }
const value = code?.trim()
if (value) next[dimNo] = value
else delete next[dimNo]
return next
})
}
async function handleSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
// Block on structurally invalid bank details before hitting the server.
const bankIssues = validateEmployeeBankAccount(clearing, account)
if (bankIssues.length > 0) {
toast({
title: 'Kontrollera bankuppgifterna',
description: bankIssues.map((i) => i.message).join('. '),
variant: 'destructive',
})
return
}
setSaving(true)
setSubmitError(null)
const form = new FormData(e.currentTarget)
const body = {
first_name: form.get('first_name') as string,
last_name: form.get('last_name') as string,
personnummer: personnummer.replace(/\D/g, ''),
employment_type: employmentType,
employment_start: form.get('employment_start') as string,
employment_end: form.get('employment_end') as string || undefined,
employment_degree: parseFloat(form.get('employment_degree') as string) || 100,
hours_per_week: parseFloat(form.get('hours_per_week') as string) || 40,
workdays_per_week: parseFloat(form.get('workdays_per_week') as string) || 5,
salary_type: salaryType,
monthly_salary: salaryType === 'monthly' ? (parseFloat(form.get('monthly_salary') as string) || undefined) : undefined,
hourly_rate: salaryType === 'hourly' ? (parseFloat(form.get('hourly_rate') as string) || undefined) : undefined,
f_skatt_status: tax.f_skatt_status,
is_sidoinkomst: tax.is_sidoinkomst,
tax_table_number: tax.tax_table_number ?? undefined,
tax_column: tax.tax_column,
tax_municipality: tax.tax_municipality || undefined,
jamkning_percentage: tax.jamkning_percentage ?? undefined,
jamkning_valid_from: tax.jamkning_valid_from ?? undefined,
jamkning_valid_to: tax.jamkning_valid_to ?? undefined,
email: form.get('email') as string || undefined,
phone: form.get('phone') as string || undefined,
address_line1: form.get('address_line1') as string || undefined,
postal_code: form.get('postal_code') as string || undefined,
city: form.get('city') as string || undefined,
clearing_number: normalizeBankNumber(clearing) || undefined,
bank_account_number: normalizeBankNumber(account) || undefined,
vacation_rule: vacationRule,
vacation_days_per_year: parseInt(form.get('vacation_days_per_year') as string) || 25,
// Always sent: {} means no default dimensions.
default_dimensions: dimensions,
}
// `saving` is released in finally no matter how the request ends: a
// thrown fetch (offline, connection reset) used to escape this handler
// before setSaving(false) ran, leaving the dialog silent with Spara stuck
// on "Sparar..." forever. #1996
let failure: SubmitError | null
try {
failure = await submitEmployee(body, locale)
} finally {
setSaving(false)
}
if (!failure) {
toast({ title: 'Anställd skapad' })
onCreated()
return
}
// One toast per attempt (TOAST_LIMIT is 1, a second would evict it) plus
// the inline line that the modal's aria-hiding cannot swallow.
setSubmitError(failure)
toast({
title: 'Kunde inte skapa anställd',
description: failure.message,
variant: 'destructive',
})
}
const bankName = lookupBankByClearing(clearing)
const showChecksumWarning =
!bankFocused &&
validateEmployeeBankAccount(clearing, account).length === 0 &&
checkEmployeeAccountChecksum(clearing, account) === 'invalid'
return (
<form onSubmit={handleSubmit} className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
<div className="divide-y divide-border">
{/* Personuppgifter */}
<section className="space-y-3 py-4 first:pt-0">
<h3 className={SECTION_HEADER}>Personuppgifter</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label="Förnamn" htmlFor="first_name" required>
<Input id="first_name" name="first_name" required />
</Field>
<Field label="Efternamn" htmlFor="last_name" required>
<Input id="last_name" name="last_name" required />
</Field>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label="Personnummer" htmlFor="personnummer" required>
<Input
id="personnummer"
name="personnummer"
placeholder="ÅÅÅÅMMDDNNNN"
required
maxLength={13}
value={personnummer}
onChange={(e) => setPersonnummer(e.target.value)}
/>
</Field>
<Field label="E-post" htmlFor="email">
<Input id="email" name="email" type="email" placeholder="Krävs för lönebesked" />
</Field>
</div>
<Field label="Telefon" htmlFor="phone" className="max-w-xs">
<Input id="phone" name="phone" />
</Field>
</section>
{/* Adress */}
<section className="space-y-3 py-4">
<h3 className={SECTION_HEADER}>Adress</h3>
<div className="grid grid-cols-1 sm:grid-cols-[1fr_160px_1fr] gap-3">
<Field label="Gatuadress" htmlFor="address_line1">
<Input id="address_line1" name="address_line1" />
</Field>
<Field label="Postnummer" htmlFor="postal_code">
<Input id="postal_code" name="postal_code" />
</Field>
<Field label="Ort" htmlFor="city">
<Input id="city" name="city" />
</Field>
</div>
</section>
{/* Anställning & lön */}
<section className="space-y-3 py-4">
<h3 className={SECTION_HEADER}>Anställning &amp; lön</h3>
<div className="grid grid-cols-2 md:grid-cols-3 gap-3">
<Field label="Typ" htmlFor="employment_type">
<Select value={employmentType} onValueChange={setEmploymentType}>
<SelectTrigger id="employment_type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="employee">Anställd</SelectItem>
<SelectItem value="company_owner">Företagsledare</SelectItem>
<SelectItem value="board_member">Styrelseledamot</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label="Anställningsdatum" htmlFor="employment_start" required>
<Input id="employment_start" name="employment_start" type="date" required />
</Field>
<Field label="Slutdatum" htmlFor="employment_end">
<Input id="employment_end" name="employment_end" type="date" />
</Field>
<Field label="Sysselsättningsgrad (%)" htmlFor="employment_degree">
<Input id="employment_degree" name="employment_degree" type="number" defaultValue="100" min="1" max="100" />
<p className="text-xs text-muted-foreground">Under 100 % räknas grundlönen som månadslön × sysselsättningsgrad.</p>
</Field>
<Field label="Timmar per vecka" htmlFor="hours_per_week">
<Input id="hours_per_week" name="hours_per_week" type="number" defaultValue="40" min="1" max="80" step="0.5" />
</Field>
<Field label="Arbetsdagar per vecka" htmlFor="workdays_per_week">
<Input id="workdays_per_week" name="workdays_per_week" type="number" defaultValue="5" min="1" max="7" step="1" />
</Field>
<Field label="Löneform" htmlFor="salary_type" required>
<Select value={salaryType} onValueChange={setSalaryType}>
<SelectTrigger id="salary_type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">Månadslön</SelectItem>
<SelectItem value="hourly">Timlön</SelectItem>
</SelectContent>
</Select>
</Field>
{salaryType === 'monthly' ? (
<Field label="Månadslön (brutto)" htmlFor="monthly_salary" required>
<Input id="monthly_salary" name="monthly_salary" type="number" step="1" min="1" required />
</Field>
) : (
<Field label="Timlön (SEK)" htmlFor="hourly_rate" required>
<Input id="hourly_rate" name="hourly_rate" type="number" step="0.01" min="0.01" required />
</Field>
)}
</div>
</section>
{/* Skatt */}
<section className="space-y-3 py-4">
<h3 className={SECTION_HEADER}>Skatt</h3>
<EmployeeTaxCard personnummer={personnummer} onChange={setTax} flat />
</section>
{/* Semester */}
<section className="space-y-3 py-4">
<h3 className={SECTION_HEADER}>Semester</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label="Semesterregel" htmlFor="vacation_rule">
<Select value={vacationRule} onValueChange={setVacationRule}>
<SelectTrigger id="vacation_rule">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="procentregeln">Procentregeln (12 %)</SelectItem>
<SelectItem value="sammaloneregeln">Sammalöneregeln</SelectItem>
<SelectItem value="semesterersattning">Semesterersättning (betalas ut direkt)</SelectItem>
<SelectItem value="none">Ingen semesteravsättning</SelectItem>
</SelectContent>
</Select>
{vacationRule === 'none' && (
<p className="text-xs text-muted-foreground">
Ingen avsättning till 2920 bokas. Vanligt för ägare som är enda anställd.
</p>
)}
{vacationRule === 'semesterersattning' && (
<p className="text-xs text-muted-foreground">
12 % läggs varje lönekörning och bokas mot 7285. Ingen semesterlöneskuld byggs upp. Vanligt för tim- och visstidsanställda.
</p>
)}
</Field>
<Field label="Semesterdagar per år" htmlFor="vacation_days_per_year">
<Input id="vacation_days_per_year" name="vacation_days_per_year" type="number" min="25" max="40" defaultValue="25" />
<p className="text-xs text-muted-foreground">Lagstadgat minimum: 25 dagar</p>
</Field>
</div>
</section>
{/* Kostnadsställe / Projekt (standard) */}
{dimensionsEnabled && (
<section className="space-y-3 py-4">
<h3 className={SECTION_HEADER}>Kostnadsställe / Projekt</h3>
<LineDimensionFields dimensions={dimensions} onChange={setDimension} />
<p className="text-xs text-muted-foreground">
Föreslås lönekostnadsrader vid bokföring av lönekörningar.
</p>
</section>
)}
{/* Bankkonto */}
<section className="space-y-3 py-4">
<h3 className={SECTION_HEADER}>Bankkonto</h3>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Field label="Clearingnummer" htmlFor="clearing_number">
<Input
id="clearing_number"
name="clearing_number"
inputMode="numeric"
value={clearing}
onChange={(e) => setClearing(e.target.value)}
onFocus={() => setBankFocused(true)}
onBlur={() => setBankFocused(false)}
aria-invalid={clearing !== '' && !isValidClearing(normalizeBankNumber(clearing))}
/>
{clearing !== '' && !isValidClearing(normalizeBankNumber(clearing)) ? (
<p className="text-xs text-destructive">{BANK_ISSUE_MESSAGES_SV.clearing_format}</p>
) : bankName ? (
<p className="text-xs text-muted-foreground">{bankName}</p>
) : (
<p className="text-xs text-muted-foreground">Krävs innan lönekörning</p>
)}
</Field>
<Field label="Kontonummer" htmlFor="bank_account_number">
<Input
id="bank_account_number"
name="bank_account_number"
inputMode="numeric"
value={account}
onChange={(e) => setAccount(e.target.value)}
onFocus={() => setBankFocused(true)}
onBlur={() => setBankFocused(false)}
aria-invalid={account !== '' && !isValidAccount(normalizeBankNumber(account))}
/>
{account !== '' && !isValidAccount(normalizeBankNumber(account)) && (
<p className="text-xs text-destructive">{BANK_ISSUE_MESSAGES_SV.account_format}</p>
)}
</Field>
</div>
{showChecksumWarning && (
<p className="text-xs text-attn">{BANK_CHECKSUM_WARNING_SV}</p>
)}
</section>
</div>
</div>
{/* Solid footer outside the scroll area: always visible, never overlaps
content (the body above scrolls independently). */}
<div className="flex items-center gap-3 border-t border-border bg-background px-6 py-4">
{submitError && (
<p role="alert" className="min-w-0 flex-1 text-sm text-destructive">
{submitError.message}
{submitError.requestId && (
<>
{' '}
<span className="tabular-nums text-muted-foreground">
{t('save_error_request_id', { id: submitError.requestId })}
</span>
</>
)}
</p>
)}
<Button type="button" variant="outline" className="ml-auto" onClick={onCancel}>
Avbryt
</Button>
<Button type="submit" disabled={saving}>
<Save className="mr-2 h-4 w-4" />
{saving ? 'Sparar...' : 'Spara'}
</Button>
</div>
</form>
)
}