Files
accounted/components/salary/NewEmployeeDialog.tsx
T
Jakob WennbergandClaude Sonnet 5 ec27228a8e style: remove em/en dashes repo-wide, add CLAUDE.md rule against them (#890)
Em dashes (—) and en dashes (–) had spread across comments, docs, tests,
and a few UI strings, reading as AI-generated boilerplate rather than
house style. Replaced each with punctuation matching its context: colon
for explanatory clauses, comma for asides, plain hyphen for numeric/legal
ranges (e.g. "21-23§"), "to"/"till" for date ranges, parentheses for
paired-dash asides. messages/en.json and messages/sv.json were fixed by
hand together to keep sv/en in sync.

Left untouched where the dash is the functional subject rather than
decorative punctuation: date-range-parser.ts's separator regex,
charset-repair.ts's CP1252 byte-mapping table (and its test), the SIE
encoding mojibake docs, generic-csv.ts's minus-sign normalizer, the
agent system-prompt files that already instruct against em dashes, and
a golden iXBRL test fixture compared byte-for-byte.

Also fixes two bugs surfaced along the way: an off-by-one in
ApiKeysPanel's scope-label split (a leftover from an earlier partial
pass), and a charset-repair test that had lost the literal en-dash it
exists to verify.

Regenerated the agent atom seed migration (skills:generate) since 27
SKILL.md files changed. Added a CLAUDE.md rule against em/en dashes,
with an explicit carve-out for the functional-dash cases above.

Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com>
2026-07-04 15:58:06 +02:00

382 lines
15 KiB
TypeScript

'use client'
import { useState, useEffect } from 'react'
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
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 { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
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>
}
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. Card sections carry over from the old /salary/employees/new page.
*/
export default function NewEmployeeDialog({ open, onOpenChange, onCreated }: Props) {
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] overflow-y-auto"
// 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>
<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')
// 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<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: '',
})
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<HTMLFormElement>) {
e.preventDefault()
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,
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: form.get('clearing_number') as string || undefined,
bank_account_number: form.get('bank_account_number') as string || 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)
}
return (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Personal info */}
<Card>
<CardHeader>
<CardTitle className="text-base">Personuppgifter</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="first_name">Förnamn<RequiredMark /></Label>
<Input id="first_name" name="first_name" required />
</div>
<div className="space-y-2">
<Label htmlFor="last_name">Efternamn<RequiredMark /></Label>
<Input id="last_name" name="last_name" required />
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="personnummer">Personnummer (12 siffror)<RequiredMark /></Label>
<Input
id="personnummer"
name="personnummer"
placeholder="ÅÅÅÅMMDDNNNN"
required
maxLength={13}
value={personnummer}
onChange={(e) => setPersonnummer(e.target.value)}
/>
<p className="text-xs text-muted-foreground">Krypteras vid lagring</p>
</div>
<div className="space-y-2">
<Label htmlFor="email">E-post</Label>
<Input id="email" name="email" type="email" />
<p className="text-xs text-muted-foreground">Krävs för att skicka lönebesked</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="phone">Telefon</Label>
<Input id="phone" name="phone" className="max-w-xs" />
</div>
</CardContent>
</Card>
{/* Address */}
<Card>
<CardHeader>
<CardTitle className="text-base">Adress</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="space-y-2">
<Label htmlFor="address_line1">Gatuadress</Label>
<Input id="address_line1" name="address_line1" />
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="postal_code">Postnummer</Label>
<Input id="postal_code" name="postal_code" className="max-w-[160px]" />
</div>
<div className="space-y-2">
<Label htmlFor="city">Ort</Label>
<Input id="city" name="city" />
</div>
</div>
</CardContent>
</Card>
{/* Employment */}
<Card>
<CardHeader>
<CardTitle className="text-base">Anställning</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="employment_type">Typ</Label>
<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>
</div>
<div className="space-y-2">
<Label htmlFor="employment_start">Anställningsdatum<RequiredMark /></Label>
<Input id="employment_start" name="employment_start" type="date" required />
</div>
<div className="space-y-2">
<Label htmlFor="employment_end">Slutdatum</Label>
<Input id="employment_end" name="employment_end" type="date" />
</div>
<div className="space-y-2">
<Label htmlFor="employment_degree">Sysselsättningsgrad (%)</Label>
<Input id="employment_degree" name="employment_degree" type="number" defaultValue="100" min="1" max="100" />
</div>
</div>
</CardContent>
</Card>
{/* Salary */}
<Card>
<CardHeader>
<CardTitle className="text-base">Lön</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="salary_type">Löneform<RequiredMark /></Label>
<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>
</div>
{salaryType === 'monthly' ? (
<div className="space-y-2">
<Label htmlFor="monthly_salary">Månadslön (brutto, SEK)<RequiredMark /></Label>
<Input id="monthly_salary" name="monthly_salary" type="number" step="1" min="1" required />
</div>
) : (
<div className="space-y-2">
<Label htmlFor="hourly_rate">Timlön (SEK)<RequiredMark /></Label>
<Input id="hourly_rate" name="hourly_rate" type="number" step="0.01" min="0.01" required />
</div>
)}
</div>
</CardContent>
</Card>
{/* Default dimensions (kostnadsställe/projekt) */}
{dimensionsEnabled && (
<Card>
<CardHeader>
<CardTitle className="text-base">Kostnadsställe / Projekt (standard)</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<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>
</CardContent>
</Card>
)}
{/* Tax */}
<EmployeeTaxCard personnummer={personnummer} onChange={setTax} />
{/* Vacation */}
<Card>
<CardHeader>
<CardTitle className="text-base">Semester</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="vacation_rule">Semesterregel</Label>
<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>
)}
</div>
<div className="space-y-2">
<Label htmlFor="vacation_days_per_year">Semesterdagar per år</Label>
<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>
</div>
</div>
</CardContent>
</Card>
{/* Bank */}
<Card>
<CardHeader>
<CardTitle className="text-base">Bankkonto</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="clearing_number">Clearingnummer</Label>
<Input id="clearing_number" name="clearing_number" />
</div>
<div className="space-y-2">
<Label htmlFor="bank_account_number">Kontonummer</Label>
<Input id="bank_account_number" name="bank_account_number" />
</div>
</div>
<p className="text-xs text-muted-foreground mt-2">Krävs innan lönekörning kan godkännas</p>
</CardContent>
</Card>
{/* Actions */}
<div className="flex justify-end gap-3">
<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>
)
}