Files
accounted/components/salary/run/RunHeader.tsx
T
Jakob Wennberg cb39cded81 fix(payroll): declare AGI for the payout month, not the run's period month (#2191) (#2228)
Arbetsgivardeklarationen is filed for the calendar month the pay went
out (kontantprincipen), so a run for August paid on 25 September belongs
to redovisningsperiod 202609. The generator, the submit route, the run
page and the run header all took run.period_year/period_month instead,
and three PATCH paths refused any payment date outside that month, which
made lön i efterskott impossible to set up at all.

- lib/salary/agi/reporting-period.ts: one dependency-free helper
  (agiReportingPeriod) derives the period from payment_date, falling
  back to the run period only when the date is missing.
- generate-declaration.ts: XML Redovisningsperiod, the agi_declarations
  lookup/insert and the sanity warnings key on the payout month. New
  AGI_PERIOD_CONFLICT (409) refuses to overwrite another live run's
  declaration for the same payout month; corrections still replace.
- submit route, run page (AGI panel, submission hook, tax-payment fetch,
  XML filename) and RunHeader use the helper; the header says "AGI
  redovisas för 2026-09 (utbetalningsmånaden)" whenever the two differ.
- The in-period payment-date guard is lifted in the dashboard PATCH,
  lib/salary/update-run.ts (MCP staged tool + pending-ops executor) and
  the v1 PATCH, plus the RunHeader min/max; its only stated reason was
  the period-keyed AGI. Generated API skill reference updated.

Existing agi_declarations rows keep their stored period: a declaration
already filed under the earned month is a correction with Skatteverket,
not a re-key. Rule verified against Skatteverket's guidance on
redovisningsperiod (kontantprincipen).

Closes #2191


Claude-Session: https://claude.ai/code/session_01QPQLwHNEiQfiCNLSMzXMiQ

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-03 17:19:19 +02:00

416 lines
15 KiB
TypeScript

'use client'
import { useState } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import {
agiPeriodDiffersFromRunPeriod,
agiReportingPeriod,
formatAgiPeriodDashed,
} from '@/lib/salary/agi/reporting-period'
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<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline'> = {
draft: 'secondary',
review: 'secondary',
approved: 'secondary',
paid: 'success',
booked: 'success',
corrected: 'outline',
}
const STATUS_RANK: Record<string, number> = {
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<boolean>
}
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 AGI is declared for the PAYOUT month (kontantprincipen), so a run
// paid the month after its period (lön i efterskott) files under that
// later month. Say so in the meta line whenever the two differ: nothing
// else on the page explains why "augusti" is declared in September.
const agiPeriod = agiReportingPeriod(run)
const agiPeriodDiffers = agiPeriodDiffersFromRunPeriod(run)
const metaParts: React.ReactNode[] = [
<span key="payment" className="inline-flex items-center gap-2">
{t('payment_date_label')}{' '}
{paymentDateEditable ? (
<Input
type="date"
value={paymentDateDraft}
onChange={(e) => 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"
/>
) : (
<span className="tabular-nums">{formatDate(run.payment_date)}</span>
)}
</span>,
<span key="count">{t('header_employees', { count: employeeCount })}</span>,
]
if (agiPeriodDiffers) {
metaParts.push(
<span key="agi-period">
{t('agi_period_note', { period: formatAgiPeriodDashed(agiPeriod) })}
</span>,
)
}
if (run.is_correction && run.corrects_run_id) {
metaParts.push(
<Link
key="corrects"
href={`/salary/runs/${run.corrects_run_id}`}
className="underline underline-offset-2 hover:text-foreground"
>
{t('corrects_link', { period: periodLabel })}
</Link>,
)
}
if (run.status === 'corrected' && run.corrected_by_run_id) {
metaParts.push(
<Link
key="corrected-by"
href={`/salary/runs/${run.corrected_by_run_id}`}
className="underline underline-offset-2 hover:text-foreground"
>
{t('corrected_by_link')}
</Link>,
)
}
return (
<>
{/* Back link on its own quiet row, so the title below keeps a stable
position across runs. */}
<div>
<Link
href="/salary"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
{t('back_to_salary')}
</Link>
</div>
{/* Header: serif title with one status element, a quiet meta line, and
the next step on the right. Everything else lives in the ⋯ menu. */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-3">
<h1 className="font-display text-2xl leading-8 tracking-tight">
{t('title', { period: periodLabel })}
</h1>
{run.status === 'booked' ? (
<span className="text-sm text-muted-foreground">{tSalary(statusKey)}</span>
) : (
<Badge variant={STATUS_VARIANTS[run.status] || 'secondary'}>
{tSalary(statusKey)}
</Badge>
)}
{run.is_correction && run.corrects_run_id && (
<Badge variant="outline">{t('correction_badge')}</Badge>
)}
</div>
<p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
{metaParts.map((part, i) => (
<span key={i} className="inline-flex items-center gap-x-2">
{i > 0 && <span aria-hidden>·</span>}
{part}
</span>
))}
</p>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{showPayslipSend && (
// The span carries the tooltip: browsers suppress `title` on
// disabled elements, and hover events don't fire on them.
<span title={!hasEmailSend ? t('payslips_send_requires_subscription') : undefined}>
<Button
variant="outline"
onClick={onSendPayslips}
disabled={busy || !hasEmailSend}
>
{actionLoading === 'payslips-send' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
{deliveries && deliveries.sent > 0
? t('action_send_payslips_again')
: t('action_send_payslips')}
</Button>
</span>
)}
{primaryAction && (
<Button onClick={primaryAction.onClick} disabled={busy}>
{actionLoading === primaryAction.key && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{primaryAction.label}
</Button>
)}
{hasMenu && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('more_actions')}>
{menuBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[240px]">
{showPreview && (
<DropdownMenuItem
onSelect={onPreview}
disabled={busy || (run.status === 'draft' && !isCalculated)}
>
<Eye className="h-4 w-4" />
{t('action_preview')}
</DropdownMenuItem>
)}
{showDownloadPayslips && (
<DropdownMenuItem onSelect={onDownloadPayslips} disabled={busy}>
<FileDown className="h-4 w-4" />
{t('action_download_payslips')}
</DropdownMenuItem>
)}
{showDownloadAgi && (
<DropdownMenuItem onSelect={onDownloadAgi} disabled={busy}>
<Download className="h-4 w-4" />
{t('action_download_agi')}
</DropdownMenuItem>
)}
{(showRevert || showUnapprove) && (
<>
<DropdownMenuSeparator />
{showRevert && (
<DropdownMenuItem onSelect={onRevert} disabled={busy}>
<ArrowLeftCircle className="h-4 w-4" />
{t('action_revert')}
</DropdownMenuItem>
)}
{/* 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 && (
<DropdownMenuItem onSelect={onUnapprove} disabled={busy}>
<ArrowLeftCircle className="h-4 w-4" />
{t('action_unapprove')}
</DropdownMenuItem>
)}
</>
)}
{(showDelete || showCorrect) && (
<>
<DropdownMenuSeparator />
{showDelete && (
<DropdownMenuItem
onSelect={onDelete}
disabled={busy}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4" />
{t('action_delete_draft')}
</DropdownMenuItem>
)}
{showCorrect && (
<DropdownMenuItem onSelect={() => setCorrectOpen(true)} disabled={busy}>
<Undo2 className="h-4 w-4" />
{t('action_correct')}
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Correction confirm: storno per BFL 5 kap. 5 §, nothing is deleted. */}
<Dialog open={correctOpen} onOpenChange={setCorrectOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('correct_dialog_title')}</DialogTitle>
<DialogDescription className="space-y-3 pt-2 text-left">
<span className="block">{t('correct_dialog_body', { period: periodLabel })}</span>
{run.agi_generated_at && (
<span className="flex items-start gap-2 rounded-lg border border-border p-3 text-xs">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>{t('correct_dialog_agi_warning')}</span>
</span>
)}
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setCorrectOpen(false)}>
{t('correct_dialog_cancel')}
</Button>
<Button
onClick={() => {
setCorrectOpen(false)
onCorrect()
}}
>
{t('correct_dialog_confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
)
}