Files
accounted/app/(dashboard)/salary/runs/[id]/page.tsx
T
Mattsson e11f70b347 Bug/gh issues fiz (#1103)
* refactor: optimize page loading and data fetching

* fix: resolve recurring production runtime errors

* feat: add MCP company and customer updates

* fix: handle year-end tax adjustments

* feat: harden annual report compliance

* fix: expand invoice logo and font support

* fix: sanitize API route error responses

* fix: sanitize user-facing error messages

* feat: persist onboarding and tax assessment notices

* fix: reduce cloud backup audit churn

* feat: refine invoice editor layout

* fix: show saved tax adjustments in INK2

* fix: complete annual report API mappings

* docs: record operational safeguards and decisions

* fix: harden annual report review findings

* fix: adjust column span for description based on VAT registration

* New css class name
2026-07-21 23:00:15 +02:00

747 lines
28 KiB
TypeScript

'use client'
import { use, useEffect, useState } from 'react'
import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Skeleton } from '@/components/ui/skeleton'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { AlertTriangle, Download, Loader2 } from 'lucide-react'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
} from '@/components/ui/destructive-confirm-dialog'
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { useAgiSubmission } from '@/lib/hooks/use-agi-submission'
import { deriveAgiFilingState } from '@/lib/salary/agi-submission-state'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { AGIPanel } from '@/components/salary/AGIPanel'
import { PaymentFilePanel } from '@/components/salary/PaymentFilePanel'
import { TaxPaymentPanel } from '@/components/salary/TaxPaymentPanel'
import { RunHeader } from '@/components/salary/run/RunHeader'
import { RunProgressBar } from '@/components/salary/run/RunProgressBar'
import { RunKpiCards } from '@/components/salary/run/RunKpiCards'
import { RunEmployeesTable } from '@/components/salary/run/RunEmployeesTable'
import { RunCalculationDetails } from '@/components/salary/run/RunCalculationDetails'
import { RunJournalPreview, type PreviewData } from '@/components/salary/run/RunJournalPreview'
import { periodLabelOf, type RunDetail } from '@/components/salary/run/types'
import type { Employee, SalaryRunEmployee } from '@/types'
export default function SalaryRunPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const router = useRouter()
const { toast } = useToast()
const { canWrite } = useCanWrite()
const t = useTranslations('salary_run')
const { dialogProps, confirm: confirmAction } = useDestructiveConfirm()
const [run, setRun] = useState<RunDetail | null>(null)
const [availableEmployees, setAvailableEmployees] = useState<Employee[]>([])
const [preview, setPreview] = useState<PreviewData | null>(null)
const [loading, setLoading] = useState(true)
const [actionLoading, setActionLoading] = useState<string | null>(null)
// Non-null while the "Godkänn ändå?" dialog is open: holds the missing
// bank-detail reasons returned by the approve route (overridable block).
const [approveOverride, setApproveOverride] = useState<string[] | null>(null)
const [preferredPaymentFormat, setPreferredPaymentFormat] = useState<'bg_lb' | 'pain001'>('pain001')
const [defaultBank, setDefaultBank] = useState<string | null>(null)
// Gates the default-dimensions chips on the employee rows: same
// company_settings.dimensions_enabled UI gate as the voucher form.
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
const [taxPayment, setTaxPayment] = useState<{
tax_payment_file_generated_at: string | null
tax_paid_at: string | null
} | null>(null)
const [taxPaymentLoading, setTaxPaymentLoading] = useState(false)
// Skatteverket's per-period AGI submission record: drives the AGI step on
// the progress rail and the panel's state machine (underlag submitted /
// awaiting BankID signature / signed). Only booked runs can file AGI, so
// the fetch is skipped (null period) for everything else.
const { submission: agiSubmission, refresh: refreshAgiSubmission } = useAgiSubmission(
run && run.status === 'booked'
? `${run.period_year}${String(run.period_month).padStart(2, '0')}`
: null,
)
async function loadRun() {
const res = await fetch(`/api/salary/runs/${id}`)
if (res.ok) {
const { data } = await res.json()
setRun(data)
if (data?.period_year && data?.period_month) {
const period = `${data.period_year}-${String(data.period_month).padStart(2, '0')}`
setTaxPaymentLoading(true)
void fetch(`/api/skatteverket/tax-payments/${period}`)
.then(async (txRes) => (txRes.ok ? txRes.json() : null))
.then((tx) => {
if (tx) setTaxPayment(tx.data)
})
.catch(() => setTaxPayment(null))
.finally(() => setTaxPaymentLoading(false))
}
}
}
useEffect(() => {
async function load() {
// Employees and settings don't depend on the run - load all three in
// parallel instead of serially.
const [, empRes, settingsRes] = await Promise.all([
loadRun(),
fetch('/api/salary/employees'),
fetch('/api/settings'),
])
if (empRes.ok) {
const { data } = await empRes.json()
setAvailableEmployees(data || [])
}
if (settingsRes.ok) {
const { data } = await settingsRes.json()
if (data?.preferred_payment_format === 'pain001' || data?.preferred_payment_format === 'bg_lb') {
setPreferredPaymentFormat(data.preferred_payment_format)
}
setDefaultBank(typeof data?.salary_default_bank === 'string' ? data.salary_default_bank : null)
setDimensionsEnabled(data?.dimensions_enabled === true)
}
setLoading(false)
}
load()
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id])
// Refetch when the tab regains focus. AGI can be generated out-of-band (via
// the MCP server, the public API, or another browser tab) and this page
// would otherwise keep showing a stale "AGI-fil har inte genererats ännu"
// (and a stale "AGI-XML saknas" error in the panel below) until a full
// reload. Reconciling agi_generated_at on visibilitychange picks up that
// generation without the user hard-refreshing.
useEffect(() => {
function onVisible() {
if (document.visibilityState === 'visible') loadRun()
}
document.addEventListener('visibilitychange', onVisible)
return () => document.removeEventListener('visibilitychange', onVisible)
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [id])
// Auto-load the journal preview once the run is calculated, so the
// "Bokföring (förhandsgranskning)" box renders beside Beräkningsdetaljer
// without a manual Förhandsgranska click. Re-runs when the calculated totals
// change (e.g. after Beräkna om) so the preview stays in sync; clears while
// the run isn't calculated yet.
const isCalculatedForPreview = run?.calculation_params != null
useEffect(() => {
if (!isCalculatedForPreview) {
setPreview(null)
return
}
let cancelled = false
;(async () => {
const res = await fetch(`/api/salary/runs/${id}/preview`)
if (!res.ok) return
const { data } = await res.json()
if (!cancelled) setPreview(data)
})()
return () => {
cancelled = true
}
}, [id, isCalculatedForPreview, run?.total_gross, run?.total_tax, run?.total_avgifter])
async function handleAction(action: string, method: string = 'POST') {
setActionLoading(action)
const res = await fetch(`/api/salary/runs/${id}/${action}`, { method })
if (res.ok) {
// Optimistic: the status-transition endpoints return the updated run row.
// Merge it in immediately so the screen flips without waiting for the
// heavy detail refetch, then reconcile in the background. This is what
// makes "Till granskning" / "Godkänn" feel instant.
const payload = await res.json().catch(() => null)
if (payload?.data) {
setRun(prev => (prev ? { ...prev, ...payload.data } : prev))
}
setActionLoading(null)
toast({ title: t('toast_status_updated') })
loadRun() // background reconcile - not awaited
return
}
const result = await res.json().catch(() => ({}))
toast({
title: t('toast_status_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
setActionLoading(null)
}
// Approval is an authorization step. Missing bank details are an *overridable*
// block (SALARY_APPROVE_BANK_DETAILS_MISSING) - rather than dead-ending on a
// 400 toast, we surface a confirm dialog and re-approve with ?force=true when
// the user chooses "Godkänn ändå". The payment-file step still hard-blocks.
async function doApprove(force: boolean) {
setActionLoading('approve')
const res = await fetch(`/api/salary/runs/${id}/approve${force ? '?force=true' : ''}`, {
method: 'POST',
})
if (res.ok) {
setApproveOverride(null)
const payload = await res.json().catch(() => null)
if (payload?.data) {
setRun(prev => (prev ? { ...prev, ...payload.data } : prev))
}
setActionLoading(null)
toast({ title: t('toast_status_updated') })
loadRun() // background reconcile - not awaited
return
}
const result = await res.json().catch(() => ({}))
// Overridable → open the confirm dialog instead of toasting the error.
if (
!force &&
result?.code === 'SALARY_APPROVE_BANK_DETAILS_MISSING' &&
Array.isArray(result.details)
) {
setApproveOverride(result.details as string[])
setActionLoading(null)
return
}
setApproveOverride(null)
toast({
title: t('toast_status_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
setActionLoading(null)
}
// Recall an approval (approved → review). Approval is only an internal
// control point, but side effects may already exist — a payment file that
// could be sitting at the bank, sent payslips, a generated AGI — so the
// confirm spells out exactly the ones that apply to this run. The API
// refuses outright once the AGI has been filed with Skatteverket.
async function handleUnapprove() {
if (!run) return
const lines = [t('confirm_unapprove_intro')]
if (run.payment_file_generated_at) lines.push(t('confirm_unapprove_payment_file'))
if ((run.payslip_deliveries_summary?.sent ?? 0) > 0) lines.push(t('confirm_unapprove_payslips'))
if (run.agi_generated_at) lines.push(t('confirm_unapprove_agi'))
const ok = await confirmAction({
title: t('confirm_unapprove_title'),
description: lines.join('\n\n'),
confirmLabel: t('action_unapprove'),
variant: 'warning',
})
if (!ok) return
handleAction('unapprove')
}
async function handleDelete() {
if (!run) return
const period = periodLabelOf(run)
const ok = await confirmAction({
title: t('confirm_delete_title'),
description: t('confirm_delete', { period }),
confirmLabel: t('action_delete_draft'),
variant: 'destructive',
})
if (!ok) return
setActionLoading('delete')
const res = await fetch(`/api/salary/runs/${id}`, { method: 'DELETE' })
if (res.ok) {
toast({ title: t('toast_draft_deleted') })
router.push('/salary')
return
}
const result = await res.json().catch(() => ({}))
toast({
title: t('toast_delete_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
setActionLoading(null)
}
// Storno-based correction (BFL 5 kap. 5 §) - the confirm dialog lives in
// RunHeader; this fires only after the user has confirmed there.
async function handleCorrect() {
setActionLoading('correct')
const res = await fetch(`/api/salary/runs/${id}/correct`, { method: 'POST' })
if (res.ok) {
const { data } = await res.json()
toast({ title: t('toast_correction_created'), description: t('toast_correction_description') })
router.push(`/salary/runs/${data.id}`)
return
}
const result = await res.json().catch(() => ({}))
toast({
title: t('toast_correction_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
setActionLoading(null)
}
async function handleAddEmployee(employeeId: string) {
setActionLoading('add-employee')
const res = await fetch(`/api/salary/runs/${id}/employees`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ employee_id: employeeId }),
})
if (res.ok) {
await loadRun()
toast({ title: t('toast_employee_added') })
} else {
const result = await res.json().catch(() => ({}))
toast({
title: t('toast_add_employee_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
}
setActionLoading(null)
}
// Remove an employee from a draft run. The DELETE endpoint is draft-only and
// cascades to the employee's line items.
async function handleRemoveEmployee(employeeId: string, name: string) {
const ok = await confirmAction({
title: t('confirm_remove_employee_title'),
description: t('confirm_remove_employee', { name }),
confirmLabel: t('remove_sr'),
variant: 'destructive',
})
if (!ok) return
setActionLoading(`remove-${employeeId}`)
const res = await fetch(`/api/salary/runs/${id}/employees/${employeeId}`, {
method: 'DELETE',
})
if (res.ok) {
await loadRun()
toast({ title: t('toast_employee_removed') })
} else {
const result = await res.json()
toast({
title: t('toast_remove_employee_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
}
setActionLoading(null)
}
// Edit this month's monthly salary for one employee (draft only). The engine
// reads this per-run value at calc time. Saved on blur; the user then clicks
// Beräkna to refresh the outcome.
async function handleSalaryEdit(employeeId: string, raw: string, previous: number) {
const monthly = Number(raw.replace(/\s/g, '').replace(',', '.'))
if (!Number.isFinite(monthly) || monthly < 0 || monthly === previous) return
setActionLoading(`salary-${employeeId}`)
const res = await fetch(`/api/salary/runs/${id}/employees/${employeeId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ monthly_salary: monthly }),
})
if (res.ok) {
await loadRun()
toast({ title: t('toast_salary_updated'), description: t('toast_salary_updated_hint') })
} else {
const result = await res.json()
toast({
title: t('toast_salary_update_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
}
setActionLoading(null)
}
async function handleCalculate() {
setActionLoading('calculate')
const res = await fetch(`/api/salary/runs/${id}/calculate`, { method: 'POST' })
if (res.ok) {
const payload = await res.json()
await loadRun()
const warnings = (payload.warnings as string[] | undefined) ?? []
if (warnings.length === 0) {
toast({ title: t('toast_calculation_done') })
} else {
for (const warning of warnings) {
toast({ title: t('toast_calculation_warning'), description: warning })
}
}
} else {
const result = await res.json()
toast({
title: t('toast_calculation_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
}
setActionLoading(null)
}
async function handlePreview() {
setActionLoading('preview')
const res = await fetch(`/api/salary/runs/${id}/preview`)
if (res.ok) {
const { data } = await res.json()
setPreview(data)
}
setActionLoading(null)
}
async function handleSendPayslips() {
setActionLoading('payslips-send')
const res = await fetch(`/api/salary/runs/${id}/payslips/send`, { method: 'POST' })
if (res.ok) {
const { data } = await res.json()
await loadRun()
toast({
title: t('toast_payslips_sent'),
description: t('toast_payslips_sent_detail', {
sent: data.sent,
skipped: data.skipped,
}),
})
if (data.errors?.length) {
for (const err of data.errors as string[]) {
toast({ title: t('toast_payslip_error'), description: err, variant: 'destructive' })
}
}
} else {
const result = await res.json()
toast({
title: t('toast_payslips_send_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
}
setActionLoading(null)
}
async function handleBulkPayslipDownload() {
if (!run) return
setActionLoading('bulk_payslip')
try {
const { default: JSZip } = await import('jszip')
const zip = new JSZip()
const periodLabel = periodLabelOf(run)
let added = 0
for (const sre of employees) {
const employee = (sre as SalaryRunEmployee & {
employee?: { first_name: string; last_name: string }
}).employee
const res = await fetch(`/api/salary/runs/${id}/payslips/${sre.employee_id}/pdf`)
if (!res.ok) continue
const blob = await res.blob()
const name = employee
? `${employee.last_name}_${employee.first_name}`.replace(/[^A-Za-z0-9_-]/g, '_')
: sre.employee_id.slice(0, 8)
zip.file(`Lonespec_${periodLabel}_${name}.pdf`, blob)
added++
}
if (added === 0) {
toast({ title: t('toast_payslips_download_empty'), variant: 'destructive' })
return
}
const archive = await zip.generateAsync({ type: 'blob' })
const url = URL.createObjectURL(archive)
const a = document.createElement('a')
a.href = url
a.download = `Lonespec_${periodLabel}.zip`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
toast({ title: t('toast_payslips_downloaded'), description: t('toast_payslips_downloaded_detail', { count: added }) })
} catch (err) {
toast({
title: t('toast_zip_failed'),
description: err instanceof Error ? getErrorMessage(err) : t('unknown_error'),
variant: 'destructive',
})
} finally {
setActionLoading(null)
}
}
async function handleDownloadAgi() {
if (!run) return
setActionLoading('agi-download')
const res = await fetch(`/api/salary/runs/${id}/agi/xml`)
if (!res.ok) {
const result = await res.json().catch(() => ({ error: t('toast_agi_failed') }))
toast({
title: t('toast_agi_failed'),
description: getErrorMessage(result, { context: 'salary', statusCode: res.status }),
variant: 'destructive',
})
setActionLoading(null)
return
}
const blob = await res.blob()
const url = URL.createObjectURL(blob)
const compactPeriod = `${run.period_year}${String(run.period_month).padStart(2, '0')}`
const a = document.createElement('a')
a.href = url
a.download = `AGI_${compactPeriod}.xml`
document.body.appendChild(a)
a.click()
document.body.removeChild(a)
URL.revokeObjectURL(url)
await loadRun()
toast({ title: t('toast_agi_downloaded') })
setActionLoading(null)
}
if (loading) {
return (
<div className="space-y-6">
<Skeleton className="h-9 w-60" />
<div className="lg:grid lg:grid-cols-[minmax(0,1fr)_280px] gap-8 space-y-6 lg:space-y-0">
<Skeleton className="rounded-lg h-48" />
<Skeleton className="rounded-lg h-64 hidden lg:block" />
</div>
</div>
)
}
if (!run) {
return <p className="text-muted-foreground">{t('not_found')}</p>
}
const periodLabel = periodLabelOf(run)
const employees = (run.employees || []) as SalaryRunEmployee[]
// calculation_params is frozen only when the run has been calculated, so it
// distinguishes "not yet calculated" from "calculated to 0" (a nollkörning).
const isCalculated = run.calculation_params != null
const isNollkorning = isCalculated && Math.round((run.total_gross ?? 0) * 100) === 0
// A run that pays out nothing (a nollkörning, or one fully consumed by a
// nettolöneavdrag) has no payment-file line and no payout to perform. The
// pay step collapses to a plain "continue" and the payment-file panel is
// hidden - mirrors the pain.001 / BG-LB generators, which emit no rows here.
const noPayout = isCalculated && Math.round((run.total_net ?? 0) * 100) === 0
// Real AGI filing state: run-row timestamps + the extension's submission
// record. Falls back gracefully when the extension is unavailable.
const agiState = deriveAgiFilingState(run, agiSubmission)
// Advancing a draft to review. For a nollkörning confirm first: an empty
// declaration is filed to Skatteverket, which should be deliberate.
async function handleToReview() {
if (isNollkorning) {
const ok = await confirmAction({
title: t('nollkorning_title'),
description: t('confirm_nollkorning'),
confirmLabel: t('action_to_review'),
variant: 'warning',
})
if (!ok) return
}
handleAction('review')
}
// The one next step for the current status, mirrored as a prominent header
// button - the rail alone buried it (nobody found Godkänn).
const primaryAction = !canWrite
? null
: run.status === 'draft'
? isCalculated
? { key: 'review', label: t('action_to_review'), onClick: handleToReview }
: { key: 'calculate', label: t('action_calculate'), onClick: handleCalculate }
: run.status === 'review'
? { key: 'approve', label: t('action_approve'), onClick: () => doApprove(false) }
: run.status === 'approved'
? { key: 'paid', label: noPayout ? t('action_continue') : t('action_mark_paid'), onClick: () => handleAction('paid') }
: run.status === 'paid'
? { key: 'book', label: t('action_book'), onClick: () => handleAction('book') }
: null
return (
<div className="space-y-6">
<RunHeader
run={run}
canWrite={canWrite}
actionLoading={actionLoading}
employeeCount={employees.length}
onDelete={handleDelete}
onCorrect={handleCorrect}
/>
{/* Control zone: the wizard line and every action for the current stage,
grouped in one place with a large primary target. */}
<RunProgressBar
run={run}
isCalculated={isCalculated}
noPayout={noPayout}
agiState={agiState}
agiKvittensnummer={agiSubmission?.kvittensnummer ?? null}
canWrite={canWrite}
actionLoading={actionLoading}
primaryAction={primaryAction}
onPreview={handlePreview}
onRevert={() => handleAction('revert')}
onUnapprove={handleUnapprove}
onSendPayslips={handleSendPayslips}
onDownloadPayslips={handleBulkPayslipDownload}
/>
<RunKpiCards run={run} employees={employees} />
{isNollkorning && (
<Card>
<CardContent className="p-4">
<p className="text-sm font-medium">{t('nollkorning_title')}</p>
<p className="text-sm text-muted-foreground mt-1">
{t('nollkorning_body', { period: periodLabel })}
</p>
</CardContent>
</Card>
)}
<RunEmployeesTable
run={run}
runId={id}
employees={employees}
availableEmployees={availableEmployees}
canWrite={canWrite}
actionLoading={actionLoading}
dimensionsEnabled={dimensionsEnabled}
isCalculated={isCalculated}
onAddEmployee={handleAddEmployee}
onRemoveEmployee={handleRemoveEmployee}
onSalaryEdit={handleSalaryEdit}
/>
{/* Calculation detail and the journal preview read best side by side on
the wide canvas; they stack on smaller viewports. */}
<div className="lg:grid lg:grid-cols-2 lg:gap-6 space-y-6 lg:space-y-0 items-start">
<RunCalculationDetails periodYear={run.period_year} employees={employees} />
{preview && (
<RunJournalPreview
preview={preview}
onRecalculate={run.status === 'draft' && canWrite ? handleCalculate : undefined}
recalculating={actionLoading === 'calculate'}
/>
)}
</div>
{/* Payment file: available once the run is approved, but only when there
is something to pay out. A zero-payout run generates no file rows. */}
{['approved', 'paid', 'booked'].includes(run.status) && !noPayout && (
<PaymentFilePanel
salaryRunId={id}
periodLabel={periodLabel}
paymentFileFormat={run.payment_file_format}
paymentFileGeneratedAt={run.payment_file_generated_at}
defaultFormat={preferredPaymentFormat}
defaultBank={defaultBank}
readOnly={!canWrite}
onDownloaded={loadRun}
/>
)}
{/* Tax payment (skatt + arbetsgivaravgifter): once AGI has been generated */}
{run.status === 'booked' && run.agi_generated_at && (
taxPaymentLoading ? (
<Card>
<CardContent className="space-y-4 p-6">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-20 w-full" />
</CardContent>
</Card>
) : (
<TaxPaymentPanel
period={periodLabel}
totalTax={run.total_tax}
totalAvgifter={run.total_avgifter}
paymentFileGeneratedAt={taxPayment?.tax_payment_file_generated_at ?? null}
taxPaidAt={taxPayment?.tax_paid_at ?? null}
readOnly={!canWrite}
onChange={loadRun}
/>
)
)}
{/* AGI (Arbetsgivardeklaration): available once the run is booked */}
{run.status === 'booked' && (
<div className="space-y-3">
<AGIPanel
salaryRunId={id}
arbetsgivare={run.arbetsgivare ?? ''}
period={`${run.period_year}${String(run.period_month).padStart(2, '0')}`}
agiGeneratedAt={run.agi_generated_at}
agiSubmittedAt={run.agi_submitted_at}
submission={agiSubmission}
onRefreshSubmission={refreshAgiSubmission}
readOnly={!canWrite}
onChange={loadRun}
/>
{canWrite && (
<div className="flex justify-end">
<Button
variant="outline"
size="sm"
onClick={handleDownloadAgi}
disabled={!!actionLoading}
>
{actionLoading === 'agi-download' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
{t('action_download_agi')}
</Button>
</div>
)}
</div>
)}
{/* Overridable approval guard: missing bank details don't dead-end -
the user can approve now and complete details before the payment file. */}
<Dialog open={approveOverride !== null} onOpenChange={(open) => { if (!open) setApproveOverride(null) }}>
<DialogContent>
<DialogHeader>
<DialogTitle>{t('approve_override_title')}</DialogTitle>
<DialogDescription className="pt-2 text-left">
{t('approve_override_body')}
</DialogDescription>
</DialogHeader>
{approveOverride && approveOverride.length > 0 && (
<div className="flex items-start gap-2 rounded-md border border-border p-3 text-xs">
<AlertTriangle className="mt-0.5 h-3.5 w-3.5 shrink-0 text-warning" />
<ul className="space-y-1">
{approveOverride.map((reason, i) => (
<li key={i}>{reason}</li>
))}
</ul>
</div>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setApproveOverride(null)} disabled={actionLoading === 'approve'}>
{t('approve_override_cancel')}
</Button>
<Button onClick={() => doApprove(true)} disabled={actionLoading === 'approve'}>
{actionLoading === 'approve' && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('approve_override_confirm')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<DestructiveConfirmDialog {...dialogProps} />
</div>
)
}