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:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
db14ac18cb
commit
a92c492dbe
@@ -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
@@ -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
@@ -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>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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 -
|
||||
|
||||
@@ -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} />
|
||||
|
||||
Reference in New Issue
Block a user