refactor(ui): record detail pages as documents, not card piles (#1739)

Bring every record detail page onto the register-detail document grammar
from #1624 (DetailSection/DefRow, one status element per the list pages'
chips-mark-exceptions rule, one primary next step plus Förhandsgranska
visible and everything else behind a ⋯ overflow menu, line tables on the
dry-table idiom with the headline total in the serif):

- invoices/[id] (11 cards, 13-button toolbar): Kund | Detaljer rows,
  Fakturarader table + totals, Anteckningar, Betalning, Påminnelser,
  Utskickshistorik (InvoiceDeliveryHistory flattened); title carries the
  doc type, related documents become link rows
- supplier-invoices/[id], bookkeeping/[id] (serif title instead of
  font-mono, JournalEntryAttachments variant="section", CorrectionChain
  flattened), invoices/[id]/credit, assets/[id]/dispose (form as Fönster
  rows), salary employees/[id] (edit form behind Redigera in a dialog,
  Ingående saldon collapsed), salary runs/[id] + run panels (Betalfil,
  Skattebetalning, AGI, förmåner, override) and the payslip page
- DetailSection gains an optional help slot (convention 7)

Styling/structure only: no API, fetch, validation, state, dialog or
permission change; every action stays reachable.

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-20 12:29:43 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent db14ac18cb
commit a92c492dbe
28 changed files with 4348 additions and 4228 deletions
+228 -123
View File
@@ -1,6 +1,6 @@
'use client'
import { use, useCallback, useEffect, useMemo, useState, type ReactNode } from 'react'
import { use, useCallback, useEffect, useMemo, useState } from 'react'
import { useTranslations } from 'next-intl'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
@@ -8,20 +8,18 @@ import { ArrowLeft, Loader2, Lock } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { DetailSection, DefRow } from '@/components/ui/detail-section'
import { Label } from '@/components/ui/label'
import { PageHeader } from '@/components/ui/page-header'
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { Skeleton } from '@/components/ui/skeleton'
import { Switch } from '@/components/ui/switch'
import { useToast } from '@/components/ui/use-toast'
import {
SettingsInput,
SettingsRow,
SettingsRowNote,
SettingsSelect,
} from '@/components/settings/SettingsRows'
import { assessJamkning, assessJamkningEligibility } from '@/lib/bokslut/assets/jamkning'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { useCanWrite } from '@/lib/hooks/use-can-write'
@@ -39,6 +37,10 @@ interface PeriodOption {
const VAT_TREATMENTS = ['standard_25', 'reverse_charge', 'export', 'exempt'] as const
// Flat Fönster inputs are sized by the row; amounts, dates and account
// numbers get a fixed short width so the row does not stretch them.
const FIELD_CLASS = 'max-w-44 flex-none tabular-nums'
function round2(value: number): number {
return Math.round(value * 100) / 100
}
@@ -246,7 +248,10 @@ export default function DisposeAssetPage({ params }: { params: Promise<{ id: str
return (
<div className="space-y-8">
<PageHeader title={t('title')} />
<Card><CardContent className="space-y-3 p-6"><Skeleton className="h-6 w-1/3" /><Skeleton className="h-4 w-2/3" /></CardContent></Card>
<div className="space-y-3">
<Skeleton className="h-6 w-1/3" />
<Skeleton className="h-4 w-2/3" />
</div>
</div>
)
}
@@ -255,12 +260,10 @@ export default function DisposeAssetPage({ params }: { params: Promise<{ id: str
return (
<div className="space-y-8">
<PageHeader title={t('title')} />
<Card>
<CardContent className="space-y-4 p-6">
<p>{!asset ? t('not_found') : t('already_disposed', { date: formatDate(asset.disposed_at!) })}</p>
<Link href="/assets"><Button variant="secondary"><ArrowLeft className="mr-1 h-4 w-4" />{t('back')}</Button></Link>
</CardContent>
</Card>
<p className="text-sm text-muted-foreground">
{!asset ? t('not_found') : t('already_disposed', { date: formatDate(asset.disposed_at!) })}
</p>
<Link href="/assets"><Button variant="secondary"><ArrowLeft className="mr-1 h-4 w-4" />{t('back')}</Button></Link>
</div>
)
}
@@ -269,112 +272,222 @@ export default function DisposeAssetPage({ params }: { params: Promise<{ id: str
const missingJamkningData = possibleInvestmentGood &&
(originalInputVat === '' || originalDeductionPercent === '')
// Which rows the adjustment section shows, so the last one can drop its
// hairline and the section never ends on a dangling rule.
const showJamkningInputs = Boolean(eligibility?.withinAdjustmentPeriod)
const showAssessment = jamkningAssessment !== null
const showTransferConfirm = disposalType === 'business_transfer'
const showDocumentConfirm = showTransferConfirm && transferNeedsDocument
const assessmentIsLast = showAssessment && !showTransferConfirm
const inputsAreLast = showJamkningInputs && !showAssessment && !showTransferConfirm
return (
<div className="space-y-8">
<PageHeader
title={t('title')}
action={<Link href="/assets"><Button variant="secondary"><ArrowLeft className="mr-1 h-4 w-4" />{t('back')}</Button></Link>}
/>
<div className="space-y-8 stagger-enter">
{/* Back link on its own quiet row, same as the register documents */}
<Link
href="/assets"
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')}
</Link>
<Card>
{/* data-ph-mask: the asset name is user data */}
<CardHeader><CardTitle data-ph-mask="" className="text-base">{asset.name}</CardTitle></CardHeader>
<CardContent className="space-y-2 p-6 pt-0 text-sm">
<SummaryRow label={t('acquisition_cost')} value={formatCurrency(Number(asset.acquisition_cost))} />
<SummaryRow label={t('acquired')} value={formatDate(asset.acquisition_date)} />
<SummaryRow label={t('bas_accounts')} value={`${asset.bas_asset_account} / ${asset.bas_accumulated_account} / ${asset.bas_expense_account}`} />
</CardContent>
</Card>
{/* data-ph-mask: the asset name is user data */}
<PageHeader title={t('title')} description={<span data-ph-mask="">{asset.name}</span>} />
<Card>
<CardHeader><CardTitle className="text-base">{t('details_title')}</CardTitle></CardHeader>
<CardContent className="grid gap-4 p-6 pt-0 md:grid-cols-2">
<Field label={t('type_label')} htmlFor="disposalType">
<Select value={disposalType} onValueChange={(value) => setDisposalType(value as AssetDisposalType)}>
<SelectTrigger id="disposalType"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="sale">{t('type_sale')}</SelectItem>
<SelectItem value="scrap">{t('type_scrap')}</SelectItem>
<SelectItem value="business_transfer">{t('type_business_transfer')}</SelectItem>
</SelectContent>
</Select>
</Field>
<Field label={t('date_label')} htmlFor="disposalDate">
<Input id="disposalDate" type="date" value={disposalDate} onChange={(event) => setDisposalDate(event.target.value)} className="tabular-nums" />
</Field>
<Field label={t('period_label')} htmlFor="period">
<Select value={periodId} onValueChange={setPeriodId}>
<SelectTrigger id="period"><SelectValue placeholder={t('period_placeholder')} /></SelectTrigger>
<SelectContent>{periods.map((period) => <SelectItem key={period.id} value={period.id}>{period.name}{period.is_closed || period.locked_at ? ` (${t('locked')})` : ''}</SelectItem>)}</SelectContent>
</Select>
{periodLocked && <p className="text-xs text-destructive">{t('period_locked')}</p>}
</Field>
<Field label={disposalType === 'business_transfer' ? t('consideration_label') : t('proceeds_label')} htmlFor="proceeds">
<Input id="proceeds" inputMode="decimal" value={proceeds} onChange={(event) => setProceeds(event.target.value)} disabled={disposalType === 'scrap'} className="tabular-nums" />
</Field>
{disposalType !== 'scrap' && <Field label={t('proceeds_account_label')} htmlFor="proceedsAccount"><Input id="proceedsAccount" value={proceedsAccount} onChange={(event) => setProceedsAccount(event.target.value)} className="tabular-nums" /></Field>}
</CardContent>
</Card>
{/* The asset being disposed: read-only context as plain rows */}
<DetailSection kicker={t('asset_section')}>
<DefRow label={t('acquisition_cost')}>
<span className="tabular-nums">{formatCurrency(Number(asset.acquisition_cost))}</span>
</DefRow>
<DefRow label={t('acquired')}>
<span className="tabular-nums">{formatDate(asset.acquisition_date)}</span>
</DefRow>
<DefRow label={t('bas_accounts')}>
<span className="tabular-nums">
{`${asset.bas_asset_account} / ${asset.bas_accumulated_account} / ${asset.bas_expense_account}`}
</span>
</DefRow>
</DetailSection>
{/* The disposal itself: flat hairline rows, label left, control right */}
<DetailSection kicker={t('details_title')}>
<SettingsRow label={t('type_label')} htmlFor="disposalType">
<SettingsSelect
id="disposalType"
value={disposalType}
onChange={(event) => setDisposalType(event.target.value as AssetDisposalType)}
>
<option value="sale">{t('type_sale')}</option>
<option value="scrap">{t('type_scrap')}</option>
<option value="business_transfer">{t('type_business_transfer')}</option>
</SettingsSelect>
</SettingsRow>
<SettingsRow label={t('date_label')} htmlFor="disposalDate" align="baseline">
<SettingsInput
id="disposalDate"
type="date"
value={disposalDate}
onChange={(event) => setDisposalDate(event.target.value)}
className={FIELD_CLASS}
/>
</SettingsRow>
<SettingsRow label={t('period_label')} htmlFor="period">
<SettingsSelect id="period" value={periodId} onChange={(event) => setPeriodId(event.target.value)}>
<option value="" disabled>{t('period_placeholder')}</option>
{periods.map((period) => (
<option key={period.id} value={period.id}>
{period.name}{period.is_closed || period.locked_at ? ` (${t('locked')})` : ''}
</option>
))}
</SettingsSelect>
{periodLocked && <p className="basis-full text-xs text-destructive">{t('period_locked')}</p>}
</SettingsRow>
<SettingsRow
label={disposalType === 'business_transfer' ? t('consideration_label') : t('proceeds_label')}
htmlFor="proceeds"
align="baseline"
borderless={disposalType === 'scrap'}
>
<SettingsInput
id="proceeds"
inputMode="decimal"
value={proceeds}
onChange={(event) => setProceeds(event.target.value)}
disabled={disposalType === 'scrap'}
className={FIELD_CLASS}
/>
</SettingsRow>
{disposalType !== 'scrap' && (
<SettingsRow label={t('proceeds_account_label')} htmlFor="proceedsAccount" align="baseline" borderless>
<SettingsInput
id="proceedsAccount"
value={proceedsAccount}
onChange={(event) => setProceedsAccount(event.target.value)}
className={FIELD_CLASS}
/>
</SettingsRow>
)}
</DetailSection>
{disposalType === 'sale' && (
<Card>
<CardHeader><CardTitle className="text-base">{t('vat_title')}</CardTitle></CardHeader>
<CardContent className="space-y-4 p-6 pt-0">
<Field label={t('vat_treatment_label')} htmlFor="vatTreatment">
<Select value={vatTreatment} onValueChange={(value) => setVatTreatment(value as VatTreatment)}>
<SelectTrigger id="vatTreatment"><SelectValue /></SelectTrigger>
<SelectContent>{VAT_TREATMENTS.map((value) => <SelectItem key={value} value={value}>{t(`vat_${value}`)}</SelectItem>)}</SelectContent>
</Select>
</Field>
<div className="rounded-lg bg-secondary/40 p-3 text-xs">
<SummaryRow label={t('gross')} value={formatCurrency(proceedsNumber)} />
<SummaryRow label={t('vat')} value={formatCurrency(vatAmount)} />
<SummaryRow label={t('net')} value={formatCurrency(netProceeds)} strong />
</div>
</CardContent>
</Card>
<DetailSection kicker={t('vat_title')}>
<SettingsRow label={t('vat_treatment_label')} htmlFor="vatTreatment">
<SettingsSelect
id="vatTreatment"
value={vatTreatment}
onChange={(event) => setVatTreatment(event.target.value as VatTreatment)}
>
{VAT_TREATMENTS.map((value) => (
<option key={value} value={value}>{t(`vat_${value}`)}</option>
))}
</SettingsSelect>
</SettingsRow>
<SettingsRow label={t('gross')}>
<span className="tabular-nums">{formatCurrency(proceedsNumber)}</span>
</SettingsRow>
<SettingsRow label={t('vat')}>
<span className="tabular-nums">{formatCurrency(vatAmount)}</span>
</SettingsRow>
<SettingsRow label={t('net')} borderless>
<span className="font-medium tabular-nums">{formatCurrency(netProceeds)}</span>
</SettingsRow>
</DetailSection>
)}
<Card>
<CardHeader><CardTitle className="text-base">{t('adjustment_title')}</CardTitle></CardHeader>
<CardContent className="space-y-4 p-6 pt-0">
{eligibility?.withinAdjustmentPeriod
? <Badge variant="warning">{t('within_adjustment_period', { years: eligibility.remainingYears, total: eligibility.totalYears })}</Badge>
: <Badge variant="secondary">{t('outside_adjustment_period')}</Badge>}
{eligibility?.withinAdjustmentPeriod && (
<div className="grid gap-4 md:grid-cols-2">
<Field label={t('original_vat_label')} htmlFor="originalInputVat" hint={t('original_vat_hint', { threshold: eligibility.threshold })}>
<Input id="originalInputVat" inputMode="decimal" value={originalInputVat} onChange={(event) => setOriginalInputVat(event.target.value)} className="tabular-nums" />
</Field>
<Field label={t('original_percent_label')} htmlFor="originalDeductionPercent">
<Input id="originalDeductionPercent" type="number" min={0} max={100} value={originalDeductionPercent} onChange={(event) => setOriginalDeductionPercent(event.target.value)} className="tabular-nums" />
</Field>
</div>
)}
{jamkningAssessment && (
<div className="rounded-lg border border-border bg-secondary/40 p-3 text-sm">
<SummaryRow label={t('adjustment_direction')} value={t(`direction_${jamkningAssessment.direction}`)} />
<SummaryRow label={t('adjustment_amount')} value={formatCurrency(jamkningAssessment.amount)} strong />
{jamkningAssessment.capped && <p className="mt-2 text-xs text-muted-foreground">{t('adjustment_capped')}</p>}
</div>
)}
{disposalType === 'business_transfer' && (
<div className="flex items-center gap-3 rounded-lg border border-border p-3">
<Switch id="businessTransfer" checked={businessTransferConfirmed} onCheckedChange={setBusinessTransferConfirmed} />
<Label htmlFor="businessTransfer" className="cursor-pointer">{t('business_transfer_confirm')}</Label>
</div>
)}
{disposalType === 'business_transfer' && transferNeedsDocument && (
<div className="flex items-center gap-3 rounded-lg border border-border p-3">
<Switch id="adjustmentDocument" checked={adjustmentDocumentConfirmed} onCheckedChange={setAdjustmentDocumentConfirmed} />
<Label htmlFor="adjustmentDocument" className="cursor-pointer">{t('adjustment_document_confirm')}</Label>
</div>
)}
{missingJamkningData && <p className="text-xs text-destructive">{t('adjustment_data_required')}</p>}
</CardContent>
</Card>
{/* Input VAT adjustment: the period status sits on the kicker line
(a chip only when the asset is still inside the period), the
underlag as rows, the assessment as read-only rows below. */}
<DetailSection
kicker={t('adjustment_title')}
aside={
eligibility?.withinAdjustmentPeriod ? (
<Badge variant="warning">
{t('within_adjustment_period', { years: eligibility.remainingYears, total: eligibility.totalYears })}
</Badge>
) : (
<span className="text-[11px] text-muted-foreground">{t('outside_adjustment_period')}</span>
)
}
>
{showJamkningInputs && eligibility && (
<>
<SettingsRow
label={t('original_vat_label')}
htmlFor="originalInputVat"
help={t('original_vat_hint', { threshold: eligibility.threshold })}
align="baseline"
>
<SettingsInput
id="originalInputVat"
inputMode="decimal"
value={originalInputVat}
onChange={(event) => setOriginalInputVat(event.target.value)}
className={FIELD_CLASS}
/>
</SettingsRow>
<SettingsRow
label={t('original_percent_label')}
htmlFor="originalDeductionPercent"
align="baseline"
borderless={inputsAreLast}
>
<SettingsInput
id="originalDeductionPercent"
type="number"
min={0}
max={100}
value={originalDeductionPercent}
onChange={(event) => setOriginalDeductionPercent(event.target.value)}
className={FIELD_CLASS}
/>
</SettingsRow>
</>
)}
{jamkningAssessment && (
<>
<SettingsRow label={t('adjustment_direction')}>
{t(`direction_${jamkningAssessment.direction}`)}
</SettingsRow>
<SettingsRow label={t('adjustment_amount')} borderless={assessmentIsLast}>
<span className="font-medium tabular-nums">{formatCurrency(jamkningAssessment.amount)}</span>
{jamkningAssessment.capped && (
<SettingsRowNote className="basis-full">{t('adjustment_capped')}</SettingsRowNote>
)}
</SettingsRow>
</>
)}
{showTransferConfirm && (
<SettingsRow label={t('type_business_transfer')} borderless={!showDocumentConfirm}>
<Switch
id="businessTransfer"
checked={businessTransferConfirmed}
onCheckedChange={setBusinessTransferConfirmed}
/>
<Label htmlFor="businessTransfer" className="min-w-0 flex-1 cursor-pointer font-normal leading-5">
{t('business_transfer_confirm')}
</Label>
</SettingsRow>
)}
{showDocumentConfirm && (
<SettingsRow label={t('adjustment_document_label')} borderless>
<Switch
id="adjustmentDocument"
checked={adjustmentDocumentConfirmed}
onCheckedChange={setAdjustmentDocumentConfirmed}
/>
<Label htmlFor="adjustmentDocument" className="min-w-0 flex-1 cursor-pointer font-normal leading-5">
{t('adjustment_document_confirm')}
</Label>
</SettingsRow>
)}
{missingJamkningData && (
<p className="mt-3 text-xs text-destructive">{t('adjustment_data_required')}</p>
)}
</DetailSection>
<div className="flex justify-end gap-2">
{/* Actions: one footer row, so the flow ends on the button it arms */}
<div className="flex flex-wrap justify-end gap-2">
<Link href="/assets"><Button variant="secondary" disabled={submitting}>{t('cancel')}</Button></Link>
<Button
onClick={handleSubmit}
@@ -389,11 +502,3 @@ export default function DisposeAssetPage({ params }: { params: Promise<{ id: str
</div>
)
}
function Field({ label, htmlFor, hint, children }: { label: string; htmlFor: string; hint?: string; children: ReactNode }) {
return <div className="space-y-2"><Label htmlFor={htmlFor}>{label}</Label>{children}{hint && <p className="text-xs text-muted-foreground">{hint}</p>}</div>
}
function SummaryRow({ label, value, strong = false }: { label: string; value: string; strong?: boolean }) {
return <div className={`flex justify-between gap-4 ${strong ? 'font-medium' : ''}`}><span className="text-muted-foreground">{label}</span><span className="text-right tabular-nums">{value}</span></div>
}
File diff suppressed because it is too large Load Diff
+153 -164
View File
@@ -5,15 +5,17 @@ import { useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Textarea } from '@/components/ui/textarea'
import { Label } from '@/components/ui/label'
import { Separator } from '@/components/ui/separator'
import { DetailSection, DefRow } from '@/components/ui/detail-section'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { AttnLine } from '@/components/ui/attn-line'
import { HelpPopover } from '@/components/ui/help-popover'
import { useToast } from '@/components/ui/use-toast'
import { cn, formatCurrency, formatDate } from '@/lib/utils'
import { getVatTreatmentLabel } from '@/lib/invoices/vat-rules'
import { Loader2, ArrowLeft, AlertTriangle, Lock } from 'lucide-react'
import { Loader2, ArrowLeft, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import SendInvoiceDialog from '@/components/invoices/SendInvoiceDialog'
import { useCompany, useCapability } from '@/contexts/CompanyContext'
@@ -182,8 +184,10 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
}
}
const confirmMismatch = Boolean(confirmText) && confirmText !== invoice.invoice_number
return (
<div className="space-y-6 max-w-3xl mx-auto">
<div className="space-y-8 stagger-enter">
{createdCreditNote && (
<SendInvoiceDialog
open={showSendPrompt}
@@ -193,188 +197,173 @@ export default function CreateCreditNotePage({ params }: { params: Promise<{ id:
onSuccess={() => undefined}
/>
)}
{/* Header */}
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()} aria-label={t('back')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
{/* Back link on its own quiet row, same as the invoice document */}
<button
type="button"
onClick={() => router.back()}
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')}
</button>
{/* Header: serif title with the explanation behind "?", the credited
invoice as the kicker, and the one attention sentence under it. */}
<div>
<div className="flex items-center gap-2">
<h1 className="font-display text-2xl leading-8 tracking-tight">{t('title')}</h1>
<p className="text-muted-foreground">
{t('subtitle', { number: invoice.invoice_number ?? '' })}
</p>
<HelpPopover>{t('warning_description')}</HelpPopover>
</div>
{/* data-ph-mask: the kicker carries the invoice number */}
<p data-ph-mask="" className="mt-1 text-sm text-muted-foreground">
{t('subtitle', { number: invoice.invoice_number ?? '' })}
</p>
<AttnLine className="mt-3">{t('warning_title')}</AttnLine>
</div>
{/* Warning */}
<Card className="border-destructive/50 bg-destructive/5">
<CardContent className="flex items-start gap-4 pt-6">
<AlertTriangle className="h-5 w-5 text-destructive flex-shrink-0 mt-0.5" />
<div>
<p className="font-medium text-destructive">{t('warning_title')}</p>
<p className="text-sm text-muted-foreground mt-1">
{t('warning_description')}
</p>
</div>
</CardContent>
</Card>
{/* Original invoice: read-only context as plain rows */}
<DetailSection kicker={t('original_card_title')}>
<DefRow label={t('invoice_number_label')}>
<span className="tabular-nums">{invoice.invoice_number}</span>
</DefRow>
<DefRow label={t('date_label')}>
<span className="tabular-nums">{formatDate(invoice.invoice_date)}</span>
</DefRow>
<DefRow label={t('customer_label')}>{customer.name}</DefRow>
<DefRow label={t('vat_treatment_label')}>{getVatTreatmentLabel(invoice.vat_treatment)}</DefRow>
</DetailSection>
{/* Original invoice info */}
<Card>
<CardHeader>
<CardTitle>{t('original_card_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 gap-4 text-sm">
<div>
<span className="text-muted-foreground">{t('invoice_number_label')}</span>
<span className="ml-2 font-medium">{invoice.invoice_number}</span>
</div>
<div>
<span className="text-muted-foreground">{t('date_label')}</span>
<span className="ml-2">{formatDate(invoice.invoice_date)}</span>
</div>
<div>
<span className="text-muted-foreground">{t('customer_label')}</span>
<span className="ml-2">{customer.name}</span>
</div>
<div>
<span className="text-muted-foreground">{t('vat_treatment_label')}</span>
<span className="ml-2">{getVatTreatmentLabel(invoice.vat_treatment)}</span>
</div>
</div>
</CardContent>
</Card>
{/* Credit note preview */}
<Card>
<CardHeader>
<CardTitle>{t('preview_card_title')}</CardTitle>
{/* data-ph-mask: the invoice number is user data */}
<CardDescription data-ph-mask="">
{/* Credit note preview: the invoice lines negated, as the list-page
table idiom straight on the panel, totals as a right-aligned block. */}
<DetailSection
kicker={t('preview_card_title')}
aside={
// data-ph-mask: the credit note number derives from the invoice number
<span data-ph-mask="" className="text-[11px] tabular-nums text-muted-foreground">
{t('preview_card_description', { number: invoice.invoice_number ?? '' })}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Header */}
<div className="grid grid-cols-12 gap-4 text-sm font-medium text-muted-foreground border-b pb-2">
<div className="col-span-5">{t('th_description')}</div>
<div className="col-span-2 text-right">{t('th_quantity')}</div>
<div className="col-span-1 text-center">{t('th_unit')}</div>
<div className="col-span-2 text-right">{t('th_unit_price')}</div>
<div className="col-span-2 text-right">{t('th_amount')}</div>
</div>
{/* Items (negated) */}
</span>
}
>
<table className="hidden w-full border-collapse text-[13px] sm:table">
<thead>
<tr>
<th className={cn(TH_CLASS, 'pl-0')}>{t('th_description')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_quantity')}</th>
<th className={TH_CLASS}>{t('th_unit')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_unit_price')}</th>
<th className={cn(TH_CLASS, 'pr-0 text-right')}>{t('th_amount')}</th>
</tr>
</thead>
<tbody>
{invoice.items.map((item) => (
<div key={item.id} className="grid grid-cols-12 gap-4 text-sm">
<div className="col-span-5">{item.description}</div>
<div className="col-span-2 text-right text-destructive">
<tr key={item.id}>
<td className={cn(TD_CLASS, 'pl-0')}>{item.description}</td>
<td className={cn(TD_CLASS, 'text-right tabular-nums text-destructive')}>
-{Math.abs(item.quantity)}
</div>
<div className="col-span-1 text-center">{item.unit}</div>
<div className="col-span-2 text-right">
</td>
<td className={cn(TD_CLASS, 'text-muted-foreground')}>{item.unit}</td>
<td className={cn(TD_CLASS, 'text-right tabular-nums')}>
{formatCurrency(item.unit_price, invoice.currency)}
</div>
<div className="col-span-2 text-right font-medium text-destructive">
</td>
<td className={cn(TD_CLASS, 'pr-0 text-right tabular-nums text-destructive')}>
{formatCurrency(-Math.abs(item.line_total), invoice.currency)}
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
<Separator />
{/* Totals (negated) */}
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">{t('subtotal')}</span>
<span className="text-destructive">
{formatCurrency(-Math.abs(invoice.subtotal), invoice.currency)}
</span>
{/* Mobile: one flat row per line, no numeric columns to cram. */}
<div className="divide-y divide-border text-sm sm:hidden">
{invoice.items.map((item) => (
<div key={item.id} className="flex items-start justify-between gap-4 py-3">
<div className="min-w-0">
<p>{item.description}</p>
<p className="text-xs text-muted-foreground tabular-nums">
-{Math.abs(item.quantity)} {item.unit} × {formatCurrency(item.unit_price, invoice.currency)}
</p>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('vat_at_rate', { rate: invoice.vat_rate })}</span>
<span className="text-destructive">
{formatCurrency(-Math.abs(invoice.vat_amount), invoice.currency)}
</span>
</div>
<Separator />
<div className="flex justify-between font-bold text-lg">
<span>{t('total')}</span>
<span className="text-destructive">
{formatCurrency(-Math.abs(invoice.total), invoice.currency)}
</span>
</div>
{invoice.currency !== 'SEK' && invoice.total_sek && (
<div className="flex justify-between text-sm text-muted-foreground">
<span>{t('in_sek', { rate: invoice.exchange_rate ?? 1 })}</span>
<span className="text-destructive">
{formatCurrency(-Math.abs(invoice.total_sek))}
</span>
</div>
)}
<span className="shrink-0 tabular-nums text-destructive">
{formatCurrency(-Math.abs(item.line_total), invoice.currency)}
</span>
</div>
))}
</div>
<div className="ml-auto mt-4 w-full max-w-xs space-y-1 text-sm tabular-nums">
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">{t('subtotal')}</span>
<span className="text-destructive">
{formatCurrency(-Math.abs(invoice.subtotal), invoice.currency)}
</span>
</div>
</CardContent>
</Card>
{/* Reason */}
<Card>
<CardHeader>
<CardTitle>{t('reason_card_title')}</CardTitle>
<CardDescription>
{t('reason_card_description')}
</CardDescription>
</CardHeader>
<CardContent>
<div className="space-y-2">
<Label htmlFor="reason">{t('reason_label')}</Label>
<Textarea
id="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={t('reason_placeholder')}
rows={3}
/>
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">{t('vat_at_rate', { rate: invoice.vat_rate })}</span>
<span className="text-destructive">
{formatCurrency(-Math.abs(invoice.vat_amount), invoice.currency)}
</span>
</div>
</CardContent>
</Card>
<div className="flex items-baseline justify-between gap-4 border-t border-border pt-2">
<span>{t('total')}</span>
<span className="font-display text-xl text-destructive">
{formatCurrency(-Math.abs(invoice.total), invoice.currency)}
</span>
</div>
{invoice.currency !== 'SEK' && invoice.total_sek && (
<div className="flex justify-between gap-4 text-muted-foreground">
<span>{t('in_sek', { rate: invoice.exchange_rate ?? 1 })}</span>
<span className="text-destructive">{formatCurrency(-Math.abs(invoice.total_sek))}</span>
</div>
)}
</div>
</DetailSection>
{/* Confirmation */}
<Card>
<CardHeader>
<CardTitle>{t('confirm_card_title')}</CardTitle>
<CardDescription>
{t('confirm_card_description_1')}
{/* data-ph-mask: the invoice number is user data */}
<span data-ph-mask="" className="font-mono font-semibold text-foreground">{invoice.invoice_number}</span>
{t('confirm_card_description_2')}
</CardDescription>
</CardHeader>
<CardContent>
<Input
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={invoice.invoice_number ?? ''}
disabled={!invoice.invoice_number}
className={cn(
// ph-no-capture: the placeholder carries the invoice number, and
// replay masking covers input values, not attributes.
'ph-no-capture',
confirmText && confirmText !== invoice.invoice_number && 'border-destructive'
)}
/>
</CardContent>
</Card>
{/* Reason: shown on the credit note (the note lives behind the "?") */}
<DetailSection
kicker={t('reason_card_title')}
help={<HelpPopover>{t('reason_card_description')}</HelpPopover>}
>
<Label htmlFor="reason" className="sr-only">{t('reason_label')}</Label>
<Textarea
id="reason"
value={reason}
onChange={(e) => setReason(e.target.value)}
placeholder={t('reason_placeholder')}
rows={3}
/>
</DetailSection>
{/* Actions */}
<div className="flex justify-end gap-4">
{/* Confirmation: type the invoice number to arm the action */}
<DetailSection kicker={t('confirm_card_title')}>
<Label htmlFor="confirm-invoice-number" className="block text-sm font-normal leading-5 text-muted-foreground">
{t('confirm_card_description_1')}
{/* data-ph-mask: the invoice number is user data */}
<span data-ph-mask="" className="font-mono font-semibold text-foreground">{invoice.invoice_number}</span>
{t('confirm_card_description_2')}
</Label>
<Input
id="confirm-invoice-number"
value={confirmText}
onChange={(e) => setConfirmText(e.target.value)}
placeholder={invoice.invoice_number ?? ''}
disabled={!invoice.invoice_number}
className={cn(
// ph-no-capture: the placeholder carries the invoice number, and
// replay masking covers input values, not attributes.
'ph-no-capture mt-3 max-w-xs',
confirmMismatch && 'border-destructive'
)}
/>
</DetailSection>
{/* Actions: one footer row right after the confirm step, so the flow
reads top to bottom and ends on the button it arms. */}
<div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" onClick={() => router.back()}>
{t('cancel')}
</Button>
<Button
variant="destructive"
onClick={handleSubmit}
disabled={
isSubmitting ||
File diff suppressed because it is too large Load Diff
+485 -280
View File
@@ -4,13 +4,15 @@ import { useState, useEffect, use } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Skeleton } from "@/components/ui/skeleton"
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { ArrowLeft, Save, Trash2 } from 'lucide-react'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { DetailSection, DefRow, DefEmpty } from '@/components/ui/detail-section'
import { AttnLine } from '@/components/ui/attn-line'
import { ArrowLeft, Save } from 'lucide-react'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
@@ -18,6 +20,7 @@ import {
import { useToast } from '@/components/ui/use-toast'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency, formatDate } from '@/lib/utils'
import {
validateEmployeeBankAccount,
isValidClearing,
@@ -38,6 +41,25 @@ const EMPLOYMENT_LABEL_KEYS: Record<string, string> = {
board_member: 'form_employment_type_board_member',
}
const VACATION_RULE_KEYS: Record<string, string> = {
procentregeln: 'form_vacation_rule_procentregeln',
sammaloneregeln: 'form_vacation_rule_sammaloneregeln',
semesterersattning: 'form_vacation_rule_semesterersattning',
none: 'form_vacation_rule_none',
}
// Same literals as the Select in EmployeeTaxCard; not_verified falls back to
// its i18n label below.
const F_SKATT_LABELS: Record<string, string> = {
a_skatt: 'A-skatt',
f_skatt: 'F-skatt',
fa_skatt: 'FA-skatt',
}
// Section headers inside the edit dialog (same idiom as NewEmployeeDialog:
// borderless sections split by hairline dividers, no per-section cards).
const SECTION_HEADER = 'text-xs font-semibold uppercase tracking-wider text-muted-foreground'
function RequiredMark() {
return <span className="text-destructive ml-0.5">*</span>
}
@@ -53,6 +75,7 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
const [loading, setLoading] = useState(true)
const [saving, setSaving] = useState(false)
const [deactivating, setDeactivating] = useState(false)
const [isEditOpen, setIsEditOpen] = useState(false)
const [employmentType, setEmploymentType] = useState('employee')
const [salaryType, setSalaryType] = useState('monthly')
const [vacationRule, setVacationRule] = useState('procentregeln')
@@ -67,18 +90,26 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
const [dimensionsEnabled, setDimensionsEnabled] = useState(false)
const [dimensions, setDimensions] = useState<Record<string, string>>({})
// The controlled form fields mirror the saved row. Called on load and again
// every time the edit dialog opens, so a cancelled edit never leaks into the
// next one (the uncontrolled inputs reset by themselves: the dialog unmounts
// its children on close).
function syncFormState(data: EmployeeMasked) {
setEmploymentType(data.employment_type)
setSalaryType(data.salary_type || 'monthly')
setVacationRule(data.vacation_rule || 'procentregeln')
setClearing(data.clearing_number || '')
setAccount(data.bank_account_number || '')
setDimensions(data.default_dimensions ?? {})
}
useEffect(() => {
async function load() {
const res = await fetch(`/api/salary/employees/${id}`)
if (res.ok) {
const { data } = await res.json()
setEmployee(data)
setEmploymentType(data.employment_type)
setSalaryType(data.salary_type || 'monthly')
setVacationRule(data.vacation_rule || 'procentregeln')
setClearing(data.clearing_number || '')
setAccount(data.bank_account_number || '')
setDimensions(data.default_dimensions ?? {})
syncFormState(data)
}
setLoading(false)
}
@@ -102,6 +133,11 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
})
}
function openEdit() {
if (employee) syncFormState(employee)
setIsEditOpen(true)
}
async function handleSave(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault()
@@ -173,6 +209,8 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
if (res.ok) {
const { data } = await res.json()
setEmployee(data)
syncFormState(data)
setIsEditOpen(false)
toast({ title: t('detail_updated') })
} else {
const result = await res.json()
@@ -229,298 +267,465 @@ export default function EmployeeDetailPage({ params }: { params: Promise<{ id: s
validateEmployeeBankAccount(clearing, account).length === 0 &&
checkEmployeeAccountChecksum(clearing, account) === 'invalid'
// Saved (not in-progress) values for the read view.
const savedBankName = lookupBankByClearing(employee.clearing_number || '')
const bankMissing = !employee.clearing_number || !employee.bank_account_number
const savedDimensions = employee.default_dimensions ?? {}
const savedDimensionLabel = Object.keys(savedDimensions)
.sort((a, b) => Number(a) - Number(b))
.map((k) => savedDimensions[k])
.join(' · ')
const salaryValue =
employee.salary_type === 'hourly'
? employee.hourly_rate != null
? formatCurrency(employee.hourly_rate)
: null
: employee.monthly_salary != null
? formatCurrency(employee.monthly_salary)
: null
const fSkattVerifiedLabel = employee.f_skatt_verified_at
? t('detail_f_skatt_verified', { date: new Date(employee.f_skatt_verified_at).toLocaleDateString('sv-SE') })
: null
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" asChild>
<Link href="/salary/employees" aria-label={t('form_back_to_employees')}><ArrowLeft className="h-4 w-4" /></Link>
</Button>
<div>
<div className="max-w-2xl space-y-8 stagger-enter">
{/* Header: serif name over a quiet personnummer/type kicker, quiet actions right */}
<div>
<Link
href="/salary/employees"
className="text-sm text-muted-foreground hover:text-foreground flex items-center gap-1 mb-6"
>
<ArrowLeft className="h-4 w-4" />
{t('form_back_to_employees')}
</Link>
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<h1 className="font-display text-2xl leading-8 tracking-tight">
{employee.first_name} {employee.last_name}
</h1>
<p className="text-sm text-muted-foreground mt-1">
{employee.personnummer_masked} · {t(EMPLOYMENT_LABEL_KEYS[employee.employment_type])}
<p className="mt-1 text-sm text-muted-foreground">
<span className="tabular-nums">{employee.personnummer_masked}</span>
{' · '}
{t(EMPLOYMENT_LABEL_KEYS[employee.employment_type])}
</p>
</div>
{canWrite && (
<div className="flex shrink-0 items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={openEdit}
className="min-h-10 text-muted-foreground hover:text-foreground"
>
{t('detail_edit')}
</Button>
<Button
variant="ghost"
size="sm"
onClick={handleDeactivate}
disabled={deactivating}
className="min-h-10 text-muted-foreground hover:text-destructive"
>
{t('detail_deactivate')}
</Button>
</div>
)}
</div>
{canWrite && (
<Button variant="outline" size="sm" onClick={handleDeactivate} disabled={deactivating} className="text-destructive">
<Trash2 className="mr-2 h-4 w-4" />
{t('detail_deactivate')}
</Button>
)}
</div>
<form onSubmit={handleSave} className="space-y-4">
{/* Person & kontakt - name, contact, and address in one dense card */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t('form_personal_info')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="first_name">{t('form_first_name')}<RequiredMark /></Label>
<Input id="first_name" name="first_name" defaultValue={employee.first_name} required disabled={!canWrite} />
</div>
<div className="space-y-2">
<Label htmlFor="last_name">{t('form_last_name')}<RequiredMark /></Label>
<Input id="last_name" name="last_name" defaultValue={employee.last_name} required disabled={!canWrite} />
</div>
<div className="space-y-2">
<Label htmlFor="email">{t('form_email')}</Label>
<Input id="email" name="email" type="email" defaultValue={employee.email || ''} disabled={!canWrite} />
<p className="text-xs text-muted-foreground">{t('form_email_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="phone">{t('form_phone')}</Label>
<Input id="phone" name="phone" defaultValue={employee.phone || ''} disabled={!canWrite} />
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="address_line1">{t('form_street_address')}</Label>
<Input id="address_line1" name="address_line1" defaultValue={employee.address_line1 || ''} disabled={!canWrite} />
</div>
<div className="space-y-2">
<Label htmlFor="postal_code">{t('form_postal_code')}</Label>
<Input id="postal_code" name="postal_code" defaultValue={employee.postal_code || ''} className="max-w-[160px]" disabled={!canWrite} />
</div>
<div className="space-y-2">
<Label htmlFor="city">{t('form_city')}</Label>
<Input id="city" name="city" defaultValue={employee.city || ''} disabled={!canWrite} />
</div>
</div>
</CardContent>
</Card>
{/* Anställning & lön - employment terms, salary, and vacation together */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t('form_employment_salary')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="employment_type">{t('form_employment_type')}</Label>
<Select value={employmentType} onValueChange={setEmploymentType} disabled={!canWrite}>
<SelectTrigger id="employment_type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="employee">{t('form_employment_type_employee')}</SelectItem>
<SelectItem value="company_owner">{t('form_employment_type_company_owner')}</SelectItem>
<SelectItem value="board_member">{t('form_employment_type_board_member')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="employment_degree">{t('form_employment_degree')}</Label>
<Input id="employment_degree" name="employment_degree" type="number" defaultValue={employee.employment_degree} min="1" max="100" disabled={!canWrite} />
<p className="text-xs text-muted-foreground">{t('form_employment_degree_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="hours_per_week">{t('form_hours_per_week')}</Label>
<Input id="hours_per_week" name="hours_per_week" type="number" defaultValue={employee.hours_per_week ?? 40} min="1" max="80" step="0.5" disabled={!canWrite} />
<p className="text-xs text-muted-foreground">{t('form_work_schedule_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="workdays_per_week">{t('form_workdays_per_week')}</Label>
<Input id="workdays_per_week" name="workdays_per_week" type="number" defaultValue={employee.workdays_per_week ?? 5} min="1" max="7" step="1" disabled={!canWrite} />
</div>
<div className="space-y-2">
<Label htmlFor="salary_type">{t('form_salary_type')}<RequiredMark /></Label>
<Select value={salaryType} onValueChange={setSalaryType} disabled={!canWrite}>
<SelectTrigger id="salary_type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">{t('form_salary_type_monthly')}</SelectItem>
<SelectItem value="hourly">{t('form_salary_type_hourly')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="employment_start">{t('form_employment_start')}<RequiredMark /></Label>
<Input id="employment_start" name="employment_start" type="date" defaultValue={employee.employment_start || ''} required disabled={!canWrite} />
<p className="text-xs text-muted-foreground">{t('detail_employment_start_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="employment_end">{t('form_employment_end')}</Label>
<Input id="employment_end" name="employment_end" type="date" defaultValue={employee.employment_end || ''} disabled={!canWrite} />
<p className="text-xs text-muted-foreground">{t('detail_employment_end_hint')}</p>
</div>
{salaryType === 'monthly' ? (
<div className="space-y-2">
<Label htmlFor="monthly_salary">{t('form_monthly_salary')}<RequiredMark /></Label>
<Input id="monthly_salary" name="monthly_salary" type="number" step="1" min="1" defaultValue={employee.monthly_salary || ''} required disabled={!canWrite} />
</div>
) : (
<div className="space-y-2">
<Label htmlFor="hourly_rate">{t('form_hourly_rate')}<RequiredMark /></Label>
<Input id="hourly_rate" name="hourly_rate" type="number" step="0.01" min="0.01" defaultValue={employee.hourly_rate || ''} required disabled={!canWrite} />
</div>
<DetailSection kicker={t('form_personal_info')}>
<DefRow label={t('form_email')}>
{employee.email ? (
<a href={`mailto:${employee.email}`} className="hover:underline">
{employee.email}
</a>
) : (
<DefEmpty />
)}
</DefRow>
<DefRow label={t('form_phone')}>{employee.phone || <DefEmpty />}</DefRow>
<DefRow label={t('form_address')}>
{employee.address_line1 || employee.city ? (
<div>
{employee.address_line1 && <p>{employee.address_line1}</p>}
{(employee.postal_code || employee.city) && (
<p>{[employee.postal_code, employee.city].filter(Boolean).join(' ')}</p>
)}
<div className="space-y-2 md:col-span-2">
<Label htmlFor="vacation_rule">{t('form_vacation_rule')}</Label>
<Select value={vacationRule} onValueChange={setVacationRule} disabled={!canWrite}>
<SelectTrigger id="vacation_rule">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="procentregeln">{t('form_vacation_rule_procentregeln')}</SelectItem>
<SelectItem value="sammaloneregeln">{t('form_vacation_rule_sammaloneregeln')}</SelectItem>
<SelectItem value="semesterersattning">{t('form_vacation_rule_semesterersattning')}</SelectItem>
<SelectItem value="none">{t('form_vacation_rule_none')}</SelectItem>
</SelectContent>
</Select>
{vacationRule === 'none' && (
<p className="text-xs text-muted-foreground">
{t('detail_vacation_none_hint')}
</p>
)}
{vacationRule === 'semesterersattning' && (
<p className="text-xs text-muted-foreground">
{t('form_vacation_semesterersattning_hint')}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="vacation_days_per_year">{t('form_vacation_days')}</Label>
<Input
id="vacation_days_per_year"
name="vacation_days_per_year"
type="number"
min="25"
max="40"
defaultValue={employee.vacation_days_per_year}
disabled={!canWrite}
/>
<p className="text-xs text-muted-foreground">{t('form_vacation_days_hint')}</p>
</div>
</div>
</CardContent>
</Card>
) : (
<DefEmpty />
)}
</DefRow>
</DetailSection>
{/* Default dimensions (kostnadsställe/projekt) */}
<DetailSection kicker={t('form_employment_salary')}>
<DefRow label={t('form_employment_start')}>
{employee.employment_start ? (
<span className="tabular-nums">{formatDate(employee.employment_start)}</span>
) : (
<DefEmpty />
)}
</DefRow>
<DefRow label={t('form_employment_end')}>
{employee.employment_end ? (
<span className="tabular-nums">{formatDate(employee.employment_end)}</span>
) : (
<DefEmpty />
)}
</DefRow>
<DefRow label={t('form_employment_degree')}>
<span className="tabular-nums">{employee.employment_degree}</span>
</DefRow>
<DefRow label={t('form_hours_per_week')}>
<span className="tabular-nums">{employee.hours_per_week ?? 40}</span>
</DefRow>
<DefRow label={t('form_workdays_per_week')}>
<span className="tabular-nums">{employee.workdays_per_week ?? 5}</span>
</DefRow>
<DefRow label={t('form_salary_type')}>
{employee.salary_type === 'hourly' ? t('form_salary_type_hourly') : t('form_salary_type_monthly')}
</DefRow>
<DefRow label={employee.salary_type === 'hourly' ? t('form_hourly_rate') : t('form_monthly_salary')}>
{salaryValue ? <span className="tabular-nums">{salaryValue}</span> : <DefEmpty />}
</DefRow>
<DefRow label={t('form_vacation_rule')}>
{VACATION_RULE_KEYS[employee.vacation_rule]
? t(VACATION_RULE_KEYS[employee.vacation_rule])
: employee.vacation_rule}
</DefRow>
<DefRow label={t('form_vacation_days')}>
<span className="tabular-nums">{employee.vacation_days_per_year}</span>
</DefRow>
{dimensionsEnabled && (
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t('form_dimensions_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
<LineDimensionFields dimensions={dimensions} onChange={setDimension} disabled={!canWrite} />
<p className="text-xs text-muted-foreground">
{t('form_dimensions_hint')}
</p>
</CardContent>
</Card>
)}
{/* Tax. The masked personnummer is enough input here: EmployeeTaxCard
only reads the leading birthdate digits to suggest a tax column. */}
<EmployeeTaxCard
personnummer={employee.personnummer_masked || ''}
disabled={!canWrite}
onChange={setTax}
initial={{
f_skatt_status: employee.f_skatt_status || 'a_skatt',
is_sidoinkomst: employee.is_sidoinkomst || false,
tax_table_number: employee.tax_table_number ?? null,
tax_column: employee.tax_column ?? 1,
tax_municipality: employee.tax_municipality || '',
}}
/>
{employee.f_skatt_verified_at && (
<p className="-mt-2 text-xs text-muted-foreground">
{t('detail_f_skatt_verified', { date: new Date(employee.f_skatt_verified_at).toLocaleDateString('sv-SE') })}
</p>
)}
{/* Bank */}
<Card>
<CardHeader className="pb-3">
<CardTitle className="text-base">{t('form_bank_account')}</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="clearing_number">{t('form_clearing_number')}</Label>
<Input
id="clearing_number"
name="clearing_number"
inputMode="numeric"
value={clearing}
onChange={(e) => setClearing(e.target.value)}
onFocus={() => setBankFocused(true)}
onBlur={() => setBankFocused(false)}
disabled={!canWrite}
aria-invalid={clearing !== '' && !isValidClearing(normalizeBankNumber(clearing))}
/>
{clearing !== '' && !isValidClearing(normalizeBankNumber(clearing)) ? (
<p className="text-xs text-destructive">{t('bank_error_clearing_format')}</p>
) : bankName ? (
<p className="text-xs text-muted-foreground">{bankName}</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="bank_account_number">{t('form_account_number')}</Label>
<Input
id="bank_account_number"
name="bank_account_number"
inputMode="numeric"
value={account}
onChange={(e) => setAccount(e.target.value)}
onFocus={() => setBankFocused(true)}
onBlur={() => setBankFocused(false)}
disabled={!canWrite}
aria-invalid={account !== '' && !isValidAccount(normalizeBankNumber(account))}
/>
{account !== '' && !isValidAccount(normalizeBankNumber(account)) && (
<p className="text-xs text-destructive">{t('bank_error_account_format')}</p>
)}
</div>
</div>
{showChecksumWarning && (
<p className="mt-2 text-xs text-attn">{t('bank_warn_checksum')}</p>
<DefRow label={t('form_dimensions_title')}>
{savedDimensionLabel ? (
<span data-ph-mask="">{savedDimensionLabel}</span>
) : (
<DefEmpty />
)}
<p className="text-xs text-muted-foreground mt-2">{t('form_bank_hint')}</p>
</CardContent>
</Card>
{canWrite && (
<div className="flex justify-end gap-3">
<Button variant="outline" asChild>
<Link href="/salary/employees">{t('form_cancel')}</Link>
</Button>
<Button type="submit" disabled={saving}>
<Save className="mr-2 h-4 w-4" />
{saving ? t('form_saving') : t('detail_save_changes')}
</Button>
</div>
</DefRow>
)}
</form>
</DetailSection>
<DetailSection
kicker={t('tax_title')}
aside={
fSkattVerifiedLabel ? (
<span className="text-[11px] tabular-nums text-muted-foreground">{fSkattVerifiedLabel}</span>
) : undefined
}
>
<DefRow label={t('tax_form_label')}>
{F_SKATT_LABELS[employee.f_skatt_status] ?? t('tax_status_not_verified')}
{employee.is_sidoinkomst && (
<span className="text-muted-foreground">{' · '}{t('tax_sidoinkomst_label')}</span>
)}
</DefRow>
<DefRow label={t('tax_municipality_label')}>{employee.tax_municipality || <DefEmpty />}</DefRow>
<DefRow label={t('tax_table_label')}>
{employee.tax_table_number != null ? (
<span className="tabular-nums">{employee.tax_table_number}</span>
) : (
<DefEmpty />
)}
</DefRow>
<DefRow label={t('tax_column_label')}>
<span className="tabular-nums">{employee.tax_column}</span>
</DefRow>
</DetailSection>
<DetailSection kicker={t('form_bank_account')}>
<DefRow label={t('form_clearing_number')}>
{employee.clearing_number ? (
<span className="tabular-nums">
{employee.clearing_number}
{savedBankName && <span className="text-muted-foreground">{' · '}{savedBankName}</span>}
</span>
) : (
<DefEmpty />
)}
</DefRow>
<DefRow label={t('form_account_number')}>
{employee.bank_account_number ? (
<span className="tabular-nums">{employee.bank_account_number}</span>
) : (
<DefEmpty />
)}
</DefRow>
{/* The one attention sentence on the page: a run cannot be approved
without bank details (the hint that used to sit under the form). */}
{bankMissing && (
<AttnLine
className="mt-2"
action={canWrite ? { label: t('detail_edit'), onClick: openEdit } : undefined}
>
{t('detail_bank_missing')}
</AttnLine>
)}
</DetailSection>
{/* Self-saving sections. These live OUTSIDE the employee form on purpose:
benefits and opening balances write to their own endpoints, so the
page-level "Spara ändringar" never touches them, and their buttons can
no longer implicitly submit the employee form (default type="submit"). */}
<section className="space-y-4">
<div>
<h2 className="text-sm font-medium uppercase tracking-wider text-muted-foreground">
{t('detail_self_saving_heading')}
</h2>
<p className="text-sm text-muted-foreground mt-1">{t('detail_self_saving_hint')}</p>
</div>
{/* Benefits */}
<EmployeeBenefitsPanel employeeId={id} canWrite={canWrite} />
{/* Ingående saldon (payroll cutover) */}
<OpeningBalancesPanel employeeId={id} canWrite={canWrite} />
</section>
"Spara ändringar" in the edit dialog never touches them. */}
<EmployeeBenefitsPanel employeeId={id} canWrite={canWrite} />
<OpeningBalancesPanel employeeId={id} canWrite={canWrite} />
<DestructiveConfirmDialog {...dialogProps} />
{/* Edit dialog: the employee form, sectioned by hairline dividers like
NewEmployeeDialog. Closing is explicit (header X or Avbryt): the
municipality combobox and dimension pickers portal outside the
dialog, so a backdrop click or stray Escape must not discard edits. */}
<Dialog open={isEditOpen} onOpenChange={setIsEditOpen}>
<DialogContent
className="sm:max-w-3xl max-h-[95dvh] sm:max-h-[90vh] flex flex-col p-0 gap-0 overflow-hidden"
onEscapeKeyDown={(e) => e.preventDefault()}
onPointerDownOutside={(e) => e.preventDefault()}
onInteractOutside={(e) => e.preventDefault()}
>
<DialogHeader className="border-b border-border px-6 pb-4 pt-6">
<DialogTitle>{t('detail_edit_dialog_title')}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSave} className="flex min-h-0 flex-1 flex-col">
<div className="min-h-0 flex-1 overflow-y-auto px-6 py-4">
<div className="divide-y divide-border">
{/* Person & kontakt - name, contact, and address */}
<section className="space-y-4 py-4 first:pt-0">
<h3 className={SECTION_HEADER}>{t('form_personal_info')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="first_name">{t('form_first_name')}<RequiredMark /></Label>
<Input id="first_name" name="first_name" defaultValue={employee.first_name} required disabled={!canWrite} />
</div>
<div className="space-y-2">
<Label htmlFor="last_name">{t('form_last_name')}<RequiredMark /></Label>
<Input id="last_name" name="last_name" defaultValue={employee.last_name} required disabled={!canWrite} />
</div>
<div className="space-y-2">
<Label htmlFor="email">{t('form_email')}</Label>
<Input id="email" name="email" type="email" defaultValue={employee.email || ''} disabled={!canWrite} />
<p className="text-xs text-muted-foreground">{t('form_email_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="phone">{t('form_phone')}</Label>
<Input id="phone" name="phone" defaultValue={employee.phone || ''} disabled={!canWrite} />
</div>
<div className="space-y-2 md:col-span-2">
<Label htmlFor="address_line1">{t('form_street_address')}</Label>
<Input id="address_line1" name="address_line1" defaultValue={employee.address_line1 || ''} disabled={!canWrite} />
</div>
<div className="space-y-2">
<Label htmlFor="postal_code">{t('form_postal_code')}</Label>
<Input id="postal_code" name="postal_code" defaultValue={employee.postal_code || ''} className="max-w-[160px]" disabled={!canWrite} />
</div>
<div className="space-y-2">
<Label htmlFor="city">{t('form_city')}</Label>
<Input id="city" name="city" defaultValue={employee.city || ''} disabled={!canWrite} />
</div>
</div>
</section>
{/* Anställning & lön - employment terms, salary, and vacation together */}
<section className="space-y-4 py-4">
<h3 className={SECTION_HEADER}>{t('form_employment_salary')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="employment_type">{t('form_employment_type')}</Label>
<Select value={employmentType} onValueChange={setEmploymentType} disabled={!canWrite}>
<SelectTrigger id="employment_type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="employee">{t('form_employment_type_employee')}</SelectItem>
<SelectItem value="company_owner">{t('form_employment_type_company_owner')}</SelectItem>
<SelectItem value="board_member">{t('form_employment_type_board_member')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="employment_degree">{t('form_employment_degree')}</Label>
<Input id="employment_degree" name="employment_degree" type="number" defaultValue={employee.employment_degree} min="1" max="100" disabled={!canWrite} />
<p className="text-xs text-muted-foreground">{t('form_employment_degree_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="hours_per_week">{t('form_hours_per_week')}</Label>
<Input id="hours_per_week" name="hours_per_week" type="number" defaultValue={employee.hours_per_week ?? 40} min="1" max="80" step="0.5" disabled={!canWrite} />
<p className="text-xs text-muted-foreground">{t('form_work_schedule_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="workdays_per_week">{t('form_workdays_per_week')}</Label>
<Input id="workdays_per_week" name="workdays_per_week" type="number" defaultValue={employee.workdays_per_week ?? 5} min="1" max="7" step="1" disabled={!canWrite} />
</div>
<div className="space-y-2">
<Label htmlFor="salary_type">{t('form_salary_type')}<RequiredMark /></Label>
<Select value={salaryType} onValueChange={setSalaryType} disabled={!canWrite}>
<SelectTrigger id="salary_type">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="monthly">{t('form_salary_type_monthly')}</SelectItem>
<SelectItem value="hourly">{t('form_salary_type_hourly')}</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="employment_start">{t('form_employment_start')}<RequiredMark /></Label>
<Input id="employment_start" name="employment_start" type="date" defaultValue={employee.employment_start || ''} required disabled={!canWrite} />
<p className="text-xs text-muted-foreground">{t('detail_employment_start_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="employment_end">{t('form_employment_end')}</Label>
<Input id="employment_end" name="employment_end" type="date" defaultValue={employee.employment_end || ''} disabled={!canWrite} />
<p className="text-xs text-muted-foreground">{t('detail_employment_end_hint')}</p>
</div>
{salaryType === 'monthly' ? (
<div className="space-y-2">
<Label htmlFor="monthly_salary">{t('form_monthly_salary')}<RequiredMark /></Label>
<Input id="monthly_salary" name="monthly_salary" type="number" step="1" min="1" defaultValue={employee.monthly_salary || ''} required disabled={!canWrite} />
</div>
) : (
<div className="space-y-2">
<Label htmlFor="hourly_rate">{t('form_hourly_rate')}<RequiredMark /></Label>
<Input id="hourly_rate" name="hourly_rate" type="number" step="0.01" min="0.01" defaultValue={employee.hourly_rate || ''} required disabled={!canWrite} />
</div>
)}
<div className="space-y-2 md:col-span-2">
<Label htmlFor="vacation_rule">{t('form_vacation_rule')}</Label>
<Select value={vacationRule} onValueChange={setVacationRule} disabled={!canWrite}>
<SelectTrigger id="vacation_rule">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="procentregeln">{t('form_vacation_rule_procentregeln')}</SelectItem>
<SelectItem value="sammaloneregeln">{t('form_vacation_rule_sammaloneregeln')}</SelectItem>
<SelectItem value="semesterersattning">{t('form_vacation_rule_semesterersattning')}</SelectItem>
<SelectItem value="none">{t('form_vacation_rule_none')}</SelectItem>
</SelectContent>
</Select>
{vacationRule === 'none' && (
<p className="text-xs text-muted-foreground">
{t('detail_vacation_none_hint')}
</p>
)}
{vacationRule === 'semesterersattning' && (
<p className="text-xs text-muted-foreground">
{t('form_vacation_semesterersattning_hint')}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="vacation_days_per_year">{t('form_vacation_days')}</Label>
<Input
id="vacation_days_per_year"
name="vacation_days_per_year"
type="number"
min="25"
max="40"
defaultValue={employee.vacation_days_per_year}
disabled={!canWrite}
/>
<p className="text-xs text-muted-foreground">{t('form_vacation_days_hint')}</p>
</div>
</div>
</section>
{/* Default dimensions (kostnadsställe/projekt) */}
{dimensionsEnabled && (
<section className="space-y-2 py-4">
<h3 className={SECTION_HEADER}>{t('form_dimensions_title')}</h3>
<LineDimensionFields dimensions={dimensions} onChange={setDimension} disabled={!canWrite} />
<p className="text-xs text-muted-foreground">
{t('form_dimensions_hint')}
</p>
</section>
)}
{/* Tax. The masked personnummer is enough input here: EmployeeTaxCard
only reads the leading birthdate digits to suggest a tax column. */}
<section className="space-y-4 py-4">
<h3 className={SECTION_HEADER}>{t('tax_title')}</h3>
<EmployeeTaxCard
personnummer={employee.personnummer_masked || ''}
disabled={!canWrite}
onChange={setTax}
flat
initial={{
f_skatt_status: employee.f_skatt_status || 'a_skatt',
is_sidoinkomst: employee.is_sidoinkomst || false,
tax_table_number: employee.tax_table_number ?? null,
tax_column: employee.tax_column ?? 1,
tax_municipality: employee.tax_municipality || '',
}}
/>
{fSkattVerifiedLabel && (
<p className="text-xs text-muted-foreground">{fSkattVerifiedLabel}</p>
)}
</section>
{/* Bank */}
<section className="space-y-4 py-4">
<h3 className={SECTION_HEADER}>{t('form_bank_account')}</h3>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="clearing_number">{t('form_clearing_number')}</Label>
<Input
id="clearing_number"
name="clearing_number"
inputMode="numeric"
value={clearing}
onChange={(e) => setClearing(e.target.value)}
onFocus={() => setBankFocused(true)}
onBlur={() => setBankFocused(false)}
disabled={!canWrite}
aria-invalid={clearing !== '' && !isValidClearing(normalizeBankNumber(clearing))}
/>
{clearing !== '' && !isValidClearing(normalizeBankNumber(clearing)) ? (
<p className="text-xs text-destructive">{t('bank_error_clearing_format')}</p>
) : bankName ? (
<p className="text-xs text-muted-foreground">{bankName}</p>
) : null}
</div>
<div className="space-y-2">
<Label htmlFor="bank_account_number">{t('form_account_number')}</Label>
<Input
id="bank_account_number"
name="bank_account_number"
inputMode="numeric"
value={account}
onChange={(e) => setAccount(e.target.value)}
onFocus={() => setBankFocused(true)}
onBlur={() => setBankFocused(false)}
disabled={!canWrite}
aria-invalid={account !== '' && !isValidAccount(normalizeBankNumber(account))}
/>
{account !== '' && !isValidAccount(normalizeBankNumber(account)) && (
<p className="text-xs text-destructive">{t('bank_error_account_format')}</p>
)}
</div>
</div>
{showChecksumWarning && (
<p className="text-xs text-attn">{t('bank_warn_checksum')}</p>
)}
<p className="text-xs text-muted-foreground">{t('form_bank_hint')}</p>
</section>
</div>
</div>
{/* Solid footer outside the scroll area: always visible, never
overlaps content (the body above scrolls independently). */}
<div className="flex justify-end gap-3 border-t border-border bg-background px-6 py-4">
<Button type="button" variant="outline" onClick={() => setIsEditOpen(false)}>
{t('form_cancel')}
</Button>
<Button type="submit" disabled={saving || !canWrite}>
<Save className="mr-2 h-4 w-4" />
{saving ? t('form_saving') : t('detail_save_changes')}
</Button>
</div>
</form>
</DialogContent>
</Dialog>
</div>
)
}
@@ -4,9 +4,11 @@ import { use, useEffect, useMemo, useState } from 'react'
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { ArrowLeft, Calculator, Loader2 } from 'lucide-react'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { DetailSection } from '@/components/ui/detail-section'
import { HelpPopover } from '@/components/ui/help-popover'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { SalaryCalendar } from '@/components/salary/SalaryCalendar'
import { SalaryOverridePanel } from '@/components/salary/SalaryOverridePanel'
import { cn, formatCurrency } from '@/lib/utils'
@@ -55,6 +57,17 @@ const LINE_ITEM_TYPE_KEYS: Record<SalaryLineItemType, string> = {
other: 'li_other',
}
// Same chip vocabulary as the Löner list and the run header (chips mark
// exceptions): booked renders as muted text, everything else as a chip.
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline'> = {
draft: 'secondary',
review: 'secondary',
approved: 'secondary',
paid: 'success',
booked: 'success',
corrected: 'outline',
}
interface DetailResponse {
run: SalaryRun
runEmployee: SalaryRunEmployee & { employee: EmployeeMasked; line_items: SalaryLineItem[] }
@@ -66,6 +79,7 @@ export default function SalaryRunEmployeeDetailPage({
params: Promise<{ id: string; employeeId: string }>
}) {
const t = useTranslations('salary_run_employee')
const tSalary = useTranslations('salary')
const { id: runId, employeeId } = use(params)
const [data, setData] = useState<DetailResponse | null>(null)
const [loading, setLoading] = useState(true)
@@ -159,13 +173,12 @@ export default function SalaryRunEmployeeDetailPage({
<div className="space-y-3">
<Link
href={`/salary/runs/${runId}`}
className="inline-flex items-center text-sm text-muted-foreground hover:underline"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="mr-1 h-3.5 w-3.5" /> {t('back_to_run')}
<ArrowLeft className="h-4 w-4" />
{t('back_to_run')}
</Link>
<div className="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
{error ?? t('error_load_employee')}
</div>
<p className="text-sm text-destructive">{error ?? t('error_load_employee')}</p>
</div>
)
}
@@ -175,63 +188,93 @@ export default function SalaryRunEmployeeDetailPage({
const lineItems = runEmployee.line_items ?? []
const periodLabel = `${run.period_year}-${String(run.period_month).padStart(2, '0')}`
const readOnly = run.status !== 'draft' && run.status !== 'review'
const statusLabel = tSalary(`status_${run.status}`)
const taxValue = runEmployee.tax_withheld_override ?? runEmployee.tax_withheld
const taxOverridden = runEmployee.tax_withheld_override !== null
const avgifterOverridden = runEmployee.avgifter_amount_override !== null
const kpis: Array<{ label: string; value: number; accent?: boolean; overridden?: boolean }> = [
{ label: t('gross'), value: runEmployee.gross_salary },
{ label: t('tax'), value: taxValue, overridden: taxOverridden },
{
label: t('net'),
value: runEmployee.net_salary + (runEmployee.tax_withheld - taxValue),
accent: true,
overridden: taxOverridden,
},
{
label: t('avgifter'),
value: runEmployee.avgifter_amount_override ?? runEmployee.avgifter_amount,
overridden: avgifterOverridden,
},
]
const absenceCounts = [
{ label: t('sick_days'), days: liveCounts?.sick ?? runEmployee.sick_days },
{ label: t('vab_days'), days: liveCounts?.vab ?? runEmployee.vab_days },
{ label: t('parental_days'), days: liveCounts?.parental ?? runEmployee.parental_days },
]
return (
<div className="space-y-8">
{/* Header */}
<div className="space-y-3">
<div className="space-y-8 stagger-enter">
{/* Back link on its own quiet row */}
<div>
<Link
href={`/salary/runs/${runId}`}
className="inline-flex items-center text-sm text-muted-foreground hover:underline"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="mr-1 h-3.5 w-3.5" /> {t('back_to_run')}
<ArrowLeft className="h-4 w-4" />
{t('back_to_run')}
</Link>
<div className="flex flex-wrap items-baseline justify-between gap-2">
<div>
<h1 className="font-display text-2xl tracking-tight">
</div>
{/* Header: serif name with the run's status as the one status element,
a quiet meta line, and the next step on the right. */}
<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">
{employee.first_name} {employee.last_name}
</h1>
<p className="text-sm text-muted-foreground tabular-nums">
{employee.personnummer_masked} · {t('payslip_period', { period: periodLabel })}
</p>
{run.status === 'booked' ? (
<span className="text-sm text-muted-foreground">{statusLabel}</span>
) : (
<Badge variant={STATUS_VARIANTS[run.status] || 'secondary'}>{statusLabel}</Badge>
)}
</div>
{run.status === 'draft' && (
<Button
variant="outline"
size="sm"
onClick={handleCalculate}
disabled={calculating}
>
<p className="mt-1 text-sm text-muted-foreground">
<span className="tabular-nums">{employee.personnummer_masked}</span>
{' · '}
<span className="tabular-nums">{t('payslip_period', { period: periodLabel })}</span>
</p>
</div>
{run.status === 'draft' && (
<div className="flex shrink-0 items-center gap-2">
<Button onClick={handleCalculate} disabled={calculating}>
{calculating ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Calculator className="mr-1.5 h-3.5 w-3.5" />
<Calculator className="mr-2 h-4 w-4" />
)}
{t('calculate')}
</Button>
)}
</div>
</div>
)}
</div>
{/* Summary */}
<div className="grid grid-cols-2 gap-3 sm:grid-cols-4">
<SummaryCard label={t('gross')} value={runEmployee.gross_salary} />
<SummaryCard
label={t('tax')}
value={runEmployee.tax_withheld_override ?? runEmployee.tax_withheld}
overridden={runEmployee.tax_withheld_override !== null}
/>
<SummaryCard
label={t('net')}
value={runEmployee.net_salary + (runEmployee.tax_withheld - (runEmployee.tax_withheld_override ?? runEmployee.tax_withheld))}
accent
overridden={runEmployee.tax_withheld_override !== null}
/>
<SummaryCard
label={t('avgifter')}
value={runEmployee.avgifter_amount_override ?? runEmployee.avgifter_amount}
overridden={runEmployee.avgifter_amount_override !== null}
/>
{/* Summary: flat label/number pairs, no tiles. The override is the
exception, so it is a chip next to the label. */}
<div className="flex flex-wrap gap-x-10 gap-y-4">
{kpis.map(({ label, value, accent, overridden }) => (
<div key={label} className="min-w-0">
<p className="flex items-center gap-2 text-[11px] uppercase tracking-wider text-muted-foreground">
{label}
{overridden && <Badge variant="warning">{t('adjusted_badge')}</Badge>}
</p>
<p className={cn('mt-1 font-display text-xl tabular-nums leading-none', accent && 'text-success')}>
{formatCurrency(value)}
</p>
</div>
))}
</div>
{/* Advanced mode: per-employee override of tax / arbetsgivaravgift */}
@@ -251,95 +294,69 @@ export default function SalaryRunEmployeeDetailPage({
/>
)}
{/* Unified calendar: worked time (for hourly) + absence on the same grid */}
<Card>
<CardHeader>
<CardTitle className="text-base">{t('time_absence_title')}</CardTitle>
<p className="text-xs text-muted-foreground">
{/* Unified calendar: worked time (for hourly) + absence on the same grid.
The how-to lives behind the kicker's "?" (convention 7). */}
<DetailSection
kicker={t('time_absence_title')}
help={
<HelpPopover>
{employee.salary_type === 'hourly'
? t('calendar_hint_hourly')
: t('calendar_hint_monthly')}
</p>
</CardHeader>
<CardContent>
<SalaryCalendar
employeeId={employee.id}
salaryType={employee.salary_type}
periodStart={periodStart}
periodEnd={periodEnd}
salaryRunEmployeeId={runEmployee.id}
readOnly={readOnly}
onChange={load}
onAbsenceCountsChange={setLiveCounts}
/>
<div className="mt-3 grid grid-cols-3 gap-2 text-xs">
<AbsenceCount label={t('sick_days')} days={liveCounts?.sick ?? runEmployee.sick_days} />
<AbsenceCount label={t('vab_days')} days={liveCounts?.vab ?? runEmployee.vab_days} />
<AbsenceCount label={t('parental_days')} days={liveCounts?.parental ?? runEmployee.parental_days} />
</div>
</CardContent>
</Card>
</HelpPopover>
}
>
<SalaryCalendar
employeeId={employee.id}
salaryType={employee.salary_type}
periodStart={periodStart}
periodEnd={periodEnd}
salaryRunEmployeeId={runEmployee.id}
readOnly={readOnly}
onChange={load}
onAbsenceCountsChange={setLiveCounts}
/>
<div className="mt-4 flex flex-wrap gap-x-10 gap-y-3">
{absenceCounts.map(({ label, days }) => (
<div key={label} className="min-w-0">
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">{label}</p>
<p className="mt-1 text-sm tabular-nums">{t('days_count', { days })}</p>
</div>
))}
</div>
</DetailSection>
{/* Line items */}
<Card>
<CardHeader>
<CardTitle className="text-base">{t('line_items_title', { count: lineItems.length })}</CardTitle>
</CardHeader>
<CardContent className="p-0">
{lineItems.length === 0 ? (
<p className="px-4 py-6 text-center text-sm text-muted-foreground">
{t('no_line_items')}
</p>
) : (
<table className="w-full">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b text-left">
<th className="px-4 py-2">{t('th_type')}</th>
<th className="px-4 py-2">{t('th_description')}</th>
<th className="px-4 py-2 text-right">{t('th_quantity')}</th>
<th className="px-4 py-2 text-right">{t('th_amount')}</th>
{/* Line items: the list-page table idiom straight on the panel. */}
<DetailSection kicker={t('line_items_title', { count: lineItems.length })}>
{lineItems.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('no_line_items')}</p>
) : (
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'pl-0')}>{t('th_type')}</th>
<th className={TH_CLASS}>{t('th_description')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('th_quantity')}</th>
<th className={cn(TH_CLASS, 'pr-0 text-right')}>{t('th_amount')}</th>
</tr>
</thead>
<tbody>
{lineItems.map(li => (
<tr key={li.id} className="border-b last:border-0">
<td className="px-4 py-2 text-xs text-muted-foreground">{LINE_ITEM_TYPE_KEYS[li.item_type] ? t(LINE_ITEM_TYPE_KEYS[li.item_type]) : li.item_type}</td>
<td className="px-4 py-2 text-sm">{li.description}</td>
<td className="px-4 py-2 text-sm text-right tabular-nums">{li.quantity ?? '-'}</td>
<td className="px-4 py-2 text-sm text-right tabular-nums">{formatCurrency(li.amount)}</td>
<tr key={li.id}>
<td className={cn(TD_CLASS, 'pl-0 text-muted-foreground')}>
{LINE_ITEM_TYPE_KEYS[li.item_type] ? t(LINE_ITEM_TYPE_KEYS[li.item_type]) : li.item_type}
</td>
<td className={TD_CLASS}>{li.description}</td>
<td className={cn(TD_CLASS, 'text-right tabular-nums')}>{li.quantity ?? '-'}</td>
<td className={cn(TD_CLASS, 'pr-0 text-right tabular-nums')}>{formatCurrency(li.amount)}</td>
</tr>
))}
</tbody>
</table>
)}
</CardContent>
</Card>
</div>
)
}
function SummaryCard({ label, value, accent, overridden }: { label: string; value: number; accent?: boolean; overridden?: boolean }) {
const t = useTranslations('salary_run_employee')
return (
<div className={cn('rounded-lg border bg-card p-3', accent && 'ring-1 ring-primary/40')}>
<div className="flex items-center gap-1.5 text-xs text-muted-foreground">
{label}
{/* The override is an exception, so it is a chip, not a second ring:
two ring-1 rules on one element set the same custom property and
one of them silently loses (design.md conventions 5 and 12). */}
{overridden && <Badge variant="warning">{t('adjusted_badge')}</Badge>}
</div>
<div className="mt-0.5 text-lg font-medium tabular-nums">{formatCurrency(value)}</div>
</div>
)
}
function AbsenceCount({ label, days }: { label: string; days: number }) {
const t = useTranslations('salary_run_employee')
return (
<div className="rounded-lg border bg-muted/30 px-3 py-2">
<div className="text-[11px] text-muted-foreground">{label}</div>
<div className="text-sm font-medium tabular-nums">{t('days_count', { days })}</div>
</div>
)}
</DetailSection>
</div>
)
}
+45 -65
View File
@@ -4,8 +4,8 @@ import { use, useEffect, useRef, useState } from 'react'
import { usePathname, 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 { AttnLine } from '@/components/ui/attn-line'
import {
Dialog,
DialogContent,
@@ -14,7 +14,7 @@ import {
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { AlertTriangle, Download, Loader2 } from 'lucide-react'
import { AlertTriangle, Loader2 } from 'lucide-react'
import {
DestructiveConfirmDialog,
useDestructiveConfirm,
@@ -768,47 +768,47 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
: null
return (
<div className="space-y-6">
<div className="space-y-8 stagger-enter">
{/* Back row + header: serif title with one status chip, the quiet meta
line, the next step as the one default button and everything else
behind the ⋯ menu. */}
<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={agiKvittensnummer}
canWrite={canWrite}
actionLoading={actionLoading}
primaryAction={primaryAction}
onPreview={handlePreview}
onRevert={() => handleAction('revert')}
onUnapprove={handleUnapprove}
onSendPayslips={handleSendPayslips}
onDownloadPayslips={handleBulkPayslipDownload}
onDownloadAgi={handleDownloadAgi}
onDelete={handleDelete}
onCorrect={handleCorrect}
/>
{/* The one attention sentence on the page: an empty declaration will be
filed for the period, which should be deliberate. */}
{isNollkorning && (
<AttnLine>
{t('nollkorning_title')}: {t('nollkorning_body', { period: periodLabel })}
</AttnLine>
)}
{/* The month's steps as a flat rail: where the run stands and what the
stage means. Actions live in the header. */}
<RunProgressBar
run={run}
isCalculated={isCalculated}
noPayout={noPayout}
agiState={agiState}
agiKvittensnummer={agiKvittensnummer}
/>
<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}
@@ -825,7 +825,7 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
{/* 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">
<div className="grid gap-x-12 gap-y-8 lg:grid-cols-2 items-start">
<RunCalculationDetails periodYear={run.period_year} employees={employees} />
{preview && (
<RunJournalPreview
@@ -856,12 +856,10 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
{/* 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>
<div className="space-y-4">
<Skeleton className="h-5 w-40" />
<Skeleton className="h-20 w-full" />
</div>
) : (
<TaxPaymentPanel
period={periodLabel}
@@ -878,38 +876,20 @@ export default function SalaryRunPage({ params }: { params: Promise<{ id: string
)
)}
{/* AGI (Arbetsgivardeklaration): available once the run is booked */}
{/* AGI (Arbetsgivardeklaration): available once the run is booked. The
XML download for manual filing lives in the header's ⋯ menu. */}
{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>
<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}
/>
)}
{/* Overridable approval guard: missing bank details don't dead-end -
+393 -416
View File
@@ -5,15 +5,24 @@ import { useParams, useRouter } from 'next/navigation'
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Skeleton } from "@/components/ui/skeleton"
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { DetailSection, DefRow, DefEmpty } from '@/components/ui/detail-section'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { HelpPopover } from '@/components/ui/help-popover'
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Info, Loader2, Pencil, Plus, CalendarClock, Paperclip } from 'lucide-react'
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2, Lock, Undo2, Loader2, Pencil, Plus, CalendarClock, MoreHorizontal } from 'lucide-react'
import LinkVoucherPicker from '@/components/invoices/LinkVoucherPicker'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import { formatDate, cn } from '@/lib/utils'
@@ -68,13 +77,14 @@ const itemHasAccrual = (item: SupplierInvoiceItem): boolean =>
const accrualMonth = (date: string): string => date.slice(0, 7)
const statusVariants: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive'> = {
registered: 'secondary',
approved: 'default',
paid: 'success',
// Chips mark exceptions (design.md convention 5): approved and paid render as
// muted text in the header; these are the states that deviate and get a chip.
// Same variants as the list page so the two surfaces read the same.
const EXCEPTION_STATUS_VARIANTS: Record<string, 'secondary' | 'outline' | 'warning' | 'destructive'> = {
registered: 'outline',
partially_paid: 'warning',
overdue: 'destructive',
disputed: 'destructive',
disputed: 'warning',
credited: 'secondary',
reversed: 'secondary',
}
@@ -88,6 +98,8 @@ export default function SupplierInvoiceDetailPage() {
const { toast } = useToast()
const t = useTranslations('supplier_invoice_detail')
const tCommon = useTranslations('common')
// The list page's "Betald {date}" label, so the header reads like the row.
const tList = useTranslations('supplier_invoices')
const [invoice, setInvoice] = useState<SupplierInvoice | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isPayDialogOpen, setIsPayDialogOpen] = useState(false)
@@ -518,9 +530,9 @@ export default function SupplierInvoiceDetailPage() {
if (isLoading) {
return (
<div className="space-y-6">
<div className="space-y-8">
<Skeleton className="h-8 w-48" />
<Card><CardContent className="p-6"><Skeleton className="h-48 w-full" /></CardContent></Card>
<Skeleton className="h-48 w-full" />
</div>
)
}
@@ -538,6 +550,10 @@ export default function SupplierInvoiceDetailPage() {
const items = (invoice.items || []) as SupplierInvoiceItem[]
const payments = (invoice.payments || []) as SupplierInvoicePayment[]
const creditedOriginal =
(invoice as SupplierInvoice & {
credited_original?: { id: string; supplier_invoice_number: string; arrival_number: number } | null
}).credited_original ?? null
// Display-only öresavrundning. The stored total/booked verifikat keep the
// exact öre; this only adjusts the rendered total. Supplier invoices never
@@ -548,8 +564,82 @@ export default function SupplierInvoiceDetailPage() {
{ ore_rounding: false },
)
// Document title: the supplier's own invoice number with the kind spelled
// out ("Leverantörsfaktura 4711", "Kreditfaktura K-12"). The arrival number
// moves to the meta line and the facts below; an invoice registered without
// a supplier number keeps the arrival number as its title.
const docNumber = invoice.supplier_invoice_number
const title = !docNumber
? t('arrival_header', { number: invoice.arrival_number })
: invoice.is_credit_note
? t('title_credit_note', { number: docNumber })
: t('title_invoice', { number: docNumber })
// One status element, same rule as the list page (chips mark exceptions):
// approved and paid render as muted text, everything that deviates (waiting
// for attest, overdue, partly paid, credited, ...) gets a chip.
const status: { label: string; exception: boolean; variant?: 'secondary' | 'outline' | 'warning' | 'destructive' } =
invoice.status === 'paid'
? {
label: invoice.paid_at
? tList('status_paid_date', { date: formatDate(invoice.paid_at) })
: statusLabels.paid,
exception: false,
}
: invoice.status === 'approved'
? { label: statusLabels.approved, exception: false }
: {
label: statusLabels[invoice.status] || invoice.status,
exception: true,
variant: EXCEPTION_STATUS_VARIANTS[invoice.status] || 'secondary',
}
const metaParts = [
invoice.supplier?.name ?? null,
docNumber ? t('arrival_header', { number: invoice.arrival_number }) : null,
t('created_at', { date: formatDate(invoice.created_at) }),
].filter(Boolean)
// Attest keys off approved_at, not the status: the overdue cron flips
// unbooked invoices to 'overdue' just by aging, and gating on 'registered'
// alone left them with no way through attest (#1206).
const canApprove = canApproveSupplierInvoice(invoice) && !invoice.is_credit_note
const canMarkPaid = ['approved', 'overdue', 'partially_paid'].includes(invoice.status)
const canCredit = canMarkPaid && invoice.status !== 'partially_paid'
const canUncredit = invoice.status === 'credited' && !invoice.is_credit_note
// Delete is allowed while nothing would be orphaned: no booking, no
// payments (server re-checks). 'approved'/'overdue' are included because
// the overdue cron flips unbooked invoices there and a registered-only gate
// made them undeletable just by aging.
const canDelete =
['registered', 'approved', 'overdue'].includes(invoice.status) &&
!invoice.is_credit_note &&
!invoice.registration_journal_entry_id &&
payments.length === 0
// Secondary actions collapse into one overflow menu (convention 9: one
// obvious next step in the header, the alternatives behind a caret).
const hasMenu = canCredit || canDelete
// #967: registered-without-booking (deferred booking). Ekonomi books the
// registration verifikat from here.
const canBookAfterwards =
companySettings?.accounting_method === 'accrual' &&
!invoice.is_credit_note &&
['registered', 'approved', 'overdue'].includes(invoice.status)
const accrualInfo = (item: SupplierInvoiceItem) =>
itemHasAccrual(item) ? (
<span className="mt-0.5 flex items-center gap-1 text-xs text-muted-foreground tabular-nums">
<CalendarClock className="h-3 w-3 shrink-0" />
{t('accrual_line_info', {
from: accrualMonth(item.accrual_period_start!),
to: accrualMonth(item.accrual_period_end!),
})}
{item.accrual_balance_account && ` · ${item.accrual_balance_account}`}
</span>
) : null
return (
<div className="space-y-8 max-w-4xl">
<div className="space-y-8 stagger-enter">
{/* Back link + prev/next record pager on their own quiet row, so the
title below keeps a stable position while stepping between records */}
<div className="flex items-center justify-between gap-4">
@@ -570,39 +660,38 @@ export default function SupplierInvoiceDetailPage() {
/>
</div>
{/* Header */}
<div className="flex flex-col sm:flex-row sm:items-center sm:justify-between gap-4">
<div className="flex items-center gap-3 sm:gap-4 min-w-0">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<h1 className="font-display text-2xl leading-8 tracking-tight">
{t('arrival_header', { number: invoice.arrival_number })}
</h1>
<Badge variant={statusVariants[invoice.status] || 'secondary'}>
{statusLabels[invoice.status] || invoice.status}
{/* 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">
{/* data-ph-mask: the title carries the supplier's invoice number */}
<h1 data-ph-mask="" className="font-display text-2xl leading-8 tracking-tight">{title}</h1>
{status.exception ? (
<Badge variant={status.variant}>{status.label}</Badge>
) : (
<span className="text-sm text-muted-foreground">{status.label}</span>
)}
{items.some(itemHasAccrual) && (
<Badge variant="outline" className="gap-1">
<CalendarClock className="h-3 w-3" />
{t('badge_accrued')}
</Badge>
{items.some(itemHasAccrual) && (
<Badge variant="outline" className="gap-1">
<CalendarClock className="h-3 w-3" />
{t('badge_accrued')}
</Badge>
)}
</div>
<p className="text-muted-foreground text-sm sm:text-base truncate">
{t('header_subtitle', {
supplier: invoice.supplier?.name ?? '',
number: invoice.supplier_invoice_number,
})}
</p>
)}
</div>
<p className="mt-1 text-sm text-muted-foreground">{metaParts.join(' · ')}</p>
</div>
{/* Actions */}
<div className="flex flex-wrap gap-2">
{/* Attest keys off approved_at, not the status: the overdue cron
flips unbooked invoices to 'overdue' just by aging, and gating on
'registered' alone left them with no way through attest (#1206). */}
{canApproveSupplierInvoice(invoice) && !invoice.is_credit_note && (
<div className="flex shrink-0 flex-wrap items-center gap-2">
{/* The supplier's own document, reviewed in the browser (#1190). */}
{invoice.document_id && (
<DocumentViewButton
documentId={invoice.document_id}
label={t('view_document')}
className="text-[13px]"
/>
)}
{canApprove && (
<Button
onClick={handleApprove}
disabled={isProcessing || !canWrite}
@@ -618,61 +707,21 @@ export default function SupplierInvoiceDetailPage() {
{t('approve')}
</Button>
)}
{/* Delete is allowed while nothing would be orphaned: no booking, no
payments (server re-checks). 'approved'/'overdue' are included
because the overdue cron flips unbooked invoices there and a
registered-only gate made them undeletable just by aging. */}
{['registered', 'approved', 'overdue'].includes(invoice.status) &&
!invoice.is_credit_note &&
!invoice.registration_journal_entry_id &&
payments.length === 0 && (
<Button
variant="destructive"
size="icon"
onClick={handleDelete}
disabled={isProcessing || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
aria-label={t('delete_confirm_label')}
>
{processingAction === 'delete' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : canWrite ? (
<Trash2 className="h-4 w-4" />
) : (
<Lock className="h-4 w-4" />
)}
</Button>
)}
{['approved', 'overdue', 'partially_paid'].includes(invoice.status) && (
<>
<Button
onClick={() => setIsPayDialogOpen(true)}
disabled={isProcessing || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <CreditCard className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('mark_paid')}
</Button>
{invoice.status !== 'partially_paid' && (
<Button
variant="outline"
onClick={handleCredit}
disabled={isProcessing || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{processingAction === 'credit' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : canWrite ? (
<FileText className="mr-2 h-4 w-4" />
) : (
<Lock className="mr-2 h-4 w-4" />
)}
{t('credit_note_button')}
</Button>
)}
</>
{/* Attest gates payment: while attest is still pending, Markera
betald steps back to a secondary so the header keeps one next
step (an aged-but-unattested invoice can have both). */}
{canMarkPaid && (
<Button
variant={canApprove ? 'outline' : 'default'}
onClick={() => setIsPayDialogOpen(true)}
disabled={isProcessing || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{canWrite ? <CreditCard className="mr-2 h-4 w-4" /> : <Lock className="mr-2 h-4 w-4" />}
{t('mark_paid')}
</Button>
)}
{invoice.status === 'credited' && !invoice.is_credit_note && (
{canUncredit && (
<Button
variant="outline"
onClick={handleUncredit}
@@ -689,360 +738,288 @@ export default function SupplierInvoiceDetailPage() {
{t('uncredit_button')}
</Button>
)}
{hasMenu && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={tCommon('more_options')}>
{processingAction === 'credit' || processingAction === 'delete' ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[240px]">
{canCredit && (
<DropdownMenuItem onSelect={() => void handleCredit()} disabled={isProcessing || !canWrite}>
<FileText className="h-4 w-4" />
{t('credit_note_button')}
</DropdownMenuItem>
)}
{canDelete && (
<>
{canCredit && <DropdownMenuSeparator />}
<DropdownMenuItem
onSelect={() => void handleDelete()}
disabled={isProcessing || !canWrite}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4" />
{t('delete_confirm_label')}
</DropdownMenuItem>
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Credit note banner: explain why this row has no edit/delete affordances and where to undo */}
{invoice.is_credit_note && (
<div className="rounded-lg border bg-muted/40 p-4 flex gap-3 text-sm">
<Info className="h-5 w-5 shrink-0 text-muted-foreground mt-0.5" />
<div className="space-y-1">
<p className="font-medium">{t('credit_note_banner_title')}</p>
<p className="text-muted-foreground">
{t('credit_note_banner_prefix')}{' '}
{(invoice as SupplierInvoice & { credited_original?: { id: string; supplier_invoice_number: string; arrival_number: number } }).credited_original ? (
<Link
href={`/supplier-invoices/${(invoice as SupplierInvoice & { credited_original: { id: string; supplier_invoice_number: string; arrival_number: number } }).credited_original.id}`}
className="text-primary hover:underline font-medium"
>
{t('credit_note_banner_link', { number: (invoice as SupplierInvoice & { credited_original: { id: string; supplier_invoice_number: string; arrival_number: number } }).credited_original.supplier_invoice_number })}
{/* Leverantör and Fakturainformation side by side like an invoice head:
who sent it on the left, the facts on the right. */}
<div className="grid gap-x-12 gap-y-8 lg:grid-cols-2">
{invoice.supplier && (
<DetailSection kicker={t('supplier_section_title')}>
<DefRow label={t('def_name')}>
<Link href={`/suppliers/${invoice.supplier.id}`} className="hover:underline">
{invoice.supplier.name}
</Link>
</DefRow>
{invoice.supplier.org_number && (
<DefRow label={t('def_org_number')}>
<span className="tabular-nums">{invoice.supplier.org_number}</span>
</DefRow>
)}
<DefRow label={t('def_email')}>
{invoice.supplier.email ? (
<a href={`mailto:${invoice.supplier.email}`} className="hover:underline">
{invoice.supplier.email}
</a>
) : (
<DefEmpty />
)}
</DefRow>
</DetailSection>
)}
<DetailSection
kicker={t('invoice_info_title')}
// A credit note carries no edit/delete affordances of its own; the
// way to undo it lives on the original, explained behind the "?".
help={invoice.is_credit_note ? <HelpPopover>{t('credit_note_help')}</HelpPopover> : undefined}
>
<DefRow label={t('arrival_number_label')}>
<span className="tabular-nums">{invoice.arrival_number}</span>
</DefRow>
<DefRow label={t('invoice_number_label')}>
{invoice.supplier_invoice_number || <DefEmpty />}
</DefRow>
<DefRow label={t('invoice_date_label')}>
<span className="tabular-nums">{formatDate(invoice.invoice_date)}</span>
</DefRow>
<DefRow label={t('due_date_label')}>
<span className="tabular-nums">{formatDate(invoice.due_date)}</span>
</DefRow>
{invoice.delivery_date && (
<DefRow label={t('delivery_date_label')}>
<span className="tabular-nums">{formatDate(invoice.delivery_date)}</span>
</DefRow>
)}
{invoice.payment_reference && (
<DefRow label={t('ocr_reference_label')}>
<span className="tabular-nums">{invoice.payment_reference}</span>
</DefRow>
)}
{invoice.reverse_charge && (
<DefRow label={t('vat_label')}>{t('reverse_charge_badge')}</DefRow>
)}
{/* The invoice a credit note cancels, as a row, not a banner. */}
{invoice.is_credit_note && (
<DefRow label={t('def_credits')}>
{creditedOriginal ? (
<Link href={`/supplier-invoices/${creditedOriginal.id}`} className="hover:underline">
{creditedOriginal.supplier_invoice_number
? t('title_invoice', { number: creditedOriginal.supplier_invoice_number })
: t('arrival_header', { number: creditedOriginal.arrival_number })}
</Link>
) : (
<span>{t('credit_note_banner_original_fallback')}</span>
<span className="text-muted-foreground">{t('credit_note_banner_original_fallback')}</span>
)}
{t('credit_note_banner_suffix')}
</p>
</div>
</div>
)}
{/* Invoice details */}
<div className="grid gap-4 md:grid-cols-2">
<Card>
<CardHeader>
<CardTitle className="text-base">{t('invoice_info_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">{t('arrival_number_label')}</span>
<span className="font-mono">{invoice.arrival_number}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('invoice_number_label')}</span>
<span>{invoice.supplier_invoice_number}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('invoice_date_label')}</span>
<span className="tabular-nums">{formatDate(invoice.invoice_date)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('due_date_label')}</span>
<span className="tabular-nums">{formatDate(invoice.due_date)}</span>
</div>
{invoice.delivery_date && (
<div className="flex justify-between">
<span className="text-muted-foreground">{t('delivery_date_label')}</span>
<span className="tabular-nums">{formatDate(invoice.delivery_date)}</span>
</div>
)}
{invoice.payment_reference && (
<div className="flex justify-between">
<span className="text-muted-foreground">{t('ocr_reference_label')}</span>
<span className="font-mono">{invoice.payment_reference}</span>
</div>
)}
{invoice.reverse_charge && (
<div className="mt-2">
<Badge variant="warning">{t('reverse_charge_badge')}</Badge>
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle className="text-base">{t('amounts_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">{t('net_excl_vat')}</span>
<span className="tabular-nums">{formatCurrency(invoice.subtotal, invoice.currency)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">{t('vat_label')}</span>
<span className="tabular-nums">{formatCurrency(invoice.vat_amount, invoice.currency)}</span>
</div>
{rounding.applies && (
<div className="flex justify-between">
<span className="text-muted-foreground">{t('ore_rounding')}</span>
<span className="tabular-nums">{formatCurrency(rounding.roundingDelta, invoice.currency)}</span>
</div>
)}
<div className="flex justify-between font-semibold text-base pt-2 border-t">
<span>{t('total_label')}</span>
<span className="tabular-nums">{formatCurrency(rounding.displayed, invoice.currency)}</span>
</div>
<div className="flex justify-between pt-2">
<span className="text-muted-foreground">{t('paid_label')}</span>
<span className="tabular-nums text-success">{formatCurrency(invoice.paid_amount, invoice.currency)}</span>
</div>
<div className="flex justify-between font-semibold">
<span>{t('remaining_label')}</span>
<span className="tabular-nums">{formatCurrency(invoice.remaining_amount, invoice.currency)}</span>
</div>
</CardContent>
</Card>
</DefRow>
)}
</DetailSection>
</div>
{/* Supplier info */}
{invoice.supplier && (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('supplier_section_title')}</CardTitle>
</CardHeader>
<CardContent className="text-sm">
<Link href={`/suppliers/${invoice.supplier.id}`} className="text-primary hover:underline font-medium">
{invoice.supplier.name}
</Link>
<div className="text-muted-foreground mt-1">
{invoice.supplier.org_number && <span>{t('org_number_inline', { number: invoice.supplier.org_number })}</span>}
{invoice.supplier.email}
</div>
</CardContent>
</Card>
)}
{/* Line items */}
<Card>
<CardHeader>
<CardTitle className="text-base">{t('rows_title')}</CardTitle>
</CardHeader>
<CardContent>
{/* Desktop table. The cells carry no horizontal padding of their
own, so every column after the first gets a pl-4 gutter: without
it a right-aligned column runs flush into the next left-aligned
one ("8 985 kr5010", "ANTALENHET"). */}
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground [&_th:not(:first-child)]:pl-4">
<tr className="border-b text-left">
<th className="pb-2">{t('col_description')}</th>
<th className="pb-2 w-16 text-right">{t('col_quantity')}</th>
<th className="pb-2 w-16">{t('col_unit')}</th>
<th className="pb-2 w-28 text-right">{t('col_unit_price')}</th>
<th className="pb-2 w-20">{t('col_account')}</th>
<th className="pb-2 w-16 text-right">{t('col_vat_rate')}</th>
<th className="pb-2 w-28 text-right">{t('col_amount')}</th>
<th className="pb-2 w-24 text-right">{t('col_vat')}</th>
</tr>
</thead>
<tbody className="[&_td:not(:first-child)]:pl-4">
{items.map((item) => (
<tr key={item.id} className="border-b last:border-0">
<td className="py-2">
{item.description}
{itemHasAccrual(item) && (
<p className="mt-1 flex items-center gap-1 text-xs text-muted-foreground">
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="tabular-nums">
{t('accrual_line_info', {
from: accrualMonth(item.accrual_period_start!),
to: accrualMonth(item.accrual_period_end!),
})}
{item.accrual_balance_account && ` · ${item.accrual_balance_account}`}
</span>
</p>
)}
</td>
<td className="py-2 text-right">{item.quantity}</td>
<td className="py-2">{item.unit}</td>
<td className="py-2 text-right tabular-nums">{formatCurrency(item.unit_price, invoice.currency)}</td>
<td className="py-2"><AccountNumber number={item.account_number} /></td>
<td className="py-2 text-right">{Math.round(item.vat_rate * 100)}%</td>
<td className="py-2 text-right tabular-nums">{formatCurrency(item.line_total, invoice.currency)}</td>
<td className="py-2 text-right tabular-nums">{formatCurrency(item.vat_amount, invoice.currency)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden space-y-3">
{/* Invoice lines: the list-page table idiom straight on the panel, with
the totals as a right-aligned block and the total in the serif. */}
<DetailSection kicker={t('rows_title')}>
<table className="hidden w-full border-collapse text-[13px] md:table">
<thead>
<tr>
<th className={cn(TH_CLASS, 'pl-0')}>{t('col_description')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('col_quantity')}</th>
<th className={TH_CLASS}>{t('col_unit')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('col_unit_price')}</th>
<th className={TH_CLASS}>{t('col_account')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('col_vat_rate')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('col_amount')}</th>
<th className={cn(TH_CLASS, 'pr-0 text-right')}>{t('col_vat')}</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<div key={item.id} className="border rounded-lg p-3 space-y-1.5">
<div className="font-medium text-sm">{item.description}</div>
{itemHasAccrual(item) && (
<p className="flex items-center gap-1 text-xs text-muted-foreground">
<CalendarClock className="h-3 w-3 shrink-0" />
<span className="tabular-nums">
{t('accrual_line_info', {
from: accrualMonth(item.accrual_period_start!),
to: accrualMonth(item.accrual_period_end!),
})}
{item.accrual_balance_account && ` · ${item.accrual_balance_account}`}
</span>
</p>
)}
<div className="flex items-center justify-between text-sm text-muted-foreground">
<span>{item.quantity} {item.unit} × {formatCurrency(item.unit_price, invoice.currency)}</span>
<span className="tabular-nums">{formatCurrency(item.line_total, invoice.currency)}</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
<span><AccountNumber number={item.account_number} /> · {t('vat_inline', { rate: Math.round(item.vat_rate * 100) })}</span>
<span className="tabular-nums">{t('vat_amount_inline', { amount: formatCurrency(item.vat_amount, invoice.currency) })}</span>
</div>
</div>
<tr key={item.id}>
<td className={cn(TD_CLASS, 'pl-0')}>
{item.description}
{accrualInfo(item)}
</td>
<td className={cn(TD_CLASS, 'text-right tabular-nums')}>{item.quantity}</td>
<td className={cn(TD_CLASS, 'text-muted-foreground')}>{item.unit}</td>
<td className={cn(TD_CLASS, 'text-right tabular-nums')}>
{formatCurrency(item.unit_price, invoice.currency)}
</td>
<td className={TD_CLASS}><AccountNumber number={item.account_number} /></td>
<td className={cn(TD_CLASS, 'text-right tabular-nums')}>{Math.round(item.vat_rate * 100)}%</td>
<td className={cn(TD_CLASS, 'text-right tabular-nums')}>
{formatCurrency(item.line_total, invoice.currency)}
</td>
<td className={cn(TD_CLASS, 'pr-0 text-right tabular-nums')}>
{formatCurrency(item.vat_amount, invoice.currency)}
</td>
</tr>
))}
</div>
</CardContent>
</Card>
</tbody>
</table>
{/* Payment history */}
{payments.length > 0 && (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('payment_history_title')}</CardTitle>
</CardHeader>
<CardContent>
{/* Desktop table: same non-first-column gutter as the line-item
table above ("11 231 krb9da9d7c" otherwise). */}
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground [&_th:not(:first-child)]:pl-4">
<tr className="border-b text-left">
<th className="pb-2">{t('col_date')}</th>
<th className="pb-2 text-right">{t('col_amount_short')}</th>
<th className="pb-2">{t('col_voucher')}</th>
<th className="pb-2">{t('col_note')}</th>
</tr>
</thead>
<tbody className="[&_td:not(:first-child)]:pl-4">
{payments.map((p) => (
<tr key={p.id} className="border-b last:border-0">
<td className="py-2 tabular-nums">{formatDate(p.payment_date)}</td>
<td className="py-2 text-right tabular-nums">{formatCurrency(p.amount, p.currency)}</td>
<td className="py-2">
{p.journal_entry_id ? (
<Link href={`/bookkeeping/${p.journal_entry_id}`} className="text-primary hover:underline font-mono text-xs">
{p.journal_entry_id.substring(0, 8)}...
</Link>
) : '-'}
</td>
<td className="py-2 text-muted-foreground">{p.notes || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile cards */}
<div className="sm:hidden space-y-3">
{payments.map((p) => (
<div key={p.id} className="border rounded-lg p-3 space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="tabular-nums">{formatDate(p.payment_date)}</span>
<span className="font-mono font-medium">{formatCurrency(p.amount, p.currency)}</span>
</div>
<div className="flex items-center justify-between text-xs text-muted-foreground">
{p.journal_entry_id ? (
<Link href={`/bookkeeping/${p.journal_entry_id}`} className="text-primary hover:underline font-mono">
{p.journal_entry_id.substring(0, 8)}...
</Link>
) : <span>-</span>}
<span>{p.notes || ''}</span>
</div>
</div>
))}
</div>
</CardContent>
</Card>
)}
{invoice.document_id && (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('document_title')}</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex items-center gap-2 text-sm text-muted-foreground">
<Paperclip className="h-4 w-4 shrink-0" />
<span>{t('document_attached')}</span>
{/* Narrow screens: one flat row per line, no numeric columns to cram. */}
<div className="divide-y divide-border text-sm md:hidden">
{items.map((item) => (
<div key={item.id} className="flex items-start justify-between gap-4 py-3">
<div className="min-w-0">
<p>{item.description}</p>
<p className="text-xs text-muted-foreground tabular-nums">
{item.quantity} {item.unit} × {formatCurrency(item.unit_price, invoice.currency)}
</p>
<p className="text-xs text-muted-foreground tabular-nums">
<AccountNumber number={item.account_number} />
{' · '}{t('vat_inline', { rate: Math.round(item.vat_rate * 100) })}
{' · '}{t('vat_amount_inline', { amount: formatCurrency(item.vat_amount, invoice.currency) })}
</p>
{accrualInfo(item)}
</div>
<DocumentViewButton
documentId={invoice.document_id}
label={t('view_document')}
/>
<span className="shrink-0 tabular-nums">
{formatCurrency(item.line_total, invoice.currency)}
</span>
</div>
</CardContent>
</Card>
)}
))}
</div>
{/* Journal entries (sambandskrav) */}
<Card>
<CardHeader>
<CardTitle className="text-base">{t('vouchers_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm">
{invoice.registration_journal_entry_id ? (
<div className="flex justify-between">
<span className="text-muted-foreground">{t('registration_voucher')}</span>
<Link
href={`/bookkeeping/${invoice.registration_journal_entry_id}`}
className="text-primary hover:underline font-mono"
>
{invoice.registration_journal_entry_id.substring(0, 8)}...
</Link>
<div className="ml-auto mt-4 w-full max-w-xs space-y-1 text-sm tabular-nums">
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">{t('net_excl_vat')}</span>
<span>{formatCurrency(invoice.subtotal, invoice.currency)}</span>
</div>
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">{t('vat_label')}</span>
<span>{formatCurrency(invoice.vat_amount, invoice.currency)}</span>
</div>
{rounding.applies && (
<div className="flex justify-between gap-4">
<span className="text-muted-foreground">{t('ore_rounding')}</span>
<span>{formatCurrency(rounding.roundingDelta, invoice.currency)}</span>
</div>
) : companySettings?.accounting_method === 'accrual' &&
!invoice.is_credit_note &&
['registered', 'approved', 'overdue'].includes(invoice.status) ? (
// #967: registered-without-booking (deferred booking). Ekonomi
// books the registration verifikat from here.
<div className="flex items-center justify-between gap-3">
)}
<div className="flex items-baseline justify-between gap-4 border-t border-border pt-2">
<span>{t('total_label')}</span>
<span className="font-display text-xl">{formatCurrency(rounding.displayed, invoice.currency)}</span>
</div>
</div>
</DetailSection>
{/* Payment: paid / remaining always, plus the payment events once any
exist, so a partly paid invoice exposes what is still open. */}
<DetailSection kicker={t('payment_section')}>
<DefRow label={t('paid_label')}>
<span className="tabular-nums">{formatCurrency(invoice.paid_amount, invoice.currency)}</span>
</DefRow>
<DefRow label={t('remaining_label')}>
<span className={cn('tabular-nums', invoice.status === 'partially_paid' && 'text-attn')}>
{formatCurrency(invoice.remaining_amount, invoice.currency)}
</span>
</DefRow>
{payments.length > 0 && (
<DefRow label={t('payment_history_title')} className="items-baseline">
<ul className="divide-y divide-border">
{payments.map((p) => (
<li key={p.id} className="flex flex-wrap items-center gap-x-4 gap-y-1 py-2 first:pt-0 last:pb-0">
<span className="tabular-nums text-muted-foreground">{formatDate(p.payment_date)}</span>
<span className="tabular-nums">{formatCurrency(p.amount, p.currency)}</span>
{p.notes && <span className="min-w-0 truncate text-muted-foreground">{p.notes}</span>}
{p.journal_entry_id && (
<Link
href={`/bookkeeping/${p.journal_entry_id}`}
className="ml-auto text-xs text-muted-foreground hover:text-foreground hover:underline"
>
{t('view_voucher')}
</Link>
)}
</li>
))}
</ul>
</DefRow>
)}
</DetailSection>
{/* Verifikat and underlag (sambandskrav): the booking this invoice
produced, and the supplier's document it rests on. */}
<DetailSection kicker={t('vouchers_title')}>
<DefRow label={t('registration_voucher')}>
{invoice.registration_journal_entry_id ? (
<Link href={`/bookkeeping/${invoice.registration_journal_entry_id}`} className="hover:underline">
{t('view_voucher')}
</Link>
) : canBookAfterwards ? (
<span className="flex flex-wrap items-center gap-3">
<span className="text-muted-foreground">{t('not_booked_yet')}</span>
<Button
size="sm"
variant="outline"
className="-my-1"
onClick={handleBook}
disabled={isProcessing || !canWrite}
title={!canWrite ? t('viewer_disabled_tooltip') : undefined}
>
{processingAction === 'book' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : canWrite ? (
<CheckCircle className="mr-2 h-4 w-4" />
) : (
) : !canWrite ? (
<Lock className="mr-2 h-4 w-4" />
)}
) : null}
{t('book_action')}
</Button>
</div>
</span>
) : (
<p className="text-muted-foreground">{t('no_registration_voucher')}</p>
<span className="text-muted-foreground">{t('no_registration_voucher')}</span>
)}
{invoice.payment_journal_entry_id && (
<div className="flex justify-between">
<span className="text-muted-foreground">{t('payment_voucher')}</span>
<Link
href={`/bookkeeping/${invoice.payment_journal_entry_id}`}
className="text-primary hover:underline font-mono"
>
{invoice.payment_journal_entry_id.substring(0, 8)}...
</Link>
</div>
)}
</CardContent>
</Card>
</DefRow>
{invoice.payment_journal_entry_id && (
<DefRow label={t('payment_voucher')}>
<Link href={`/bookkeeping/${invoice.payment_journal_entry_id}`} className="hover:underline">
{t('view_voucher')}
</Link>
</DefRow>
)}
{invoice.document_id && (
<DefRow label={t('document_title')}>
<span className="text-muted-foreground">{t('document_attached')}</span>
</DefRow>
)}
</DetailSection>
{/* Notes */}
{invoice.notes && (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('notes_title')}</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{invoice.notes}</p>
</CardContent>
</Card>
<DetailSection kicker={t('notes_title')}>
<p className="py-2 text-sm text-muted-foreground whitespace-pre-wrap">{invoice.notes}</p>
</DetailSection>
)}
<DestructiveConfirmDialog {...confirmDialogProps} />