feat(supplier-invoices): "Vem betalade?" control replaces the paid privately switch and books an open utlägg (#2362)

The supplier-invoice form asks who paid with the same control as the
Underlag pane (Företaget / Jag, privat / En anställd / Ingen ännu) instead
of its own switch under Förval. A person paying is an utlägg: the route
hands the invoice to registerExpenseClaim with the invoice's kontering as
the claim's lines, so the verifikat and the expense_claims row come from
the same writer as the Underlag pane, the person shows up under "Betala ut
utlägg" on Hem and the bank matcher closes the debt. Employees book on
2820 with employee_id; the owner's blank name falls back to the shared
label so Hem groups one person.

Also routes a person-paid inbox document through the core route with
inbox_item_id: the extension's convert endpoint never read
paid_with_private_funds, so the old switch was silently dropped whenever
a receipt was attached. The second entry generator, the Förval switch,
the outline "Registrera & markera som betald" button and the duplicated
owner/employee picker are removed; PayerChoiceSelect and the claimant
fields move to components/expenses so core and the extension share them.

Closes #2332


Claude-Session: https://claude.ai/code/session_01LvMaHcTnwAfxzgYD1fGYX1

Co-authored-by: Jakob Wennberg <311770904+jakobwennberg-oss@users.noreply.github.com>
Co-authored-by: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-09-06 21:04:17 +02:00
committed by GitHub
co-authored by Jakob Wennberg Claude Fable 5.1
parent 6776cb4fc6
commit ebbe50c0f3
20 changed files with 1318 additions and 575 deletions
@@ -0,0 +1,117 @@
'use client'
import { useEffect, useState } from 'react'
import { useTranslations } from 'next-intl'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { cn } from '@/lib/utils'
import { OWNER_FALLBACK_NAME, type ExpensePayer } from '@/lib/expenses/payer'
interface EmployeeOption {
id: string
first_name: string
last_name: string
}
/**
* Who the utlägg belongs to, once "Vem betalade?" is a person: the owner's
* name (optional, the shared fallback label otherwise) or one of the
* company's employees. Shared by the Underlag dialog and the supplier-invoice
* form so both post the same claimant to the same claims writer. Employees
* are fetched on first need; the host only sees the chosen id and name.
*/
export function ExpenseClaimantFields({
payer,
ownerName,
onOwnerNameChange,
employeeId,
onEmployeeChange,
disabled,
idPrefix = 'claimant',
className,
labelClassName,
inputClassName,
}: {
payer: ExpensePayer
ownerName: string
onOwnerNameChange: (name: string) => void
employeeId: string
/** The picked employee's id and display name ('' when cleared). */
onEmployeeChange: (id: string, name: string) => void
disabled?: boolean
idPrefix?: string
className?: string
labelClassName?: string
inputClassName?: string
}) {
const t = useTranslations('inbox_workspace')
const [employees, setEmployees] = useState<EmployeeOption[]>([])
const [employeesLoaded, setEmployeesLoaded] = useState(false)
useEffect(() => {
if (payer !== 'employee' || employeesLoaded) return
let cancelled = false
fetch('/api/salary/employees')
.then((res) => (res.ok ? res.json() : null))
.then((json) => {
if (!cancelled) setEmployees((json?.data ?? []) as EmployeeOption[])
})
.catch(() => {
if (!cancelled) setEmployees([])
})
.finally(() => {
if (!cancelled) setEmployeesLoaded(true)
})
return () => {
cancelled = true
}
}, [payer, employeesLoaded])
if (payer === 'owner') {
return (
<div className={cn('space-y-1.5', className)}>
<Label htmlFor={`${idPrefix}-owner`} className={labelClassName}>
{t('expense_owner_name')}
</Label>
<Input
id={`${idPrefix}-owner`}
value={ownerName}
onChange={(e) => onOwnerNameChange(e.target.value)}
placeholder={OWNER_FALLBACK_NAME}
disabled={disabled}
className={inputClassName}
/>
</div>
)
}
return (
<div className={cn('space-y-1.5', className)}>
<Label htmlFor={`${idPrefix}-employee`} className={labelClassName}>
{t('expense_employee')}
</Label>
<Select
value={employeeId}
onValueChange={(id) => {
const picked = employees.find((e) => e.id === id)
onEmployeeChange(id, picked ? `${picked.first_name} ${picked.last_name}`.trim() : '')
}}
disabled={disabled}
>
<SelectTrigger id={`${idPrefix}-employee`} className={inputClassName}>
<SelectValue
placeholder={employeesLoaded && employees.length === 0 ? t('expense_no_employees') : t('expense_pick_employee')}
/>
</SelectTrigger>
<SelectContent>
{employees.map((e) => (
<SelectItem key={e.id} value={e.id}>
{e.first_name} {e.last_name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
)
}
+79
View File
@@ -0,0 +1,79 @@
'use client'
import { useTranslations } from 'next-intl'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { useCompanyOptional } from '@/contexts/CompanyContext'
import { PAYER_ORDER, type PayerChoice } from '@/lib/expenses/payer'
import type { AccountingMethod } from '@/types'
export type { ExpensePayer, PayerChoice } from '@/lib/expenses/payer'
/**
* The "Vem betalade?" control, shared by the Underlag pane and the
* supplier-invoice form: a compact select so a rail keeps its primary button
* above the fold, with the chosen answer's one-line consequence under it.
* Each option in the list carries the same help so the choice is made with
* the consequence visible, not after.
*
* 'company' -> match the bank line; 'unpaid' -> supplier invoice (2440);
* 'owner' / 'employee' -> utlägg against the person's liability account.
*/
export function PayerChoiceSelect({
value,
onChange,
accountingMethod,
id,
labelClassName,
disabled,
}: {
value: PayerChoice
onChange: (next: PayerChoice) => void
accountingMethod: AccountingMethod
/** Trigger id, so a host can point a label or focus router at it. */
id?: string
/** Overrides the rail's label style when the control sits in a form grid. */
labelClassName?: string
disabled?: boolean
}) {
const t = useTranslations('inbox_workspace')
// An enskild firma owner makes an egen insättning, not a loan to the
// company: no debt, nothing to pay out, so the help line says so.
const isEf = useCompanyOptional()?.company?.entity_type === 'enskild_firma'
// Företaget carries no help line: the button under it ("Matcha mot
// transaktion") already says what happens. The other answers name the
// liability the company takes on, which is the consequence worth reading.
const helpKey = (choice: PayerChoice): string | null =>
choice === 'company'
? null
: choice === 'owner' && isEf
? 'payer_help_owner_ef'
: choice === 'unpaid' && accountingMethod === 'cash'
? 'payer_help_unpaid_cash'
: `payer_help_${choice}`
const selectedHelp = helpKey(value)
return (
<div className="space-y-1.5">
<p className={labelClassName ?? 'text-[13px] font-medium'}>{t('payer_question')}</p>
<Select value={value} onValueChange={(next) => onChange(next as PayerChoice)} disabled={disabled}>
<SelectTrigger id={id} aria-label={t('payer_question')} className="h-9 text-[13px]">
{/* Explicit children: the items render label + help, and the
trigger must show the label alone. */}
<SelectValue>{t(`payer_${value}`)}</SelectValue>
</SelectTrigger>
{/* Match the trigger width so the two-line options wrap inside the rail
instead of spilling over the document viewer. */}
<SelectContent align="start" className="w-[var(--radix-select-trigger-width)]">
{PAYER_ORDER.map((choice) => (
<SelectItem key={choice} value={choice} className="py-2">
<span className="block text-[13px]">{t(`payer_${choice}`)}</span>
{helpKey(choice) && (
<span className="block text-xs text-muted-foreground">{t(helpKey(choice)!)}</span>
)}
</SelectItem>
))}
</SelectContent>
</Select>
{selectedHelp && <p className="text-xs text-muted-foreground">{t(selectedHelp)}</p>}
</div>
)
}