'use client' import { useEffect, useRef, useState } from 'react' import { useTranslations } from 'next-intl' 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 { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { DetailSection } from '@/components/ui/detail-section' import { HelpPopover } from '@/components/ui/help-popover' import { Skeleton } from '@/components/ui/skeleton' import { Loader2 } from 'lucide-react' import { useToast } from '@/components/ui/use-toast' import { formatCurrency, formatDate } from '@/lib/utils' import { getErrorMessage } from '@/lib/errors/get-error-message' type RecurringLineType = | 'gross_deduction_pension' | 'gross_deduction_other' | 'net_deduction_union' | 'net_deduction_benefit_payment' | 'net_deduction_other' interface EmployeeRecurringLine { id: string item_type: RecurringLineType description: string amount: number account_number: string | null valid_from: string valid_to: string | null metadata: Record is_active: boolean } // Swedish defaults written to the DB when the description is left empty: // stored data stays Swedish regardless of the viewer's UI locale. const LINE_LABELS: Record = { gross_deduction_pension: 'Bruttolöneavdrag pension (löneväxling)', gross_deduction_other: 'Bruttolöneavdrag', net_deduction_union: 'Fackavgift', net_deduction_benefit_payment: 'Nettolöneavdrag förmån', net_deduction_other: 'Nettolöneavdrag', } // In-row text action: same idiom as EmployeeBenefitsPanel. const ROW_ACTION_CLASS = 'text-xs text-muted-foreground underline decoration-border underline-offset-4 transition-colors duration-150 hover:text-foreground hover:decoration-foreground disabled:opacity-50' export function EmployeeRecurringLinesPanel({ employeeId, canWrite }: { employeeId: string; canWrite: boolean }) { const t = useTranslations('salary_employee') const { toast } = useToast() const [lines, setLines] = useState([]) const [loading, setLoading] = useState(true) const [adding, setAdding] = useState(false) const [submitting, setSubmitting] = useState(false) // Monotonic request id: a reload issued after a create/delete must not be // overwritten by an earlier, slower in-flight load resolving late. const loadSeq = useRef(0) const [type, setType] = useState('gross_deduction_other') const [description, setDescription] = useState('') const [amount, setAmount] = useState('') const [validFrom, setValidFrom] = useState(() => new Date().toISOString().slice(0, 10)) const [validTo, setValidTo] = useState('') async function load() { const seq = ++loadSeq.current setLoading(true) try { const res = await fetch(`/api/salary/employees/${employeeId}/recurring-lines`) if (res.ok) { const { data } = await res.json() if (seq === loadSeq.current) setLines(data || []) } } catch { // Network failure: keep whatever is shown; the empty/stale list plus // the still-enabled actions let the user retry. } finally { if (seq === loadSeq.current) setLoading(false) } } useEffect(() => { load() // eslint-disable-next-line react-hooks/exhaustive-deps }, [employeeId]) function reset() { setType('gross_deduction_other') setDescription('') setAmount('') setValidFrom(new Date().toISOString().slice(0, 10)) setValidTo('') setAdding(false) } async function handleAdd() { setSubmitting(true) try { // The field takes a positive number; every recurring line is a // deduction and is stored negative so the payslip math reads the sign // from the row. const magnitude = Math.abs(parseFloat(amount) || 0) const body: Record = { item_type: type, description: description || LINE_LABELS[type], amount: -magnitude, valid_from: validFrom, } if (validTo) body.valid_to = validTo const res = await fetch(`/api/salary/employees/${employeeId}/recurring-lines`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) if (res.ok) { toast({ title: t('recurring_added') }) reset() await load() } else { const result = await res.json() toast({ title: t('recurring_save_failed'), description: getErrorMessage(result, { statusCode: res.status }), variant: 'destructive', }) } } catch { toast({ title: t('recurring_save_failed'), variant: 'destructive' }) } finally { setSubmitting(false) } } async function handleDelete(id: string) { try { const res = await fetch(`/api/salary/employees/${employeeId}/recurring-lines/${id}`, { method: 'DELETE' }) if (res.ok) { toast({ title: t('recurring_removed') }) await load() } else { toast({ title: t('recurring_remove_failed'), variant: 'destructive' }) } } catch { toast({ title: t('recurring_remove_failed'), variant: 'destructive' }) } } return ( {t('recurring_help')}} aside={ canWrite ? ( ) : undefined } > {loading ? (
) : lines.filter((l) => l.is_active).length === 0 ? (

{t('recurring_empty')}

) : (
    {/* Deactivated lines are hidden: "Ta bort" soft-deactivates a line that has already been derived into a run, and showing it again would read as the delete having failed. */} {lines.filter((l) => l.is_active).map((l) => { const typeLabel = t(`recurring_type_${l.item_type}`) // The description defaults to the Swedish type label when left // empty on creation; repeating it next to the type says nothing. const showDescription = !!l.description && l.description !== typeLabel && l.description !== LINE_LABELS[l.item_type] return (
  • {typeLabel} {showDescription && ( {' · '}{l.description} )} {formatDate(l.valid_from)} → {l.valid_to ? formatDate(l.valid_to) : t('recurring_ongoing')} {formatCurrency(l.amount)} {t('recurring_per_month')} {canWrite && ( )}
  • ) })}
)} {/* Add dialog (convention 13: centered modal for create). Closing by Escape or backdrop is the same as Avbryt; both are held while a save is in flight. */} { if (!open && !submitting) reset() }} > {t('recurring_add')} {t('recurring_help')}
setDescription(e.target.value)} placeholder={t(`recurring_type_${type}`)} />
setAmount(e.target.value)} placeholder={t('recurring_amount_placeholder')} className="max-w-xs" />

{t('recurring_deduction_hint')}

setValidFrom(e.target.value)} />
setValidTo(e.target.value)} />

{t('recurring_window_hint')}

) }