Files
accounted/components/salary/NewEmployeeDialog.tsx
T
4560ccbfc9 perf(forms): supplier-invoice form, register forms and review dialogs read the session cache (#1938)
The supplier-invoice editor issued four requests on every mount (suppliers,
accounts, settings, fiscal periods) and defaulted vatRegistered=true,
entity type and rounding until /api/settings landed, so the moms controls
visibly flipped. The register forms fetched the whole chart of accounts to
fill one konto combobox, and each transaction review dialog refetched
accounts, cash accounts or settings per open.

- use-supplier-invoice-data: thin composition of useSuppliers, useAccounts,
  useCompanySettings and useFiscalPeriods; the settings-driven gates come
  from a pure deriveSupplierInvoiceDefaults() (tested) instead of state
  that flips when the fetch returns; the per-invoice öresavrundning toggle
  is the one local override. Inline supplier create invalidates the shared
  list instead of patching local state.
- SupplierForm, ArticleForm (posting accounts), QuickReviewDialog,
  InvoiceMatchDialog, supplier-invoices/[id] (payment dialog chart):
  useAccounts; ArticleForm's inline account create invalidates the chart.
- BulkBookDialog, MatchVoucherDialog, DuplicateBookingDialog: cash
  accounts from useCashAccounts (resolveAccount over the cached list; an
  empty list still resolves to 1930 with the fallback note).
- QuickReviewDialog, BulkBookDialog, NewEmployeeDialog, customers list
  (default payment terms), salary run page (payment format, bank, IBAN,
  dimensions): derived from useCompanySettings; the salary page's
  post-settings-modal refetch becomes a cache invalidation.

raw-reference-fetch ratchet: 45 -> 35 files.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-26 14:50:26 +02:00

434 lines
18 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 { 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 } 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>
)
}
// 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 { 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: '',
})
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)
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 (
<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 på 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 på 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 justify-end gap-3 border-t border-border bg-background px-6 py-4">
<Button type="button" variant="outline" onClick={onCancel}>
Avbryt
</Button>
<Button type="submit" disabled={saving}>
<Save className="mr-2 h-4 w-4" />
{saving ? 'Sparar...' : 'Spara'}
</Button>
</div>
</form>
)
}