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