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

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

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

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

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-08-20 12:29:43 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5
parent db14ac18cb
commit a92c492dbe
28 changed files with 4348 additions and 4228 deletions
+11 -13
View File
@@ -1,7 +1,7 @@
'use client'
import { useTranslations } from 'next-intl'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { DetailSection } from '@/components/ui/detail-section'
import { TaxTableStatus } from '@/components/salary/TaxTableStatus'
import { formatCurrency } from '@/lib/utils'
import type { SalaryRunEmployee } from '@/types'
@@ -21,11 +21,8 @@ export function RunCalculationDetails({ periodYear, employees }: RunCalculationD
if (withBreakdown.length === 0) return null
return (
<Card>
<CardHeader>
<CardTitle className="text-base">{t('calculation_details_title')}</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<DetailSection kicker={t('calculation_details_title')}>
<div className="space-y-6">
<TaxTableStatus year={periodYear} compact />
{withBreakdown.map(sre => {
const breakdown = sre.calculation_breakdown as {
@@ -33,20 +30,21 @@ export function RunCalculationDetails({ periodYear, employees }: RunCalculationD
}
const employee = (sre as SreWithEmployee).employee
return (
<div key={sre.id} className="space-y-2">
<div key={sre.id}>
<h4 className="text-sm font-medium">
{employee
? `${employee.first_name} ${employee.last_name}`
: sre.employee_id.slice(0, 8)}
</h4>
<div className="text-xs space-y-1 bg-muted/50 rounded-lg p-3">
{/* Flat calculation steps: one hairline per step, no box. */}
<div className="mt-1 divide-y divide-border text-xs">
{(breakdown?.steps || []).map((step, i) => (
<div key={i} className="flex justify-between gap-4">
<span className="text-muted-foreground">
<div key={i} className="flex justify-between gap-4 py-1">
<span className="min-w-0 text-muted-foreground">
{step.label}: <span className="font-mono">{step.formula}</span>
</span>
{step.output !== null && (
<span className="font-medium tabular-nums">{formatCurrency(step.output)}</span>
<span className="shrink-0 tabular-nums">{formatCurrency(step.output)}</span>
)}
</div>
))}
@@ -54,7 +52,7 @@ export function RunCalculationDetails({ periodYear, employees }: RunCalculationD
</div>
)
})}
</CardContent>
</Card>
</div>
</DetailSection>
)
}
+163 -141
View File
@@ -7,6 +7,8 @@ import { useTranslations } from 'next-intl'
import { Badge } from '@/components/ui/badge'
import { Button } from '@/components/ui/button'
import { Input } from '@/components/ui/input'
import { DetailSection } from '@/components/ui/detail-section'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import {
Select,
SelectContent,
@@ -14,11 +16,11 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { ChevronRight, FileDown, Loader2, Trash2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { FileDown, Loader2, Trash2 } from 'lucide-react'
import { cn, formatCurrency } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import type { EmployeeMasked, SalaryRunEmployee } from '@/types'
import type { RunDetail } from './types'
import { periodLabelOf, type RunDetail } from './types'
type SreWithEmployee = SalaryRunEmployee & {
employee?: {
@@ -67,7 +69,7 @@ export function RunEmployeesTable({
const canRemoveEmployee = run.status === 'draft' && canWrite
const isDraft = run.status === 'draft'
// Δ vs the latest booked run — hidden on the first-ever run and before
// Δ vs the latest booked run: hidden on the first-ever run and before
// calculation (gross is 0 until then, the diff would be noise).
const previous = run.previous_run ?? null
const showDiff = previous != null && isCalculated
@@ -76,6 +78,7 @@ export function RunEmployeesTable({
if (!previous) return null
const prev = previous.by_employee[sre.employee_id]
if (!prev) {
// A new employee is the exception worth a chip; a plain delta is text.
return (
<Badge variant="secondary" className="font-normal">
{t('diff_new_employee')}
@@ -93,13 +96,14 @@ export function RunEmployeesTable({
)
}
const numericTh = cn(TH_CLASS, 'text-right')
const numericTd = cn(TD_CLASS, 'text-right tabular-nums')
return (
<div className="space-y-3">
<div className="flex items-center justify-between gap-3">
<h2 className="text-sm uppercase tracking-wider text-muted-foreground">
{t('employees_title', { count: employees.length })}
</h2>
{isDraft && canWrite && notAdded.length > 0 && (
<DetailSection
kicker={t('employees_title', { count: employees.length })}
aside={
isDraft && canWrite && notAdded.length > 0 ? (
<Select
key={addEmployeeKey}
onValueChange={value => {
@@ -107,7 +111,7 @@ export function RunEmployeesTable({
setAddEmployeeKey(k => k + 1)
}}
>
<SelectTrigger className="w-[200px] h-8 text-sm">
<SelectTrigger className="-my-1 h-8 w-[200px] text-sm">
<SelectValue placeholder={t('add_employee_placeholder')} />
</SelectTrigger>
<SelectContent>
@@ -118,143 +122,161 @@ export function RunEmployeesTable({
))}
</SelectContent>
</Select>
)}
</div>
) : undefined
}
>
{employees.length === 0 ? (
<div className="rounded-lg border border-border px-4 py-6 text-center text-sm text-muted-foreground">
{t('no_employees_yet')}
</div>
<p className="text-sm text-muted-foreground">{t('no_employees_yet')}</p>
) : (
<div className="rounded-lg border border-border divide-y divide-border">
{employees.map(sre => {
const employee = (sre as SreWithEmployee).employee
const name = employee
? `${employee.first_name} ${employee.last_name}`
: `${t('employee_fallback')} ${sre.employee_id.slice(0, 8)}...`
const dims = employee?.default_dimensions ?? {}
const dimLabel = Object.keys(dims)
.sort((a, b) => Number(a) - Number(b))
.map(k => dims[k])
.join(' · ')
const taxValue = sre.tax_withheld_override ?? sre.tax_withheld
const avgifterValue = sre.avgifter_amount_override ?? sre.avgifter_amount
const netValue = sre.net_salary + (sre.tax_withheld - taxValue)
const editableSalary = isDraft && canWrite && sre.salary_type === 'monthly'
const primaryNumber = isDraft ? sre.monthly_salary : sre.gross_salary
// In a draft the number on the row is the full-time monthly salary
// the engine multiplies by the employee's sysselsättningsgrad. Below
// 100 % that product is not obvious from the input alone (a 10 %
// employee typed 45 310 to get a 4 531 kr gross), so spell it out
// right under the number instead of only in Beräkningsdetaljer.
const degree = Number(sre.employment_degree)
const showDegreeHint =
isDraft && sre.salary_type === 'monthly' && Number.isFinite(degree) && degree < 100
const degreeHint = showDegreeHint ? (
<span className="text-[11px] leading-tight text-muted-foreground tabular-nums whitespace-nowrap">
{t('degree_hint', {
degree: degree.toLocaleString('sv-SE'),
amount: formatCurrency(roundOre((sre.monthly_salary || 0) * (degree / 100))),
})}
</span>
) : null
return (
<div
key={sre.id}
className="group flex items-center gap-3 px-4 py-3 hover:bg-secondary/60 transition-colors cursor-pointer"
onClick={() => router.push(`/salary/runs/${runId}/employees/${sre.employee_id}`)}
>
{/* Name + inline meta + (once calculated) a breakdown sub-line */}
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<Link
href={`/salary/runs/${runId}/employees/${sre.employee_id}`}
className="font-medium truncate hover:underline"
onClick={e => e.stopPropagation()}
>
{name}
</Link>
{dimensionsEnabled && dimLabel && (
<Badge data-ph-mask="" variant="secondary">{dimLabel}</Badge>
)}
{showDiff && diffNode(sre)}
</div>
{isCalculated && (
<p className="mt-1 text-xs text-muted-foreground tabular-nums">
{t('th_tax')} {formatCurrency(taxValue)} · {t('th_net')}{' '}
{formatCurrency(netValue)} · {t('th_avgifter')}{' '}
{formatCurrency(avgifterValue)} · {t('th_vacation')}{' '}
{formatCurrency(sre.vacation_accrual)}
</p>
)}
</div>
{/* Right side: editable monthly salary (draft) or the number */}
{editableSalary ? (
<div className="flex flex-col items-end gap-0.5 shrink-0">
<Input
type="number"
step="0.01"
min="0"
defaultValue={sre.monthly_salary}
onClick={e => e.stopPropagation()}
onBlur={e => onSalaryEdit(sre.employee_id, e.target.value, sre.monthly_salary)}
disabled={actionLoading === `salary-${sre.employee_id}`}
aria-label={t('salary_input_aria', { name })}
className="h-8 w-28 text-right tabular-nums"
/>
{degreeHint}
</div>
) : (
<div className="flex flex-col items-end gap-0.5 shrink-0">
<span className="text-right tabular-nums font-medium">
{formatCurrency(primaryNumber)}
</span>
{degreeHint}
</div>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'pl-0')}>{t('th_employee')}</th>
{showDiff && previous && (
<th className={TH_CLASS}>{t('th_diff', { period: periodLabelOf(previous) })}</th>
)}
<th className={numericTh}>{isDraft ? t('th_monthly_salary') : t('th_gross')}</th>
{isCalculated && (
<>
<th className={numericTh}>{t('th_tax')}</th>
<th className={numericTh}>{t('th_net')}</th>
<th className={numericTh}>{t('th_avgifter')}</th>
<th className={numericTh}>{t('th_vacation')}</th>
</>
)}
<th className={cn(TH_CLASS, 'pr-0 text-right')}>
<span className="sr-only">{t('th_payslip')}</span>
</th>
</tr>
</thead>
<tbody>
{employees.map(sre => {
const employee = (sre as SreWithEmployee).employee
const name = employee
? `${employee.first_name} ${employee.last_name}`
: `${t('employee_fallback')} ${sre.employee_id.slice(0, 8)}...`
const dims = employee?.default_dimensions ?? {}
const dimLabel = Object.keys(dims)
.sort((a, b) => Number(a) - Number(b))
.map(k => dims[k])
.join(' · ')
const taxValue = sre.tax_withheld_override ?? sre.tax_withheld
const avgifterValue = sre.avgifter_amount_override ?? sre.avgifter_amount
const netValue = sre.net_salary + (sre.tax_withheld - taxValue)
const editableSalary = isDraft && canWrite && sre.salary_type === 'monthly'
const primaryNumber = isDraft ? sre.monthly_salary : sre.gross_salary
// In a draft the number on the row is the full-time monthly salary
// the engine multiplies by the employee's sysselsättningsgrad. Below
// 100 % that product is not obvious from the input alone (a 10 %
// employee typed 45 310 to get a 4 531 kr gross), so spell it out
// as a muted suffix right after the number instead of only in
// Beräkningsdetaljer.
const degree = Number(sre.employment_degree)
const showDegreeHint =
isDraft && sre.salary_type === 'monthly' && Number.isFinite(degree) && degree < 100
const degreeHint = showDegreeHint ? (
<span className="text-[11px] text-muted-foreground tabular-nums whitespace-nowrap">
{t('degree_hint', {
degree: degree.toLocaleString('sv-SE'),
amount: formatCurrency(roundOre((sre.monthly_salary || 0) * (degree / 100))),
})}
</span>
) : null
const removing = actionLoading === `remove-${sre.employee_id}`
{/* Payslip PDF */}
<a
href={`/api/salary/runs/${runId}/payslips/${sre.employee_id}/pdf`}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="shrink-0 text-muted-foreground hover:text-foreground transition-colors"
title={t('view_payslip_title')}
aria-label={t('view_payslip_title')}
>
<FileDown className="h-4 w-4" />
</a>
{canRemoveEmployee && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 shrink-0 text-muted-foreground hover:text-destructive"
onClick={e => {
e.stopPropagation()
onRemoveEmployee(sre.employee_id, name)
}}
disabled={actionLoading === `remove-${sre.employee_id}`}
aria-label={t('remove_employee_aria', { name })}
title={t('remove_employee_title')}
return (
<tr
key={sre.id}
className="group cursor-pointer transition-colors duration-150 hover:bg-secondary/35"
onClick={() => router.push(`/salary/runs/${runId}/employees/${sre.employee_id}`)}
>
{actionLoading === `remove-${sre.employee_id}` ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
<td className={cn(TD_CLASS, 'pl-0')}>
<Link
href={`/salary/runs/${runId}/employees/${sre.employee_id}`}
className="font-medium hover:underline"
onClick={e => e.stopPropagation()}
>
{name}
</Link>
{dimensionsEnabled && dimLabel && (
<span data-ph-mask="" className="ml-2 text-xs text-muted-foreground">
{dimLabel}
</span>
)}
</td>
{showDiff && <td className={TD_CLASS}>{diffNode(sre)}</td>}
<td className={numericTd}>
<span className="inline-flex items-center justify-end gap-2">
{editableSalary ? (
<Input
type="number"
step="0.01"
min="0"
defaultValue={sre.monthly_salary}
onClick={e => e.stopPropagation()}
onBlur={e => onSalaryEdit(sre.employee_id, e.target.value, sre.monthly_salary)}
disabled={actionLoading === `salary-${sre.employee_id}`}
aria-label={t('salary_input_aria', { name })}
className="-my-1 h-8 w-28 text-right tabular-nums"
/>
) : (
<span>{formatCurrency(primaryNumber)}</span>
)}
{degreeHint}
</span>
</td>
{isCalculated && (
<>
<td className={numericTd}>{formatCurrency(taxValue)}</td>
<td className={numericTd}>{formatCurrency(netValue)}</td>
<td className={numericTd}>{formatCurrency(avgifterValue)}</td>
<td className={numericTd}>{formatCurrency(sre.vacation_accrual)}</td>
</>
)}
</Button>
)}
<ChevronRight className="h-4 w-4 shrink-0 text-muted-foreground" />
</div>
)
})}
<td className={cn(TD_CLASS, 'pr-0 text-right')}>
<span className="-my-1 inline-flex items-center justify-end gap-1">
{/* Payslip PDF */}
<a
href={`/api/salary/runs/${runId}/payslips/${sre.employee_id}/pdf`}
target="_blank"
rel="noopener noreferrer"
onClick={e => e.stopPropagation()}
className="inline-flex h-8 w-8 items-center justify-center text-muted-foreground transition-colors hover:text-foreground"
title={t('view_payslip_title')}
aria-label={t('view_payslip_title')}
>
<FileDown className="h-4 w-4" />
</a>
{canRemoveEmployee && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-muted-foreground hover:text-destructive"
onClick={e => {
e.stopPropagation()
onRemoveEmployee(sre.employee_id, name)
}}
disabled={removing}
aria-label={t('remove_employee_aria', { name })}
title={t('remove_employee_title')}
>
{removing ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<Trash2 className="h-4 w-4" />
)}
</Button>
)}
</span>
</td>
</tr>
)
})}
</tbody>
</table>
</div>
)}
</div>
</DetailSection>
)
}
+264 -80
View File
@@ -17,123 +17,307 @@ import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from '@/components/ui/dropdown-menu'
import { AlertTriangle, ArrowLeft, Loader2, MoreVertical, Trash2, Undo2 } from 'lucide-react'
import {
AlertTriangle,
ArrowLeft,
ArrowLeftCircle,
Download,
Eye,
FileDown,
Loader2,
MoreHorizontal,
Send,
Trash2,
Undo2,
} from 'lucide-react'
import { formatDate } from '@/lib/utils'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import { periodLabelOf, type RunDetail } from './types'
// Same chip vocabulary as the Löner list (chips mark exceptions): in-flight
// states wear the quiet beige chip, paid is sage, corrected the outline
// exception, and booked renders as muted text.
const STATUS_VARIANTS: Record<string, 'default' | 'secondary' | 'success' | 'warning' | 'destructive' | 'outline'> = {
draft: 'secondary',
review: 'warning',
approved: 'default',
review: 'secondary',
approved: 'secondary',
paid: 'success',
booked: 'success',
corrected: 'outline',
}
const STATUS_RANK: Record<string, number> = {
draft: 0,
review: 1,
approved: 2,
paid: 3,
booked: 4,
corrected: 4,
}
interface RunHeaderProps {
run: RunDetail
canWrite: boolean
actionLoading: string | null
employeeCount: number
isCalculated: boolean
// The single "forward" step for the current status (Beräkna, Till
// granskning, Godkänn, Markera utbetald, Bokför): the one default button
// in the header. Everything else lives behind the ⋯ menu, except the
// payslip send, which is a rail step of its own and stays visible.
primaryAction?: { key: string; label: string; onClick: () => void } | null
onPreview: () => void
onRevert: () => void
onUnapprove: () => void
onSendPayslips: () => void
onDownloadPayslips: () => void
onDownloadAgi: () => void
onDelete: () => void
onCorrect: () => void
}
export function RunHeader({ run, canWrite, actionLoading, employeeCount, onDelete, onCorrect }: RunHeaderProps) {
export function RunHeader({
run,
canWrite,
actionLoading,
employeeCount,
isCalculated,
primaryAction,
onPreview,
onRevert,
onUnapprove,
onSendPayslips,
onDownloadPayslips,
onDownloadAgi,
onDelete,
onCorrect,
}: RunHeaderProps) {
const t = useTranslations('salary_run')
const tSalary = useTranslations('salary')
const [correctOpen, setCorrectOpen] = useState(false)
const periodLabel = periodLabelOf(run)
const hasEmailSend = useCapability(CAPABILITY.email_send)
const statusKey = `status_${run.status}`
const showMenu = canWrite && (run.status === 'draft' || run.status === 'booked')
const rank = STATUS_RANK[run.status] ?? 0
const busy = !!actionLoading
const deliveries = run.payslip_deliveries_summary
// Payslips are a parallel obligation from `approved` onwards. Email send is
// a paid capability (server 403s without it); the PDF download alternative
// in the menu stays free.
const payslipsAvailable = rank >= 2
const showPayslipSend = payslipsAvailable && canWrite
const showPreview = canWrite && (run.status === 'draft' || run.status === 'review')
const showRevert = canWrite && run.status === 'review'
const showUnapprove = canWrite && run.status === 'approved'
const showDownloadPayslips = payslipsAvailable && canWrite
const showDownloadAgi = canWrite && run.status === 'booked'
const showDelete = canWrite && run.status === 'draft'
const showCorrect = canWrite && run.status === 'booked'
const hasMenu =
showPreview ||
showRevert ||
showUnapprove ||
showDownloadPayslips ||
showDownloadAgi ||
showDelete ||
showCorrect
const menuBusy =
actionLoading === 'delete' ||
actionLoading === 'correct' ||
actionLoading === 'preview' ||
actionLoading === 'bulk_payslip' ||
actionLoading === 'agi-download'
const metaParts: React.ReactNode[] = [
<span key="payment">
{t('payment_date_label')} <span className="tabular-nums">{formatDate(run.payment_date)}</span>
</span>,
<span key="count">{t('header_employees', { count: employeeCount })}</span>,
]
if (run.is_correction && run.corrects_run_id) {
metaParts.push(
<Link
key="corrects"
href={`/salary/runs/${run.corrects_run_id}`}
className="underline underline-offset-2 hover:text-foreground"
>
{t('corrects_link', { period: periodLabel })}
</Link>,
)
}
if (run.status === 'corrected' && run.corrected_by_run_id) {
metaParts.push(
<Link
key="corrected-by"
href={`/salary/runs/${run.corrected_by_run_id}`}
className="underline underline-offset-2 hover:text-foreground"
>
{t('corrected_by_link')}
</Link>,
)
}
return (
<div className="flex items-start gap-2">
<Button variant="ghost" size="icon" asChild className="h-8 w-8 -ml-2 shrink-0">
<Link href="/salary" aria-label={t('back_to_salary')}>
<>
{/* Back link on its own quiet row, so the title below keeps a stable
position across runs. */}
<div>
<Link
href="/salary"
className="inline-flex items-center gap-1 text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4" />
{t('back_to_salary')}
</Link>
</Button>
<div className="min-w-0 flex-1">
<div className="flex items-center gap-2 flex-wrap">
<h1 className="font-display text-2xl tracking-tight truncate">
{t('title', { period: periodLabel })}
</h1>
<Badge variant={STATUS_VARIANTS[run.status] || 'secondary'} className="shrink-0">
{tSalary(statusKey)}
</Badge>
</div>
{/* Inline property row — Linear-style dot-separated metadata. */}
<div className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-xs text-muted-foreground">
<span>
{t('payment_date_label')} {formatDate(run.payment_date)}
</span>
<span aria-hidden>·</span>
<span>{t('header_employees', { count: employeeCount })}</span>
{run.is_correction && run.corrects_run_id && (
<>
<span aria-hidden>·</span>
<Badge variant="secondary">{t('correction_badge')}</Badge>
<Link
href={`/salary/runs/${run.corrects_run_id}`}
className="underline underline-offset-2 hover:text-foreground"
>
{t('corrects_link', { period: periodLabel })}
</Link>
</>
)}
{run.status === 'corrected' && run.corrected_by_run_id && (
<>
<span aria-hidden>·</span>
<Link
href={`/salary/runs/${run.corrected_by_run_id}`}
className="underline underline-offset-2 hover:text-foreground"
>
{t('corrected_by_link')}
</Link>
</>
)}
</div>
</div>
<div className="flex items-center gap-2 shrink-0">
{showMenu && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('more_actions')}>
{actionLoading === 'delete' || actionLoading === 'correct' ? (
<Loader2 className="h-4 w-4 animate-spin" />
{/* Header: serif title with one status element, a quiet meta line, and
the next step on the right. Everything else lives in the ⋯ menu. */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-3">
<h1 className="font-display text-2xl leading-8 tracking-tight">
{t('title', { period: periodLabel })}
</h1>
{run.status === 'booked' ? (
<span className="text-sm text-muted-foreground">{tSalary(statusKey)}</span>
) : (
<Badge variant={STATUS_VARIANTS[run.status] || 'secondary'}>
{tSalary(statusKey)}
</Badge>
)}
{run.is_correction && run.corrects_run_id && (
<Badge variant="outline">{t('correction_badge')}</Badge>
)}
</div>
<p className="mt-1 flex flex-wrap items-center gap-x-2 gap-y-1 text-sm text-muted-foreground">
{metaParts.map((part, i) => (
<span key={i} className="inline-flex items-center gap-x-2">
{i > 0 && <span aria-hidden>·</span>}
{part}
</span>
))}
</p>
</div>
<div className="flex shrink-0 flex-wrap items-center gap-2">
{showPayslipSend && (
// The span carries the tooltip: browsers suppress `title` on
// disabled elements, and hover events don't fire on them.
<span title={!hasEmailSend ? t('payslips_send_requires_subscription') : undefined}>
<Button
variant="outline"
onClick={onSendPayslips}
disabled={busy || !hasEmailSend}
>
{actionLoading === 'payslips-send' ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<MoreVertical className="h-4 w-4" />
<Send className="mr-2 h-4 w-4" />
)}
{deliveries && deliveries.sent > 0
? t('action_send_payslips_again')
: t('action_send_payslips')}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{run.status === 'draft' && (
<DropdownMenuItem
onClick={onDelete}
className="text-destructive focus:text-destructive"
>
<Trash2 className="mr-2 h-4 w-4" />
{t('action_delete_draft')}
</DropdownMenuItem>
)}
{run.status === 'booked' && (
<DropdownMenuItem onClick={() => setCorrectOpen(true)}>
<Undo2 className="mr-2 h-4 w-4" />
{t('action_correct')}
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</span>
)}
{primaryAction && (
<Button onClick={primaryAction.onClick} disabled={busy}>
{actionLoading === primaryAction.key && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{primaryAction.label}
</Button>
)}
{hasMenu && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" size="icon" aria-label={t('more_actions')}>
{menuBusy ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<MoreHorizontal className="h-4 w-4" />
)}
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end" className="min-w-[240px]">
{showPreview && (
<DropdownMenuItem
onSelect={onPreview}
disabled={busy || (run.status === 'draft' && !isCalculated)}
>
<Eye className="h-4 w-4" />
{t('action_preview')}
</DropdownMenuItem>
)}
{showDownloadPayslips && (
<DropdownMenuItem onSelect={onDownloadPayslips} disabled={busy}>
<FileDown className="h-4 w-4" />
{t('action_download_payslips')}
</DropdownMenuItem>
)}
{showDownloadAgi && (
<DropdownMenuItem onSelect={onDownloadAgi} disabled={busy}>
<Download className="h-4 w-4" />
{t('action_download_agi')}
</DropdownMenuItem>
)}
{(showRevert || showUnapprove) && (
<>
<DropdownMenuSeparator />
{showRevert && (
<DropdownMenuItem onSelect={onRevert} disabled={busy}>
<ArrowLeftCircle className="h-4 w-4" />
{t('action_revert')}
</DropdownMenuItem>
)}
{/* Approval is an internal control point, not a legal
event: the run can be unlocked again as long as nothing
has been paid, booked, or filed. The API refuses once
the AGI has reached Skatteverket. */}
{showUnapprove && (
<DropdownMenuItem onSelect={onUnapprove} disabled={busy}>
<ArrowLeftCircle className="h-4 w-4" />
{t('action_unapprove')}
</DropdownMenuItem>
)}
</>
)}
{(showDelete || showCorrect) && (
<>
<DropdownMenuSeparator />
{showDelete && (
<DropdownMenuItem
onSelect={onDelete}
disabled={busy}
className="text-destructive focus:text-destructive"
>
<Trash2 className="h-4 w-4" />
{t('action_delete_draft')}
</DropdownMenuItem>
)}
{showCorrect && (
<DropdownMenuItem onSelect={() => setCorrectOpen(true)} disabled={busy}>
<Undo2 className="h-4 w-4" />
{t('action_correct')}
</DropdownMenuItem>
)}
</>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
{/* Correction confirm — storno per BFL 5 kap. 5 §, nothing is deleted. */}
{/* Correction confirm: storno per BFL 5 kap. 5 §, nothing is deleted. */}
<Dialog open={correctOpen} onOpenChange={setCorrectOpen}>
<DialogContent>
<DialogHeader>
@@ -163,6 +347,6 @@ export function RunHeader({ run, canWrite, actionLoading, employeeCount, onDelet
</DialogFooter>
</DialogContent>
</Dialog>
</div>
</>
)
}
+48 -46
View File
@@ -2,9 +2,10 @@
import { useTranslations } from 'next-intl'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { DetailSection } from '@/components/ui/detail-section'
import { TH_CLASS, TD_CLASS } from '@/components/ui/dry-table'
import { Calculator, Loader2 } from 'lucide-react'
import { formatCurrency } from '@/lib/utils'
import { cn, formatCurrency } from '@/lib/utils'
export interface EntryPreviewLine {
account_number: string
@@ -31,8 +32,8 @@ export interface PreviewData {
interface RunJournalPreviewProps {
preview: PreviewData
// When provided (draft + write access), a "Beräkna om" button renders in the
// header — recalculation sits on the output it refreshes.
// When provided (draft + write access), a "Beräkna om" button renders on
// the kicker line: recalculation sits on the output it refreshes.
onRecalculate?: () => void
recalculating?: boolean
}
@@ -48,13 +49,11 @@ export function RunJournalPreview({ preview, onRecalculate, recalculating }: Run
].filter(Boolean) as EntryPreview[]
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between gap-3 space-y-0">
<CardTitle className="text-base">
{preview.booked ? t('journal_booked_title') : t('journal_preview_title')}
</CardTitle>
{onRecalculate && (
<Button variant="outline" size="sm" onClick={onRecalculate} disabled={recalculating}>
<DetailSection
kicker={preview.booked ? t('journal_booked_title') : t('journal_preview_title')}
aside={
onRecalculate ? (
<Button variant="outline" size="sm" onClick={onRecalculate} disabled={recalculating} className="-my-1">
{recalculating ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
@@ -62,43 +61,46 @@ export function RunJournalPreview({ preview, onRecalculate, recalculating }: Run
)}
{t('action_recalculate')}
</Button>
)}
</CardHeader>
<CardContent className="space-y-6">
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('journal_preview_nollkorning')}</p>
) : (
entries.map((entry, idx) => (
<div key={idx} className="space-y-2">
) : undefined
}
>
{entries.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('journal_preview_nollkorning')}</p>
) : (
<div className="space-y-6">
{entries.map((entry, idx) => (
<div key={idx}>
<h4 className="text-sm font-medium">{entry.description}</h4>
<table className="w-full text-xs">
<thead className="[&_th]:font-medium [&_th]:text-[11px] [&_th]:uppercase [&_th]:tracking-wider [&_th]:text-muted-foreground">
<tr className="border-b">
<th className="text-left py-1">{t('journal_th_account')}</th>
<th className="text-left py-1">{t('journal_th_description')}</th>
<th className="text-right py-1">{t('journal_th_debit')}</th>
<th className="text-right py-1">{t('journal_th_credit')}</th>
</tr>
</thead>
<tbody>
{entry.lines.map((line, li) => (
<tr key={li} className="border-t border-border">
<td className="py-1.5 tabular-nums font-mono">{line.account_number}</td>
<td className="py-1.5 text-muted-foreground">{line.line_description}</td>
<td className="py-1.5 text-right tabular-nums">
{line.debit_amount ? formatCurrency(line.debit_amount) : ''}
</td>
<td className="py-1.5 text-right tabular-nums">
{line.credit_amount ? formatCurrency(line.credit_amount) : ''}
</td>
<div className="overflow-x-auto">
<table className="w-full border-collapse text-[13px]">
<thead>
<tr>
<th className={cn(TH_CLASS, 'pl-0')}>{t('journal_th_account')}</th>
<th className={TH_CLASS}>{t('journal_th_description')}</th>
<th className={cn(TH_CLASS, 'text-right')}>{t('journal_th_debit')}</th>
<th className={cn(TH_CLASS, 'pr-0 text-right')}>{t('journal_th_credit')}</th>
</tr>
))}
</tbody>
</table>
</thead>
<tbody>
{entry.lines.map((line, li) => (
<tr key={li}>
<td className={cn(TD_CLASS, 'pl-0 tabular-nums font-mono')}>{line.account_number}</td>
<td className={cn(TD_CLASS, 'text-muted-foreground')}>{line.line_description}</td>
<td className={cn(TD_CLASS, 'text-right tabular-nums')}>
{line.debit_amount ? formatCurrency(line.debit_amount) : ''}
</td>
<td className={cn(TD_CLASS, 'pr-0 text-right tabular-nums')}>
{line.credit_amount ? formatCurrency(line.credit_amount) : ''}
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
))
)}
</CardContent>
</Card>
))}
</div>
)}
</DetailSection>
)
}
+5 -5
View File
@@ -45,15 +45,15 @@ export function RunKpiCards({ run, employees }: RunKpiCardsProps) {
{ label: t('kpi_employer_cost'), value: effEmployerCost },
]
// Inline stat row framed by hairlines — Linear-style, no boxes. Metrics
// breathe on whitespace and wrap on narrow viewports.
// Flat stat row: label/number pairs on whitespace, no boxes or rules.
// Metrics wrap on narrow viewports.
return (
<div className="flex flex-wrap gap-x-10 gap-y-3 border-y border-border py-4">
<div className="flex flex-wrap gap-x-10 gap-y-4">
{cards.map(({ label, value, accent }) => (
<div key={label} className="min-w-0">
<p className="text-[11px] uppercase tracking-wide text-muted-foreground">{label}</p>
<p className="text-[11px] uppercase tracking-wider text-muted-foreground">{label}</p>
<p
className={`mt-1 font-sans font-medium text-lg tabular-nums leading-none ${accent ? 'text-success' : ''}`}
className={`mt-1 font-display text-xl tabular-nums leading-none ${accent ? 'text-success' : ''}`}
>
{formatCurrency(value)}
</p>
+14 -133
View File
@@ -1,11 +1,7 @@
'use client'
import { useTranslations, useLocale } from 'next-intl'
import { Button } from '@/components/ui/button'
import { ArrowLeftCircle, Eye, FileDown, Loader2, Send } from 'lucide-react'
import { formatDateLong } from '@/lib/utils'
import { useCapability } from '@/contexts/CompanyContext'
import { CAPABILITY } from '@/lib/entitlements/keys'
import type { AgiFilingState } from '@/lib/salary/agi-submission-state'
import type { RunDetail } from './types'
@@ -23,19 +19,6 @@ interface RunProgressBarProps {
agiState: AgiFilingState
// Shown on the AGI step once signed: the kvittens is the filing receipt.
agiKvittensnummer?: string | null
canWrite: boolean
actionLoading: string | null
// The single "forward" step for the current status (Beräkna → Skicka till
// granskning → Godkänn → Markera utbetald → Bokför). Rendered as the large
// primary target in the control zone, right next to the secondary actions.
primaryAction?: { key: string; label: string; onClick: () => void } | null
// Secondary actions for the stage: preview, revert, and the parallel payslip
// obligation. (Recalculate lives with the employee rows.)
onPreview: () => void
onRevert: () => void
onUnapprove: () => void
onSendPayslips: () => void
onDownloadPayslips: () => void
}
const STATUS_RANK: Record<string, number> = {
@@ -47,20 +30,19 @@ const STATUS_RANK: Record<string, number> = {
corrected: 4,
}
/**
* The month's steps as a flat segmented rail: no box, no buttons. The actions
* for the current stage live in the header (primary button + ⋯ menu); the
* rail only says where the run stands and what the stage means.
*/
export function RunProgressBar(props: RunProgressBarProps) {
const t = useTranslations('salary_run')
const locale = useLocale()
const { run, isCalculated, noPayout, canWrite, actionLoading, primaryAction, agiState, agiKvittensnummer } = props
const { run, isCalculated, noPayout, agiState, agiKvittensnummer } = props
const rank = STATUS_RANK[run.status] ?? 0
const busy = !!actionLoading
const deliveries = run.payslip_deliveries_summary
const hasEmailSend = useCapability(CAPABILITY.email_send)
function spinnerOr(icon: React.ReactNode, key: string) {
return actionLoading === key ? <Loader2 className="mr-2 h-4 w-4 animate-spin" /> : icon
}
// Payslips are a parallel obligation from `approved` onwards — never gate
// Payslips are a parallel obligation from `approved` onwards: never gate
// progression, so their "done" is simply having reached every employee.
const payslipsAvailable = rank >= 2
const payslipsDone =
@@ -144,82 +126,6 @@ export function RunProgressBar(props: RunProgressBarProps) {
const doneCount = steps.filter(s => s.state === 'done').length
const activeStep = steps.find(s => s.state === 'active')
// Payslip send/download — shared by the mobile summary and the desktop bar.
// Email send is a paid capability (server 403s without it); the PDF
// download alternative right next to it stays free.
const payslipActions = payslipsAvailable && canWrite && (
<>
{/* The span carries the tooltip: browsers suppress `title` on
disabled elements, and hover events don't fire on them. */}
<span title={!hasEmailSend ? t('payslips_send_requires_subscription') : undefined}>
<Button
size="sm"
variant={deliveries && deliveries.sent > 0 ? 'outline' : 'default'}
onClick={props.onSendPayslips}
disabled={busy || !hasEmailSend}
>
{spinnerOr(<Send className="mr-2 h-4 w-4" />, 'payslips-send')}
{deliveries && deliveries.sent > 0
? t('action_send_payslips_again')
: t('action_send_payslips')}
</Button>
</span>
<Button variant="ghost" size="sm" onClick={props.onDownloadPayslips} disabled={busy}>
{spinnerOr(<FileDown className="mr-2 h-4 w-4" />, 'bulk_payslip')}
{t('action_download_payslips')}
</Button>
</>
)
// Secondary actions for the current status. The forward/primary action is
// the header CTA, so this is deliberately the "everything else" set.
let secondaryActions: React.ReactNode = null
if (canWrite) {
if (run.status === 'draft') {
// Recalculate ("Beräkna om") lives with the employee rows it recalculates,
// not here. This band keeps only the preview toggle.
secondaryActions = (
<Button
variant="outline"
size="sm"
onClick={props.onPreview}
disabled={busy || !isCalculated}
>
{spinnerOr(<Eye className="mr-2 h-4 w-4" />, 'preview')}
{t('action_preview')}
</Button>
)
} else if (run.status === 'review') {
secondaryActions = (
<>
<Button variant="outline" size="sm" onClick={props.onPreview} disabled={busy}>
{spinnerOr(<Eye className="mr-2 h-4 w-4" />, 'preview')}
{t('action_preview')}
</Button>
<Button variant="ghost" size="sm" onClick={props.onRevert} disabled={busy}>
<ArrowLeftCircle className="mr-2 h-4 w-4" />
{t('action_revert')}
</Button>
</>
)
} else if (run.status === 'approved') {
// Approval is an internal control point, not a legal event — the run can
// be unlocked again as long as nothing has been paid, booked, or filed.
// The API refuses once the AGI has reached Skatteverket.
secondaryActions = (
<>
{payslipActions}
<Button variant="ghost" size="sm" onClick={props.onUnapprove} disabled={busy}>
<ArrowLeftCircle className="mr-2 h-4 w-4" />
{t('action_unapprove')}
</Button>
</>
)
} else if (payslipsAvailable) {
secondaryActions = payslipActions
}
}
function segClass(state: StepState) {
return state === 'done'
? 'bg-primary'
@@ -228,19 +134,12 @@ export function RunProgressBar(props: RunProgressBarProps) {
: 'bg-border'
}
// The stage line + its detail — a single sentence that sits under the track.
// The stage line + its detail: a single sentence that sits under the track.
const currentLine = activeStep?.detail ?? (activeStep ? activeStep.label : t('rail_all_done'))
const primaryButton = primaryAction && (
<Button size="sm" onClick={primaryAction.onClick} disabled={busy}>
{actionLoading === primaryAction.key && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
{primaryAction.label}
</Button>
)
return (
<div className="rounded-lg border border-border p-4">
{/* Mobile: segmented track + current step + a full-width primary button. */}
<div>
{/* Mobile: segmented track + current step + counter. */}
<div className="md:hidden space-y-3">
<div className="flex gap-1">
{steps.map(step => (
@@ -258,20 +157,11 @@ export function RunProgressBar(props: RunProgressBarProps) {
{activeStep?.detail && (
<p className="text-[11px] text-muted-foreground">{activeStep.detail}</p>
)}
{primaryAction && (
<Button className="w-full" onClick={primaryAction.onClick} disabled={busy}>
{actionLoading === primaryAction.key && (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
)}
{primaryAction.label}
</Button>
)}
{secondaryActions && <div className="flex flex-wrap gap-2">{secondaryActions}</div>}
</div>
{/* Desktop: segmented track with per-segment labels, then a single action
row — the stage sentence on the left, secondary + primary on the right. */}
<div className="hidden md:block space-y-4">
{/* Desktop: segmented track with per-segment labels, then the stage
sentence. */}
<div className="hidden md:block space-y-3">
<ol className="flex gap-2">
{steps.map(step => (
<li key={step.key} className="flex-1 min-w-0 space-y-2">
@@ -291,16 +181,7 @@ export function RunProgressBar(props: RunProgressBarProps) {
</li>
))}
</ol>
{(secondaryActions || primaryButton) && (
<div className="flex items-center justify-between gap-4">
<p className="text-xs text-muted-foreground">{currentLine}</p>
<div className="flex flex-wrap items-center justify-end gap-2 shrink-0">
{secondaryActions}
{primaryButton}
</div>
</div>
)}
<p className="text-xs text-muted-foreground">{currentLine}</p>
</div>
</div>
)