'use client' /** * Semester card on the salary dashboard (payroll gap-closure 3.5). * * Shows the open vacation-ledger totals (remaining + saved days across * active employees) and hosts the year-close dialog: dry-run report first, * a working confirm second (soft-guard convention). */ import { useEffect, useState } from 'react' import { useTranslations } from 'next-intl' import { Card, CardContent } from '@/components/ui/card' import { Button } from '@/components/ui/button' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { Palmtree, Loader2 } from 'lucide-react' import { useToast } from '@/components/ui/use-toast' import { formatCurrency } from '@/lib/utils' import { getErrorMessage } from '@/lib/errors/get-error-message' interface BalanceRow { employee_vacation_balance_id: string employee_name: string remaining_days: number saved_days_total: number forced_payout_days: number } interface CloseReportRow { employee_name: string remaining_days: number saveable_days: number untaken_below_floor_days: number expiring_days: number next_year_entitled: number } interface CloseReport { vacation_year_start: string vacation_year_end: string rows: CloseReportRow[] sek: { computed_liability: number booked_2920: number drift_2920: number adjustment_needed: boolean } } export function VacationBalanceCard({ canWrite }: { canWrite: boolean }) { const t = useTranslations('salary') const { toast } = useToast() const [rows, setRows] = useState([]) const [loading, setLoading] = useState(true) const [dialogOpen, setDialogOpen] = useState(false) const [report, setReport] = useState(null) const [previewError, setPreviewError] = useState(null) const [previewing, setPreviewing] = useState(false) const [closing, setClosing] = useState(false) async function fetchBalances(): Promise { const res = await fetch('/api/salary/vacation-balances') if (!res.ok) return null const { data } = await res.json() return (data ?? []) as BalanceRow[] } useEffect(() => { let cancelled = false fetchBalances().then((data) => { if (cancelled) return if (data) setRows(data) setLoading(false) }) return () => { cancelled = true } }, []) const totalRemaining = rows.reduce((s, r) => s + r.remaining_days, 0) const totalSaved = rows.reduce((s, r) => s + r.saved_days_total, 0) async function openCloseDialog() { setDialogOpen(true) setPreviewing(true) setReport(null) setPreviewError(null) const res = await fetch('/api/salary/vacation-year-close', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ dry_run: true }), }) const body = await res.json().catch(() => null) if (res.ok && body) { setReport(body.data.report as CloseReport) } else { // Resolve via the parsed body plus the status: the route answers thrown // errors with the canonical envelope `{ error: { code, message } }`, // and rendering that object would crash React. setPreviewError(getErrorMessage(body, { statusCode: res.status })) } setPreviewing(false) } async function confirmClose() { setClosing(true) const res = await fetch('/api/salary/vacation-year-close', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ book_adjustment: true }), }) const body = await res.json().catch(() => null) if (res.ok) { toast({ title: t('vacation_close_done') }) setDialogOpen(false) const refreshed = await fetchBalances() if (refreshed) setRows(refreshed) } else { toast({ title: t('vacation_close_failed'), description: getErrorMessage(body, { statusCode: res.status }), variant: 'destructive', }) } setClosing(false) } return (

{t('card_vacation_title')}

{loading ? (

…

) : rows.length > 0 ? ( <>

{totalRemaining}

{t('card_vacation_detail', { employees: rows.length, saved: totalSaved })}

{canWrite && ( )} ) : (

{t('card_vacation_none')}

)}
{t('vacation_close_title')} {t('vacation_close_description')} {previewing && (
{t('vacation_close_previewing')}
)} {previewError &&

{previewError}

} {report && (

{t('vacation_close_period', { from: report.vacation_year_start, to: report.vacation_year_end, })}

{report.rows.map((row, idx) => ( ))}
{t('vacation_close_col_employee')} {t('vacation_close_col_saved')} {t('vacation_close_col_flagged')} {t('vacation_close_col_expiring')} {t('vacation_close_col_next')}
{row.employee_name} {row.saveable_days} {row.untaken_below_floor_days} {row.expiring_days} {row.next_year_entitled}

{t('vacation_close_computed', { amount: formatCurrency(report.sek.computed_liability), })}

{t('vacation_close_booked', { amount: formatCurrency(report.sek.booked_2920) })}

{report.sek.adjustment_needed ? t('vacation_close_drift', { amount: formatCurrency(report.sek.drift_2920) }) : t('vacation_close_no_drift')}

)}
) }