feat(expenses): utlägg as an answer to "Vem betalade?" in Underlag, not a page (#2317)
An out-of-pocket purchase differs from any other receipt only in the credit account, so the Underlag pane now asks one question for an unmatched underlag (Företaget / Jag, privat / En anställd / Ingen ännu) and books a privately paid receipt in place through POST /api/expense-claims, replacing the "Andra sätt att bokföra" dropdown and the deep link into the two-step wizard. The verifikat editor stays reachable below as the escape hatch (BFL 5 kap 6-7 §). The person owed surfaces in Att göra under a new Betala band, one row per person (lib/worklist expense_payout, counted in the total and exposed to agents through the attention resource). The Utlägg nav row is gated on existing claims, the same hybrid gate as Körjournal, since the entry point for a new utlägg is now the Underlag pane. Claude-Session: https://claude.ai/code/session_01P8YsvPqjfGxGZUkGeBVUWQ 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
bff44e5757
commit
cbe5580886
@@ -21,6 +21,7 @@ import {
|
||||
ChevronRight,
|
||||
Eye,
|
||||
FileWarning,
|
||||
HandCoins,
|
||||
Inbox,
|
||||
Landmark,
|
||||
Loader2,
|
||||
@@ -29,14 +30,15 @@ import {
|
||||
ShieldCheck,
|
||||
Stamp,
|
||||
} from 'lucide-react'
|
||||
import type { SuggestedMatch, WorklistCounts } from '@/lib/worklist/types'
|
||||
import type { ExpensePayoutDue, SuggestedMatch, WorklistCounts } from '@/lib/worklist/types'
|
||||
|
||||
/**
|
||||
* AttGoraSection: the dashboard's unified worklist ("Att göra").
|
||||
*
|
||||
* One flat ledger of everything actionable, grouped into three bands by
|
||||
* session intent: Bokför (the daily loop), Granska & komplettera (close the
|
||||
* gaps), Bevaka (time-driven). Every count comes from lib/worklist (the same
|
||||
* One flat ledger of everything actionable, grouped into four bands by
|
||||
* session intent: Bokför (the daily loop), Betala (money the company owes a
|
||||
* person for utlägg), Granska & komplettera (close the gaps), Bevaka
|
||||
* (time-driven). Every count comes from lib/worklist (the same
|
||||
* source as the sidebar badges) so the numbers can never disagree.
|
||||
*
|
||||
* Suggested transaction↔invoice matches render inline with one-click confirm:
|
||||
@@ -53,6 +55,8 @@ interface ExpiringBankConnection {
|
||||
interface AttGoraSectionProps {
|
||||
worklist: WorklistCounts
|
||||
suggestedMatches: SuggestedMatch[]
|
||||
/** People owed for registered, unpaid utlägg: one Betala row each. */
|
||||
expensePayouts?: ExpensePayoutDue[]
|
||||
expiringBankConnections?: ExpiringBankConnection[]
|
||||
/**
|
||||
* True while the setup checklist is open and the company has zero posted
|
||||
@@ -114,6 +118,7 @@ function BandHeader({ children }: { children: React.ReactNode }) {
|
||||
export default function AttGoraSection({
|
||||
worklist,
|
||||
suggestedMatches,
|
||||
expensePayouts = [],
|
||||
expiringBankConnections = [],
|
||||
emptyLedger = false,
|
||||
hasActiveBankConnection = true,
|
||||
@@ -209,6 +214,7 @@ export default function AttGoraSection({
|
||||
counts.book_skattekonto > 0 ||
|
||||
showInboxDocuments ||
|
||||
matches.length > 0
|
||||
const betalaRows = expensePayouts.length > 0
|
||||
const granskaRows =
|
||||
counts.supplier_invoice_approval > 0 ||
|
||||
counts.verifikat_missing_document > 0 ||
|
||||
@@ -218,7 +224,7 @@ export default function AttGoraSection({
|
||||
counts.deadline_action > 0 ||
|
||||
counts.reconciliation_due > 0 ||
|
||||
expiringBankConnections.length > 0
|
||||
const allClear = !bokforRows && !granskaRows && !bevakaRows
|
||||
const allClear = !bokforRows && !betalaRows && !granskaRows && !bevakaRows
|
||||
|
||||
// The header total must equal what the section actually shows, computed off
|
||||
// the same visibleWorklistTotal helper as the dashboard KPI tile so the two
|
||||
@@ -380,6 +386,36 @@ export default function AttGoraSection({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{betalaRows && (
|
||||
<div>
|
||||
<BandHeader>{t('band_betala')}</BandHeader>
|
||||
<div>
|
||||
{expensePayouts.map((p) => (
|
||||
<WorklistRow
|
||||
key={p.key}
|
||||
href="/expenses"
|
||||
icon={HandCoins}
|
||||
label={t('row_expense_payout', { name: p.claimant_name })}
|
||||
detail={
|
||||
p.claim_count === 1
|
||||
? t('row_expense_payout_detail_one', { date: formatDate(p.oldest_expense_date) })
|
||||
: t('row_expense_payout_detail_other', {
|
||||
count: p.claim_count,
|
||||
date: formatDate(p.oldest_expense_date),
|
||||
})
|
||||
}
|
||||
count={p.claim_count}
|
||||
badge={
|
||||
<span className="text-xs tabular-nums text-muted-foreground">
|
||||
{formatCurrency(p.total_sek)}
|
||||
</span>
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{granskaRows && (
|
||||
<div>
|
||||
<BandHeader>{t('band_granska')}</BandHeader>
|
||||
|
||||
@@ -102,6 +102,10 @@ interface DashboardNavProps {
|
||||
// toggle OR existing mileage_trips rows (trips created via API/MCP must
|
||||
// stay reachable). Computed by the dashboard layout.
|
||||
hasMileage?: boolean
|
||||
// Whether the Utlägg row shows: existing expense_claims rows. New utlägg
|
||||
// start from the Underlag pane ("Vem betalade?"), so the page only earns a
|
||||
// rail row once there is a person to pay out. Computed by the layout.
|
||||
hasExpenseClaims?: boolean
|
||||
isSandbox?: boolean
|
||||
extensionNavItems?: ExtensionNavItem[]
|
||||
// Signed-in user's full name + email: drives the bottom-left account
|
||||
@@ -203,6 +207,9 @@ interface NavItem {
|
||||
// bookkeeping settings toggle (company_settings.mileage_enabled) or already
|
||||
// has trips. UI-visibility gate only; the page and APIs work regardless.
|
||||
requiresMileage?: boolean
|
||||
// Utlägg row: visible only when the company already has expense claims
|
||||
// (same "data stays reachable" gate as Körjournal). UI-visibility only.
|
||||
requiresExpenses?: boolean
|
||||
// Paywall surfaces: hidden unless the active company holds this paid
|
||||
// capability. Cosmetic only, the page and API gates are the real
|
||||
// enforcement; this just keeps the sidebar honest for non-payers.
|
||||
@@ -246,10 +253,12 @@ const navItems: NavItem[] = [
|
||||
// must still reach its already-imported orders (accounting underlag).
|
||||
{ href: '/orders', labelKey: 'webshop_orders', icon: ShoppingCart, group: 'arbeta', requiresWebshop: true, betaBadge: true },
|
||||
{ href: '/supplier-invoices', labelKey: 'supplier_invoices', icon: Wallet, group: 'arbeta' },
|
||||
// Utlägg: out-of-pocket purchases and their reimbursement batches. The
|
||||
// Utlägg: out-of-pocket purchases and their reimbursement batches. Hidden
|
||||
// until a claim exists: a receipt paid privately is registered from the
|
||||
// Underlag pane, and the person to pay out surfaces in Att göra. The
|
||||
// /expenses route previously redirected to supplier invoices; the nav key
|
||||
// has existed in the nav namespace since then.
|
||||
{ href: '/expenses', labelKey: 'expenses', icon: Receipt, group: 'arbeta' },
|
||||
{ href: '/expenses', labelKey: 'expenses', icon: Receipt, group: 'arbeta', requiresExpenses: true },
|
||||
{ href: '/salary', labelKey: 'salary', icon: HandCoins, group: 'arbeta', employerOnly: true },
|
||||
// Körjournal: hidden by default (most companies have no car); shows when
|
||||
// the settings toggle is on or trips already exist (hybrid gate, same
|
||||
@@ -347,7 +356,7 @@ const groupLabelKey: Record<Exclude<GroupKey, 'top'>, string> = {
|
||||
skatt: 'group_tax',
|
||||
}
|
||||
|
||||
export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, dimensionsEnabled = false, salesOrdersEnabled = false, hasWebshop = false, hasMileage = false, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null, initialUiState }: DashboardNavProps) {
|
||||
export default function DashboardNav({ companyName: _companyName, entityType, paysSalaries = false, dimensionsEnabled = false, salesOrdersEnabled = false, hasWebshop = false, hasMileage = false, hasExpenseClaims = false, isSandbox = false, extensionNavItems = [], userName = null, userEmail = null, initialUiState }: DashboardNavProps) {
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
const supabase = useRealtimeSupabase()
|
||||
@@ -608,6 +617,8 @@ export default function DashboardNav({ companyName: _companyName, entityType, pa
|
||||
// Körjournal is hidden until the company opts in via the bookkeeping
|
||||
// settings toggle (or trips already exist, e.g. created via MCP).
|
||||
if (item.requiresMileage && !hasMileage) return false
|
||||
// Utlägg is hidden until a claim exists (registered from Underlag).
|
||||
if (item.requiresExpenses && !hasExpenseClaims) return false
|
||||
// Paywalled surfaces (e.g. the AI-only Dokumentinkorg) are hidden unless
|
||||
// the active company holds the capability. The page + API gates enforce
|
||||
// the paywall; this keeps the sidebar from advertising a dead workspace.
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useCallback, useEffect, useRef, useMemo } from 'react'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useCompanySettings } from '@/lib/reference-data/hooks'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
@@ -74,6 +73,7 @@ import {
|
||||
type InboxKindFilter,
|
||||
} from '@/lib/documents/inbox-kind'
|
||||
import BookDirectlyDialog from '@/components/extensions/general/BookDirectlyDialog'
|
||||
import RegisterExpenseDialog, { type ExpensePayer } from '@/components/extensions/general/RegisterExpenseDialog'
|
||||
import NewSupplierInvoiceDialog from '@/components/supplier-invoices/NewSupplierInvoiceDialog'
|
||||
import BulkBookInboxDialog from '@/components/extensions/general/BulkBookInboxDialog'
|
||||
// InboxCustomDomainDialog (egen domän) is built but gated off: see
|
||||
@@ -431,7 +431,6 @@ const WorkspaceSkeleton = InvoiceInboxSkeleton
|
||||
|
||||
export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
const { toast } = useToast()
|
||||
const router = useRouter()
|
||||
const t = useTranslations('inbox_workspace')
|
||||
const tStart = useTranslations('start_cards')
|
||||
const dismissKeyCompanyId = useCompanyOptional()?.company?.id ?? null
|
||||
@@ -494,6 +493,8 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
const [isRotating, setIsRotating] = useState(false)
|
||||
const [isDragging, setIsDragging] = useState(false)
|
||||
const [bookDirectOpen, setBookDirectOpen] = useState(false)
|
||||
// "Vem betalade?" answered with a person: the utlägg confirm step.
|
||||
const [registerExpensePayer, setRegisterExpensePayer] = useState<ExpensePayer | null>(null)
|
||||
// Bulk-book selected underlag (Modell B): the "Bokför valda" selection-bar
|
||||
// action. The dialog filters the selection to bookable items itself.
|
||||
const [bulkBookOpen, setBulkBookOpen] = useState(false)
|
||||
@@ -2155,7 +2156,7 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
onDelete={() => handleDelete(selected.id)}
|
||||
onBookDirect={() => setBookDirectOpen(true)}
|
||||
onCreateSupplierInvoice={() => setCreateSupplierInvoiceOpen(true)}
|
||||
onRegisterExpense={() => router.push(`/expenses?new=1&inbox_item=${selected.id}`)}
|
||||
onRegisterExpense={(payer) => setRegisterExpensePayer(payer)}
|
||||
onMatchTransaction={() => setMatchPickerOpen(true)}
|
||||
onUnmatchTransaction={async () => {
|
||||
const targetId = selected.id
|
||||
@@ -2233,6 +2234,19 @@ export default function InvoiceInboxWorkspace(_props: WorkspaceComponentProps) {
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selected && registerExpensePayer && (
|
||||
<RegisterExpenseDialog
|
||||
open
|
||||
onOpenChange={(next) => {
|
||||
if (!next) setRegisterExpensePayer(null)
|
||||
}}
|
||||
item={selected}
|
||||
payer={registerExpensePayer}
|
||||
onSuccess={async () => {
|
||||
await Promise.all([fetchItems(), handleSelect(selected.id)])
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
{selected && (
|
||||
<NewSupplierInvoiceDialog
|
||||
open={createSupplierInvoiceOpen}
|
||||
@@ -3053,6 +3067,68 @@ 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']
|
||||
|
||||
export function PayerChoiceList({
|
||||
value,
|
||||
onChange,
|
||||
accountingMethod,
|
||||
}: {
|
||||
value: PayerChoice
|
||||
onChange: (next: PayerChoice) => void
|
||||
accountingMethod: AccountingMethod
|
||||
}) {
|
||||
const t = useTranslations('inbox_workspace')
|
||||
const helpKey = (choice: PayerChoice): string =>
|
||||
choice === 'unpaid' && accountingMethod === 'cash' ? 'payer_help_unpaid_cash' : `payer_help_${choice}`
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<p className="text-[13px] font-medium">{t('payer_question')}</p>
|
||||
<div role="radiogroup" aria-label={t('payer_question')} className="rounded-lg border border-border">
|
||||
{PAYER_ORDER.map((choice) => {
|
||||
const selected = choice === value
|
||||
return (
|
||||
<button
|
||||
key={choice}
|
||||
type="button"
|
||||
role="radio"
|
||||
aria-checked={selected}
|
||||
onClick={() => onChange(choice)}
|
||||
className={cn(
|
||||
'flex w-full items-start gap-3 border-b border-border px-3 py-2.5 text-left transition-colors duration-150 last:border-b-0 hover:bg-secondary/30 first:rounded-t-lg last:rounded-b-lg',
|
||||
selected && 'bg-secondary/40',
|
||||
)}
|
||||
>
|
||||
<span
|
||||
aria-hidden
|
||||
className={cn(
|
||||
'mt-0.5 flex h-4 w-4 shrink-0 items-center justify-center rounded-full border bg-background',
|
||||
selected ? 'border-primary' : 'border-input',
|
||||
)}
|
||||
>
|
||||
{selected && <span className="h-1.5 w-1.5 rounded-full bg-primary" />}
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block text-[13px]">{t(`payer_${choice}`)}</span>
|
||||
<span className="block text-xs text-muted-foreground">{t(helpKey(choice))}</span>
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
// ── Fields rail ──────────────────────────────────────────────
|
||||
|
||||
function FieldsRail({
|
||||
@@ -3077,7 +3153,7 @@ function FieldsRail({
|
||||
onDelete: () => void
|
||||
onBookDirect: () => void
|
||||
onCreateSupplierInvoice: () => void
|
||||
onRegisterExpense: () => void
|
||||
onRegisterExpense: (payer: ExpensePayer) => void
|
||||
onMatchTransaction: () => void
|
||||
onUnmatchTransaction: () => Promise<void>
|
||||
onAskAssistant?: (transactionId: string) => void
|
||||
@@ -3135,6 +3211,17 @@ function FieldsRail({
|
||||
setFieldsExpanded(false)
|
||||
}, [item.id])
|
||||
const t = useTranslations('inbox_workspace')
|
||||
// "Vem betalade?": the one question that decides how an unmatched underlag
|
||||
// is booked. Company money is matched against the bank line; a person's
|
||||
// money books cost + moms against that person's liability account and puts
|
||||
// them in Att göra; "ingen ännu" is an unpaid supplier invoice (2440). A
|
||||
// supplier invoice defaults to unpaid, a receipt to paid by the company.
|
||||
const [payer, setPayer] = useState<PayerChoice>(() =>
|
||||
resolvedKind === 'supplier_invoice' ? 'unpaid' : 'company',
|
||||
)
|
||||
useEffect(() => {
|
||||
setPayer(resolvedKind === 'supplier_invoice' ? 'unpaid' : 'company')
|
||||
}, [item.id, resolvedKind])
|
||||
|
||||
// WhatsApp chat context: verified human answers captured by the intake bot
|
||||
// (photo caption, representation deltagare + syfte, sender note). Rendered
|
||||
@@ -3593,61 +3680,31 @@ function FieldsRail({
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
{/* Unmatched state: the canonical next step is to find the bank
|
||||
transaction this underlag belongs to. Two escape hatches sit
|
||||
below it: "Skapa leverantörsfaktura" for users who want
|
||||
supplier-invoice tracking (accrual flow), and "Bokför som
|
||||
verifikat" for underlag that aren't a supplier invoice at all
|
||||
(bank fees, owner expenses, the underlag for a correction). The
|
||||
latter opens the same BookDirectlyDialog as the matched state,
|
||||
which works without a bank transaction and lets the user attach
|
||||
one if they want. Per BFL 5 kap 6-7 § the underlag must be
|
||||
bookable as a verifikat, not forced into a supplier invoice. */}
|
||||
<Button
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="w-full"
|
||||
onClick={onMatchTransaction}
|
||||
{/* Unmatched state: one question decides the booking path. Every
|
||||
answer keeps BFL 5 kap 6-7 § intact (the underlag is booked
|
||||
as a verifikat, never forced into a supplier invoice), and the
|
||||
verifikat editor stays reachable below as the escape hatch. */}
|
||||
<PayerChoiceList value={payer} onChange={setPayer} accountingMethod={accountingMethod} />
|
||||
{payer === 'company' ? (
|
||||
<Button variant="default" size="sm" className="w-full" onClick={onMatchTransaction}>
|
||||
Matcha mot transaktion
|
||||
</Button>
|
||||
) : payer === 'unpaid' ? (
|
||||
<Button variant="default" size="sm" className="w-full" onClick={onCreateSupplierInvoice}>
|
||||
Skapa leverantörsfaktura
|
||||
</Button>
|
||||
) : (
|
||||
<Button variant="default" size="sm" className="w-full" onClick={() => onRegisterExpense(payer)}>
|
||||
{t('payer_book_expense')}
|
||||
</Button>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
onClick={onBookDirect}
|
||||
className="w-full text-xs text-muted-foreground hover:text-foreground hover:underline pt-1"
|
||||
>
|
||||
Matcha mot transaktion
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="outline" size="sm" className="w-full justify-between">
|
||||
Andra sätt att bokföra
|
||||
<ChevronDown className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start" className="w-64">
|
||||
<DropdownMenuItem
|
||||
onClick={onCreateSupplierInvoice}
|
||||
className="flex flex-col items-start gap-1"
|
||||
>
|
||||
<span>Skapa leverantörsfaktura</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
För leverantörsskulder du vill följa (periodisering).
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={onRegisterExpense}
|
||||
className="flex flex-col items-start gap-1"
|
||||
>
|
||||
<span>Registrera som utlägg</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
För köp du eller en anställd betalat privat.
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={onBookDirect}
|
||||
className="flex flex-col items-start gap-1"
|
||||
>
|
||||
<span>Bokför som verifikat</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
För underlag som inte är en leverantörsfaktura (bankavgift, utlägg).
|
||||
</span>
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
{t('payer_open_editor')}
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
<Button
|
||||
|
||||
@@ -0,0 +1,339 @@
|
||||
'use client'
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { useTranslations } from 'next-intl'
|
||||
import { Loader2 } from 'lucide-react'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog'
|
||||
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 { 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 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'
|
||||
|
||||
interface InboxItemLike {
|
||||
id: string
|
||||
document_id: string | null
|
||||
extracted_data: InvoiceExtractionResult | null
|
||||
}
|
||||
|
||||
interface EmployeeOption {
|
||||
id: string
|
||||
first_name: string
|
||||
last_name: string
|
||||
}
|
||||
|
||||
interface Props {
|
||||
open: boolean
|
||||
onOpenChange: (open: boolean) => void
|
||||
item: InboxItemLike
|
||||
payer: ExpensePayer
|
||||
/** Re-read the item after the claim posted its verifikat. */
|
||||
onSuccess: () => void | Promise<void>
|
||||
}
|
||||
|
||||
const OWNER_FALLBACK_NAME = 'Ägare'
|
||||
|
||||
function todayIso(): string {
|
||||
return new Date().toISOString().slice(0, 10)
|
||||
}
|
||||
|
||||
function parseAmount(raw: string): number {
|
||||
const n = parseFloat(raw.replace(/\s/g, '').replace(',', '.'))
|
||||
return Number.isFinite(n) ? n : 0
|
||||
}
|
||||
|
||||
/**
|
||||
* RegisterExpenseDialog: the confirm step behind "Vem betalade? Jag, privat /
|
||||
* En anställd" in the Underlag pane. One screen, prefilled from the
|
||||
* extraction: who, cost account, amount and VAT, then the outcome spelled out
|
||||
* before anything posts (design convention 10). Posts through
|
||||
* POST /api/expense-claims, which books cost + moms against the person's
|
||||
* liability account and stamps the inbox item as booked.
|
||||
*/
|
||||
export default function RegisterExpenseDialog({ open, onOpenChange, item, payer, onSuccess }: Props) {
|
||||
const t = useTranslations('inbox_workspace')
|
||||
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 data = item.extracted_data
|
||||
const [description, setDescription] = useState('')
|
||||
const [expenseDate, setExpenseDate] = useState(todayIso())
|
||||
const [amountInput, setAmountInput] = useState('')
|
||||
const [vatInput, setVatInput] = useState('')
|
||||
const [expenseAccount, setExpenseAccount] = useState('')
|
||||
const [ownerName, setOwnerName] = useState('')
|
||||
const [employeeId, setEmployeeId] = useState('')
|
||||
const [employees, setEmployees] = useState<EmployeeOption[]>([])
|
||||
const [employeesLoaded, setEmployeesLoaded] = useState(false)
|
||||
const [isSubmitting, setIsSubmitting] = useState(false)
|
||||
const currency = (data?.invoice?.currency ?? 'SEK').toUpperCase()
|
||||
|
||||
// Reset per open so a previous underlag's numbers never carry over.
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
setDescription(data?.supplier?.name?.trim() || '')
|
||||
setExpenseDate(data?.invoice?.invoiceDate || todayIso())
|
||||
const total = data?.totals?.total
|
||||
const vat = data?.totals?.vatAmount
|
||||
setAmountInput(total != null && total > 0 ? String(roundOre(total)).replace('.', ',') : '')
|
||||
setVatInput(vat != null && vat > 0 ? String(roundOre(vat)).replace('.', ',') : '0')
|
||||
setExpenseAccount('')
|
||||
setEmployeeId('')
|
||||
}, [open, item.id, data])
|
||||
|
||||
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)
|
||||
const vatAmount = 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 canSubmit =
|
||||
!isSubmitting &&
|
||||
description.trim().length > 0 &&
|
||||
ISO_DATE_RE.test(expenseDate) &&
|
||||
amount > 0 &&
|
||||
vatAmount >= 0 &&
|
||||
vatAmount < amount &&
|
||||
ACCOUNT_NUMBER_RE.test(expenseAccount) &&
|
||||
/^[4-8]/.test(expenseAccount) &&
|
||||
(payer === 'owner' || !!employeeId)
|
||||
|
||||
const accountName = useMemo(
|
||||
() => accounts.find((a) => a.account_number === expenseAccount)?.account_name ?? '',
|
||||
[accounts, expenseAccount],
|
||||
)
|
||||
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!canSubmit) return
|
||||
setIsSubmitting(true)
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
description: description.trim(),
|
||||
expense_date: expenseDate,
|
||||
amount,
|
||||
vat_amount: vatAmount,
|
||||
currency,
|
||||
expense_account: expenseAccount,
|
||||
inbox_item_id: item.id,
|
||||
document_id: item.document_id ?? undefined,
|
||||
}
|
||||
if (payer === 'owner') body.claimant_name = claimantName
|
||||
else body.employee_id = employeeId
|
||||
const res = await fetch('/api/expense-claims', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
const json = (await res.json().catch(() => ({}))) as {
|
||||
data?: { journal_entry_id?: string | null }
|
||||
error?: unknown
|
||||
}
|
||||
if (!res.ok) {
|
||||
toast({
|
||||
title: t('expense_failed_title'),
|
||||
description: getErrorMessage(json, { context: 'journal_entry', statusCode: res.status }),
|
||||
variant: 'destructive',
|
||||
})
|
||||
return
|
||||
}
|
||||
toast({
|
||||
title: t('expense_booked_title'),
|
||||
description: t('expense_booked_description', { name: claimantName }),
|
||||
})
|
||||
await onSuccess()
|
||||
onOpenChange(false)
|
||||
} finally {
|
||||
setIsSubmitting(false)
|
||||
}
|
||||
}, [
|
||||
canSubmit,
|
||||
description,
|
||||
expenseDate,
|
||||
amount,
|
||||
vatAmount,
|
||||
currency,
|
||||
expenseAccount,
|
||||
item.id,
|
||||
item.document_id,
|
||||
payer,
|
||||
claimantName,
|
||||
employeeId,
|
||||
toast,
|
||||
t,
|
||||
onSuccess,
|
||||
onOpenChange,
|
||||
])
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={(next) => !isSubmitting && onOpenChange(next)}>
|
||||
<DialogContent className="max-w-lg">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{t('expense_dialog_title')}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{payer === 'owner'
|
||||
? t('expense_dialog_help_owner', { account: liabilityAccount })
|
||||
: t('expense_dialog_help_employee')}
|
||||
</DialogDescription>
|
||||
</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>
|
||||
)}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="re-description">{t('expense_description')}</Label>
|
||||
<Input
|
||||
id="re-description"
|
||||
value={description}
|
||||
onChange={(e) => setDescription(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-3 gap-3">
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="re-date">{t('expense_date')}</Label>
|
||||
<Input
|
||||
id="re-date"
|
||||
type="date"
|
||||
value={expenseDate}
|
||||
onChange={(e) => setExpenseDate(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="re-amount">{t('expense_amount', { currency })}</Label>
|
||||
<Input
|
||||
id="re-amount"
|
||||
inputMode="decimal"
|
||||
value={amountInput}
|
||||
onChange={(e) => setAmountInput(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
className="tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1.5">
|
||||
<Label htmlFor="re-vat">{t('expense_vat')}</Label>
|
||||
<Input
|
||||
id="re-vat"
|
||||
inputMode="decimal"
|
||||
value={vatInput}
|
||||
onChange={(e) => setVatInput(e.target.value)}
|
||||
disabled={isSubmitting}
|
||||
className="tabular-nums"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<Label>{t('expense_account')}</Label>
|
||||
<AccountCombobox
|
||||
value={expenseAccount}
|
||||
accounts={accounts}
|
||||
onChange={setExpenseAccount}
|
||||
disabled={isSubmitting}
|
||||
selectedName={accountName}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{amount > 0 && vatAmount < amount && (
|
||||
<div className="rounded-lg border border-border px-4 py-3 text-xs text-muted-foreground space-y-1">
|
||||
<p className="tabular-nums">
|
||||
{expenseAccount || '____'} D {formatCurrency(net, currency)}
|
||||
{vatAmount > 0 ? ` · 2641 D ${formatCurrency(vatAmount, currency)}` : ''}
|
||||
{` · ${liabilityAccount} K ${formatCurrency(amount, currency)}`}
|
||||
</p>
|
||||
{claimantName && (
|
||||
<p>{t('expense_outcome_att_gora', { name: claimantName })}</p>
|
||||
)}
|
||||
{currency !== 'SEK' && <p>{t('expense_fx_note')}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
|
||||
{t('expense_cancel')}
|
||||
</Button>
|
||||
<Button onClick={handleSubmit} disabled={!canSubmit}>
|
||||
{isSubmitting && <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" />}
|
||||
{t('expense_confirm')}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
Reference in New Issue
Block a user