'use client' import { useEffect, 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 BenefitType = 'bike' | 'car' | 'meals' | 'housing' | 'wellness' | 'other' interface EmployeeBenefit { id: string benefit_type: BenefitType description: string monthly_value: number 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 BENEFIT_LABELS: Record = { bike: 'Cykelförmån', car: 'Bilförmån', meals: 'Kostförmån', housing: 'Bostadsförmån', wellness: 'Friskvård (skattepliktig del)', other: 'Övrig förmån', } // In-row text action: underlined so it reads as an action next to plain // values, the hairline underline darkening on hover (same idiom as the // invoice detail rows). 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 EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: string; canWrite: boolean }) { const t = useTranslations('salary_employee') const { toast } = useToast() const [benefits, setBenefits] = useState([]) const [loading, setLoading] = useState(true) const [adding, setAdding] = useState(false) const [submitting, setSubmitting] = useState(false) const [type, setType] = useState('bike') const [description, setDescription] = useState('') const [monthlyValue, setMonthlyValue] = useState('') const [annualMarketValue, setAnnualMarketValue] = useState('') const [validFrom, setValidFrom] = useState(() => new Date().toISOString().slice(0, 10)) const [validTo, setValidTo] = useState('') async function load() { setLoading(true) const res = await fetch(`/api/salary/employees/${employeeId}/benefits`) if (res.ok) { const { data } = await res.json() setBenefits(data || []) } setLoading(false) } useEffect(() => { load() // eslint-disable-next-line react-hooks/exhaustive-deps }, [employeeId]) function reset() { setType('bike') setDescription('') setMonthlyValue('') setAnnualMarketValue('') setValidFrom(new Date().toISOString().slice(0, 10)) setValidTo('') setAdding(false) } async function handleAdd() { setSubmitting(true) const body: Record = { benefit_type: type, description: description || BENEFIT_LABELS[type], valid_from: validFrom, } if (validTo) body.valid_to = validTo if (type === 'bike') { body.annual_market_value = parseFloat(annualMarketValue) || 0 } else { body.monthly_value = parseFloat(monthlyValue) || 0 } const res = await fetch(`/api/salary/employees/${employeeId}/benefits`, { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify(body), }) if (res.ok) { toast({ title: t('benefits_added') }) reset() await load() } else { const result = await res.json() toast({ title: t('benefits_save_failed'), description: getErrorMessage(result, { statusCode: res.status }), variant: 'destructive', }) } setSubmitting(false) } async function handleDelete(id: string) { const res = await fetch(`/api/salary/employees/${employeeId}/benefits/${id}`, { method: 'DELETE' }) if (res.ok) { toast({ title: t('benefits_removed') }) await load() } else { toast({ title: t('benefits_remove_failed'), variant: 'destructive' }) } } const previewMonthlyBike = (() => { const annual = parseFloat(annualMarketValue) || 0 return Math.max(0, annual - 3000) / 12 })() return ( {t('benefits_help')}} aside={ canWrite ? ( ) : undefined } > {loading ? (
) : benefits.length === 0 ? (

{t('benefits_empty')}

) : (
    {benefits.map((b) => { const typeLabel = t(`benefits_type_${b.benefit_type}`) // The description defaults to the Swedish type label when left // empty on creation; repeating it next to the type says nothing. const showDescription = !!b.description && b.description !== typeLabel && b.description !== BENEFIT_LABELS[b.benefit_type] return (
  • {typeLabel} {showDescription && ( {' · '}{b.description} )} {formatDate(b.valid_from)} → {b.valid_to ? formatDate(b.valid_to) : t('benefits_ongoing')} {formatCurrency(b.monthly_value)} {t('benefits_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('benefits_add')} {t('benefits_help')}
setDescription(e.target.value)} placeholder={t(`benefits_type_${type}`)} />
{type === 'bike' ? (
setAnnualMarketValue(e.target.value)} placeholder={t('benefits_annual_market_value_placeholder')} className="max-w-xs" />

{t('benefits_bike_hint')} {parseFloat(annualMarketValue) > 0 && ( {t('benefits_monthly_value_preview')} {formatCurrency(previewMonthlyBike)} )}

) : (
setMonthlyValue(e.target.value)} className="max-w-xs" />
)}
setValidFrom(e.target.value)} />
setValidTo(e.target.value)} />
) }