'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 (
)
}
/**
* 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 (
)
}