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:
co-authored by
Jakob Wennberg
Claude Fable 5.1
parent
6776cb4fc6
commit
ebbe50c0f3
@@ -23,7 +23,6 @@ import {
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import {
|
||||
Inbox,
|
||||
@@ -76,6 +75,7 @@ import {
|
||||
} from '@/lib/documents/inbox-kind'
|
||||
import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog'
|
||||
import RegisterExpenseDialog, { type ExpensePayer } from '@/components/extensions/general/RegisterExpenseDialog'
|
||||
import { PayerChoiceSelect, type PayerChoice } from '@/components/expenses/PayerChoiceSelect'
|
||||
import NewSupplierInvoiceDialog from '@/components/supplier-invoices/NewSupplierInvoiceDialog'
|
||||
import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDialog'
|
||||
// InboxCustomDomainDialog (egen domän) is built but gated off: see
|
||||
@@ -3092,75 +3092,6 @@ function ProposedBooking({
|
||||
)
|
||||
}
|
||||
|
||||
// ── Vem betalade? ────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* How an unmatched underlag gets booked, phrased as who paid for it.
|
||||
* 'company' → match the bank line; 'unpaid' → supplier invoice (2440);
|
||||
* 'owner' / 'employee' → utlägg against the person's liability account.
|
||||
*/
|
||||
export type PayerChoice = 'company' | 'unpaid' | ExpensePayer
|
||||
|
||||
const PAYER_ORDER: PayerChoice[] = ['company', 'owner', 'employee', 'unpaid']
|
||||
|
||||
/**
|
||||
* The "Vem betalade?" control: a compact select so the 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.
|
||||
*/
|
||||
export function PayerChoiceSelect({
|
||||
value,
|
||||
onChange,
|
||||
accountingMethod,
|
||||
}: {
|
||||
value: PayerChoice
|
||||
onChange: (next: PayerChoice) => void
|
||||
accountingMethod: AccountingMethod
|
||||
}) {
|
||||
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="text-[13px] font-medium">{t('payer_question')}</p>
|
||||
<Select value={value} onValueChange={(next) => onChange(next as PayerChoice)}>
|
||||
<SelectTrigger 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>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Fields rail ──────────────────────────────────────────────
|
||||
|
||||
function FieldsRail({
|
||||
|
||||
@@ -14,29 +14,22 @@ import {
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Label } from '@/components/ui/label'
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select'
|
||||
import { useToast } from '@/components/ui/use-toast'
|
||||
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
|
||||
import { ExpenseClaimantFields } from '@/components/expenses/ExpenseClaimantFields'
|
||||
import { useCompanyOptional } from '@/contexts/CompanyContext'
|
||||
import { useAccounts } from '@/lib/reference-data/hooks'
|
||||
import { getErrorMessage } from '@/lib/errors/get-error-message'
|
||||
import { formatCurrency } from '@/lib/utils'
|
||||
import { roundOre } from '@/lib/money'
|
||||
import { ACCOUNT_NUMBER_RE, ISO_DATE_RE } from '@/lib/invariants'
|
||||
import { OWNER_FALLBACK_NAME, resolveExpenseLiabilityAccount, type ExpensePayer } from '@/lib/expenses/payer'
|
||||
import type { InvoiceExtractionResult } from '@/types'
|
||||
|
||||
/**
|
||||
* Who paid for the underlag out of their own pocket. The owner's liability
|
||||
* account follows the entity type (2893 skuld till ägare in an AB, 2018 egen
|
||||
* insättning in an enskild firma); an employee is always 2820.
|
||||
*/
|
||||
export type ExpensePayer = 'owner' | 'employee'
|
||||
// Who paid for the underlag out of their own pocket. The account rule (2893
|
||||
// AB owner / 2018 EF owner / 2820 employee) lives in lib/expenses/payer.ts,
|
||||
// shared with the supplier-invoice form.
|
||||
export type { ExpensePayer }
|
||||
|
||||
interface InboxItemLike {
|
||||
id: string
|
||||
@@ -44,12 +37,6 @@ interface InboxItemLike {
|
||||
extracted_data: InvoiceExtractionResult | null
|
||||
}
|
||||
|
||||
interface EmployeeOption {
|
||||
id: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
@@ -59,8 +46,6 @@ interface Props {
|
||||
onSuccess: () => void | Promise<void>
|
||||
}
|
||||
|
||||
const OWNER_FALLBACK_NAME = 'Ägare'
|
||||
|
||||
function todayIso(): string {
|
||||
// Local calendar date: toISOString() is UTC and would date a receipt booked
|
||||
// after midnight CEST to the previous day (wrong period, wrong FX rate).
|
||||
@@ -88,8 +73,7 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer,
|
||||
const { toast } = useToast()
|
||||
const { accounts } = useAccounts()
|
||||
const entityType = useCompanyOptional()?.company?.entity_type ?? null
|
||||
const ownerLiability = entityType === 'enskild_firma' ? '2018' : '2893'
|
||||
const liabilityAccount = payer === 'owner' ? ownerLiability : '2820'
|
||||
const liabilityAccount = resolveExpenseLiabilityAccount(entityType, payer)
|
||||
|
||||
const data = item.extracted_data
|
||||
const [description, setDescription] = useState('')
|
||||
@@ -99,8 +83,7 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer,
|
||||
const [expenseAccount, setExpenseAccount] = useState('')
|
||||
const [ownerName, setOwnerName] = useState('')
|
||||
const [employeeId, setEmployeeId] = useState('')
|
||||
const [employees, setEmployees] = useState<EmployeeOption[]>([])
|
||||
const [employeesLoaded, setEmployeesLoaded] = useState(false)
|
||||
const [employeeName, setEmployeeName] = useState('')
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const currency = (data?.invoice?.currency ?? 'SEK').toUpperCase()
|
||||
// A foreign receipt carries VAT the company cannot deduct on 2641: the whole
|
||||
@@ -120,29 +103,15 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer,
|
||||
setVatInput(!isForeign && vat != null && vat > 0 ? String(roundOre(vat)).replace('.', ',') : '0')
|
||||
setExpenseAccount('')
|
||||
setEmployeeId('')
|
||||
setEmployeeName('')
|
||||
}, [open, item.id, data, isForeign])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open || payer !== 'employee' || employeesLoaded) return
|
||||
fetch('/api/salary/employees')
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((json) => setEmployees((json?.data ?? []) as EmployeeOption[]))
|
||||
.catch(() => setEmployees([]))
|
||||
.finally(() => setEmployeesLoaded(true))
|
||||
}, [open, payer, employeesLoaded])
|
||||
|
||||
const amount = parseAmount(amountInput)
|
||||
// Foreign VAT is never deductible here: the field is locked and 0 is what
|
||||
// gets submitted, whatever the extraction said.
|
||||
const vatAmount = isForeign ? 0 : parseAmount(vatInput)
|
||||
const net = roundOre(amount - vatAmount)
|
||||
const employee = employees.find((e) => e.id === employeeId) ?? null
|
||||
const claimantName =
|
||||
payer === 'owner'
|
||||
? ownerName.trim() || OWNER_FALLBACK_NAME
|
||||
: employee
|
||||
? `${employee.first_name} ${employee.last_name}`.trim()
|
||||
: ''
|
||||
const claimantName = payer === 'owner' ? ownerName.trim() || OWNER_FALLBACK_NAME : employeeName
|
||||
|
||||
const canSubmit =
|
||||
!isSubmitting &&
|
||||
@@ -236,36 +205,18 @@ export default function RegisterExpenseDialog({ open, onOpenChange, item, payer,
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-4">
|
||||
{payer === 'owner' ? (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="re-owner">{t('expense_owner_name')}</Label>
|
||||
<Input
|
||||
id="re-owner"
|
||||
value={ownerName}
|
||||
onChange={(e) => setOwnerName(e.target.value)}
|
||||
placeholder={OWNER_FALLBACK_NAME}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="re-employee">{t('expense_employee')}</Label>
|
||||
<Select value={employeeId} onValueChange={setEmployeeId} disabled={isSubmitting}>
|
||||
<SelectTrigger id="re-employee">
|
||||
<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>
|
||||
)}
|
||||
<ExpenseClaimantFields
|
||||
payer={payer}
|
||||
ownerName={ownerName}
|
||||
onOwnerNameChange={setOwnerName}
|
||||
employeeId={employeeId}
|
||||
onEmployeeChange={(id, name) => {
|
||||
setEmployeeId(id)
|
||||
setEmployeeName(name)
|
||||
}}
|
||||
disabled={isSubmitting}
|
||||
idPrefix="re"
|
||||
/>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="re-description">{t('expense_description')}</Label>
|
||||
|
||||
Reference in New Issue
Block a user