refactor(ui): record detail pages as documents, not card piles (#1739)
Bring every record detail page onto the register-detail document grammar from #1624 (DetailSection/DefRow, one status element per the list pages' chips-mark-exceptions rule, one primary next step plus Förhandsgranska visible and everything else behind a ⋯ overflow menu, line tables on the dry-table idiom with the headline total in the serif): - invoices/[id] (11 cards, 13-button toolbar): Kund | Detaljer rows, Fakturarader table + totals, Anteckningar, Betalning, Påminnelser, Utskickshistorik (InvoiceDeliveryHistory flattened); title carries the doc type, related documents become link rows - supplier-invoices/[id], bookkeeping/[id] (serif title instead of font-mono, JournalEntryAttachments variant="section", CorrectionChain flattened), invoices/[id]/credit, assets/[id]/dispose (form as Fönster rows), salary employees/[id] (edit form behind Redigera in a dialog, Ingående saldon collapsed), salary runs/[id] + run panels (Betalfil, Skattebetalning, AGI, förmåner, override) and the payslip page - DetailSection gains an optional help slot (convention 7) Styling/structure only: no API, fetch, validation, state, dialog or permission change; every action stays reachable. Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com> Co-authored-by: Claude Fable 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Jakob Wennberg
Claude Fable 5
parent
db14ac18cb
commit
a92c492dbe
@@ -1,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>
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user