'use client' import { useState } from 'react' import Link from 'next/link' import { useTranslations } from 'next-intl' import { Badge } from '@/components/ui/badge' import { Button } from '@/components/ui/button' import { Input } from '@/components/ui/input' import { Dialog, DialogContent, DialogDescription, DialogFooter, DialogHeader, DialogTitle, } from '@/components/ui/dialog' import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu' import { AlertTriangle, ArrowLeft, ArrowLeftCircle, Download, Eye, FileDown, Loader2, MoreHorizontal, Send, Trash2, Undo2, } from 'lucide-react' import { formatDate } from '@/lib/utils' import { useCapability } from '@/contexts/CompanyContext' import { CAPABILITY } from '@/lib/entitlements/keys' import { periodLabelOf, type RunDetail } from './types' // Same chip vocabulary as the Löner list (chips mark exceptions): in-flight // states wear the quiet beige chip, paid is sage, corrected the outline // exception, and booked renders as muted text. const STATUS_VARIANTS: Record = { draft: 'secondary', review: 'secondary', approved: 'secondary', paid: 'success', booked: 'success', corrected: 'outline', } const STATUS_RANK: Record = { draft: 0, review: 1, approved: 2, paid: 3, booked: 4, corrected: 4, } interface RunHeaderProps { run: RunDetail canWrite: boolean actionLoading: string | null employeeCount: number isCalculated: boolean // The single "forward" step for the current status (Beräkna, Till // granskning, Godkänn, Markera utbetald, Bokför): the one default button // in the header. Everything else lives behind the ⋯ menu, except the // payslip send, which is a rail step of its own and stays visible. primaryAction?: { key: string; label: string; onClick: () => void } | null onPreview: () => void onRevert: () => void onUnapprove: () => void onSendPayslips: () => void onDownloadPayslips: () => void onDownloadAgi: () => void onDelete: () => void onCorrect: () => void // Draft-only: payment_date is frozen once the run leaves draft (it becomes // the booking entry date), so the header only offers the editor there. // Resolves false when the save failed, so the input can snap back. onUpdatePaymentDate: (date: string) => Promise } export function RunHeader({ run, canWrite, actionLoading, employeeCount, isCalculated, primaryAction, onPreview, onRevert, onUnapprove, onSendPayslips, onDownloadPayslips, onDownloadAgi, onDelete, onCorrect, onUpdatePaymentDate, }: RunHeaderProps) { const t = useTranslations('salary_run') const tSalary = useTranslations('salary') const [correctOpen, setCorrectOpen] = useState(false) const periodLabel = periodLabelOf(run) const hasEmailSend = useCapability(CAPABILITY.email_send) const statusKey = `status_${run.status}` const rank = STATUS_RANK[run.status] ?? 0 const busy = !!actionLoading const deliveries = run.payslip_deliveries_summary // Payslips are a parallel obligation from `approved` onwards. Email send is // a paid capability (server 403s without it); the PDF download alternative // in the menu stays free. const payslipsAvailable = rank >= 2 const showPayslipSend = payslipsAvailable && canWrite const showPreview = canWrite && (run.status === 'draft' || run.status === 'review') const showRevert = canWrite && run.status === 'review' const showUnapprove = canWrite && run.status === 'approved' const showDownloadPayslips = payslipsAvailable && canWrite const showDownloadAgi = canWrite && run.status === 'booked' const showDelete = canWrite && run.status === 'draft' const showCorrect = canWrite && run.status === 'booked' const hasMenu = showPreview || showRevert || showUnapprove || showDownloadPayslips || showDownloadAgi || showDelete || showCorrect const menuBusy = actionLoading === 'delete' || actionLoading === 'correct' || actionLoading === 'preview' || actionLoading === 'bulk_payslip' || actionLoading === 'agi-download' // Editable while draft: prefilled with the effective value, committed on // blur/Enter so half-typed dates never fire a PATCH. The draft state resyncs // whenever the server value changes (save reconcile, status transitions), // via the adjust-state-during-render pattern rather than an effect. const paymentDateEditable = canWrite && run.status === 'draft' const [paymentDateDraft, setPaymentDateDraft] = useState(run.payment_date) const [lastServerPaymentDate, setLastServerPaymentDate] = useState(run.payment_date) if (run.payment_date !== lastServerPaymentDate) { setLastServerPaymentDate(run.payment_date) setPaymentDateDraft(run.payment_date) } async function commitPaymentDate() { if (!paymentDateDraft || paymentDateDraft === run.payment_date) { // Empty or unchanged: snap back to the server value instead of saving. setPaymentDateDraft(run.payment_date) return } const saved = await onUpdatePaymentDate(paymentDateDraft) if (!saved) setPaymentDateDraft(run.payment_date) } // The payment date must stay within the run's period month: the AGI is // declared per payment month (kontantprincipen), so the API refuses dates // outside it. min/max keeps the native picker inside the month; typed // values outside it still get the server's 400 toast. const periodMonthPrefix = `${run.period_year}-${String(run.period_month).padStart(2, '0')}` const periodMonthLastDay = new Date(run.period_year, run.period_month, 0).getDate() const metaParts: React.ReactNode[] = [ {t('payment_date_label')}{' '} {paymentDateEditable ? ( setPaymentDateDraft(e.target.value)} onBlur={commitPaymentDate} onKeyDown={(e) => { if (e.key === 'Enter') e.currentTarget.blur() }} disabled={busy} aria-label={t('payment_date_edit_aria')} className="h-8 w-fit px-2 py-0 text-sm tabular-nums" /> ) : ( {formatDate(run.payment_date)} )} , {t('header_employees', { count: employeeCount })}, ] if (run.is_correction && run.corrects_run_id) { metaParts.push( {t('corrects_link', { period: periodLabel })} , ) } if (run.status === 'corrected' && run.corrected_by_run_id) { metaParts.push( {t('corrected_by_link')} , ) } return ( <> {/* Back link on its own quiet row, so the title below keeps a stable position across runs. */}
{t('back_to_salary')}
{/* Header: serif title with one status element, a quiet meta line, and the next step on the right. Everything else lives in the ⋯ menu. */}

{t('title', { period: periodLabel })}

{run.status === 'booked' ? ( {tSalary(statusKey)} ) : ( {tSalary(statusKey)} )} {run.is_correction && run.corrects_run_id && ( {t('correction_badge')} )}

{metaParts.map((part, i) => ( {i > 0 && ·} {part} ))}

{showPayslipSend && ( // The span carries the tooltip: browsers suppress `title` on // disabled elements, and hover events don't fire on them. )} {primaryAction && ( )} {hasMenu && ( {showPreview && ( {t('action_preview')} )} {showDownloadPayslips && ( {t('action_download_payslips')} )} {showDownloadAgi && ( {t('action_download_agi')} )} {(showRevert || showUnapprove) && ( <> {showRevert && ( {t('action_revert')} )} {/* Approval is an internal control point, not a legal event: the run can be unlocked again as long as nothing has been paid, booked, or filed. The API refuses once the AGI has reached Skatteverket. */} {showUnapprove && ( {t('action_unapprove')} )} )} {(showDelete || showCorrect) && ( <> {showDelete && ( {t('action_delete_draft')} )} {showCorrect && ( setCorrectOpen(true)} disabled={busy}> {t('action_correct')} )} )} )}
{/* Correction confirm: storno per BFL 5 kap. 5 §, nothing is deleted. */} {t('correct_dialog_title')} {t('correct_dialog_body', { period: periodLabel })} {run.agi_generated_at && ( {t('correct_dialog_agi_warning')} )} ) }