Files
4fce2d7b94 feat(salary): repay utlägg with the salary as a tax-free payslip line (#2361)
* feat(salary): repay utlägg with the salary as a tax-free payslip line (#2331)

- expense_reimbursement line type: kostnadsersättning outside gross, tax,
  avgifter and the AGI. The engine adds tax-free reimbursements (utlägg,
  skattefritt traktamente, skattefri milersättning) to the net payout only.
- booking debits the claim's liability account (2820) on top of gross,
  never a 7xxx cost; a run that only repays utlägg posts 2820 D / 1930 K
  instead of being treated as a nollkörning
- salary_line_items.source_expense_claim_id (tenant-scoped FK, cascade,
  one payslip line per claim); settle_expense_claims_via_salary_run marks
  the claims paid with an expense_payout_batches row pointing at the
  salary verifikat, no second verifikat, idempotent on retry; wired into
  bookLoadedRun and the v1 book route with a pre-check before posting
- create_expense_payout_batch refuses claims scheduled on a payslip
  (ON_PAYSLIP); deleteExpenseClaim refuses once the run has left draft
- "Lägg till utlägg" on the employee row of a draft run; the payslip page
  labels and removes the lines
- pg-real: tests/pg/utlagg-via-lon.pg.test.ts + ON_PAYSLIP case

Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

* fix(salary): PR #2361 review: claim delete cannot cascade into a booked payslip; AGI excludes the utlägg line

- salary_line_items_source_expense_claim_fkey is ON DELETE RESTRICT (edited
  in the unmerged 20260906210300): the database refuses to delete a claim a
  payslip line still references, whichever path issues the DELETE
- deleteExpenseClaim removes the draft line first (before the storno) and
  keeps refusing with ON_PAYSLIP once the run has left draft
- pg-real: delete refused with 23503 on a booked and on a draft run; the
  app order (line, then claim) succeeds
- unit: AGI builder keeps FK011/FK001/FK487 and emits no benefit field for
  an expense_reimbursement line (FK011 derives from sre.gross_salary; only
  benefit_* types are read from line items)

Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1
Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>

---------

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-06 21:17:46 +02:00

323 lines
14 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
'use client'
import { useState } from 'react'
import Link from 'next/link'
import { useRouter } from 'next/navigation'
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,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select'
import { FileDown, Loader2, Receipt, Trash2 } from 'lucide-react'
import { cn, formatCurrency } from '@/lib/utils'
import { roundOre } from '@/lib/money'
import type { EmployeeMasked, SalaryRunEmployee } from '@/types'
import { periodLabelOf, type RunDetail } from './types'
type SreWithEmployee = SalaryRunEmployee & {
employee?: {
first_name: string
last_name: string
// The run detail endpoint returns the masked display form under this key;
// the encrypted `personnummer` column never reaches the client.
personnummer_masked: string
default_dimensions?: Record<string, string>
}
}
interface RunEmployeesTableProps {
run: RunDetail
runId: string
employees: SalaryRunEmployee[]
availableEmployees: EmployeeMasked[]
canWrite: boolean
actionLoading: string | null
dimensionsEnabled: boolean
isCalculated: boolean
onAddEmployee: (employeeId: string) => void
onRemoveEmployee: (employeeId: string, name: string) => void
onSalaryEdit: (employeeId: string, raw: string, previous: number) => void
/**
* Registered utlägg per employee that are not yet on any payslip (#2331):
* drives the per-row "Lägg till utlägg" control in a draft.
*/
openExpenseClaims?: Record<string, { count: number; total_sek: number }>
onAddExpenseClaims?: (employeeId: string, name: string) => void
}
export function RunEmployeesTable({
run,
runId,
employees,
availableEmployees,
canWrite,
actionLoading,
dimensionsEnabled,
isCalculated,
onAddEmployee,
onRemoveEmployee,
onSalaryEdit,
openExpenseClaims,
onAddExpenseClaims,
}: RunEmployeesTableProps) {
const t = useTranslations('salary_run')
const router = useRouter()
const [addEmployeeKey, setAddEmployeeKey] = useState(0)
const addedEmployeeIds = new Set(employees.map(e => e.employee_id))
const notAdded = availableEmployees.filter(e => !addedEmployeeIds.has(e.id))
const canRemoveEmployee = run.status === 'draft' && canWrite
const isDraft = run.status === 'draft'
// Δ 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
function diffNode(sre: SalaryRunEmployee) {
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')}
</Badge>
)
}
const delta = roundOre(sre.gross_salary - prev.gross)
if (delta === 0) return null
const sign = delta > 0 ? '+' : '−'
return (
<span className="text-xs text-muted-foreground tabular-nums">
{sign}
{formatCurrency(Math.abs(delta))}
</span>
)
}
const numericTh = cn(TH_CLASS, 'text-right')
const numericTd = cn(TD_CLASS, 'text-right tabular-nums')
return (
<DetailSection
kicker={t('employees_title', { count: employees.length })}
aside={
isDraft && canWrite && notAdded.length > 0 ? (
<Select
key={addEmployeeKey}
onValueChange={value => {
onAddEmployee(value)
setAddEmployeeKey(k => k + 1)
}}
>
<SelectTrigger className="-my-1 h-8 w-[200px] text-sm">
<SelectValue placeholder={t('add_employee_placeholder')} />
</SelectTrigger>
<SelectContent>
{notAdded.map(emp => (
<SelectItem key={emp.id} value={emp.id}>
{emp.first_name} {emp.last_name}
</SelectItem>
))}
</SelectContent>
</Select>
) : undefined
}
>
{employees.length === 0 ? (
<p className="text-sm text-muted-foreground">{t('no_employees_yet')}</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_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}`
const openClaims = openExpenseClaims?.[sre.employee_id]
const addingClaims = actionLoading === `expense-claims-${sre.employee_id}`
const showAddClaims =
isDraft && canWrite && onAddExpenseClaims != null && openClaims != null && openClaims.count > 0
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}`)}
>
<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>
</>
)}
<td className={cn(TD_CLASS, 'pr-0 text-right')}>
<span className="-my-1 inline-flex items-center justify-end gap-1">
{/* Utlägg repaid with this salary: pulls the employee's
registered claims onto the payslip (#2331). */}
{showAddClaims && (
<Button
variant="ghost"
size="sm"
className="h-8 px-3 text-xs text-muted-foreground hover:text-foreground"
onClick={e => {
e.stopPropagation()
onAddExpenseClaims(sre.employee_id, name)
}}
disabled={addingClaims}
title={t('add_expense_claims_title')}
aria-label={t('add_expense_claims_aria', { name })}
>
{addingClaims ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Receipt className="mr-2 h-4 w-4" />
)}
<span className="tabular-nums">
{t('add_expense_claims', {
count: openClaims.count,
amount: formatCurrency(openClaims.total_sek),
})}
</span>
</Button>
)}
{/* 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>
)}
</DetailSection>
)
}