'use client' import { useState, useEffect } from 'react' 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 } 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)} /> ) } // 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 { toast } = useToast() const [saving, setSaving] = useState(false) 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 [dimensionsEnabled, setDimensionsEnabled] = useState(false) 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: '', }) useEffect(() => { fetch('/api/settings') .then((r) => r.json()) .then(({ data }) => setDimensionsEnabled(data?.dimensions_enabled === true)) .catch(() => {/* keep the dimension fields hidden */}) }, []) 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) 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, 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, } const res = await fetch('/api/salary/employees', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) if (res.ok) { toast({ title: 'Anställd skapad' }) onCreated() } else { const result = await res.json() toast({ title: 'Kunde inte skapa anställd', description: getErrorMessage(result, { context: 'salary', statusCode: res.status }), variant: 'destructive', }) } setSaving(false) } 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

{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). */}
) }