Files
accounted/components/salary/run/RunHeader.tsx
T
Mattsson f43a6653f1 feat(salary): update_salary_run MCP tool and editable draft payment date (#2041)
* feat(salary): update_salary_run MCP tool and editable draft payment date

payment_date drives the booking entry date but was only editable via the
v1 PATCH. Close the gap on both remaining surfaces:

- New staged MCP write tool gnubok_update_salary_run (search-only
  catalog; tools/list budget is at zero headroom) accepting the exact
  v1 PATCH field set: payment_date, voucher_series, notes. Draft-only
  with the same optimistic lock semantics, via a new shared service
  lib/salary/update-run.ts used by both the staging preflight and the
  commit executor.
- Run header UI: payment date on a draft run is now an inline date
  input (prefilled, committed on blur/Enter, snaps back on failure),
  saved through the existing internal PATCH. Read-only once not draft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(salary): op-type migration, calc invalidation on date change, scanner compliance

Consolidated CI + review fix pass for #2041:

- pg-real: add 'update_salary_run' to pending_operations_operation_type_check
  (wholesale re-create, NOT VALID + VALIDATE pair, mirroring 20260828160000/1).
- Swedish accounting review: a payment_date change on a draft run now clears
  every roster row's calculation_breakdown (shared service and internal PATCH
  alike), so both book preflights refuse the run until a recalculation has run
  against the new date; skatteavdrag and the AGI redovisningsperiod follow the
  payment month. Staging preview exposes invalidates_calculation and the next
  hint states the clearing.
- no-phantom-columns: literal select strings in update-run.ts; ceiling +1 with
  a documented reason for the inherent patch-shaped UPDATE payload.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(salary): close skeptic findings on payment_date editing

Skeptic round 1 refuted two paths; both closed:

- Retry idempotency (correctness): the calculation_breakdown clear was
  gated on new-date-differs-from-stored, so a retry after a partial
  failure (header committed, clear failed) compared against the already
  updated date and skipped the clear forever, leaving a stale
  calculation bookable. The clear is now gated on payment_date being
  SUPPLIED, on all three surfaces (shared service, internal PATCH, v1
  PATCH: the v1 route previously had no clear at all and bypassed the
  invariant).
- Kontantprincipen (compliance): AGI derives its redovisningsperiod
  from period_year/period_month while the verifikat books on
  payment_date, so a cross-month payment_date change could book salary
  in one month and declare it in another. All three edit surfaces now
  refuse a payment_date outside the run's period month with the new
  structured error SALARY_RUN_PAYMENT_DATE_OUTSIDE_PERIOD; the UI date
  input is min/max-bounded to the period month.
- The internal PATCH update is now optimistic-locked on status='draft'
  (races return 400 instead of silently writing), matching the v1 PATCH
  and the shared service, and the clear cannot fire for a run that left
  draft.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(salary): carry book_skattekonto op types through the constraint re-create

The sibling migration 20260830130000 (merged from main) re-created
pending_operations_operation_type_check with book_skattekonto_row and
book_skattekonto_rows. This branch's 20260830150000 sorts after it and
re-creates the constraint wholesale, so its list must be that migration's
superset or the two values would be silently revoked at apply time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(salary): value-validate internal PATCH and grandfather out-of-period dates

Two skeptic follow-ups:

- The internal PATCH now validates values, not just keys: JSON body must
  be an object, payment_date must be ISO (shared ISO_DATE_RE),
  voucher_series a single A-Z letter, notes a string of max 2000 chars
  or null: the same rules as the v1 UpdateSalaryRunSchema, so nothing
  unvalidated can reach the DB through the whitelist.
- Creation does not (yet) couple payment_date to the period month, so a
  legally created out-of-period date must stay correctable. All three
  edit surfaces now allow day adjustments within the run's CURRENT
  payment month as well as the period month (grandfather clause); no
  move can introduce a new wrong month.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* fix(salary): resolve migration version collision with delete_draft_invoice

Main's delete_draft_invoice PR landed on the same 20260830150000/150001
versions and also re-creates pending_operations_operation_type_check.
Rename this branch's pair to 20260830160000/160001 (applies last) and
carry delete_draft_invoice through the wholesale re-create so nothing is
silently revoked. Final list = sibling's list + update_salary_run.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

* docs(salary): regenerate accounted-api skill for the new PATCH pitfalls

apiskill:check byte-compares the generated skill against the registry;
the two pitfalls added to the v1 salary-runs PATCH endpoint made
references/salary-runs.md stale and failed Core Build.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_018zGah8Yy49esAwpnKGxiGy

---------

Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
2026-08-30 21:43:23 +02:00

406 lines
15 KiB
TypeScript

'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<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 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[] = [
<span key="payment" className="inline-flex items-center gap-2">
{t('payment_date_label')}{' '}
{paymentDateEditable ? (
<Input
type="date"
value={paymentDateDraft}
min={`${periodMonthPrefix}-01`}
max={`${periodMonthPrefix}-${String(periodMonthLastDay).padStart(2, '0')}`}
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 (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>
</>
)
}