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>
382 lines
15 KiB
TypeScript
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>
|
|
)
|
|
}
|