'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 * } // 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 (
{children}
) } 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 ( e.preventDefault()} onPointerDownOutside={(e) => e.preventDefault()} onInteractOutside={(e) => e.preventDefault()} > Ny anställd onOpenChange(false)} /> ) } /** * 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 { 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(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>({}) const [tax, setTax] = useState({ 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) { 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 (
{/* Personuppgifter */}

Personuppgifter

setPersonnummer(e.target.value)} />
{/* Adress */}

Adress

{/* Anställning & lön */}

Anställning & lön

Under 100 % räknas grundlönen som månadslön × sysselsättningsgrad.

{salaryType === 'monthly' ? ( ) : ( )}
{/* Skatt */}

Skatt

{/* Semester */}

Semester

{vacationRule === 'none' && (

Ingen avsättning till 2920 bokas. Vanligt för ägare som är enda anställd.

)} {vacationRule === 'semesterersattning' && (

12 % läggs på varje lönekörning och bokas mot 7285. Ingen semesterlöneskuld byggs upp. Vanligt för tim- och visstidsanställda.

)}

Lagstadgat minimum: 25 dagar

{/* Kostnadsställe / Projekt (standard) */} {dimensionsEnabled && (

Kostnadsställe / Projekt

Föreslås på lönekostnadsrader vid bokföring av lönekörningar.

)} {/* Bankkonto */}

Bankkonto

setClearing(e.target.value)} onFocus={() => setBankFocused(true)} onBlur={() => setBankFocused(false)} aria-invalid={clearing !== '' && !isValidClearing(normalizeBankNumber(clearing))} /> {clearing !== '' && !isValidClearing(normalizeBankNumber(clearing)) ? (

{BANK_ISSUE_MESSAGES_SV.clearing_format}

) : bankName ? (

{bankName}

) : (

Krävs innan lönekörning

)}
setAccount(e.target.value)} onFocus={() => setBankFocused(true)} onBlur={() => setBankFocused(false)} aria-invalid={account !== '' && !isValidAccount(normalizeBankNumber(account))} /> {account !== '' && !isValidAccount(normalizeBankNumber(account)) && (

{BANK_ISSUE_MESSAGES_SV.account_format}

)}
{showChecksumWarning && (

{BANK_CHECKSUM_WARNING_SV}

)}
{/* Solid footer outside the scroll area: always visible, never overlaps content (the body above scrolls independently). */}
{submitError && (

{submitError.message} {submitError.requestId && ( <> {' '} {t('save_error_request_id', { id: submitError.requestId })} )}

)}
) }