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
+2
View File
@@ -1105,3 +1105,5 @@ One line per decision: `[YYYY-MM-DD] <decision>: <why>`. Appended by agents and
[2026-08-19] The CI build OOM is the TYPE-CHECK pass, not bundle growth: measured with tsc --extendedDiagnostics the repo needs ~4.19 GB at 506d030b and ~4.19 GB on a branch on top of it, i.e. a steady-state ceiling against Node 20 default old-space (~4 GB), not any one PR's regression. Fixed on main independently by raising the build heap to 8192, which this branch keeps; recording the measurement so the next person does not go hunting in a diff. Vercel builds already run with a larger heap and were never affected.
[2026-08-20] Bokio getCompany accepts both the spec envelope and the live flat body: the published v1 spec (bokio/bokio-api company-api.yaml) wraps company-information in `companyInformation`, but api.bokio.se/v1 returned the company object flat on a 200 in prod (BokioResponseError in logs, customer script showed the same). Tolerating both instead of picking one means a spec/live drift in either direction can no longer turn a valid integration token into a connection failure.
[2026-08-20] Detail pages (kundfaktura first, then the stale card-pile siblings: leverantörsfaktura, verifikat, kreditfaktura, avyttring, lön) adopt the register-detail document grammar from #1624 instead of card stacks: DetailSection/DefRow groups, 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, the line table on the dry-table idiom with the headline total in the serif. Considered keeping a two-column card sidebar with fewer cards; rejected because the card border carried no hierarchy the hairline kicker does not already carry, and a second column of stacked boxes is exactly what the founder called clutter.
+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} />
+46 -66
View File
@@ -3,9 +3,8 @@
import Link from 'next/link'
import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { Info } from 'lucide-react'
import JournalEntryStatusBadge from '@/components/bookkeeping/JournalEntryStatusBadge'
import { formatDate, formatCurrency } from '@/lib/utils'
import { formatDate, formatCurrency, cn } from '@/lib/utils'
import { formatVoucher } from '@/lib/bookkeeping/voucher-series-resolver'
import type { JournalEntry, JournalEntryLine } from '@/types'
@@ -16,14 +15,10 @@ interface Props {
function useGetRole() {
const t = useTranslations('journal_correction')
return (entry: JournalEntry): { label: string; color: string } => {
if (entry.source_type === 'storno') {
return { label: t('role_storno'), color: 'bg-destructive' }
}
if (entry.source_type === 'correction') {
return { label: t('role_correction'), color: 'bg-primary' }
}
return { label: t('role_original'), color: 'bg-muted-foreground' }
return (entry: JournalEntry): string => {
if (entry.source_type === 'storno') return t('role_storno')
if (entry.source_type === 'correction') return t('role_correction')
return t('role_original')
}
}
@@ -32,6 +27,12 @@ function getTotal(entry: JournalEntry): number {
return lines.reduce((sum, l) => sum + (Number(l.debit_amount) || 0), 0)
}
/**
* The storno/rättelse chain as a flat chronological list: one hairline row
* per verifikat (role, voucher, date, description, amount). The page owns the
* kicker and puts the explanatory copy behind its "?" (convention 7), so this
* renders no heading and no info box of its own.
*/
export default function CorrectionChain({ currentEntryId, chain }: Props) {
const t = useTranslations('journal_correction')
const getRole = useGetRole()
@@ -44,62 +45,41 @@ export default function CorrectionChain({ currentEntryId, chain }: Props) {
)
return (
<div className="space-y-3">
<h3 className="text-sm">{t('title')}</h3>
<ul className="divide-y divide-border text-sm">
{sorted.map((entry) => {
const total = getTotal(entry)
const isCurrent = entry.id === currentEntryId
// A cancelled entry is residue from an aborted correction attempt:
// it was voided before taking effect and its lines were removed, so
// it always sums to 0,00. Without the status chip it renders exactly
// like a live storno: dim it and say what it is. Live rows carry no
// chip at all (chips mark exceptions); the role label says storno
// or rättelse.
const isCancelled = entry.status === 'cancelled'
<div className="rounded-lg bg-muted/50 border p-3 flex gap-2 text-sm text-muted-foreground">
<Info className="h-4 w-4 shrink-0 mt-0.5" />
<p>{t('info')}</p>
</div>
<div className="relative space-y-0">
{/* Vertical line connecting nodes */}
<div className="absolute left-[7px] top-3 bottom-3 w-px bg-border" />
{sorted.map((entry) => {
const role = getRole(entry)
const total = getTotal(entry)
const isCurrent = entry.id === currentEntryId
// A cancelled entry is residue from an aborted correction attempt:
// it was voided before taking effect and its lines were removed, so
// it always sums to 0,00. Without the status badge it renders
// exactly like a live storno: dim it and say what it is.
const isCancelled = entry.status === 'cancelled'
return (
<Link
key={entry.id}
href={`/bookkeeping/${entry.id}`}
className="block"
>
<div className={`relative pl-7 py-2 rounded-sm transition-colors hover:bg-muted/50 ${isCurrent ? 'bg-muted/30' : ''} ${isCancelled ? 'opacity-60' : ''}`}>
{/* Timeline dot */}
<div className={`absolute left-0.5 top-[18px] h-3 w-3 rounded-full border-2 border-background ${isCancelled ? 'bg-muted-foreground' : role.color}`} />
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs font-medium text-muted-foreground">{role.label}</span>
<span className="font-mono text-sm">
{formatVoucher(entry)}
</span>
<span className="text-sm text-muted-foreground tabular-nums">{formatDate(entry.entry_date)}</span>
<JournalEntryStatusBadge entry={entry} showStatus={isCancelled} />
{isCurrent && (
<Badge variant="outline" className="text-[10px] px-1.5 py-0">
{t('current')}
</Badge>
)}
<span className="ml-auto text-sm tabular-nums text-muted-foreground">
{formatCurrency(total)}
</span>
</div>
{entry.description && (
<p className="text-xs text-muted-foreground truncate mt-0.5">{entry.description}</p>
)}
</div>
</Link>
)
})}
</div>
</div>
return (
<li
key={entry.id}
className={cn('flex flex-wrap items-center gap-x-4 gap-y-1 py-2', isCancelled && 'opacity-60')}
>
<span className="w-16 shrink-0 text-xs text-muted-foreground">{getRole(entry)}</span>
{isCurrent ? (
<span className="tabular-nums">{formatVoucher(entry)}</span>
) : (
<Link href={`/bookkeeping/${entry.id}`} className="tabular-nums hover:underline">
{formatVoucher(entry)}
</Link>
)}
<span className="tabular-nums text-muted-foreground">{formatDate(entry.entry_date)}</span>
{entry.description && (
<span className="min-w-0 flex-1 truncate text-muted-foreground">{entry.description}</span>
)}
{isCancelled && <JournalEntryStatusBadge entry={entry} />}
{isCurrent && <Badge variant="outline">{t('current')}</Badge>}
<span className="ml-auto tabular-nums text-muted-foreground">{formatCurrency(total)}</span>
</li>
)
})}
</ul>
)
}
@@ -13,6 +13,7 @@ import {
DialogFooter,
} from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { cn } from '@/lib/utils'
import { openDeferredTab } from '@/lib/browser/deferred-tab'
import {
FileText,
@@ -46,6 +47,14 @@ interface DocumentRecord {
interface JournalEntryAttachmentsProps {
journalEntryId: string
onCountChange?: (count: number) => void
/**
* 'section': embedded under a DetailSection kicker that already names the
* group and states the empty case (verifikat detail page). Drops the own
* border, heading and empty line so nothing is said twice; the action
* buttons and the document list stay. Default keeps the self-contained
* foldout look used by JournalEntryList.
*/
variant?: 'default' | 'section'
}
function formatFileSize(bytes: number): string {
@@ -69,7 +78,9 @@ function isPreviewable(type: string | null): boolean {
export default function JournalEntryAttachments({
journalEntryId,
onCountChange,
variant = 'default',
}: JournalEntryAttachmentsProps) {
const embedded = variant === 'section'
const t = useTranslations('journal_attachments')
const tCommon = useTranslations('common')
const { toast } = useToast()
@@ -251,7 +262,7 @@ export default function JournalEntryAttachments({
}
return (
<div className="border-t pt-3 mt-3">
<div className={embedded ? undefined : 'border-t pt-3 mt-3'}>
<input
ref={replaceFileInputRef}
type="file"
@@ -260,10 +271,12 @@ export default function JournalEntryAttachments({
onChange={(e) => handleReplaceFileSelected(e.target.files?.[0] ?? null)}
/>
<div className="flex items-center justify-between mb-2">
<h4 className="text-sm font-medium">
{t('title')} {documents.length > 0 && `(${documents.length})`}
</h4>
<div className={cn('mb-2 flex items-center gap-2', embedded ? 'justify-end' : 'justify-between')}>
{!embedded && (
<h4 className="text-sm font-medium">
{t('title')} {documents.length > 0 && `(${documents.length})`}
</h4>
)}
<div className="flex items-center gap-2">
<Button
variant="outline"
@@ -298,9 +311,11 @@ export default function JournalEntryAttachments({
)}
{documents.length === 0 && !showUpload ? (
<p className="text-sm text-muted-foreground py-1">
{t('empty')}
</p>
!embedded && (
<p className="text-sm text-muted-foreground py-1">
{t('empty')}
</p>
)
) : (
<div className="space-y-1">
{documents.map((doc) => {
+150 -176
View File
@@ -1,11 +1,10 @@
'use client'
import { useFormatter, useTranslations } from 'next-intl'
import { ExternalLink, Mail, Send } from 'lucide-react'
import { ChevronRight, ExternalLink } from 'lucide-react'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Separator } from '@/components/ui/separator'
import { DetailSection } from '@/components/ui/detail-section'
import { HelpPopover } from '@/components/ui/help-popover'
import type { InvoiceDelivery, InvoiceDeliveryProviderStatus } from '@/types'
export type InvoiceDeliveryView = Pick<
@@ -52,18 +51,15 @@ function outcomeOf(delivery: InvoiceDeliveryView): DeliveryOutcome {
return delivery.status
}
// Green is reserved for a confirmed arrival. "Skickad" without a delivery
// report is a neutral, honest in-between state.
const outcomeVariant: Record<DeliveryOutcome, 'secondary' | 'success' | 'warning' | 'destructive' | 'outline'> = {
pending: 'secondary',
sent: 'secondary',
delivered: 'success',
// Chips mark exceptions (convention 5): a confirmed arrival, a plain handoff
// and a manual mark read as muted text; only the outcomes that need a second
// look get a chip. Green is never chrome here.
const outcomeVariant: Partial<Record<DeliveryOutcome, 'warning' | 'destructive'>> = {
delayed: 'warning',
complained: 'warning',
bounced: 'destructive',
failed: 'destructive',
suppressed: 'destructive',
marked_sent: 'outline',
}
export function InvoiceDeliveryHistory({
@@ -86,201 +82,179 @@ export function InvoiceDeliveryHistory({
})
return (
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Mail className="h-5 w-5" />
{t('delivery_history_title')}
</CardTitle>
<CardDescription>{t('delivery_history_description')}</CardDescription>
</CardHeader>
<CardContent>
{deliveries.length === 0 ? (
<div className="rounded-lg border border-dashed p-4 text-sm text-muted-foreground">
<p className="font-medium text-foreground">{t('delivery_history_legacy_title')}</p>
<p className="mt-1">{t('delivery_history_legacy_description')}</p>
</div>
) : (
<div className="space-y-3">
{deliveries.map((delivery) => {
const occurredAt = delivery.sent_at || delivery.failed_at || delivery.created_at
const isManual = delivery.channel === 'manual'
const outcome = outcomeOf(delivery)
const isEmailSend = !isManual && delivery.status === 'sent'
const recipientCount =
delivery.to_addresses.length + delivery.cc_addresses.length
const recipientStatuses = delivery.provider_recipient_statuses ?? {}
const hasRecipientStatuses = Object.keys(recipientStatuses).length > 0
const recipientRows = [
...delivery.to_addresses.map((address, index) => ({
address,
label: t('delivery_to_label'),
reference: `to:${index + 1}`,
outcome: recipientStatuses[`to:${index + 1}`],
})),
...delivery.cc_addresses.map((address, index) => ({
address,
label: t('delivery_cc_label'),
reference: `cc:${index + 1}`,
outcome: recipientStatuses[`cc:${index + 1}`],
})),
]
<DetailSection
kicker={t('delivery_history_title')}
help={<HelpPopover>{t('delivery_history_description')}</HelpPopover>}
>
{deliveries.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('delivery_history_legacy_description')}</p>
) : (
<div className="divide-y divide-border">
{deliveries.map((delivery) => {
const occurredAt = delivery.sent_at || delivery.failed_at || delivery.created_at
const isManual = delivery.channel === 'manual'
const outcome = outcomeOf(delivery)
const outcomeChip = outcomeVariant[outcome]
const isEmailSend = !isManual && delivery.status === 'sent'
const recipientCount =
delivery.to_addresses.length + delivery.cc_addresses.length
const recipientStatuses = delivery.provider_recipient_statuses ?? {}
const hasRecipientStatuses = Object.keys(recipientStatuses).length > 0
const recipientRows = [
...delivery.to_addresses.map((address, index) => ({
address,
label: t('delivery_to_label'),
reference: `to:${index + 1}`,
outcome: recipientStatuses[`to:${index + 1}`],
})),
...delivery.cc_addresses.map((address, index) => ({
address,
label: t('delivery_cc_label'),
reference: `cc:${index + 1}`,
outcome: recipientStatuses[`cc:${index + 1}`],
})),
]
return (
<details key={delivery.id} className="group rounded-lg border bg-card">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-3 px-4 py-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 [&::-webkit-details-marker]:hidden">
<span className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-muted">
{isManual ? <Send className="h-4 w-4" /> : <Mail className="h-4 w-4" />}
</span>
<span className="min-w-0 flex-1">
<span className="block truncate text-sm font-medium">
{isManual ? t('delivery_channel_manual') : t('delivery_channel_email')}
</span>
<span className="block text-xs text-muted-foreground tabular-nums">
{formatTimestamp(occurredAt)}
</span>
</span>
<Badge variant={outcomeVariant[outcome]}>
{t(`delivery_status_${outcome}`)}
</Badge>
</summary>
return (
<details key={delivery.id} className="group">
<summary className="flex min-h-11 cursor-pointer list-none items-center gap-3 py-2 text-sm transition-colors duration-150 hover:bg-secondary/35 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring [&::-webkit-details-marker]:hidden">
<ChevronRight className="h-3.5 w-3.5 shrink-0 text-muted-foreground transition-transform duration-150 group-open:rotate-90" />
<span className="tabular-nums text-muted-foreground">{formatTimestamp(occurredAt)}</span>
<span className="min-w-0 flex-1 truncate">
{isManual ? t('delivery_channel_manual') : t('delivery_channel_email')}
</span>
{outcomeChip ? (
<Badge variant={outcomeChip}>{t(`delivery_status_${outcome}`)}</Badge>
) : (
<span className="text-xs text-muted-foreground">{t(`delivery_status_${outcome}`)}</span>
)}
</summary>
<div className="px-4 pb-4">
<Separator className="mb-4" />
{isManual ? (
<p className="text-sm text-muted-foreground">
{t('delivery_manual_unknown_details')}
</p>
) : (
<div className="space-y-4 text-sm">
<dl className="grid gap-3 sm:grid-cols-[8rem_minmax(0,1fr)]">
<dt className="text-muted-foreground">{t('delivery_to_label')}</dt>
<dd className="break-words">{delivery.to_addresses.join(', ')}</dd>
{delivery.cc_addresses.length > 0 && (
<>
<dt className="text-muted-foreground">{t('delivery_cc_label')}</dt>
<dd className="break-words">{delivery.cc_addresses.join(', ')}</dd>
</>
)}
</dl>
{isEmailSend && (
<div className="rounded-lg border bg-muted/30 p-3">
<div className="flex flex-wrap items-center gap-2">
<span className="font-medium">
{t('delivery_provider_status_label')}
</span>
<div className="pb-4 pl-6 pt-1">
{isManual ? (
<p className="text-sm text-muted-foreground">
{t('delivery_manual_unknown_details')}
</p>
) : (
<dl className="grid gap-x-6 gap-y-1 text-sm sm:grid-cols-[8rem_minmax(0,1fr)]">
<dt className="text-muted-foreground">{t('delivery_to_label')}</dt>
<dd className="break-words">{delivery.to_addresses.join(', ')}</dd>
{delivery.cc_addresses.length > 0 && (
<>
<dt className="text-muted-foreground">{t('delivery_cc_label')}</dt>
<dd className="break-words">{delivery.cc_addresses.join(', ')}</dd>
</>
)}
{isEmailSend && (
<>
<dt className="text-muted-foreground">{t('delivery_provider_status_label')}</dt>
<dd>
<span className="flex flex-wrap items-center gap-2">
<span>{t(`delivery_status_${outcome}`)}</span>
{delivery.provider_status_at && (
<span className="text-xs text-muted-foreground tabular-nums">
{formatTimestamp(delivery.provider_status_at)}
</span>
)}
</div>
</span>
<p className="mt-1 text-xs text-muted-foreground">
{hasRecipientStatuses
? t('delivery_recipient_statuses_summary')
: t(`delivery_status_explanation_${outcome}`)}
</p>
{hasRecipientStatuses && (
<div className="mt-3 border-t pt-3">
<p className="mb-2 text-xs font-medium">
{t('delivery_recipient_statuses_label')}
</p>
<ul className="space-y-2">
{recipientRows.map((recipient) => (
<li
key={recipient.reference}
className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1"
>
<span className="min-w-0 break-all text-xs">
<span className="text-muted-foreground">
{recipient.label}:
</span>{' '}
{recipient.address}
</span>
{recipient.outcome ? (
<span className="flex items-center gap-2">
{recipient.outcome.status === 'delivered' ? (
<span className="text-xs text-muted-foreground">
{t('delivery_status_delivered')}
</span>
) : (
<Badge variant={outcomeVariant[recipient.outcome.status]}>
{t(`delivery_status_${recipient.outcome.status}`)}
</Badge>
)}
<span className="text-xs text-muted-foreground tabular-nums">
{formatTimestamp(recipient.outcome.status_at)}
</span>
</span>
) : (
<span className="text-xs text-muted-foreground">
{t('delivery_recipient_status_unknown')}
</span>
)}
</li>
))}
</ul>
</div>
)}
{delivery.provider_status_detail && (
<p className="mt-2 break-words text-xs text-muted-foreground">
<p className="mt-1 break-words text-xs text-muted-foreground">
{t('delivery_provider_reason_label')}: {delivery.provider_status_detail}
</p>
)}
{recipientCount > 1
&& delivery.provider_status
&& !hasRecipientStatuses && (
<p className="mt-2 text-xs text-muted-foreground">
<p className="mt-1 text-xs text-muted-foreground">
{t('delivery_status_whole_send_note')}
</p>
)}
</div>
)}
{delivery.document_attachment_id && (
<Button asChild variant="outline" size="sm" className="max-w-full">
</dd>
</>
)}
{isEmailSend && hasRecipientStatuses && (
<>
<dt className="text-muted-foreground">{t('delivery_recipient_statuses_label')}</dt>
<dd>
<ul className="space-y-1">
{recipientRows.map((recipient) => (
<li
key={recipient.reference}
className="flex flex-wrap items-center justify-between gap-x-3 gap-y-1 text-xs"
>
<span className="min-w-0 break-all">
<span className="text-muted-foreground">{recipient.label}:</span>{' '}
{recipient.address}
</span>
{recipient.outcome ? (
<span className="flex items-center gap-2">
{outcomeVariant[recipient.outcome.status] ? (
<Badge variant={outcomeVariant[recipient.outcome.status]}>
{t(`delivery_status_${recipient.outcome.status}`)}
</Badge>
) : (
<span className="text-muted-foreground">
{t(`delivery_status_${recipient.outcome.status}`)}
</span>
)}
<span className="text-muted-foreground tabular-nums">
{formatTimestamp(recipient.outcome.status_at)}
</span>
</span>
) : (
<span className="text-muted-foreground">
{t('delivery_recipient_status_unknown')}
</span>
)}
</li>
))}
</ul>
</dd>
</>
)}
{delivery.document_attachment_id && (
<>
<dt className="text-muted-foreground">{t('delivery_pdf_label')}</dt>
<dd>
<a
href={`/api/documents/${delivery.document_attachment_id}/inline`}
target="_blank"
rel="noreferrer"
className="inline-flex max-w-full items-center gap-1 hover:underline"
>
<ExternalLink className="mr-2 h-4 w-4" />
<span className="truncate">
{t('delivery_open_pdf', {
filename: delivery.attachment_filename || t('delivery_pdf_fallback'),
})}
{delivery.attachment_filename || t('delivery_pdf_fallback')}
</span>
<ExternalLink className="h-3 w-3 shrink-0 text-muted-foreground" />
</a>
</Button>
)}
{(delivery.provider || delivery.error_code) && (
<dl className="grid gap-2 border-t pt-3 text-xs text-muted-foreground sm:grid-cols-[8rem_minmax(0,1fr)]">
{delivery.provider && (
<>
<dt>{t('delivery_provider_label')}</dt>
<dd>{delivery.provider}</dd>
</>
)}
{delivery.error_code && (
<>
<dt>{t('delivery_error_label')}</dt>
<dd>{delivery.error_code}</dd>
</>
)}
</dl>
)}
</div>
)}
</div>
</details>
)
})}
</div>
)}
</CardContent>
</Card>
</dd>
</>
)}
{delivery.provider && (
<>
<dt className="text-muted-foreground">{t('delivery_provider_label')}</dt>
<dd className="text-muted-foreground">{delivery.provider}</dd>
</>
)}
{delivery.error_code && (
<>
<dt className="text-muted-foreground">{t('delivery_error_label')}</dt>
<dd className="text-muted-foreground">{delivery.error_code}</dd>
</>
)}
</dl>
)}
</div>
</details>
)
})}
</div>
)}
</DetailSection>
)
}
+283 -306
View File
@@ -11,15 +11,15 @@ import {
Link2Off,
Loader2,
Lock,
PlugZap,
Send,
ShieldAlert,
Unlock,
} from 'lucide-react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { InfoTooltip } from '@/components/ui/info-tooltip'
import { DetailSection } from '@/components/ui/detail-section'
import { HelpPopover } from '@/components/ui/help-popover'
import { AttnLine } from '@/components/ui/attn-line'
import { Skeleton } from '@/components/ui/skeleton'
import { useToast } from '@/components/ui/use-toast'
import { UpgradeNote } from '@/components/billing/UpgradeNote'
import { useCapability } from '@/contexts/CompanyContext'
@@ -30,6 +30,7 @@ import {
type AgiSubmissionState,
} from '@/lib/salary/agi-submission-state'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
import { cn, formatDateTime } from '@/lib/utils'
interface AGIPanelProps {
salaryRunId: string
@@ -862,55 +863,40 @@ export function AGIPanel(props: AGIPanelProps) {
if (extensionDisabled) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-2 text-sm text-muted-foreground">
<div className="flex items-start gap-2">
<ShieldAlert className="mt-0.5 h-4 w-4 shrink-0" />
<p>
{t('disabled_before')}
<code className="mx-1 rounded-sm bg-muted px-1 py-0.5 text-xs">SKATTEVERKET_ENABLED</code>
{t('disabled_after')}
</p>
</div>
</CardContent>
</Card>
<DetailSection kicker={t('title')}>
<p className="text-sm text-muted-foreground">
{t('disabled_before')}
<code className="mx-1 rounded-sm bg-muted px-1 py-0.5 text-xs">SKATTEVERKET_ENABLED</code>
{t('disabled_after')}
</p>
</DetailSection>
)
}
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="text-sm text-muted-foreground">
<Loader2 className="mr-2 inline h-4 w-4 animate-spin" /> {t('loading_status')}
</CardContent>
</Card>
<DetailSection kicker={t('title')}>
<div role="status" aria-label={t('loading_status')} className="space-y-2">
<Skeleton className="h-4 w-64" />
<Skeleton className="h-4 w-48" />
</div>
</DetailSection>
)
}
if (!status?.connected) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-muted-foreground">
{t('connect_description')}
</p>
{!readOnly && (
<DetailSection kicker={t('title')}>
<p className="text-sm text-muted-foreground">{t('connect_description')}</p>
{!readOnly && (
<div className="mt-3 flex justify-end">
<Button onClick={handleConnect} disabled={connecting}>
<Link2 className="mr-2 h-4 w-4" />
{connecting ? t('connect_waiting') : t('connect_button')}
</Button>
)}
</CardContent>
</Card>
</div>
)}
</DetailSection>
)
}
@@ -930,6 +916,19 @@ export function AGIPanel(props: AGIPanelProps) {
const missingKvittensScope =
grantedScopes !== null && !missingAgdScope && !grantedScopes.includes('agdredovisningperiod')
// Expired session: the token row exists (so status.connected is true) but
// the access token is past expiry and either has no refresh token or has
// burned through its 10-refresh budget. The only fix is a fresh BankID
// round-trip.
const sessionExpiredStatus = status?.expired === true || status?.canRefresh === false
// One attention sentence per section (convention 6). The expired session
// outranks the missing inlämning scope: nothing can be filed until the
// user has reconnected, and the fresh grant re-evaluates the scope. When
// both hold, the scope notice drops to a muted line so it is still said.
const attn: 'expired' | 'scope' | null =
readOnly ? null : sessionExpiredStatus ? 'expired' : missingAgdScope ? 'scope' : null
// Recovery states expose the advanced actions on their own: the stale-draft
// and error-report guidance below reference them by name.
const forcedAdvanced = draftIsStale || underlagRejected
@@ -937,7 +936,7 @@ export function AGIPanel(props: AGIPanelProps) {
const signedAtRaw =
runSubmission?.signeradTid ?? runSubmission?.submittedAt ?? agiSubmittedAt ?? null
const signedAtText = signedAtRaw ? new Date(signedAtRaw).toLocaleString('sv-SE') : null
const signedAtText = signedAtRaw ? formatDateTime(signedAtRaw) : null
// A signed record without Skatteverket's signeradTid carries our
// reconciliation-time stamp instead (an upper bound on the signing moment,
// see agi-kvittens-reconcile.ts): say so rather than presenting it as the
@@ -956,151 +955,125 @@ export function AGIPanel(props: AGIPanelProps) {
return 'upcoming'
}
const reconnectLabel = connecting ? t('connect_waiting') : t('reconnect_button')
return (
<Card>
<CardHeader>
<CardTitle className="flex items-center justify-between text-base">
<span>{t('title')}</span>
<span className="flex items-center gap-2 text-xs font-normal text-muted-foreground">
<span className="flex items-center gap-1">
<CheckCircle2 className="h-3.5 w-3.5 text-success" />
{t('connected')}
</span>
{!readOnly && (
<button
type="button"
onClick={handleDisconnect}
disabled={actionLoading === 'disconnect' || connecting}
className="inline-flex items-center gap-1 rounded-full border border-border px-1.5 py-0.5 text-[11px] font-normal text-muted-foreground transition-colors hover:border-destructive/50 hover:text-destructive disabled:cursor-not-allowed disabled:opacity-50"
title={t('disconnect_title')}
>
{actionLoading === 'disconnect' ? (
<Loader2 className="h-3 w-3 animate-spin" />
) : (
<PlugZap className="h-3 w-3" />
)}
{t('disconnect_button')}
</button>
)}
</span>
</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{/* Filed: the terminal state deserves more than a gray status row.
Kvittensnummer + signature metadata come from the run-scoped
record, which /agi/status serves from the in-flight cache or,
once the kvittens reconciliation has deleted that cache, from
agi_declarations (#1597). A run stamped only via agi_submitted_at
(an original whose receipt a later correction has replaced) still
gets the card, just without a number: better than showing another
declaration's. */}
<DetailSection
kicker={t('title')}
help={<HelpPopover>{t('granskningsunderlag_gloss')}</HelpPopover>}
aside={
// Connected is the normal state: muted text, no chip (convention 5).
// Disconnect is the quiet action beside it.
<span className="flex items-center gap-3 text-[11px] text-muted-foreground">
<span>{t('connected')}</span>
{!readOnly && (
<button
type="button"
onClick={handleDisconnect}
disabled={actionLoading === 'disconnect' || connecting}
className="inline-flex items-center gap-1 underline decoration-border underline-offset-4 transition-colors duration-150 hover:text-destructive disabled:cursor-not-allowed disabled:opacity-50"
title={t('disconnect_title')}
>
{actionLoading === 'disconnect' && <Loader2 className="h-3 w-3 animate-spin" />}
{t('disconnect_button')}
</button>
)}
</span>
}
>
<div className="space-y-4">
{/* Filed: the terminal state, stated first. Kvittensnummer + signature
metadata come from the run-scoped record, which /agi/status serves
from the in-flight cache or, once the kvittens reconciliation has
deleted that cache, from agi_declarations (#1597). A run stamped
only via agi_submitted_at (an original whose receipt a later
correction has replaced) still gets the lines, just without a
number: better than showing another declaration's. */}
{isSigned && (
<div className="rounded-lg border border-border bg-muted/30 p-4">
<div className="flex items-start gap-3">
<CheckCircle2 className="mt-0.5 h-5 w-5 shrink-0 text-success" />
<div className="space-y-1">
<p className="text-sm font-medium">
{t('success_card_title', { period: prettyPeriod })}
</p>
{runSubmission?.kvittensnummer && (
<p className="text-sm text-muted-foreground tabular-nums">
{t('success_card_kvittens', { kvittens: runSubmission.kvittensnummer })}
</p>
)}
{(runSubmission?.signeradAv || signedAtText) && (
<p className="text-sm text-muted-foreground">
{runSubmission?.signeradAv
? signedAtText
? t(
signedAtEstimated
? 'success_card_signed_by_at_estimated'
: 'success_card_signed_by_at',
{ name: runSubmission.signeradAv, date: signedAtText },
)
: t('success_card_signed_by', { name: runSubmission.signeradAv })
: t(
signedAtEstimated
? 'success_card_signed_at_estimated'
: 'success_card_signed_at',
{ date: signedAtText ?? '' },
)}
</p>
)}
</div>
</div>
<div className="space-y-1 text-sm">
<p className="font-medium">{t('success_card_title', { period: prettyPeriod })}</p>
{runSubmission?.kvittensnummer && (
<p className="text-muted-foreground tabular-nums">
{t('success_card_kvittens', { kvittens: runSubmission.kvittensnummer })}
</p>
)}
{(runSubmission?.signeradAv || signedAtText) && (
<p className="text-muted-foreground">
{runSubmission?.signeradAv
? signedAtText
? t(
signedAtEstimated
? 'success_card_signed_by_at_estimated'
: 'success_card_signed_by_at',
{ name: runSubmission.signeradAv, date: signedAtText },
)
: t('success_card_signed_by', { name: runSubmission.signeradAv })
: t(
signedAtEstimated
? 'success_card_signed_at_estimated'
: 'success_card_signed_at',
{ date: signedAtText ?? '' },
)}
</p>
)}
</div>
)}
{/* Expired-session banner: the token row exists (so status.connected
is true) but the access token is past expiry and either has no
refresh token or has burned through its 10-refresh budget. The
only fix is a fresh BankID round-trip. */}
{(status?.expired === true || status?.canRefresh === false) && !readOnly && (
<div className="rounded-lg border border-border bg-muted/30 p-3">
<p className="text-sm font-medium">{t('expired_banner_title')}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('expired_banner_description')}
</p>
<Button size="sm" variant="outline" className="mt-2" onClick={handleConnect} disabled={connecting}>
<Link2 className="mr-1.5 h-3.5 w-3.5" />
{connecting ? t('connect_waiting') : t('reconnect_button')}
</Button>
</div>
{attn === 'expired' && (
<AttnLine
action={{
label: reconnectLabel,
onClick: () => {
if (!connecting) handleConnect()
},
}}
>
{t('expired_banner_title')}. {t('expired_banner_description')}
</AttnLine>
)}
{/* Missing-scope nudges: proactive, before the user hits a 403
invalid_scope. The agd scope was added after some users had
already connected, so their stored token grants moms/skattekonto
but not AGI: that one stays a hard nudge. The kvittens scope only
breaks the final receipt fetch and may not be grantable yet, so
its notice is softer and dismissible. */}
{missingAgdScope && !readOnly && (
<div className="rounded-lg border border-border bg-muted/30 p-3">
<p className="text-sm font-medium">
{t('missing_scope_title')}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('missing_scope_description')}
</p>
<a
href="/settings/tax"
className="mt-2 inline-flex items-center gap-1 text-sm font-medium hover:underline"
>
{t('open_settings')} <ExternalLink className="h-3.5 w-3.5" />
but not AGI: that one is the hard nudge (the attention line, or a
muted line when the expired session already holds it). The
kvittens scope only breaks the final receipt fetch and may not be
grantable yet, so its notice is softer and dismissible. */}
{attn === 'scope' && (
<AttnLine action={{ label: t('open_settings'), href: '/settings/tax' }}>
{t('missing_scope_title')}. {t('missing_scope_description')}
</AttnLine>
)}
{missingAgdScope && !readOnly && attn !== 'scope' && (
<p className="text-xs text-muted-foreground">
{t('missing_scope_title')}. {t('missing_scope_description')}{' '}
<a href="/settings/tax" className="underline underline-offset-2 hover:text-foreground">
{t('open_settings')}
</a>
</div>
</p>
)}
{missingKvittensScope && !kvittensNoticeDismissed && !readOnly && (
<div className="rounded-lg border border-border bg-muted/30 p-3">
<p className="text-sm font-medium">
{t('kvittens_scope_title')}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('kvittens_scope_description')}
</p>
<div className="mt-2 flex items-center gap-4">
<a
href="/settings/tax"
className="inline-flex items-center gap-1 text-sm font-medium hover:underline"
>
{t('open_settings')} <ExternalLink className="h-3.5 w-3.5" />
</a>
<button
type="button"
onClick={dismissKvittensNotice}
className="text-sm text-muted-foreground hover:underline"
>
{t('kvittens_scope_dismiss')}
</button>
</div>
</div>
<p className="text-xs text-muted-foreground">
<span className="text-foreground">{t('kvittens_scope_title')}.</span>{' '}
{t('kvittens_scope_description')}{' '}
<a href="/settings/tax" className="underline underline-offset-2 hover:text-foreground">
{t('open_settings')}
</a>{' '}
<button
type="button"
onClick={dismissKvittensNotice}
className="underline underline-offset-2 hover:text-foreground"
>
{t('kvittens_scope_dismiss')}
</button>
</p>
)}
{/* Status summary */}
<div className="space-y-1.5 text-sm">
{/* Status summary: one line per step, hairlines between. */}
<div className="divide-y divide-border text-sm">
<StatusRow
ok={!!agiGeneratedAt}
okText={agiGeneratedAt ? t('file_generated', { date: new Date(agiGeneratedAt).toLocaleString('sv-SE') }) : ''}
okText={agiGeneratedAt ? t('file_generated', { date: formatDateTime(agiGeneratedAt) }) : ''}
pendingText={t('file_not_generated')}
/>
<StatusRow
@@ -1109,7 +1082,7 @@ export function AGIPanel(props: AGIPanelProps) {
runSubmission?.kvittensnummer
? t('submitted_with_kvittens', { kvittens: runSubmission.kvittensnummer })
: agiSubmittedAt
? t('submitted_at', { date: new Date(agiSubmittedAt).toLocaleString('sv-SE') })
? t('submitted_at', { date: formatDateTime(agiSubmittedAt) })
: t('submitted')
}
pendingText={
@@ -1130,20 +1103,14 @@ export function AGIPanel(props: AGIPanelProps) {
treatment so the user understands they must fix errors before
BankID signing is even possible. */}
{submission?.signeringslank && awaitingSigning && !draftIsStale && (
<div className="rounded-lg border border-border bg-muted/30 p-3">
<p className="text-sm font-medium">
<InfoTooltip variant="help" content={t('granskningsunderlag_gloss')}>
{t('draft_locked_title')}
</InfoTooltip>
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
{t('draft_locked_description')}
</p>
<div className="text-sm">
<p className="font-medium">{t('draft_locked_title')}</p>
<p className="mt-1 text-xs text-muted-foreground">{t('draft_locked_description')}</p>
<a
href={submission.signeringslank}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 text-sm font-medium hover:underline"
className="mt-2 inline-flex items-center gap-1 font-medium underline underline-offset-2 hover:opacity-80"
>
{t('open_signing_link')} <ExternalLink className="h-3.5 w-3.5" />
</a>
@@ -1156,13 +1123,13 @@ export function AGIPanel(props: AGIPanelProps) {
it would file the old amounts. The "Lås upp" button below releases
the SKV lock; the user then re-submits the freshly generated XML. */}
{awaitingSigning && draftIsStale && (
<div className="rounded-lg border border-border bg-muted/30 p-3">
<p className="text-sm font-medium">{t('stale_draft_title')}</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<div className="text-sm">
<p className="font-medium">{t('stale_draft_title')}</p>
<p className="mt-1 text-xs text-muted-foreground">
{t('stale_draft_description', {
generatedAt: agiGeneratedAt ? new Date(agiGeneratedAt).toLocaleString('sv-SE') : '',
generatedAt: agiGeneratedAt ? formatDateTime(agiGeneratedAt) : '',
draftCreatedAt: submission?.updatedAt
? ` (${new Date(submission.updatedAt).toLocaleString('sv-SE')})`
? ` (${formatDateTime(submission.updatedAt)})`
: '',
})}{' '}
{t('stale_draft_click')}{' '}
@@ -1180,32 +1147,34 @@ export function AGIPanel(props: AGIPanelProps) {
link would be permanently unreachable even though the extension
persisted it. */}
{submission?.signeringslank && underlagRejected && (
<div className="rounded-lg border border-destructive/40 bg-destructive/5 p-3">
<p className="text-sm font-medium text-destructive">
{t('incorrect_data_title')}
</p>
<p className="mt-0.5 text-xs text-muted-foreground">
<div className="text-sm">
<p className="font-medium text-destructive">{t('incorrect_data_title')}</p>
<p className="mt-1 text-xs text-muted-foreground">
{submission.meddelande || t('incorrect_data_description')}
</p>
<a
href={submission.signeringslank}
target="_blank"
rel="noreferrer"
className="mt-2 inline-flex items-center gap-1 text-sm font-medium text-destructive hover:underline"
className="mt-2 inline-flex items-center gap-1 font-medium text-destructive underline underline-offset-2 hover:opacity-80"
>
{t('open_error_report')} <ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
)}
{/* Skatteverket's kontrollresultat findings: one line per finding,
severity carried by the text tone (terracotta for STOPP, ochre
for ärende/warning), no box. */}
{kontroller.length > 0 && (
<div className="space-y-1 rounded-lg border bg-muted/30 p-3">
<div className="divide-y divide-border text-xs">
{kontroller.map((k, i) => (
<div
key={i}
className={`flex items-start gap-2 text-xs ${
k.status === 'STOPP' ? 'text-destructive' : 'text-warning'
}`}
className={cn(
'flex items-start gap-2 py-2',
k.status === 'STOPP' ? 'text-destructive' : 'text-attn',
)}
>
<AlertCircle className="mt-0.5 h-3.5 w-3.5 shrink-0" />
<span>
@@ -1225,17 +1194,18 @@ export function AGIPanel(props: AGIPanelProps) {
// don't have to hunt for it in settings.
const sessionExpired =
/sessionen har gått ut|logga in med bankid igen/i.test(error) ||
status?.expired === true ||
status?.canRefresh === false
sessionExpiredStatus
return (
<div className="rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="mr-1 inline h-3.5 w-3.5" />
{error}
<div className="text-sm text-destructive">
<p>
<AlertCircle className="mr-1 inline h-3.5 w-3.5" />
{error}
</p>
{sessionExpired && !readOnly && (
<div className="mt-2">
<Button size="sm" variant="outline" onClick={handleConnect} disabled={connecting}>
<Link2 className="mr-1.5 h-3.5 w-3.5" />
{connecting ? t('connect_waiting') : t('reconnect_button')}
<Link2 className="mr-2 h-3.5 w-3.5" />
{reconnectLabel}
</Button>
</div>
)}
@@ -1243,56 +1213,23 @@ export function AGIPanel(props: AGIPanelProps) {
)
})()}
{success && !error && (
<div className="rounded-lg border border-border bg-muted/30 p-3 text-sm">
<p className="text-sm">
<CheckCircle2 className="mr-1 inline h-3.5 w-3.5 text-success" />
{success}
</div>
</p>
)}
{!readOnly && !isSigned && (
<div className="space-y-3">
{/* Primary path: one click runs the whole filing chain. Hidden
while a signing draft is open at SKV (the period is locked,
so a resubmission would be refused): the signing-link card
above is the CTA then, and the stale-draft recovery goes
through the advanced actions per the guidance text. The
XML download stays free for manual filing regardless. */}
<div className="flex flex-wrap items-center gap-2">
{!awaitingSigning && (
<Button
onClick={handleSubmitChain}
disabled={actionLoading !== null || !hasSkatteverket}
>
{actionLoading === 'chain' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
{t('chain_button')}
</Button>
)}
<Button
size="sm"
variant="outline"
onClick={handleDownloadXml}
disabled={actionLoading === 'download'}
title={t('download_xml_title')}
>
{actionLoading === 'download' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-1.5 h-3.5 w-3.5" />
)}
{t('download_xml_button')}
</Button>
</div>
{/* Live progress of the one-click chain: a flat step list that
sits with the status above, so the action row does not move
while it runs. */}
{chain && (
<ol className="space-y-1.5 rounded-lg border border-border bg-muted/30 p-3">
<ol className="space-y-1 text-xs">
{CHAIN_STEPS.map(step => {
const state = chainStepState(step)
return (
<li key={step} className="flex items-center gap-2 text-xs">
<li key={step} className="flex items-center gap-2">
{state === 'done' ? (
<CheckCircle2 className="h-3.5 w-3.5 shrink-0 text-success" />
) : state === 'running' ? (
@@ -1311,87 +1248,127 @@ export function AGIPanel(props: AGIPanelProps) {
</ol>
)}
{/* The one action row. Primary path: one click runs the whole
filing chain. Hidden while a signing draft is open at SKV (the
period is locked, so a resubmission would be refused): the
signing link above is the CTA then, and the stale-draft
recovery goes through the advanced actions per the guidance
text. The XML download stays free for manual filing
regardless. The advanced toggle sits quietly at the left of
the row; its actions fold out beneath. */}
<div className="flex flex-wrap items-center justify-between gap-3">
<div>
{!forcedAdvanced && (
<button
type="button"
onClick={() => setShowAdvanced(v => !v)}
className="text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
>
{advancedOpen ? t('advanced_hide') : t('advanced_show')}
</button>
)}
</div>
<div className="flex flex-wrap items-center gap-2">
<Button
size="sm"
variant="outline"
onClick={handleDownloadXml}
disabled={actionLoading === 'download'}
title={t('download_xml_title')}
>
{actionLoading === 'download' ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-2 h-3.5 w-3.5" />
)}
{t('download_xml_button')}
</Button>
{!awaitingSigning && (
<Button
onClick={handleSubmitChain}
disabled={actionLoading !== null || !hasSkatteverket}
>
{actionLoading === 'chain' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Send className="mr-2 h-4 w-4" />
)}
{t('chain_button')}
</Button>
)}
</div>
</div>
{/* Recovery/expert actions: each is one step of the chain above,
for resuming after a partial failure. Auto-expanded when a
recovery state (stale draft, rejected underlag) references
them by name. */}
<div>
{!forcedAdvanced && (
<button
type="button"
onClick={() => setShowAdvanced(v => !v)}
className="text-xs text-muted-foreground transition-colors hover:text-foreground"
{advancedOpen && (
<div className="flex flex-wrap justify-end gap-2">
<Button
size="sm"
variant="outline"
onClick={handleSubmit}
disabled={actionLoading !== null || !hasSkatteverket}
>
{advancedOpen ? t('advanced_hide') : t('advanced_show')}
</button>
)}
{advancedOpen && (
<div className="mt-2 flex flex-wrap gap-2">
<Button
size="sm"
variant="outline"
onClick={handleSubmit}
disabled={actionLoading !== null || !hasSkatteverket}
>
{actionLoading === 'submit' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Send className="mr-1.5 h-3.5 w-3.5" />
)}
{t('submit_button')}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleCreateSigningLink}
disabled={actionLoading !== null || !underlagSubmitted}
>
{actionLoading === 'granskning' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Lock className="mr-1.5 h-3.5 w-3.5" />
)}
{t('signing_link_button')}
</Button>
{actionLoading === 'submit' ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Send className="mr-2 h-3.5 w-3.5" />
)}
{t('submit_button')}
</Button>
<Button
size="sm"
variant="outline"
onClick={handleCreateSigningLink}
disabled={actionLoading !== null || !underlagSubmitted}
>
{actionLoading === 'granskning' ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Lock className="mr-2 h-3.5 w-3.5" />
)}
{t('signing_link_button')}
</Button>
<Button
size="sm"
variant="ghost"
onClick={handleCheckSubmitted}
disabled={actionLoading !== null}
>
{actionLoading === 'check' ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-2 h-3.5 w-3.5" />
)}
{t('check_kvittens_button')}
</Button>
{awaitingSigning && (
<Button
size="sm"
variant="ghost"
onClick={handleCheckSubmitted}
onClick={handleUnlock}
disabled={actionLoading !== null}
>
{actionLoading === 'check' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
{actionLoading === 'unlock' ? (
<Loader2 className="mr-2 h-3.5 w-3.5 animate-spin" />
) : (
<Download className="mr-1.5 h-3.5 w-3.5" />
<Unlock className="mr-2 h-3.5 w-3.5" />
)}
{t('check_kvittens_button')}
{t('unlock_button')}
</Button>
{awaitingSigning && (
<Button
size="sm"
variant="ghost"
onClick={handleUnlock}
disabled={actionLoading !== null}
>
{actionLoading === 'unlock' ? (
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />
) : (
<Unlock className="mr-1.5 h-3.5 w-3.5" />
)}
{t('unlock_button')}
</Button>
)}
</div>
)}
</div>
)}
</div>
)}
</div>
)}
{!readOnly && !isSigned && !hasSkatteverket && (
<UpgradeNote>{t('upgrade_note')}</UpgradeNote>
)}
</CardContent>
</Card>
</div>
</DetailSection>
)
}
@@ -1405,11 +1382,11 @@ function StatusRow({
pendingText: string
}) {
return (
<div className="flex items-center gap-2">
<div className="flex items-start gap-2 py-2">
{ok ? (
<CheckCircle2 className="h-4 w-4 text-success" />
<CheckCircle2 className="mt-0.5 h-4 w-4 shrink-0 text-success" />
) : (
<Link2Off className="h-4 w-4 text-muted-foreground" />
<Link2Off className="mt-0.5 h-4 w-4 shrink-0 text-muted-foreground" />
)}
<span className="text-muted-foreground">{ok ? okText : pendingText}</span>
</div>
+98 -68
View File
@@ -2,13 +2,22 @@
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
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 { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
import { Plus, Trash2, Loader2 } from 'lucide-react'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import { DetailSection } from '@/components/ui/detail-section'
import { HelpPopover } from '@/components/ui/help-popover'
import { Skeleton } from '@/components/ui/skeleton'
import { Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -37,6 +46,12 @@ const BENEFIT_LABELS: Record<BenefitType, string> = {
other: 'Övrig förmån',
}
// In-row text action: underlined so it reads as an action next to plain
// values, the hairline underline darkening on hover (same idiom as the
// invoice detail rows).
const ROW_ACTION_CLASS =
'text-xs text-muted-foreground underline decoration-border underline-offset-4 transition-colors duration-150 hover:text-foreground hover:decoration-foreground disabled:opacity-50'
export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: string; canWrite: boolean }) {
const t = useTranslations('salary_employee')
const { toast } = useToast()
@@ -128,62 +143,77 @@ export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: st
})()
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-base">{t('benefits_title')}</CardTitle>
{canWrite && !adding && (
<Button type="button" size="sm" variant="outline" onClick={() => setAdding(true)}>
<Plus className="mr-2 h-4 w-4" />
<DetailSection
kicker={t('benefits_title')}
help={<HelpPopover>{t('benefits_help')}</HelpPopover>}
aside={
canWrite ? (
<Button type="button" size="sm" variant="outline" className="-my-1" onClick={() => setAdding(true)}>
{t('benefits_add')}
</Button>
)}
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<p className="text-sm text-muted-foreground">{t('benefits_loading')}</p>
) : benefits.length === 0 && !adding ? (
<p className="text-sm text-muted-foreground">
{t('benefits_empty')}
</p>
) : (
benefits.length > 0 && (
<Table>
<TableHeader>
<TableRow>
<TableHead>{t('benefits_type')}</TableHead>
<TableHead>{t('benefits_description')}</TableHead>
<TableHead className="text-right">{t('benefits_value_per_month')}</TableHead>
<TableHead>{t('benefits_period')}</TableHead>
<TableHead></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{benefits.map(b => (
<TableRow key={b.id}>
<TableCell>{t(`benefits_type_${b.benefit_type}`)}</TableCell>
<TableCell className="text-muted-foreground">{b.description}</TableCell>
<TableCell className="text-right tabular-nums">{formatCurrency(b.monthly_value)}</TableCell>
<TableCell className="text-muted-foreground tabular-nums text-xs">
{formatDate(b.valid_from)}
{b.valid_to ? ` ${formatDate(b.valid_to)}` : ` ${t('benefits_ongoing')}`}
</TableCell>
<TableCell className="text-right">
{canWrite && (
<Button type="button" size="icon" variant="ghost" onClick={() => handleDelete(b.id)} aria-label={t('benefits_remove')}>
<Trash2 className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
)
)}
) : undefined
}
>
{loading ? (
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
) : benefits.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('benefits_empty')}</p>
) : (
<ul className="divide-y divide-border text-sm">
{benefits.map((b) => {
const typeLabel = t(`benefits_type_${b.benefit_type}`)
// The description defaults to the Swedish type label when left
// empty on creation; repeating it next to the type says nothing.
const showDescription =
!!b.description &&
b.description !== typeLabel &&
b.description !== BENEFIT_LABELS[b.benefit_type]
return (
<li key={b.id} className="flex min-h-10 flex-wrap items-center gap-x-3 gap-y-1 py-2">
<span className="min-w-0 truncate">
{typeLabel}
{showDescription && (
<span className="text-muted-foreground">{' · '}{b.description}</span>
)}
</span>
<span className="text-xs tabular-nums text-muted-foreground">
{formatDate(b.valid_from)} → {b.valid_to ? formatDate(b.valid_to) : t('benefits_ongoing')}
</span>
<span className="ml-auto tabular-nums">
{formatCurrency(b.monthly_value)}
<span className="text-muted-foreground">{t('benefits_per_month')}</span>
</span>
{canWrite && (
<button type="button" onClick={() => handleDelete(b.id)} className={ROW_ACTION_CLASS}>
{t('benefits_remove')}
</button>
)}
</li>
)
})}
</ul>
)}
{adding && (
<div className="space-y-4 rounded-lg border bg-muted/30 p-4">
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Add dialog (convention 13: centered modal for create). Closing by
Escape or backdrop is the same as Avbryt; both are held while a
save is in flight. */}
<Dialog
open={adding}
onOpenChange={(open) => {
if (!open && !submitting) reset()
}}
>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>{t('benefits_add')}</DialogTitle>
<DialogDescription>{t('benefits_help')}</DialogDescription>
</DialogHeader>
<div className="space-y-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="benefit_type">{t('benefits_type')}</Label>
<Select value={type} onValueChange={(v) => setType(v as BenefitType)}>
@@ -245,7 +275,7 @@ export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: st
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="grid grid-cols-1 gap-4 sm:grid-cols-2">
<div className="space-y-2">
<Label htmlFor="valid_from">{t('benefits_valid_from')}</Label>
<Input id="valid_from" type="date" value={validFrom} onChange={e => setValidFrom(e.target.value)} />
@@ -255,17 +285,17 @@ export function EmployeeBenefitsPanel({ employeeId, canWrite }: { employeeId: st
<Input id="valid_to" type="date" value={validTo} onChange={e => setValidTo(e.target.value)} />
</div>
</div>
<div className="flex justify-end gap-2">
<Button type="button" variant="outline" size="sm" onClick={reset} disabled={submitting}>{t('form_cancel')}</Button>
<Button type="button" size="sm" onClick={handleAdd} disabled={submitting}>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('form_save')}
</Button>
</div>
</div>
)}
</CardContent>
</Card>
<DialogFooter>
<Button type="button" variant="outline" onClick={reset} disabled={submitting}>{t('form_cancel')}</Button>
<Button type="button" onClick={handleAdd} disabled={submitting}>
{submitting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('form_save')}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</DetailSection>
)
}
+156 -138
View File
@@ -1,7 +1,7 @@
'use client'
/**
* Ingående saldon (payroll cutover) panel on the employee editor.
* Ingående saldon (payroll cutover) section on the employee detail page.
*
* Mid-year switchers from another payroll system enter per-employee state
* here: YTD accumulators, vacation balances (incl. sparade dagar per
@@ -12,12 +12,12 @@
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { formatDate } from '@/lib/utils'
import { DetailSection } from '@/components/ui/detail-section'
import { HelpPopover } from '@/components/ui/help-popover'
import { Skeleton } from '@/components/ui/skeleton'
import { Save } from 'lucide-react'
import { SettingsGroup, SettingsInput, SettingsRow } from '@/components/settings/SettingsRows'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
@@ -40,6 +40,9 @@ const currentYear = new Date().getFullYear()
/** Sparade dagar origin years: Semesterlagen allows saving max 5 years. */
const SAVED_YEARS = Array.from({ length: 5 }, (_, i) => String(currentYear - 1 - i))
// Numeric and date fields are sized by their content, not by the row.
const FIELD_CLASS = 'max-w-44 flex-none tabular-nums'
interface PanelValues {
cutoverDate: string
ytdGross: string
@@ -72,6 +75,10 @@ export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: str
const [saving, setSaving] = useState(false)
const [locked, setLocked] = useState(false)
const [hasRow, setHasRow] = useState(false)
// Collapsed by default: this is a one-time form for switching payroll
// system, and 14 empty input rows on every employee is clutter. Opens by
// itself once balances exist, so stored values are never hidden.
const [expanded, setExpanded] = useState<boolean | null>(null)
const [cutoverDate, setCutoverDate] = useState(`${currentYear}-01-01`)
const [ytdGross, setYtdGross] = useState('')
@@ -202,151 +209,161 @@ export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: str
if (loading) {
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('opening_balances_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<Skeleton className="h-10 w-full" />
<Skeleton className="h-10 w-full" />
</CardContent>
</Card>
<DetailSection kicker={t('opening_balances_title')}>
<div className="space-y-3">
<Skeleton className="h-4 w-full" />
<Skeleton className="h-4 w-2/3" />
</div>
</DetailSection>
)
}
const readOnly = locked || !canWrite
const open = expanded ?? hasRow
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('opening_balances_title')}</CardTitle>
<p className="text-sm text-muted-foreground">{t('opening_balances_description')}</p>
</CardHeader>
{/* Own form: this panel is its own save scope. It must never be nested
inside another form (invalid HTML, and its submit would leak). */}
<form
onSubmit={(e) => {
e.preventDefault()
handleSave()
}}
/* Own form: this section is its own save scope. It must never be nested
inside another form (invalid HTML, and its submit would leak). The
save button closes the expanded form and is a submit, so Enter in any
field saves too. */
<form
onSubmit={(e) => {
e.preventDefault()
handleSave()
}}
>
<DetailSection
kicker={t('opening_balances_title')}
help={<HelpPopover>{t('opening_balances_description')}</HelpPopover>}
aside={
<Button
type="button"
variant="outline"
size="sm"
className="-my-1"
aria-expanded={open}
onClick={() => setExpanded(!open)}
>
{open ? t('opening_balances_hide') : t('opening_balances_show')}
</Button>
}
>
<CardContent className="space-y-6">
{locked && (
<p className="text-sm text-muted-foreground border border-border rounded-lg p-3">
{t('opening_balances_locked_notice')}
{!open ? (
<p className="text-sm text-muted-foreground">
{hasRow
? t('opening_balances_collapsed_set', { date: formatDate(cutoverDate) })
: t('opening_balances_collapsed_none')}
</p>
) : (
<>
{locked && (
<p className="mb-3 text-sm text-muted-foreground">{t('opening_balances_locked_notice')}</p>
)}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="ob-cutover">{t('opening_balances_cutover_date')}</Label>
<Input
id="ob-cutover"
type="date"
value={cutoverDate}
onChange={(e) => setCutoverDate(e.target.value)}
disabled={readOnly}
/>
<p className="text-xs text-muted-foreground">{t('opening_balances_cutover_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="ob-karens">{t('opening_balances_karens')}</Label>
<Input
id="ob-karens"
type="number"
min={0}
max={10}
step={1}
value={karens}
onChange={(e) => setKarens(e.target.value)}
disabled={readOnly}
className="tabular-nums"
/>
<p className="text-xs text-muted-foreground">{t('opening_balances_karens_hint')}</p>
</div>
</div>
<SettingsRow
label={t('opening_balances_cutover_date')}
htmlFor="ob-cutover"
help={t('opening_balances_cutover_hint')}
align="baseline"
>
<SettingsInput
id="ob-cutover"
type="date"
value={cutoverDate}
onChange={(e) => setCutoverDate(e.target.value)}
disabled={readOnly}
className={FIELD_CLASS}
/>
</SettingsRow>
<SettingsRow
label={t('opening_balances_karens')}
htmlFor="ob-karens"
help={t('opening_balances_karens_hint')}
align="baseline"
>
<SettingsInput
id="ob-karens"
type="number"
min={0}
max={10}
step={1}
value={karens}
onChange={(e) => setKarens(e.target.value)}
disabled={readOnly}
className={FIELD_CLASS}
/>
</SettingsRow>
<div>
<h2 className="text-sm uppercase tracking-wider text-muted-foreground mb-3">
{t('opening_balances_ytd_heading')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="ob-ytd-gross">{t('opening_balances_ytd_gross')}</Label>
<Input id="ob-ytd-gross" type="number" min={0} value={ytdGross}
onChange={(e) => setYtdGross(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
<div className="space-y-2">
<Label htmlFor="ob-ytd-tax">{t('opening_balances_ytd_tax')}</Label>
<Input id="ob-ytd-tax" type="number" min={0} value={ytdTax}
onChange={(e) => setYtdTax(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
<div className="space-y-2">
<Label htmlFor="ob-ytd-net">{t('opening_balances_ytd_net')}</Label>
<Input id="ob-ytd-net" type="number" min={0} value={ytdNet}
onChange={(e) => setYtdNet(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
</div>
</div>
<SettingsGroup label={t('opening_balances_ytd_heading')} className="pt-6">
<SettingsRow label={t('opening_balances_ytd_gross')} htmlFor="ob-ytd-gross" align="baseline">
<SettingsInput id="ob-ytd-gross" type="number" min={0} value={ytdGross}
onChange={(e) => setYtdGross(e.target.value)} disabled={readOnly} className={FIELD_CLASS} />
</SettingsRow>
<SettingsRow label={t('opening_balances_ytd_tax')} htmlFor="ob-ytd-tax" align="baseline">
<SettingsInput id="ob-ytd-tax" type="number" min={0} value={ytdTax}
onChange={(e) => setYtdTax(e.target.value)} disabled={readOnly} className={FIELD_CLASS} />
</SettingsRow>
<SettingsRow label={t('opening_balances_ytd_net')} htmlFor="ob-ytd-net" align="baseline">
<SettingsInput id="ob-ytd-net" type="number" min={0} value={ytdNet}
onChange={(e) => setYtdNet(e.target.value)} disabled={readOnly} className={FIELD_CLASS} />
</SettingsRow>
</SettingsGroup>
<div>
<h2 className="text-sm uppercase tracking-wider text-muted-foreground mb-3">
{t('opening_balances_vacation_heading')}
</h2>
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
<div className="space-y-2">
<Label htmlFor="ob-days-remaining">{t('opening_balances_days_remaining')}</Label>
<Input id="ob-days-remaining" type="number" min={0} max={40} step={0.5} value={daysRemaining}
onChange={(e) => setDaysRemaining(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
<div className="space-y-2">
<Label htmlFor="ob-days-taken">{t('opening_balances_days_taken')}</Label>
<Input id="ob-days-taken" type="number" min={0} max={40} step={0.5} value={daysTaken}
onChange={(e) => setDaysTaken(e.target.value)} disabled={readOnly} className="tabular-nums" />
<p className="text-xs text-muted-foreground">{t('opening_balances_days_taken_hint')}</p>
</div>
<div className="space-y-2">
<Label htmlFor="ob-liability">{t('opening_balances_liability')}</Label>
<Input id="ob-liability" type="number" min={0} value={liability}
onChange={(e) => setLiability(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
<div className="space-y-2">
<Label htmlFor="ob-liability-avgifter">{t('opening_balances_liability_avgifter')}</Label>
<Input id="ob-liability-avgifter" type="number" min={0} value={liabilityAvgifter}
onChange={(e) => setLiabilityAvgifter(e.target.value)} disabled={readOnly} className="tabular-nums" />
</div>
</div>
</div>
<SettingsGroup label={t('opening_balances_vacation_heading')} className="pt-6">
<SettingsRow label={t('opening_balances_days_remaining')} htmlFor="ob-days-remaining" align="baseline">
<SettingsInput id="ob-days-remaining" type="number" min={0} max={40} step={0.5} value={daysRemaining}
onChange={(e) => setDaysRemaining(e.target.value)} disabled={readOnly} className={FIELD_CLASS} />
</SettingsRow>
<SettingsRow
label={t('opening_balances_days_taken')}
htmlFor="ob-days-taken"
help={t('opening_balances_days_taken_hint')}
align="baseline"
>
<SettingsInput id="ob-days-taken" type="number" min={0} max={40} step={0.5} value={daysTaken}
onChange={(e) => setDaysTaken(e.target.value)} disabled={readOnly} className={FIELD_CLASS} />
</SettingsRow>
<SettingsRow label={t('opening_balances_liability')} htmlFor="ob-liability" align="baseline">
<SettingsInput id="ob-liability" type="number" min={0} value={liability}
onChange={(e) => setLiability(e.target.value)} disabled={readOnly} className={FIELD_CLASS} />
</SettingsRow>
<SettingsRow label={t('opening_balances_liability_avgifter')} htmlFor="ob-liability-avgifter" align="baseline">
<SettingsInput id="ob-liability-avgifter" type="number" min={0} value={liabilityAvgifter}
onChange={(e) => setLiabilityAvgifter(e.target.value)} disabled={readOnly} className={FIELD_CLASS} />
</SettingsRow>
</SettingsGroup>
<div>
<h2 className="text-sm uppercase tracking-wider text-muted-foreground mb-3">
{t('opening_balances_saved_heading')}
</h2>
<p className="text-xs text-muted-foreground mb-3">{t('opening_balances_saved_hint')}</p>
<div className="grid grid-cols-2 md:grid-cols-5 gap-4">
{SAVED_YEARS.map((year) => (
<div key={year} className="space-y-2">
<Label htmlFor={`ob-saved-${year}`} className="tabular-nums">{year}</Label>
<Input
id={`ob-saved-${year}`}
type="number"
min={0}
max={40}
step={0.5}
value={savedByYear[year] ?? ''}
onChange={(e) => setSavedByYear((prev) => ({ ...prev, [year]: e.target.value }))}
disabled={readOnly}
className="tabular-nums"
/>
</div>
))}
</div>
</div>
<SettingsGroup
label={t('opening_balances_saved_heading')}
help={t('opening_balances_saved_hint')}
className="pt-6"
>
{SAVED_YEARS.map((year, index) => (
<SettingsRow
key={year}
label={<span className="tabular-nums">{year}</span>}
htmlFor={`ob-saved-${year}`}
align="baseline"
borderless={index === SAVED_YEARS.length - 1}
>
<SettingsInput
id={`ob-saved-${year}`}
type="number"
min={0}
max={40}
step={0.5}
value={savedByYear[year] ?? ''}
onChange={(e) => setSavedByYear((prev) => ({ ...prev, [year]: e.target.value }))}
disabled={readOnly}
className={FIELD_CLASS}
/>
</SettingsRow>
))}
</SettingsGroup>
{!readOnly && (
<div className="flex justify-end">
<Button type="submit" disabled={saving || !dirty}>
<Save className="h-4 w-4 mr-2" />
<div className="mt-4 flex justify-end">
<Button type="submit" size="sm" disabled={saving || !dirty}>
{saving
? t('opening_balances_saving')
: hasRow
@@ -355,8 +372,9 @@ export function OpeningBalancesPanel({ employeeId, canWrite }: { employeeId: str
</Button>
</div>
)}
</CardContent>
</form>
</Card>
</>
)}
</DetailSection>
</form>
)
}
+114 -113
View File
@@ -3,13 +3,16 @@
import Link from 'next/link'
import { useState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { AlertTriangle, Download, Loader2, CheckCircle2, ChevronDown, Info } from 'lucide-react'
import { DetailSection, DefRow } from '@/components/ui/detail-section'
import { HelpPopover } from '@/components/ui/help-popover'
import { AttnLine } from '@/components/ui/attn-line'
import { SettingsSelect } from '@/components/settings/SettingsRows'
import { Download, Loader2, ChevronDown } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { downloadFile } from '@/lib/browser/download-file'
import { failureDescription } from '@/lib/browser/action-failure'
import { cn, formatDateTime } from '@/lib/utils'
import type { ErrorLocale } from '@/lib/errors/get-error-message'
type PaymentFormat = 'bg_lb' | 'pain001'
@@ -86,6 +89,14 @@ export function PaymentFilePanel({
? `/api/salary/runs/${salaryRunId}/payment/bg-lb`
: `/api/salary/runs/${salaryRunId}/payment/pain001`
// The sender account lives in company_settings, not in the Bolagsverket
// snapshot shown on the settings overview: users see a bankgiro there and
// reasonably believe it is configured. Say the precondition here, before
// the download 400s on it.
const senderMissing =
(format === 'bg_lb' && senderBankgiro === null) ||
(format === 'pain001' && senderIban === null)
async function handleDownload() {
// The button is disabled while a file is in flight; this guard closes the
// double-click / Enter-repeat race before React has re-rendered it. Two
@@ -124,135 +135,125 @@ export function PaymentFilePanel({
}
}
// The flow looked clean all the way until the bank said no: the file
// downloads fine and only fails at upload, on the pay date. Say the delivery
// precondition of the chosen format up front. It is one sentence per
// format: the pain.001 agreement caveat, or the LB sunset with its link.
const formatCaveat =
format === 'pain001' ? (
t('pain001_agreement_warning')
) : (
<>
{t('sunset_warning')}{' '}
<Link href="/settings/salary" className="underline underline-offset-2 hover:text-foreground">
{t('sunset_link')}
</Link>
</>
)
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<DetailSection
kicker={t('title')}
help={
<HelpPopover>
<p>
<span className="font-medium">{FORMAT_LABEL.pain001}</span>: {t('format_description_pain001')}
</p>
<p className="mt-2">
<span className="font-medium">{FORMAT_LABEL.bg_lb}</span>: {t('format_description_bg_lb')}
</p>
<p className="mt-2 text-muted-foreground">{t('open_payments_note')}</p>
</HelpPopover>
}
>
<div>
{paymentFileFormat && paymentFileGeneratedAt && (
<div className="flex items-start gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-success" />
<div>
{t('last_generated')}{' '}
<span className="text-foreground">
{FORMAT_LABEL[paymentFileFormat as PaymentFormat] ?? paymentFileFormat}
</span>{' '}
({new Date(paymentFileGeneratedAt).toLocaleString('sv-SE')})
</div>
</div>
<DefRow label={t('last_generated')}>
{FORMAT_LABEL[paymentFileFormat as PaymentFormat] ?? paymentFileFormat}{' '}
<span className="text-muted-foreground tabular-nums">
({formatDateTime(paymentFileGeneratedAt)})
</span>
</DefRow>
)}
{!readOnly && (
<>
<div className="space-y-1">
<label className="text-sm font-medium">{t('format_label')}</label>
<Select value={format} onValueChange={(v) => setFormat(v as PaymentFormat)}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="pain001">{FORMAT_LABEL.pain001}</SelectItem>
<SelectItem value="bg_lb">{FORMAT_LABEL.bg_lb}</SelectItem>
</SelectContent>
</Select>
<p className="text-xs text-muted-foreground">
{format === 'bg_lb' ? t('format_description_bg_lb') : t('format_description_pain001')}
</p>
</div>
<DefRow label={t('format_label')}>
<SettingsSelect
aria-label={t('format_label')}
value={format}
onChange={(e) => setFormat(e.target.value as PaymentFormat)}
wrapperClassName="-my-1"
>
<option value="pain001">{FORMAT_LABEL.pain001}</option>
<option value="bg_lb">{FORMAT_LABEL.bg_lb}</option>
</SettingsSelect>
</DefRow>
)}
</div>
{/* The flow looked clean all the way until the bank said no: the
file downloads fine and only fails at upload, on the pay date.
Say the delivery precondition up front instead. */}
{format === 'pain001' && (
<div className="flex items-start gap-2 rounded-lg border border-border p-3 text-xs">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span className="text-muted-foreground">{t('pain001_agreement_warning')}</span>
</div>
)}
{format === 'bg_lb' && (
<div className="flex items-start gap-2 rounded-lg border border-border p-3 text-xs">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span className="text-muted-foreground">
{t('sunset_warning')}{' '}
<Link
href="/settings/salary"
className="underline underline-offset-2 hover:text-foreground"
>
{t('sunset_link')}
</Link>
</span>
</div>
)}
{/* The sender account lives in company_settings, not in the
Bolagsverket snapshot shown on the settings overview: users see
a bankgiro there and reasonably believe it is configured. Say
the precondition here, before the download 400s on it. */}
{((format === 'bg_lb' && senderBankgiro === null) ||
(format === 'pain001' && senderIban === null)) && (
<div className="flex items-start gap-2 rounded-lg border border-border p-3 text-xs">
<AlertTriangle className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span className="text-muted-foreground">
{format === 'bg_lb' ? t('missing_bankgiro_warning') : t('missing_iban_warning')}{' '}
<Link
href="/settings/invoicing"
className="underline underline-offset-2 hover:text-foreground"
>
{t('missing_sender_link')}
</Link>
</span>
</div>
)}
<div className="flex justify-end">
<Button onClick={handleDownload} disabled={downloading}>
{downloading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
{t('download')}
</Button>
</div>
{!readOnly && (
<div className="mt-3 space-y-3">
{/* One attention sentence per section (convention 6). A missing
sender account outranks the format caveat: without it the
download fails outright. The caveat then drops to a muted line
so the delivery precondition is still said. */}
{senderMissing ? (
<>
<AttnLine action={{ label: t('missing_sender_link'), href: '/settings/invoicing' }}>
{format === 'bg_lb' ? t('missing_bankgiro_warning') : t('missing_iban_warning')}
</AttnLine>
<p className="text-xs text-muted-foreground">{formatCaveat}</p>
</>
) : format === 'pain001' ? (
<AttnLine>{t('pain001_agreement_warning')}</AttnLine>
) : (
<AttnLine action={{ label: t('sunset_link'), href: '/settings/salary' }}>
{t('sunset_warning')}
</AttnLine>
)}
{/* The one action row: the bank instructions are an appendix that
folds out under it, so the button stays where the section ends. */}
<div className="flex flex-wrap items-center justify-between gap-3">
<button
type="button"
onClick={() => setShowInstructions(s => !s)}
className="flex items-center gap-1.5 text-xs text-muted-foreground hover:text-foreground"
onClick={() => setShowInstructions((s) => !s)}
className="inline-flex items-center gap-2 text-xs text-muted-foreground transition-colors duration-150 hover:text-foreground"
aria-expanded={showInstructions}
>
<ChevronDown className={`h-3 w-3 transition-transform ${showInstructions ? 'rotate-180' : ''}`} />
<ChevronDown
className={cn('h-3 w-3 transition-transform duration-150', showInstructions && 'rotate-180')}
/>
{t('instructions_toggle')}
</button>
<Button onClick={handleDownload} disabled={downloading}>
{downloading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
{t('download')}
</Button>
</div>
{showInstructions && (
<div className="space-y-2 rounded-lg border bg-muted/30 p-3 text-xs">
{showInstructions && (
<div className="text-xs">
<div className="divide-y divide-border">
{sortedBanks.map((bank) => (
<div key={bank}>
<strong className="text-foreground">
<p key={bank} className="py-2">
<span className="text-foreground">
{BANK_NAME[bank]}
{bank === matchedBank ? ` (${t('your_bank')})` : ''}.
</strong>{' '}
<span className="text-muted-foreground">
{t(`steps_${format}_${bank}`)}
</span>
</div>
</span>{' '}
<span className="text-muted-foreground">{t(`steps_${format}_${bank}`)}</span>
</p>
))}
<p className="pt-1 text-muted-foreground border-t mt-2">
{t('instructions_footer')}
</p>
</div>
)}
<div className="flex items-start gap-2 rounded-lg border border-dashed bg-muted/20 p-3 text-xs text-muted-foreground">
<Info className="h-3.5 w-3.5 mt-0.5 shrink-0" />
<span>{t('open_payments_note')}</span>
<p className="pt-2 text-muted-foreground">{t('instructions_footer')}</p>
</div>
</>
)}
</CardContent>
</Card>
)}
</div>
)}
</DetailSection>
)
}
+90 -94
View File
@@ -2,13 +2,17 @@
import { useState } from 'react'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Badge } from '@/components/ui/badge'
import { Settings2, Loader2 } from 'lucide-react'
import { Button } from '@/components/ui/button'
import { DetailSection } from '@/components/ui/detail-section'
import { HelpPopover } from '@/components/ui/help-popover'
import {
SettingsInput,
SettingsRow,
SettingsRowNote,
SettingsTextarea,
} from '@/components/settings/SettingsRows'
import { Loader2 } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { getErrorMessage as getUserErrorMessage } from '@/lib/errors/get-error-message'
@@ -27,6 +31,11 @@ interface SalaryOverridePanelProps {
disabled?: boolean
}
// Amount fields are sized by their content, not by the row. ph-no-capture:
// the placeholder is the employee's effective amount, and replay masking
// covers values, not attributes.
const FIELD_CLASS = 'max-w-44 flex-none tabular-nums ph-no-capture'
function num(v: string): number | null {
const trimmed = v.trim()
if (!trimmed) return null
@@ -130,118 +139,105 @@ export function SalaryOverridePanel(props: SalaryOverridePanelProps) {
}
}
const fieldDisabled = props.disabled || saving
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0">
<div className="flex items-center gap-2">
<CardTitle className="text-base">{t('title')}</CardTitle>
<DetailSection
kicker={t('title')}
help={<HelpPopover>{t('description')}</HelpPopover>}
aside={
// Chips mark exceptions: the chip appears only once an override is
// in effect; the show/hide toggle is the section's one action.
<span className="-my-1 flex items-center gap-3">
{hasOverride && <Badge variant="warning">{t('adjusted_badge')}</Badge>}
</div>
<Button
variant="outline"
size="sm"
onClick={() => setExpanded((v) => !v)}
disabled={props.disabled}
>
<Settings2 className="mr-1.5 h-3.5 w-3.5" />
{expanded ? t('hide') : t('show')}
</Button>
</CardHeader>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => setExpanded((v) => !v)}
disabled={props.disabled}
>
{expanded ? t('hide') : t('show')}
</Button>
</span>
}
>
{expanded && (
<CardContent className="space-y-4">
<p className="text-xs text-muted-foreground">
{t('description')}
</p>
<>
<SettingsRow label={t('tax_label')} htmlFor="tax_override" align="baseline">
<SettingsInput
id="tax_override"
inputMode="decimal"
placeholder={String(props.taxWithheld)}
value={taxStr}
onChange={(e) => setTaxStr(e.target.value)}
disabled={fieldDisabled}
className={FIELD_CLASS}
/>
<SettingsRowNote>
{t('calculated')} <span className="tabular-nums">{formatCurrency(props.taxWithheld)}</span>
</SettingsRowNote>
</SettingsRow>
<div className="grid gap-4 sm:grid-cols-3">
<div className="space-y-1.5">
<Label htmlFor="tax_override" className="text-xs">
{t('tax_label')}
</Label>
<Input
id="tax_override"
inputMode="decimal"
placeholder={String(props.taxWithheld)}
value={taxStr}
onChange={(e) => setTaxStr(e.target.value)}
disabled={props.disabled || saving}
// ph-no-capture: the placeholder is the employee's effective
// amount, and replay masking covers values, not attributes.
className="tabular-nums ph-no-capture"
/>
<p className="text-[11px] text-muted-foreground">
{t('calculated')} <span className="tabular-nums">{formatCurrency(props.taxWithheld)}</span>
</p>
</div>
<SettingsRow label={t('avgifter_label')} htmlFor="avgifter_override" align="baseline">
<SettingsInput
id="avgifter_override"
inputMode="decimal"
placeholder={String(props.avgifterAmount)}
value={avgStr}
onChange={(e) => setAvgStr(e.target.value)}
disabled={fieldDisabled}
className={FIELD_CLASS}
/>
<SettingsRowNote>
{t('calculated')} <span className="tabular-nums">{formatCurrency(props.avgifterAmount)}</span>
</SettingsRowNote>
</SettingsRow>
<div className="space-y-1.5">
<Label htmlFor="avgifter_override" className="text-xs">
{t('avgifter_label')}
</Label>
<Input
id="avgifter_override"
inputMode="decimal"
placeholder={String(props.avgifterAmount)}
value={avgStr}
onChange={(e) => setAvgStr(e.target.value)}
disabled={props.disabled || saving}
className="tabular-nums ph-no-capture"
/>
<p className="text-[11px] text-muted-foreground">
{t('calculated')} <span className="tabular-nums">{formatCurrency(props.avgifterAmount)}</span>
</p>
</div>
<SettingsRow label={t('basis_label')} htmlFor="avgifter_basis_override" align="baseline">
<SettingsInput
id="avgifter_basis_override"
inputMode="decimal"
placeholder={String(props.avgifterBasis)}
value={basisStr}
onChange={(e) => setBasisStr(e.target.value)}
disabled={fieldDisabled}
className={FIELD_CLASS}
/>
<SettingsRowNote>
{t('calculated')} <span className="tabular-nums">{formatCurrency(props.avgifterBasis)}</span>
</SettingsRowNote>
</SettingsRow>
<div className="space-y-1.5">
<Label htmlFor="avgifter_basis_override" className="text-xs">
{t('basis_label')}
</Label>
<Input
id="avgifter_basis_override"
inputMode="decimal"
placeholder={String(props.avgifterBasis)}
value={basisStr}
onChange={(e) => setBasisStr(e.target.value)}
disabled={props.disabled || saving}
className="tabular-nums ph-no-capture"
/>
<p className="text-[11px] text-muted-foreground">
{t('calculated')} <span className="tabular-nums">{formatCurrency(props.avgifterBasis)}</span>
</p>
</div>
</div>
<div className="space-y-1.5">
<Label htmlFor="override_reason" className="text-xs">
{t('reason_label')}
</Label>
<Textarea
<SettingsRow label={t('reason_label')} htmlFor="override_reason" align="baseline" borderless>
<SettingsTextarea
id="override_reason"
rows={2}
placeholder={t('reason_placeholder')}
value={reason}
onChange={(e) => setReason(e.target.value)}
disabled={props.disabled || saving}
disabled={fieldDisabled}
/>
</div>
</SettingsRow>
<div className="flex flex-wrap gap-2">
<Button onClick={handleSave} disabled={props.disabled || saving}>
{saving && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
<div className="flex flex-wrap gap-2 pt-3">
<Button size="sm" onClick={handleSave} disabled={fieldDisabled}>
{saving && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{t('save')}
</Button>
{hasOverride && (
<Button
size="sm"
variant="outline"
onClick={handleClear}
disabled={props.disabled || saving}
disabled={fieldDisabled}
>
{t('clear')}
</Button>
)}
</div>
</CardContent>
</>
)}
</Card>
</DetailSection>
)
}
+66 -84
View File
@@ -2,15 +2,17 @@
import { useCallback, useEffect, useState } from 'react'
import { useLocale, useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Button } from '@/components/ui/button'
import { DetailSection, DefRow } from '@/components/ui/detail-section'
import { HelpPopover } from '@/components/ui/help-popover'
import { QUIET_LINK_CLASS } from '@/components/ui/dry-table'
import { Download, Loader2, CheckCircle2, ExternalLink } from 'lucide-react'
import { useToast } from '@/components/ui/use-toast'
import { downloadFile } from '@/lib/browser/download-file'
import { postAction } from '@/lib/browser/post-action'
import { failureDescription } from '@/lib/browser/action-failure'
import type { ErrorLocale } from '@/lib/errors/get-error-message'
import { formatCurrency } from '@/lib/utils'
import { cn, formatCurrency, formatDateTime } from '@/lib/utils'
import { roundOre } from '@/lib/money'
interface TaxPaymentPanelProps {
@@ -128,93 +130,73 @@ export function TaxPaymentPanel({
if (totalAmount <= 0) return null
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('tax_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-2 sm:grid-cols-3 gap-4 text-sm">
<div>
<p className="text-xs text-muted-foreground">{t('tax_label_tax')}</p>
<p className="font-semibold tabular-nums">{formatCurrency(totalTax)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t('tax_label_avgifter')}</p>
<p className="font-semibold tabular-nums">{formatCurrency(totalAvgifter)}</p>
</div>
<div>
<p className="text-xs text-muted-foreground">{t('tax_label_total')}</p>
<p className="font-semibold tabular-nums">{formatCurrency(totalAmount)}</p>
</div>
</div>
<div className="text-sm text-muted-foreground space-y-1">
<p>
{t('tax_recipient')} <span className="text-foreground">{t('tax_recipient_value')}</span>
</p>
<p>
{t('tax_due_date')} <span className="text-foreground tabular-nums">{paymentDeadline}</span>
</p>
<p className="text-xs">
{t('tax_ocr_note')}
</p>
</div>
<DetailSection
kicker={t('tax_title')}
help={<HelpPopover>{t('tax_ocr_note')}</HelpPopover>}
aside={
!readOnly ? (
// Quiet action on the kicker line: the skattekonto at Skatteverket
// is where the payment lands, not something this page does.
<a
href="https://www.skatteverket.se/foretag/skatterochavdrag/skattekonto.4.18e1b10334ebe8bc80004481.html"
target="_blank"
rel="noopener noreferrer"
className={cn(QUIET_LINK_CLASS, 'inline-flex items-center gap-1')}
>
{t('tax_skattekonto_button')}
<ExternalLink className="h-3 w-3" />
</a>
) : undefined
}
>
<div>
<DefRow label={t('tax_label_tax')}>
<span className="tabular-nums">{formatCurrency(totalTax)}</span>
</DefRow>
<DefRow label={t('tax_label_avgifter')}>
<span className="tabular-nums">{formatCurrency(totalAvgifter)}</span>
</DefRow>
<DefRow label={t('tax_label_total')}>
<span className="font-medium tabular-nums">{formatCurrency(totalAmount)}</span>
</DefRow>
<DefRow label={t('tax_recipient')}>{t('tax_recipient_value')}</DefRow>
<DefRow label={t('tax_due_date')}>
<span className="tabular-nums">{paymentDeadline}</span>
</DefRow>
{paymentFileGeneratedAt && (
<div className="flex items-start gap-2 text-sm text-muted-foreground">
<CheckCircle2 className="h-4 w-4 mt-0.5 text-success" />
<div>
{t('tax_file_generated')}{' '}
<span className="text-foreground">
{new Date(paymentFileGeneratedAt).toLocaleString('sv-SE')}
</span>
</div>
</div>
<DefRow label={t('tax_file_generated')}>
<span className="tabular-nums">{formatDateTime(paymentFileGeneratedAt)}</span>
</DefRow>
)}
{taxPaidAt && (
<div className="flex items-start gap-2 text-sm text-success">
<CheckCircle2 className="h-4 w-4 mt-0.5" />
<div>
{t('tax_marked_paid')}{' '}
<span className="font-medium">{new Date(taxPaidAt).toLocaleString('sv-SE')}</span>
</div>
</div>
<DefRow label={t('tax_marked_paid')}>
<span className="tabular-nums">{formatDateTime(taxPaidAt)}</span>
</DefRow>
)}
</div>
{!readOnly && (
<div className="flex flex-wrap justify-end gap-2">
<Button variant="outline" size="sm" asChild>
<a
href="https://www.skatteverket.se/foretag/skatterochavdrag/skattekonto.4.18e1b10334ebe8bc80004481.html"
target="_blank"
rel="noopener noreferrer"
>
<ExternalLink className="mr-2 h-4 w-4" />
{t('tax_skattekonto_button')}
</a>
</Button>
<Button onClick={handleDownload} disabled={downloading || marking}>
{downloading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
{t('tax_download_button')}
</Button>
{!taxPaidAt && (
<Button
variant="outline"
onClick={handleMarkPaid}
disabled={downloading || marking}
>
{marking ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
{t('tax_mark_paid_button')}
</Button>
{!readOnly && (
<div className="mt-3 flex flex-wrap justify-end gap-2">
<Button onClick={handleDownload} disabled={downloading || marking}>
{downloading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
</div>
)}
</CardContent>
</Card>
{t('tax_download_button')}
</Button>
{!taxPaidAt && (
<Button
variant="outline"
onClick={handleMarkPaid}
disabled={downloading || marking}
>
{marking ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : <CheckCircle2 className="mr-2 h-4 w-4" />}
{t('tax_mark_paid_button')}
</Button>
)}
</div>
)}
</DetailSection>
)
}
+11 -13
View File
@@ -1,7 +1,7 @@
'use client'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { DetailSection } from '@/components/ui/detail-section'
import { TaxTableStatus } from '@/components/salary/TaxTableStatus'
import { formatCurrency } from '@/lib/utils'
import type { SalaryRunEmployee } from '@/types'
@@ -21,11 +21,8 @@ export function RunCalculationDetails({ periodYear, employees }: RunCalculationD
if (withBreakdown.length === 0) return null
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('calculation_details_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<DetailSection kicker={t('calculation_details_title')}>
<div className="space-y-6">
<TaxTableStatus year={periodYear} compact />
{withBreakdown.map(sre => {
const breakdown = sre.calculation_breakdown as {
@@ -33,20 +30,21 @@ export function RunCalculationDetails({ periodYear, employees }: RunCalculationD
}
const employee = (sre as SreWithEmployee).employee
return (
<div key={sre.id} className="space-y-2">
<div key={sre.id}>
<h4 className="text-sm font-medium">
{employee
? `${employee.first_name} ${employee.last_name}`
: sre.employee_id.slice(0, 8)}
</h4>
<div className="text-xs space-y-1 bg-muted/50 rounded-lg p-3">
{/* Flat calculation steps: one hairline per step, no box. */}
<div className="mt-1 divide-y divide-border text-xs">
{(breakdown?.steps || []).map((step, i) => (
<div key={i} className="flex justify-between gap-4">
<span className="text-muted-foreground">
<div key={i} className="flex justify-between gap-4 py-1">
<span className="min-w-0 text-muted-foreground">
{step.label}: <span className="font-mono">{step.formula}</span>
</span>
{step.output !== null && (
<span className="font-medium tabular-nums">{formatCurrency(step.output)}</span>
<span className="shrink-0 tabular-nums">{formatCurrency(step.output)}</span>
)}
</div>
))}
@@ -54,7 +52,7 @@ export function RunCalculationDetails({ periodYear, employees }: RunCalculationD
</div>
)
})}
</CardContent>
</Card>
</div>
</DetailSection>
)
}
+163 -141
View File
@@ -7,6 +7,8 @@ import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { DetailSection } from '@/components/ui/detail-section'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import {
Select,
SelectContent,
@@ -14,11 +16,11 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ChevronRight, FileDown, Loader2, Trash2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { FileDown, Loader2, Trash2 } from 'lucide-react'
import { cn, formatCurrency } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import type { EmployeeMasked, SalaryRunEmployee } from '@/types'
import type { RunDetail } from './types'
import { periodLabelOf, type RunDetail } from './types'
type SreWithEmployee = SalaryRunEmployee & {
employee?: {
@@ -67,7 +69,7 @@ export function RunEmployeesTable({
const canRemoveEmployee = run.status === 'draft' && canWrite
const isDraft = run.status === 'draft'
// Δ vs the latest booked run — hidden on the first-ever run and before
// Δ vs the latest booked run: hidden on the first-ever run and before
// calculation (gross is 0 until then, the diff would be noise).
const previous = run.previous_run ?? null
const showDiff = previous != null && isCalculated
@@ -76,6 +78,7 @@ export function RunEmployeesTable({
if (!previous) return null
const prev = previous.by_employee[sre.employee_id]
if (!prev) {
// A new employee is the exception worth a chip; a plain delta is text.
return (
<Badge variant="secondary" className="font-normal">
{t('diff_new_employee')}
@@ -93,13 +96,14 @@ export function RunEmployeesTable({
)
}
const numericTh = cn(TH_CLASS, 'text-right')
const numericTd = cn(TD_CLASS, 'text-right tabular-nums')
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm uppercase tracking-wider text-muted-foreground">
{t('employees_title', { count: employees.length })}
</h2>
{isDraft && canWrite && notAdded.length > 0 && (
<DetailSection
kicker={t('employees_title', { count: employees.length })}
aside={
isDraft && canWrite && notAdded.length > 0 ? (
<Select
key={addEmployeeKey}
onValueChange={value => {
@@ -107,7 +111,7 @@ export function RunEmployeesTable({
setAddEmployeeKey(k => k + 1)
}}
>
<SelectTrigger className="w-[200px] h-8 text-sm">
<SelectTrigger className="-my-1 h-8 w-[200px] text-sm">
<SelectValue placeholder={t('add_employee_placeholder')} />
</SelectTrigger>
<SelectContent>
@@ -118,143 +122,161 @@ export function RunEmployeesTable({
))}
</SelectContent>
</Select>
)}
</div>
) : undefined
}
>
{employees.length === 0 ? (
<div className="rounded-lg border border-border px-4 py-6 text-center text-sm text-muted-foreground">
{t('no_employees_yet')}
</div>
<p className="text-sm text-muted-foreground">{t('no_employees_yet')}</p>
) : (
<div className="rounded-lg border border-border divide-y divide-border">
{employees.map(sre => {
const employee = (sre as SreWithEmployee).employee
const name = employee
? `${employee.first_name} ${employee.last_name}`
: `${t('employee_fallback')} ${sre.employee_id.slice(0, 8)}...`
const dims = employee?.default_dimensions ?? {}
const dimLabel = Object.keys(dims)
.sort((a, b) => Number(a) - Number(b))
.map(k => dims[k])
.join(' · ')
const taxValue = sre.tax_withheld_override ?? sre.tax_withheld
const avgifterValue = sre.avgifter_amount_override ?? sre.avgifter_amount
const netValue = sre.net_salary + (sre.tax_withheld - taxValue)
const editableSalary = isDraft && canWrite && sre.salary_type === 'monthly'
const primaryNumber = isDraft ? sre.monthly_salary : sre.gross_salary
// In a draft the number on the row is the full-time monthly salary
// the engine multiplies by the employee's sysselsättningsgrad. Below
// 100 % that product is not obvious from the input alone (a 10 %
// employee typed 45 310 to get a 4 531 kr gross), so spell it out
// right under the number instead of only in Beräkningsdetaljer.
const degree = Number(sre.employment_degree)
const showDegreeHint =
isDraft && sre.salary_type === 'monthly' && Number.isFinite(degree) && degree < 100
const degreeHint = showDegreeHint ? (
<span className="text-[11px] leading-tight text-muted-foreground tabular-nums whitespace-nowrap">
{t('degree_hint', {
degree: degree.toLocaleString('sv-SE'),
amount: formatCurrency(roundOre((sre.monthly_salary || 0) * (degree / 100))),
})}
</span>
) : null
return (
<div
key={sre.id}
className="group flex items-center gap-3 px-4 py-3 hover:bg-secondary/60 transition-colors cursor-pointer"
onClick={() => router.push(`/salary/runs/${runId}/employees/${sre.employee_id}`)}
>
{/* Name + inline meta + (once calculated) a breakdown sub-line */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<Link
href={`/salary/runs/${runId}/employees/${sre.employee_id}`}
className="font-medium truncate hover:underline"
onClick={e => e.stopPropagation()}
>
{name}
</Link>
{dimensionsEnabled && dimLabel && (
<Badge data-ph-mask="" variant="secondary">{dimLabel}</Badge>
)}
{showDiff && diffNode(sre)}
</div>
{isCalculated && (
<p className="mt-1 text-xs text-muted-foreground tabular-nums">
{t('th_tax')} {formatCurrency(taxValue)} · {t('th_net')}{' '}
{formatCurrency(netValue)} · {t('th_avgifter')}{' '}
{formatCurrency(avgifterValue)} · {t('th_vacation')}{' '}
{formatCurrency(sre.vacation_accrual)}
</p>
)}
</div>
{/* Right side: editable monthly salary (draft) or the number */}
{editableSalary ? (
<div className="flex flex-col items-end gap-0.5 shrink-0">
<Input
type="number"
step="0.01"
min="0"
defaultValue={sre.monthly_salary}
onClick={e => e.stopPropagation()}
onBlur={e => onSalaryEdit(sre.employee_id, e.target.value, sre.monthly_salary)}
disabled={actionLoading === `salary-${sre.employee_id}`}
aria-label={t('salary_input_aria', { name })}
className="h-8 w-28 text-right tabular-nums"
/>
{degreeHint}
</div>
) : (
<div className="flex flex-col items-end gap-0.5 shrink-0">
<span className="text-right tabular-nums font-medium">
{formatCurrency(primaryNumber)}
</span>
{degreeHint}
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'pl-0')}>{t('th_employee')}</th>
{showDiff && previous && (
<th className={TH_CLASS}>{t('th_diff', { period: periodLabelOf(previous) })}</th>
)}
<th className={numericTh}>{isDraft ? t('th_monthly_salary') : t('th_gross')}</th>
{isCalculated && (
<>
<th className={numericTh}>{t('th_tax')}</th>
<th className={numericTh}>{t('th_net')}</th>
<th className={numericTh}>{t('th_avgifter')}</th>
<th className={numericTh}>{t('th_vacation')}</th>
</>
)}
<th className={cn(TH_CLASS, 'pr-0 text-right')}>
<span className="sr-only">{t('th_payslip')}</span>
</th>
</tr>
</thead>
<tbody>
{employees.map(sre => {
const employee = (sre as SreWithEmployee).employee
const name = employee
? `${employee.first_name} ${employee.last_name}`
: `${t('employee_fallback')} ${sre.employee_id.slice(0, 8)}...`
const dims = employee?.default_dimensions ?? {}
const dimLabel = Object.keys(dims)
.sort((a, b) => Number(a) - Number(b))
.map(k => dims[k])
.join(' · ')
const taxValue = sre.tax_withheld_override ?? sre.tax_withheld
const avgifterValue = sre.avgifter_amount_override ?? sre.avgifter_amount
const netValue = sre.net_salary + (sre.tax_withheld - taxValue)
const editableSalary = isDraft && canWrite && sre.salary_type === 'monthly'
const primaryNumber = isDraft ? sre.monthly_salary : sre.gross_salary
// In a draft the number on the row is the full-time monthly salary
// the engine multiplies by the employee's sysselsättningsgrad. Below
// 100 % that product is not obvious from the input alone (a 10 %
// employee typed 45 310 to get a 4 531 kr gross), so spell it out
// as a muted suffix right after the number instead of only in
// Beräkningsdetaljer.
const degree = Number(sre.employment_degree)
const showDegreeHint =
isDraft && sre.salary_type === 'monthly' && Number.isFinite(degree) && degree < 100
const degreeHint = showDegreeHint ? (
<span className="text-[11px] text-muted-foreground tabular-nums whitespace-nowrap">
{t('degree_hint', {
degree: degree.toLocaleString('sv-SE'),
amount: formatCurrency(roundOre((sre.monthly_salary || 0) * (degree / 100))),
})}
</span>
) : null
const removing = actionLoading === `remove-${sre.employee_id}`
{/* Payslip PDF */}
<a
href={`/api/salary/runs/${runId}/payslips/${sre.employee_id}/pdf`}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
title={t('view_payslip_title')}
aria-label={t('view_payslip_title')}
>
<FileDown className="h-4 w-4" />
</a>
{canRemoveEmployee && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
onClick={e => {
e.stopPropagation()
onRemoveEmployee(sre.employee_id, name)
}}
disabled={actionLoading === `remove-${sre.employee_id}`}
aria-label={t('remove_employee_aria', { name })}
title={t('remove_employee_title')}
return (
<tr
key={sre.id}
className="group cursor-pointer transition-colors duration-150 hover:bg-secondary/35"
onClick={() => router.push(`/salary/runs/${runId}/employees/${sre.employee_id}`)}
>
{actionLoading === `remove-${sre.employee_id}` ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
<td className={cn(TD_CLASS, 'pl-0')}>
<Link
href={`/salary/runs/${runId}/employees/${sre.employee_id}`}
className="font-medium hover:underline"
onClick={e => e.stopPropagation()}
>
{name}
</Link>
{dimensionsEnabled && dimLabel && (
<span data-ph-mask="" className="ml-2 text-xs text-muted-foreground">
{dimLabel}
</span>
)}
</td>
{showDiff && <td className={TD_CLASS}>{diffNode(sre)}</td>}
<td className={numericTd}>
<span className="inline-flex items-center justify-end gap-2">
{editableSalary ? (
<Input
type="number"
step="0.01"
min="0"
defaultValue={sre.monthly_salary}
onClick={e => e.stopPropagation()}
onBlur={e => onSalaryEdit(sre.employee_id, e.target.value, sre.monthly_salary)}
disabled={actionLoading === `salary-${sre.employee_id}`}
aria-label={t('salary_input_aria', { name })}
className="-my-1 h-8 w-28 text-right tabular-nums"
/>
) : (
<span>{formatCurrency(primaryNumber)}</span>
)}
{degreeHint}
</span>
</td>
{isCalculated && (
<>
<td className={numericTd}>{formatCurrency(taxValue)}</td>
<td className={numericTd}>{formatCurrency(netValue)}</td>
<td className={numericTd}>{formatCurrency(avgifterValue)}</td>
<td className={numericTd}>{formatCurrency(sre.vacation_accrual)}</td>
</>
)}
</Button>
)}
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</div>
)
})}
<td className={cn(TD_CLASS, 'pr-0 text-right')}>
<span className="-my-1 inline-flex items-center justify-end gap-1">
{/* Payslip PDF */}
<a
href={`/api/salary/runs/${runId}/payslips/${sre.employee_id}/pdf`}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="inline-flex h-8 w-8 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title={t('view_payslip_title')}
aria-label={t('view_payslip_title')}
>
<FileDown className="h-4 w-4" />
</a>
{canRemoveEmployee && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={e => {
e.stopPropagation()
onRemoveEmployee(sre.employee_id, name)
}}
disabled={removing}
aria-label={t('remove_employee_aria', { name })}
title={t('remove_employee_title')}
>
{removing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
)}
</span>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</DetailSection>
)
}
+264 -80
View File
@@ -17,123 +17,307 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { AlertTriangle, ArrowLeft, Loader2, MoreVertical, Trash2, Undo2 } from 'lucide-react'
import {
AlertTriangle,
ArrowLeft,
ArrowLeftCircle,
Download,
Eye,
FileDown,
Loader2,
MoreHorizontal,
Send,
Trash2,
Undo2,
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { periodLabelOf, type RunDetail } from './types'
// Same chip vocabulary as the Löner list (chips mark exceptions): in-flight
// states wear the quiet beige chip, paid is sage, corrected the outline
// exception, and booked renders as muted text.
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline'> = {
draft: 'secondary',
review: 'warning',
approved: 'default',
review: 'secondary',
approved: 'secondary',
paid: 'success',
booked: 'success',
corrected: 'outline',
}
const STATUS_RANK: Record<string, number> = {
draft: 0,
review: 1,
approved: 2,
paid: 3,
booked: 4,
corrected: 4,
}
interface RunHeaderProps {
run: RunDetail
canWrite: boolean
actionLoading: string | null
employeeCount: number
isCalculated: boolean
// The single "forward" step for the current status (Beräkna, Till
// granskning, Godkänn, Markera utbetald, Bokför): the one default button
// in the header. Everything else lives behind the ⋯ menu, except the
// payslip send, which is a rail step of its own and stays visible.
primaryAction?: { key: string; label: string; onClick: () => void } | null
onPreview: () => void
onRevert: () => void
onUnapprove: () => void
onSendPayslips: () => void
onDownloadPayslips: () => void
onDownloadAgi: () => void
onDelete: () => void
onCorrect: () => void
}
export function RunHeader({ run, canWrite, actionLoading, employeeCount, onDelete, onCorrect }: RunHeaderProps) {
export function RunHeader({
run,
canWrite,
actionLoading,
employeeCount,
isCalculated,
primaryAction,
onPreview,
onRevert,
onUnapprove,
onSendPayslips,
onDownloadPayslips,
onDownloadAgi,
onDelete,
onCorrect,
}: RunHeaderProps) {
const t = useTranslations('salary_run')
const tSalary = useTranslations('salary')
const [correctOpen, setCorrectOpen] = useState(false)
const periodLabel = periodLabelOf(run)
const hasEmailSend = useCapability(CAPABILITY.email_send)
const statusKey = `status_${run.status}`
const showMenu = canWrite && (run.status === 'draft' || run.status === 'booked')
const rank = STATUS_RANK[run.status] ?? 0
const busy = !!actionLoading
const deliveries = run.payslip_deliveries_summary
// Payslips are a parallel obligation from `approved` onwards. Email send is
// a paid capability (server 403s without it); the PDF download alternative
// in the menu stays free.
const payslipsAvailable = rank >= 2
const showPayslipSend = payslipsAvailable && canWrite
const showPreview = canWrite && (run.status === 'draft' || run.status === 'review')
const showRevert = canWrite && run.status === 'review'
const showUnapprove = canWrite && run.status === 'approved'
const showDownloadPayslips = payslipsAvailable && canWrite
const showDownloadAgi = canWrite && run.status === 'booked'
const showDelete = canWrite && run.status === 'draft'
const showCorrect = canWrite && run.status === 'booked'
const hasMenu =
showPreview ||
showRevert ||
showUnapprove ||
showDownloadPayslips ||
showDownloadAgi ||
showDelete ||
showCorrect
const menuBusy =
actionLoading === 'delete' ||
actionLoading === 'correct' ||
actionLoading === 'preview' ||
actionLoading === 'bulk_payslip' ||
actionLoading === 'agi-download'
const metaParts: React.ReactNode[] = [
<span key="payment">
{t('payment_date_label')} <span className="tabular-nums">{formatDate(run.payment_date)}</span>
</span>,
<span key="count">{t('header_employees', { count: employeeCount })}</span>,
]
if (run.is_correction && run.corrects_run_id) {
metaParts.push(
<Link
key="corrects"
href={`/salary/runs/${run.corrects_run_id}`}
className="underline underline-offset-2 hover:text-foreground"
>
{t('corrects_link', { period: periodLabel })}
</Link>,
)
}
if (run.status === 'corrected' && run.corrected_by_run_id) {
metaParts.push(
<Link
key="corrected-by"
href={`/salary/runs/${run.corrected_by_run_id}`}
className="underline underline-offset-2 hover:text-foreground"
>
{t('corrected_by_link')}
</Link>,
)
}
return (
<div className="flex items-start gap-2">
<Button variant="ghost" size="icon" asChild className="h-8 w-8 -ml-2 shrink-0">
<Link href="/salary" aria-label={t('back_to_salary')}>
<>
{/* Back link on its own quiet row, so the title below keeps a stable
position across runs. */}
<div>
<Link
href="/salary"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
{t('back_to_salary')}
</Link>
</Button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="font-display text-2xl tracking-tight truncate">
{t('title', { period: periodLabel })}
</h1>
<Badge variant={STATUS_VARIANTS[run.status] || 'secondary'} className="shrink-0">
{tSalary(statusKey)}
</Badge>
</div>
{/* Inline property row — Linear-style dot-separated metadata. */}
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>
{t('payment_date_label')} {formatDate(run.payment_date)}
</span>
<span aria-hidden>·</span>
<span>{t('header_employees', { count: employeeCount })}</span>
{run.is_correction && run.corrects_run_id && (
<>
<span aria-hidden>·</span>
<Badge variant="secondary">{t('correction_badge')}</Badge>
<Link
href={`/salary/runs/${run.corrects_run_id}`}
className="underline underline-offset-2 hover:text-foreground"
>
{t('corrects_link', { period: periodLabel })}
</Link>
</>
)}
{run.status === 'corrected' && run.corrected_by_run_id && (
<>
<span aria-hidden>·</span>
<Link
href={`/salary/runs/${run.corrected_by_run_id}`}
className="underline underline-offset-2 hover:text-foreground"
>
{t('corrected_by_link')}
</Link>
</>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{showMenu && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('more_actions')}>
{actionLoading === 'delete' || actionLoading === 'correct' ? (
<Loader2 className="h-4 w-4 animate-spin" />
{/* Header: serif title with one status element, a quiet meta line, and
the next step on the right. Everything else lives in the ⋯ menu. */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-3">
<h1 className="font-display text-2xl leading-8 tracking-tight">
{t('title', { period: periodLabel })}
</h1>
{run.status === 'booked' ? (
<span className="text-sm text-muted-foreground">{tSalary(statusKey)}</span>
) : (
<Badge variant={STATUS_VARIANTS[run.status] || 'secondary'}>
{tSalary(statusKey)}
</Badge>
)}
{run.is_correction && run.corrects_run_id && (
<Badge variant="outline">{t('correction_badge')}</Badge>
)}
</div>
<p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
{metaParts.map((part, i) => (
<span key={i} className="inline-flex items-center gap-x-2">
{i > 0 && <span aria-hidden>·</span>}
{part}
</span>
))}
</p>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{showPayslipSend && (
// The span carries the tooltip: browsers suppress `title` on
// disabled elements, and hover events don't fire on them.
<span title={!hasEmailSend ? t('payslips_send_requires_subscription') : undefined}>
<Button
variant="outline"
onClick={onSendPayslips}
disabled={busy || !hasEmailSend}
>
{actionLoading === 'payslips-send' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<MoreVertical className="h-4 w-4" />
<Send className="mr-2 h-4 w-4" />
)}
{deliveries && deliveries.sent > 0
? t('action_send_payslips_again')
: t('action_send_payslips')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{run.status === 'draft' && (
<DropdownMenuItem
onClick={onDelete}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{t('action_delete_draft')}
</DropdownMenuItem>
)}
{run.status === 'booked' && (
<DropdownMenuItem onClick={() => setCorrectOpen(true)}>
<Undo2 className="mr-2 h-4 w-4" />
{t('action_correct')}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</span>
)}
{primaryAction && (
<Button onClick={primaryAction.onClick} disabled={busy}>
{actionLoading === primaryAction.key && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{primaryAction.label}
</Button>
)}
{hasMenu && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('more_actions')}>
{menuBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[240px]">
{showPreview && (
<DropdownMenuItem
onSelect={onPreview}
disabled={busy || (run.status === 'draft' && !isCalculated)}
>
<Eye className="h-4 w-4" />
{t('action_preview')}
</DropdownMenuItem>
)}
{showDownloadPayslips && (
<DropdownMenuItem onSelect={onDownloadPayslips} disabled={busy}>
<FileDown className="h-4 w-4" />
{t('action_download_payslips')}
</DropdownMenuItem>
)}
{showDownloadAgi && (
<DropdownMenuItem onSelect={onDownloadAgi} disabled={busy}>
<Download className="h-4 w-4" />
{t('action_download_agi')}
</DropdownMenuItem>
)}
{(showRevert || showUnapprove) && (
<>
<DropdownMenuSeparator />
{showRevert && (
<DropdownMenuItem onSelect={onRevert} disabled={busy}>
<ArrowLeftCircle className="h-4 w-4" />
{t('action_revert')}
</DropdownMenuItem>
)}
{/* Approval is an internal control point, not a legal
event: the run can be unlocked again as long as nothing
has been paid, booked, or filed. The API refuses once
the AGI has reached Skatteverket. */}
{showUnapprove && (
<DropdownMenuItem onSelect={onUnapprove} disabled={busy}>
<ArrowLeftCircle className="h-4 w-4" />
{t('action_unapprove')}
</DropdownMenuItem>
)}
</>
)}
{(showDelete || showCorrect) && (
<>
<DropdownMenuSeparator />
{showDelete && (
<DropdownMenuItem
onSelect={onDelete}
disabled={busy}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4" />
{t('action_delete_draft')}
</DropdownMenuItem>
)}
{showCorrect && (
<DropdownMenuItem onSelect={() => setCorrectOpen(true)} disabled={busy}>
<Undo2 className="h-4 w-4" />
{t('action_correct')}
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Correction confirm — storno per BFL 5 kap. 5 §, nothing is deleted. */}
{/* Correction confirm: storno per BFL 5 kap. 5 §, nothing is deleted. */}
<Dialog open={correctOpen} onOpenChange={setCorrectOpen}>
<DialogContent>
<DialogHeader>
@@ -163,6 +347,6 @@ export function RunHeader({ run, canWrite, actionLoading, employeeCount, onDelet
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</>
)
}
+48 -46
View File
@@ -2,9 +2,10 @@
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { DetailSection } from '@/components/ui/detail-section'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { Calculator, Loader2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { cn, formatCurrency } from '@/lib/utils'
export interface EntryPreviewLine {
account_number: string
@@ -31,8 +32,8 @@ export interface PreviewData {
interface RunJournalPreviewProps {
preview: PreviewData
// When provided (draft + write access), a "Beräkna om" button renders in the
// header — recalculation sits on the output it refreshes.
// When provided (draft + write access), a "Beräkna om" button renders on
// the kicker line: recalculation sits on the output it refreshes.
onRecalculate?: () => void
recalculating?: boolean
}
@@ -48,13 +49,11 @@ export function RunJournalPreview({ preview, onRecalculate, recalculating }: Run
].filter(Boolean) as EntryPreview[]
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3 space-y-0">
<CardTitle className="text-base">
{preview.booked ? t('journal_booked_title') : t('journal_preview_title')}
</CardTitle>
{onRecalculate && (
<Button variant="outline" size="sm" onClick={onRecalculate} disabled={recalculating}>
<DetailSection
kicker={preview.booked ? t('journal_booked_title') : t('journal_preview_title')}
aside={
onRecalculate ? (
<Button variant="outline" size="sm" onClick={onRecalculate} disabled={recalculating} className="-my-1">
{recalculating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
@@ -62,43 +61,46 @@ export function RunJournalPreview({ preview, onRecalculate, recalculating }: Run
)}
{t('action_recalculate')}
</Button>
)}
</CardHeader>
<CardContent className="space-y-6">
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('journal_preview_nollkorning')}</p>
) : (
entries.map((entry, idx) => (
<div key={idx} className="space-y-2">
) : undefined
}
>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('journal_preview_nollkorning')}</p>
) : (
<div className="space-y-6">
{entries.map((entry, idx) => (
<div key={idx}>
<h4 className="text-sm font-medium">{entry.description}</h4>
<table className="w-full text-xs">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
<th className="text-left py-1">{t('journal_th_account')}</th>
<th className="text-left py-1">{t('journal_th_description')}</th>
<th className="text-right py-1">{t('journal_th_debit')}</th>
<th className="text-right py-1">{t('journal_th_credit')}</th>
</tr>
</thead>
<tbody>
{entry.lines.map((line, li) => (
<tr key={li} className="border-t border-border">
<td className="py-1.5 tabular-nums font-mono">{line.account_number}</td>
<td className="py-1.5 text-muted-foreground">{line.line_description}</td>
<td className="py-1.5 text-right tabular-nums">
{line.debit_amount ? formatCurrency(line.debit_amount) : ''}
</td>
<td className="py-1.5 text-right tabular-nums">
{line.credit_amount ? formatCurrency(line.credit_amount) : ''}
</td>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'pl-0')}>{t('journal_th_account')}</th>
<th className={TH_CLASS}>{t('journal_th_description')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('journal_th_debit')}</th>
<th className={cn(TH_CLASS, 'pr-0 text-right')}>{t('journal_th_credit')}</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{entry.lines.map((line, li) => (
<tr key={li}>
<td className={cn(TD_CLASS, 'pl-0 tabular-nums font-mono')}>{line.account_number}</td>
<td className={cn(TD_CLASS, 'text-muted-foreground')}>{line.line_description}</td>
<td className={cn(TD_CLASS, 'text-right tabular-nums')}>
{line.debit_amount ? formatCurrency(line.debit_amount) : ''}
</td>
<td className={cn(TD_CLASS, 'pr-0 text-right tabular-nums')}>
{line.credit_amount ? formatCurrency(line.credit_amount) : ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))
)}
</CardContent>
</Card>
))}
</div>
)}
</DetailSection>
)
}
+5 -5
View File
@@ -45,15 +45,15 @@ export function RunKpiCards({ run, employees }: RunKpiCardsProps) {
{ label: t('kpi_employer_cost'), value: effEmployerCost },
]
// Inline stat row framed by hairlines — Linear-style, no boxes. Metrics
// breathe on whitespace and wrap on narrow viewports.
// Flat stat row: label/number pairs on whitespace, no boxes or rules.
// Metrics wrap on narrow viewports.
return (
<div className="flex flex-wrap gap-x-10 gap-y-3 border-y border-border py-4">
<div className="flex flex-wrap gap-x-10 gap-y-4">
{cards.map(({ label, value, accent }) => (
<div key={label} className="min-w-0">
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">{label}</p>
<p
className={`mt-1 font-sans font-medium text-lg tabular-nums leading-none ${accent ? 'text-success' : ''}`}
className={`mt-1 font-display text-xl tabular-nums leading-none ${accent ? 'text-success' : ''}`}
>
{formatCurrency(value)}
</p>
+14 -133
View File
@@ -1,11 +1,7 @@
'use client'
import { useTranslations, useLocale } from 'next-intl'
import { Button } from '@/components/ui/button'
import { ArrowLeftCircle, Eye, FileDown, Loader2, Send } from 'lucide-react'
import { formatDateLong } from '@/lib/utils'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import type { AgiFilingState } from '@/lib/salary/agi-submission-state'
import type { RunDetail } from './types'
@@ -23,19 +19,6 @@ interface RunProgressBarProps {
agiState: AgiFilingState
// Shown on the AGI step once signed: the kvittens is the filing receipt.
agiKvittensnummer?: string | null
canWrite: boolean
actionLoading: string | null
// The single "forward" step for the current status (Beräkna → Skicka till
// granskning → Godkänn → Markera utbetald → Bokför). Rendered as the large
// primary target in the control zone, right next to the secondary actions.
primaryAction?: { key: string; label: string; onClick: () => void } | null
// Secondary actions for the stage: preview, revert, and the parallel payslip
// obligation. (Recalculate lives with the employee rows.)
onPreview: () => void
onRevert: () => void
onUnapprove: () => void
onSendPayslips: () => void
onDownloadPayslips: () => void
}
const STATUS_RANK: Record<string, number> = {
@@ -47,20 +30,19 @@ const STATUS_RANK: Record<string, number> = {
corrected: 4,
}
/**
* The month's steps as a flat segmented rail: no box, no buttons. The actions
* for the current stage live in the header (primary button + ⋯ menu); the
* rail only says where the run stands and what the stage means.
*/
export function RunProgressBar(props: RunProgressBarProps) {
const t = useTranslations('salary_run')
const locale = useLocale()
const { run, isCalculated, noPayout, canWrite, actionLoading, primaryAction, agiState, agiKvittensnummer } = props
const { run, isCalculated, noPayout, agiState, agiKvittensnummer } = props
const rank = STATUS_RANK[run.status] ?? 0
const busy = !!actionLoading
const deliveries = run.payslip_deliveries_summary
const hasEmailSend = useCapability(CAPABILITY.email_send)
function spinnerOr(icon: React.ReactNode, key: string) {
return actionLoading === key ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : icon
}
// Payslips are a parallel obligation from `approved` onwards — never gate
// Payslips are a parallel obligation from `approved` onwards: never gate
// progression, so their "done" is simply having reached every employee.
const payslipsAvailable = rank >= 2
const payslipsDone =
@@ -144,82 +126,6 @@ export function RunProgressBar(props: RunProgressBarProps) {
const doneCount = steps.filter(s => s.state === 'done').length
const activeStep = steps.find(s => s.state === 'active')
// Payslip send/download — shared by the mobile summary and the desktop bar.
// Email send is a paid capability (server 403s without it); the PDF
// download alternative right next to it stays free.
const payslipActions = payslipsAvailable && canWrite && (
<>
{/* The span carries the tooltip: browsers suppress `title` on
disabled elements, and hover events don't fire on them. */}
<span title={!hasEmailSend ? t('payslips_send_requires_subscription') : undefined}>
<Button
size="sm"
variant={deliveries && deliveries.sent > 0 ? 'outline' : 'default'}
onClick={props.onSendPayslips}
disabled={busy || !hasEmailSend}
>
{spinnerOr(<Send className="mr-2 h-4 w-4" />, 'payslips-send')}
{deliveries && deliveries.sent > 0
? t('action_send_payslips_again')
: t('action_send_payslips')}
</Button>
</span>
<Button variant="ghost" size="sm" onClick={props.onDownloadPayslips} disabled={busy}>
{spinnerOr(<FileDown className="mr-2 h-4 w-4" />, 'bulk_payslip')}
{t('action_download_payslips')}
</Button>
</>
)
// Secondary actions for the current status. The forward/primary action is
// the header CTA, so this is deliberately the "everything else" set.
let secondaryActions: React.ReactNode = null
if (canWrite) {
if (run.status === 'draft') {
// Recalculate ("Beräkna om") lives with the employee rows it recalculates,
// not here. This band keeps only the preview toggle.
secondaryActions = (
<Button
variant="outline"
size="sm"
onClick={props.onPreview}
disabled={busy || !isCalculated}
>
{spinnerOr(<Eye className="mr-2 h-4 w-4" />, 'preview')}
{t('action_preview')}
</Button>
)
} else if (run.status === 'review') {
secondaryActions = (
<>
<Button variant="outline" size="sm" onClick={props.onPreview} disabled={busy}>
{spinnerOr(<Eye className="mr-2 h-4 w-4" />, 'preview')}
{t('action_preview')}
</Button>
<Button variant="ghost" size="sm" onClick={props.onRevert} disabled={busy}>
<ArrowLeftCircle className="mr-2 h-4 w-4" />
{t('action_revert')}
</Button>
</>
)
} else if (run.status === 'approved') {
// Approval is an internal control point, not a legal event — the run can
// be unlocked again as long as nothing has been paid, booked, or filed.
// The API refuses once the AGI has reached Skatteverket.
secondaryActions = (
<>
{payslipActions}
<Button variant="ghost" size="sm" onClick={props.onUnapprove} disabled={busy}>
<ArrowLeftCircle className="mr-2 h-4 w-4" />
{t('action_unapprove')}
</Button>
</>
)
} else if (payslipsAvailable) {
secondaryActions = payslipActions
}
}
function segClass(state: StepState) {
return state === 'done'
? 'bg-primary'
@@ -228,19 +134,12 @@ export function RunProgressBar(props: RunProgressBarProps) {
: 'bg-border'
}
// The stage line + its detail — a single sentence that sits under the track.
// The stage line + its detail: a single sentence that sits under the track.
const currentLine = activeStep?.detail ?? (activeStep ? activeStep.label : t('rail_all_done'))
const primaryButton = primaryAction && (
<Button size="sm" onClick={primaryAction.onClick} disabled={busy}>
{actionLoading === primaryAction.key && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{primaryAction.label}
</Button>
)
return (
<div className="rounded-lg border border-border p-4">
{/* Mobile: segmented track + current step + a full-width primary button. */}
<div>
{/* Mobile: segmented track + current step + counter. */}
<div className="md:hidden space-y-3">
<div className="flex gap-1">
{steps.map(step => (
@@ -258,20 +157,11 @@ export function RunProgressBar(props: RunProgressBarProps) {
{activeStep?.detail && (
<p className="text-[11px] text-muted-foreground">{activeStep.detail}</p>
)}
{primaryAction && (
<Button className="w-full" onClick={primaryAction.onClick} disabled={busy}>
{actionLoading === primaryAction.key && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{primaryAction.label}
</Button>
)}
{secondaryActions && <div className="flex flex-wrap gap-2">{secondaryActions}</div>}
</div>
{/* Desktop: segmented track with per-segment labels, then a single action
row — the stage sentence on the left, secondary + primary on the right. */}
<div className="hidden md:block space-y-4">
{/* Desktop: segmented track with per-segment labels, then the stage
sentence. */}
<div className="hidden md:block space-y-3">
<ol className="flex gap-2">
{steps.map(step => (
<li key={step.key} className="flex-1 min-w-0 space-y-2">
@@ -291,16 +181,7 @@ export function RunProgressBar(props: RunProgressBarProps) {
</li>
))}
</ol>
{(secondaryActions || primaryButton) && (
<div className="flex items-center justify-between gap-4">
<p className="text-xs text-muted-foreground">{currentLine}</p>
<div className="flex flex-wrap items-center justify-end gap-2 shrink-0">
{secondaryActions}
{primaryButton}
</div>
</div>
)}
<p className="text-xs text-muted-foreground">{currentLine}</p>
</div>
</div>
)
+5 -1
View File
@@ -11,11 +11,14 @@ import { cn } from '@/lib/utils'
export function DetailSection({
kicker,
help,
aside,
children,
className,
}: {
kicker: string
/** Section help behind a "?" right after the kicker (convention 7). Pass a <HelpPopover>. */
help?: React.ReactNode
/** Optional right-aligned element on the kicker line: a count, a quiet action. */
aside?: React.ReactNode
children: React.ReactNode
@@ -25,8 +28,9 @@ export function DetailSection({
<section className={className}>
<div className="flex items-baseline justify-between gap-4 border-b border-border pb-2">
{/* data-ph-unmask: kickers are static i18n chrome in session replays. */}
<h2 data-ph-unmask="" className="text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
<h2 data-ph-unmask="" className="flex items-center gap-2 text-[11px] font-medium uppercase tracking-wider text-muted-foreground">
{kicker}
{help}
</h2>
{aside}
</div>
+59 -48
View File
@@ -1089,6 +1089,7 @@
"not_found": "The asset could not be found.",
"already_disposed": "The asset was already disposed ({date}).",
"back": "Back",
"asset_section": "Asset",
"acquisition_cost": "Acquisition cost",
"acquired": "Acquired",
"bas_accounts": "Accounts (BAS)",
@@ -1128,6 +1129,7 @@
"direction_transferred": "Transferred to the acquirer",
"adjustment_capped": "The amount was capped at 25% of the sale price excluding VAT.",
"business_transfer_confirm": "I confirm that the transfer covers an entire business or independent branch and meets the conditions in ML chapter 5, section 38.",
"adjustment_document_label": "Adjustment document",
"adjustment_document_confirm": "I confirm that an adjustment document is prepared for the acquirer.",
"adjustment_data_required": "VAT basis data is required because the asset may be an investment good.",
"cancel": "Cancel",
@@ -2000,7 +2002,7 @@
},
"salary_payments": {
"title": "Payment file for your bank",
"last_generated": "Last generated:",
"last_generated": "Last generated",
"format_label": "Format",
"format_bg_lb": "Bankgirot LB file",
"format_pain001": "ISO 20022 pain.001 (XML)",
@@ -2034,9 +2036,9 @@
"tax_label_tax": "Tax",
"tax_label_avgifter": "Employer contributions",
"tax_label_total": "Total to pay",
"tax_recipient": "Recipient:",
"tax_recipient": "Recipient",
"tax_recipient_value": "Skatteverket Bankgiro 5050-1055",
"tax_due_date": "Due date:",
"tax_due_date": "Due date",
"tax_ocr_note": "The payment file uses your skattekonto OCR (org number + Luhn check digit). Skatteverket applies the payment to the amount you declared in the AGI for the period.",
"tax_file_generated": "Payment file last generated",
"tax_marked_paid": "Marked as paid",
@@ -3807,6 +3809,29 @@
},
"invoice_detail": {
"back": "Back",
"title_invoice": "Invoice {number}",
"title_credit_note": "Credit note {number}",
"title_credit_draft": "Credit note draft",
"title_proforma": "Proforma {number}",
"title_delivery_note": "Delivery note {number}",
"title_self_billed": "Self-billing invoice {number}",
"title_draft": "Invoice draft",
"sent_on": "Sent {date}",
"def_customer": "Name",
"def_org_number": "Org. no.",
"def_vat_number": "VAT no.",
"def_email": "Email",
"def_phone": "Phone",
"def_address": "Address",
"def_credit_note": "Credit note",
"def_credits": "Credits",
"def_converted_from": "Converted from",
"payment_section": "Payment",
"payment_confirmation_label": "Payment confirmation",
"payment_confirmation_download_short": "Download",
"payment_confirmation_send_short": "Send to customer",
"reminders_schedule_aside": "Automatic after {day1}, {day2} and {day3} days",
"reminders_disabled_aside": "Automatic reminders are off",
"confirm_book_title": "Book invoice",
"confirm_book_description": "Will be posted as voucher {voucher}: invoice {number}, {amount}. A posted voucher cannot be edited, only corrected or reversed.",
"confirm_book_description_generic": "Invoice {number} will be posted with the next available voucher number and can then only be corrected or reversed.",
@@ -3828,10 +3853,8 @@
"external_number_label": "Invoice number (customer's)",
"agreement_ref_label": "Agreement reference",
"created_at": "Created {date}",
"sent_at_suffix": " • Sent {date}",
"delivery_history_title": "Delivery history",
"delivery_history_description": "See when the invoice was sent, who received it, and the exact message and PDF that were used.",
"delivery_history_legacy_title": "Delivery details are unavailable",
"delivery_history_legacy_description": "This invoice was sent before delivery history was recorded. Its time, recipients, and exact content cannot be shown reliably.",
"delivery_history_unreadable_title": "Delivery history could not be loaded",
"delivery_history_unreadable_description": "The sends for this invoice could not be retrieved. That does not mean the invoice is unsent: the data is simply missing right now.",
@@ -3850,7 +3873,7 @@
"delivery_from_label": "Sender name",
"delivery_subject_label": "Subject",
"delivery_message_label": "Message",
"delivery_open_pdf": "Open attached PDF: {filename}",
"delivery_pdf_label": "Attached PDF",
"delivery_pdf_fallback": "invoice.pdf",
"delivery_provider_label": "Email provider",
"delivery_message_id_label": "Message ID",
@@ -3920,9 +3943,7 @@
"view_voucher": "View verifikat",
"correction_loading": "Loading…",
"correction_prompt": "Something wrong? Create a correction verifikat",
"paid_card_title": "Paid",
"paid_received_at": "Payment received {date}",
"payment_status_card_title": "Payment status",
"payment_status_paid_label": "Paid",
"payment_status_remaining_label": "Remaining",
"payment_status_payments_heading": "Payments",
@@ -3942,27 +3963,13 @@
"payment_confirmation_confirm_description": "An email titled \"Payment confirmation for invoice {number}\" is sent to {email} with the paid copy (PAID stamp, nothing left to pay) attached. The invoice status and delivery history are not affected.",
"payment_confirmation_confirm_action": "Send",
"reminders_card_title": "Reminders",
"reminders_description": "Automatic reminders are sent when the invoice is {day1}, {day2} and {day3} days overdue",
"reminders_disabled": "Automatic reminders are turned off in settings.",
"reminders_empty": "No reminders have been sent yet.",
"reminder_level_label": "Level {level}",
"reminder_level_1": "Friendly reminder",
"reminder_level_2": "Second reminder",
"reminder_level_3": "Final reminder",
"reminder_sent_to": "Sent {date} to {email}",
"reminder_marked_paid": "Customer marked as paid",
"reminder_objection": "Customer has objections",
"credited_card_title": "Credited",
"see_credit_note": "See credit note {number}",
"credit_draft_card_title": "Credit note waiting to be sent",
"open_credit_draft": "Open draft {number}",
"credit_note_card_title": "Credit note",
"credit_note_description": "This credit note credits",
"see_original_invoice": "See invoice {number}",
"converted_card_title": "Converted",
"converted_description": "Converted from a proforma invoice",
"see_proforma": "See proforma {number}",
"actions_card_title": "Actions",
"cancel_action": "Cancel",
"delete_draft": "Cancel draft",
"remove_credit_draft": "Delete credit draft",
@@ -4069,10 +4076,10 @@
"warning_title": "The credit note is created as a draft",
"warning_description": "All amounts become negative. The original invoice is marked as credited only when the credit note is sent or marked as sent. Bookkeeping is then handled according to the company's selected accounting method.",
"original_card_title": "Original invoice",
"invoice_number_label": "Invoice number:",
"date_label": "Date:",
"customer_label": "Customer:",
"vat_treatment_label": "VAT treatment:",
"invoice_number_label": "Invoice number",
"date_label": "Date",
"customer_label": "Customer",
"vat_treatment_label": "VAT treatment",
"preview_card_title": "Credit note preview",
"preview_card_description": "Credit note number: KR-{number}",
"th_description": "Description",
@@ -4545,13 +4552,22 @@
"review_accrual_line_info": "Accrued {from} to {to}"
},
"supplier_invoice_detail": {
"title_invoice": "Supplier invoice {number}",
"title_credit_note": "Credit note {number}",
"created_at": "Created {date}",
"def_name": "Name",
"def_org_number": "Org. no.",
"def_email": "Email",
"def_credits": "Credits",
"credit_note_help": "To remove the credit note and free up the invoice number, go to the original invoice and select \"Undo credit\".",
"payment_section": "Payment",
"view_voucher": "View verifikat",
"ore_rounding": "Öre rounding",
"back": "Back",
"back_aria": "Back to supplier invoices",
"viewer_disabled_tooltip": "You only have read access in this company",
"not_found": "Invoice not found",
"arrival_header": "Arrival #{number}",
"header_subtitle": "{supplier} | Invoice {number}",
"status_registered": "Registered",
"status_approved": "Approved",
"status_paid": "Paid",
@@ -4588,11 +4604,7 @@
"uncredit_failed_title": "Could not undo credit note",
"uncredit_success_title": "Credit undone",
"uncredit_success_description": "The original invoice has been restored and the number is available.",
"credit_note_banner_title": "This is a credit note",
"credit_note_banner_prefix": "It is linked to",
"credit_note_banner_link": "invoice {number}",
"credit_note_banner_original_fallback": "the original invoice",
"credit_note_banner_suffix": ". To remove the credit note and free up the invoice number, go to the original and select \"Undo credit\".",
"invoice_info_title": "Invoice information",
"arrival_number_label": "Arrival number",
"invoice_number_label": "Invoice number",
@@ -4603,14 +4615,12 @@
"reverse_charge_badge": "Reverse charge",
"badge_accrued": "Accrued",
"accrual_line_info": "Accrued {from} to {to}",
"amounts_title": "Amounts",
"net_excl_vat": "Net (excl. VAT)",
"vat_label": "VAT",
"total_label": "Total",
"paid_label": "Paid",
"remaining_label": "Remaining to pay",
"supplier_section_title": "Supplier",
"org_number_inline": "Org. no.: {number} | ",
"rows_title": "Rows",
"col_description": "Description",
"col_quantity": "Qty",
@@ -4623,10 +4633,6 @@
"vat_inline": "{rate}% VAT",
"vat_amount_inline": "VAT {amount}",
"payment_history_title": "Payment history",
"col_date": "Date",
"col_amount_short": "Amount",
"col_voucher": "Verifikat",
"col_note": "Note",
"vouchers_title": "Verifikat (sambandskrav)",
"registration_voucher": "Registration verifikat",
"no_registration_voucher": "No registration verifikat (cash method)",
@@ -4959,7 +4965,6 @@
"source_label_vat_settlement": "VAT settlement"
},
"journal_correction": {
"title": "Correction chain",
"info": "Posted journal entries cannot be edited directly. Instead, a storno entry is created that reverses the original, and a new correction entry with the correct details.",
"role_storno": "Storno",
"role_correction": "Correction",
@@ -4967,6 +4972,11 @@
"current": "Current"
},
"journal_detail": {
"title": "Voucher {label}",
"title_draft": "Draft voucher",
"posted_on": "Posted {date}",
"field_voucher": "Voucher number",
"lines_count": "{count, plural, one {# line} other {# lines}}",
"accounts_loading": "Loading chart of accounts...",
"accounts_load_failed": "The chart of accounts could not be loaded. Try again to change the account.",
"accounts_retry": "Try again",
@@ -5027,10 +5037,8 @@
"edit_note_aria": "Edit note",
"note_placeholder": "Internal note...",
"no_note": "No note",
"summary_title": "Summary",
"summary_debit": "Debit",
"summary_credit": "Credit",
"summary_lines": "Lines",
"attachments_title": "Documents",
"attachments_count": "{count} documents",
"no_attachments": "No documents attached",
@@ -5038,7 +5046,6 @@
"references_title": "Linked supporting documents",
"reference_invoice": "Customer invoice {number}",
"reference_supplier_invoice": "Supplier invoice {number}",
"currency_title": "Currency conversion",
"currency_rate": "Rate",
"currency_original_amount": "Original amount",
"lines_title": "Entry lines",
@@ -6728,7 +6735,7 @@
"action_send_payslips": "Send payslips",
"payslips_send_requires_subscription": "Emailing payslips requires a subscription. Download the PDFs for free.",
"action_send_payslips_again": "Send again",
"action_download_payslips": "Download all",
"action_download_payslips": "Download all payslips",
"action_download_agi": "Download AGI file (XML)",
"action_delete_draft": "Delete draft",
"action_correct": "Correct payroll run (storno)",
@@ -6981,6 +6988,9 @@
},
"salary_employee": {
"form_back_to_employees": "Back to employees",
"detail_edit": "Edit",
"detail_edit_dialog_title": "Edit employee",
"detail_bank_missing": "Bank details missing: required before a payroll run can be approved.",
"form_personal_info": "Personal information",
"form_first_name": "First name",
"form_last_name": "Last name",
@@ -7052,8 +7062,6 @@
"detail_f_skatt_verified": "F-skatt verified: {date}",
"detail_vacation_none_hint": "No accrual to 2920 is booked. Use if vacation is included in the monthly salary — common for owners who are the only employee.",
"detail_save_changes": "Save changes",
"detail_self_saving_heading": "Saved separately",
"detail_self_saving_hint": "Benefits and opening balances are saved directly in each section and are not included in Save changes above.",
"tax_title": "Tax",
"tax_form_label": "Tax status",
"tax_form_tooltip": "A-skatt: you withhold preliminary tax according to the tax table (skattetabell). F-skatt/FA-skatt: the person handles their own tax — no tax is withheld.",
@@ -7075,12 +7083,11 @@
"tax_no_table_f_skatt": "With F-skatt or FA-skatt the person handles their own tax — no tax is withheld and no tax table is needed.",
"benefits_title": "Benefits",
"benefits_add": "Add benefit",
"benefits_loading": "Loading…",
"benefits_empty": "No benefits registered. Active benefits are added automatically as rows in salary runs.",
"benefits_empty": "No benefits registered.",
"benefits_help": "Active benefits are added automatically as rows in salary runs.",
"benefits_type": "Type",
"benefits_description": "Description",
"benefits_value_per_month": "Value/month",
"benefits_period": "Period",
"benefits_per_month": "/month",
"benefits_ongoing": "ongoing",
"benefits_remove": "Remove",
"benefits_added": "Benefit added",
@@ -7122,6 +7129,10 @@
"opening_balances_liability_avgifter": "Charges on the liability (2940)",
"opening_balances_saved_heading": "Saved days per earning year",
"opening_balances_saved_hint": "Days saved from earlier years (max 5 years back per the Annual Leave Act).",
"opening_balances_show": "Show",
"opening_balances_hide": "Hide",
"opening_balances_collapsed_none": "No opening balances entered.",
"opening_balances_collapsed_set": "Entered, cutover date {date}.",
"opening_balances_save": "Save opening balances",
"opening_balances_update": "Update opening balances",
"opening_balances_saving": "Saving…",
+59 -48
View File
@@ -1089,6 +1089,7 @@
"not_found": "Tillgången kunde inte hittas.",
"already_disposed": "Tillgången är redan avyttrad ({date}).",
"back": "Tillbaka",
"asset_section": "Tillgång",
"acquisition_cost": "Anskaffningsvärde",
"acquired": "Anskaffad",
"bas_accounts": "Konton (BAS)",
@@ -1128,6 +1129,7 @@
"direction_transferred": "Överförs till förvärvaren",
"adjustment_capped": "Beloppet har begränsats till 25 % av försäljningspriset exklusive moms.",
"business_transfer_confirm": "Jag bekräftar att överlåtelsen omfattar en hel verksamhet eller självständig verksamhetsgren och uppfyller villkoren i ML 5 kap. 38 §.",
"adjustment_document_label": "Justeringshandling",
"adjustment_document_confirm": "Jag bekräftar att en justeringshandling upprättas för förvärvaren.",
"adjustment_data_required": "Momsunderlaget krävs eftersom tillgången kan vara en investeringsvara.",
"cancel": "Avbryt",
@@ -2000,7 +2002,7 @@
},
"salary_payments": {
"title": "Betalfil till bank",
"last_generated": "Senast genererad:",
"last_generated": "Senast genererad",
"format_label": "Format",
"format_bg_lb": "Bankgirot LB-fil",
"format_pain001": "ISO 20022 pain.001 (XML)",
@@ -2034,9 +2036,9 @@
"tax_label_tax": "Skatt",
"tax_label_avgifter": "Arbetsgivaravgifter",
"tax_label_total": "Totalt att betala",
"tax_recipient": "Mottagare:",
"tax_recipient": "Mottagare",
"tax_recipient_value": "Skatteverket Bankgiro 5050-1055",
"tax_due_date": "Förfallodag:",
"tax_due_date": "Förfallodag",
"tax_ocr_note": "Betalfil använder ditt Skattekonto-OCR (org-nummer + Luhn-kontrollsiffra). Skatteverket applicerar betalningen på det belopp du deklarerat i AGI för perioden.",
"tax_file_generated": "Betalfil senast genererad",
"tax_marked_paid": "Markerad som betald",
@@ -3807,6 +3809,29 @@
},
"invoice_detail": {
"back": "Tillbaka",
"title_invoice": "Faktura {number}",
"title_credit_note": "Kreditfaktura {number}",
"title_credit_draft": "Kreditutkast",
"title_proforma": "Proforma {number}",
"title_delivery_note": "Följesedel {number}",
"title_self_billed": "Självfaktura {number}",
"title_draft": "Fakturautkast",
"sent_on": "Skickad {date}",
"def_customer": "Namn",
"def_org_number": "Org.nr",
"def_vat_number": "Momsnr",
"def_email": "E-post",
"def_phone": "Telefon",
"def_address": "Adress",
"def_credit_note": "Kreditfaktura",
"def_credits": "Krediterar",
"def_converted_from": "Konverterad från",
"payment_section": "Betalning",
"payment_confirmation_label": "Betalningsbekräftelse",
"payment_confirmation_download_short": "Ladda ner",
"payment_confirmation_send_short": "Skicka till kund",
"reminders_schedule_aside": "Automatiskt efter {day1}, {day2} och {day3} dagar",
"reminders_disabled_aside": "Automatiska påminnelser avstängda",
"confirm_book_title": "Bokför faktura",
"confirm_book_description": "Bokförs som verifikat {voucher}: faktura {number}, {amount}. Verifikatet kan inte ändras i efterhand, bara rättas eller stornas.",
"confirm_book_description_generic": "Faktura {number} bokförs med nästa lediga verifikationsnummer och kan därefter inte ändras, bara rättas eller stornas.",
@@ -3828,10 +3853,8 @@
"external_number_label": "Fakturanummer (kundens)",
"agreement_ref_label": "Avtalsreferens",
"created_at": "Skapad {date}",
"sent_at_suffix": " • Skickad {date}",
"delivery_history_title": "Utskickshistorik",
"delivery_history_description": "Se när fakturan skickades, till vem och exakt vilket meddelande och vilken PDF som användes.",
"delivery_history_legacy_title": "Detaljer saknas för detta utskick",
"delivery_history_legacy_description": "Fakturan skickades innan utskickshistorik började sparas. Tidpunkt, mottagare och exakt innehåll kan därför inte visas säkert.",
"delivery_history_unreadable_title": "Utskickshistoriken kunde inte läsas in",
"delivery_history_unreadable_description": "Det gick inte att hämta utskicken för den här fakturan. Det betyder inte att fakturan är oskickad: uppgifterna saknas bara just nu.",
@@ -3850,7 +3873,7 @@
"delivery_from_label": "Avsändarnamn",
"delivery_subject_label": "Ämne",
"delivery_message_label": "Meddelande",
"delivery_open_pdf": "Öppna bifogad PDF: {filename}",
"delivery_pdf_label": "Bifogad PDF",
"delivery_pdf_fallback": "faktura.pdf",
"delivery_provider_label": "E-posttjänst",
"delivery_message_id_label": "Meddelande-id",
@@ -3920,9 +3943,7 @@
"view_voucher": "Visa verifikation",
"correction_loading": "Hämtar…",
"correction_prompt": "Något fel? Skapa ändringsverifikation",
"paid_card_title": "Betald",
"paid_received_at": "Betalning mottagen {date}",
"payment_status_card_title": "Betalningsstatus",
"payment_status_paid_label": "Betalt",
"payment_status_remaining_label": "Återstår",
"payment_status_payments_heading": "Betalningar",
@@ -3942,27 +3963,13 @@
"payment_confirmation_confirm_description": "Ett mejl med rubriken \"Betalningsbekräftelse för faktura {number}\" skickas till {email} med den betalda kopian (BETALD-stämpel, inget kvar att betala) som bilaga. Fakturans status och utskickshistorik påverkas inte.",
"payment_confirmation_confirm_action": "Skicka",
"reminders_card_title": "Påminnelser",
"reminders_description": "Automatiska påminnelser skickas när fakturan varit förfallen i {day1}, {day2} och {day3} dagar",
"reminders_disabled": "Automatiska påminnelser är avstängda i inställningarna.",
"reminders_empty": "Inga påminnelser har skickats ännu.",
"reminder_level_label": "Nivå {level}",
"reminder_level_1": "Vänlig påminnelse",
"reminder_level_2": "Andra påminnelsen",
"reminder_level_3": "Slutlig påminnelse",
"reminder_sent_to": "Skickad {date} till {email}",
"reminder_marked_paid": "Kunden markerat som betald",
"reminder_objection": "Kunden har invändningar",
"credited_card_title": "Krediterad",
"see_credit_note": "Se kreditfaktura {number}",
"credit_draft_card_title": "Kreditfaktura väntar på att skickas",
"open_credit_draft": "Öppna utkast {number}",
"credit_note_card_title": "Kreditfaktura",
"credit_note_description": "Denna kreditfaktura krediterar",
"see_original_invoice": "Se faktura {number}",
"converted_card_title": "Konverterad",
"converted_description": "Konverterad från proformafaktura",
"see_proforma": "Se proforma {number}",
"actions_card_title": "Åtgärder",
"cancel_action": "Makulera",
"delete_draft": "Makulera utkast",
"remove_credit_draft": "Ta bort kreditutkast",
@@ -4069,10 +4076,10 @@
"warning_title": "Kreditfakturan skapas som utkast",
"warning_description": "Alla belopp blir negativa. Originalfakturan markeras som krediterad först när kreditfakturan skickas eller markeras som skickad. Bokföringen hanteras då enligt företagets valda bokföringsmetod.",
"original_card_title": "Ursprunglig faktura",
"invoice_number_label": "Fakturanummer:",
"date_label": "Datum:",
"customer_label": "Kund:",
"vat_treatment_label": "Momsbehandling:",
"invoice_number_label": "Fakturanummer",
"date_label": "Datum",
"customer_label": "Kund",
"vat_treatment_label": "Momsbehandling",
"preview_card_title": "Kreditfaktura förhandsgranskning",
"preview_card_description": "Kreditfakturanummer: KR-{number}",
"th_description": "Beskrivning",
@@ -4545,13 +4552,22 @@
"review_accrual_line_info": "Periodiseras {from} till {to}"
},
"supplier_invoice_detail": {
"title_invoice": "Leverantörsfaktura {number}",
"title_credit_note": "Kreditfaktura {number}",
"created_at": "Skapad {date}",
"def_name": "Namn",
"def_org_number": "Org.nr",
"def_email": "E-post",
"def_credits": "Krediterar",
"credit_note_help": "För att ta bort kreditfakturan och frigöra fakturanumret, gå till originalfakturan och välj \"Ångra kreditering\".",
"payment_section": "Betalning",
"view_voucher": "Visa verifikation",
"ore_rounding": "Öresavrundning",
"back": "Tillbaka",
"back_aria": "Tillbaka till leverantörsfakturor",
"viewer_disabled_tooltip": "Du har endast läsbehörighet i detta företag",
"not_found": "Fakturan hittades inte",
"arrival_header": "Ankomst #{number}",
"header_subtitle": "{supplier} | Faktura {number}",
"status_registered": "Registrerad",
"status_approved": "Godkänd",
"status_paid": "Betald",
@@ -4588,11 +4604,7 @@
"uncredit_failed_title": "Kunde inte ångra kreditering",
"uncredit_success_title": "Kreditering ångrad",
"uncredit_success_description": "Originalfakturan är återställd och numret är ledigt.",
"credit_note_banner_title": "Detta är en kreditfaktura",
"credit_note_banner_prefix": "Den är kopplad till",
"credit_note_banner_link": "faktura {number}",
"credit_note_banner_original_fallback": "originalfakturan",
"credit_note_banner_suffix": ". För att ta bort kreditfakturan och frigöra fakturanumret, gå till originalet och välj \"Ångra kreditering\".",
"invoice_info_title": "Fakturainformation",
"arrival_number_label": "Ankomstnummer",
"invoice_number_label": "Fakturanummer",
@@ -4603,14 +4615,12 @@
"reverse_charge_badge": "Omvänd skattskyldighet",
"badge_accrued": "Periodiserad",
"accrual_line_info": "Periodiseras {from} till {to}",
"amounts_title": "Belopp",
"net_excl_vat": "Netto (exkl. moms)",
"vat_label": "Moms",
"total_label": "Totalt",
"paid_label": "Betalt",
"remaining_label": "Kvar att betala",
"supplier_section_title": "Leverantör",
"org_number_inline": "Org.nr: {number} | ",
"rows_title": "Rader",
"col_description": "Beskrivning",
"col_quantity": "Antal",
@@ -4623,10 +4633,6 @@
"vat_inline": "{rate}% moms",
"vat_amount_inline": "moms {amount}",
"payment_history_title": "Betalningshistorik",
"col_date": "Datum",
"col_amount_short": "Belopp",
"col_voucher": "Verifikation",
"col_note": "Anteckning",
"vouchers_title": "Verifikationer (sambandskrav)",
"registration_voucher": "Registreringsverifikation",
"no_registration_voucher": "Ingen registreringsverifikation (kontantmetoden)",
@@ -4959,7 +4965,6 @@
"source_label_vat_settlement": "Momsredovisning"
},
"journal_correction": {
"title": "Ändringskedja",
"info": "Bokförda verifikationer kan inte ändras direkt. Istället skapas en stornoverifikation som nollställer den ursprungliga, och en ny rättelsepost med de korrekta uppgifterna.",
"role_storno": "Storno",
"role_correction": "Rättelse",
@@ -4967,6 +4972,11 @@
"current": "Aktuell"
},
"journal_detail": {
"title": "Verifikat {label}",
"title_draft": "Utkast till verifikat",
"posted_on": "Bokförd {date}",
"field_voucher": "Verifikationsnummer",
"lines_count": "{count, plural, one {# rad} other {# rader}}",
"accounts_loading": "Laddar kontoplan...",
"accounts_load_failed": "Kontoplanen kunde inte laddas. Försök igen för att ändra konto.",
"accounts_retry": "Försök igen",
@@ -5027,10 +5037,8 @@
"edit_note_aria": "Redigera anteckning",
"note_placeholder": "Intern anteckning...",
"no_note": "Ingen anteckning",
"summary_title": "Summering",
"summary_debit": "Debet",
"summary_credit": "Kredit",
"summary_lines": "Antal rader",
"attachments_title": "Underlag",
"attachments_count": "{count} dokument",
"no_attachments": "Inga underlag bifogade",
@@ -5038,7 +5046,6 @@
"references_title": "Kopplat underlag",
"reference_invoice": "Kundfaktura {number}",
"reference_supplier_invoice": "Leverantörsfaktura {number}",
"currency_title": "Valutaomräkning",
"currency_rate": "Kurs",
"currency_original_amount": "Ursprungsbelopp",
"lines_title": "Kontorader",
@@ -6728,7 +6735,7 @@
"action_send_payslips": "Skicka lönebesked",
"payslips_send_requires_subscription": "Utskick av lönebesked via e-post kräver ett abonnemang. Ladda ner PDF:erna gratis.",
"action_send_payslips_again": "Skicka igen",
"action_download_payslips": "Ladda ner alla",
"action_download_payslips": "Ladda ner alla lönebesked",
"action_download_agi": "Ladda ner AGI-fil (XML)",
"action_delete_draft": "Radera utkast",
"action_correct": "Korrigera lönekörning (storno)",
@@ -6981,6 +6988,9 @@
},
"salary_employee": {
"form_back_to_employees": "Tillbaka till anställda",
"detail_edit": "Redigera",
"detail_edit_dialog_title": "Redigera anställd",
"detail_bank_missing": "Bankuppgifter saknas: krävs innan en lönekörning kan godkännas.",
"form_personal_info": "Personuppgifter",
"form_first_name": "Förnamn",
"form_last_name": "Efternamn",
@@ -7052,8 +7062,6 @@
"detail_f_skatt_verified": "F-skatt verifierad: {date}",
"detail_vacation_none_hint": "Ingen avsättning till 2920 bokas. Använd om semester ingår i månadslönen — vanligt för ägare som är enda anställd.",
"detail_save_changes": "Spara ändringar",
"detail_self_saving_heading": "Sparas separat",
"detail_self_saving_hint": "Förmåner och ingående saldon sparas direkt i respektive sektion och ingår inte i Spara ändringar ovan.",
"tax_title": "Skatt",
"tax_form_label": "Skatteform",
"tax_form_tooltip": "A-skatt: du drar preliminärskatt enligt skattetabell. F-skatt/FA-skatt: personen sköter sin egen skatt — inget skatteavdrag görs.",
@@ -7075,12 +7083,11 @@
"tax_no_table_f_skatt": "Med F-skatt eller FA-skatt sköter personen sin egen skatt — inget skatteavdrag görs och ingen skattetabell behövs.",
"benefits_title": "Förmåner",
"benefits_add": "Lägg till förmån",
"benefits_loading": "Laddar…",
"benefits_empty": "Inga förmåner registrerade. Aktiva förmåner läggs till automatiskt som rader vid lönekörning.",
"benefits_empty": "Inga förmåner registrerade.",
"benefits_help": "Aktiva förmåner läggs till automatiskt som rader vid lönekörning.",
"benefits_type": "Typ",
"benefits_description": "Beskrivning",
"benefits_value_per_month": "Värde/mån",
"benefits_period": "Period",
"benefits_per_month": "/mån",
"benefits_ongoing": "löpande",
"benefits_remove": "Ta bort",
"benefits_added": "Förmån tillagd",
@@ -7122,6 +7129,10 @@
"opening_balances_liability_avgifter": "Avgifter på skulden (2940)",
"opening_balances_saved_heading": "Sparade dagar per intjänandeår",
"opening_balances_saved_hint": "Dagar sparade från tidigare år (max 5 år bakåt enligt semesterlagen).",
"opening_balances_show": "Visa",
"opening_balances_hide": "Dölj",
"opening_balances_collapsed_none": "Inga ingående saldon angivna.",
"opening_balances_collapsed_set": "Angivna, bytesdatum {date}.",
"opening_balances_save": "Spara ingående saldon",
"opening_balances_update": "Uppdatera ingående saldon",
"opening_balances_saving": "Sparar…",
+1 -1
View File
@@ -7,7 +7,7 @@
]
},
"naiveOreRound": {
"count": 630
"count": 629
},
"handRolledInvariants": {
"count": 115