feat: consolidate /expenses into /supplier-invoices, dual-channel support feedback, compliance skills (#417)

* feat(supplier-invoices): consolidate /expenses into /supplier-invoices and add bank matching

Collapse the duplicate AP entry points by redirecting /expenses, /expenses/new,
and /expenses/[id] into the canonical /supplier-invoices routes, and absorb the
expense-entry flow into /supplier-invoices/new. Add a BankTransactionPicker so
a supplier invoice can be registered and matched to an existing outgoing bank
transaction in one step. Extend the transaction → invoice match dialog to
handle supplier invoices alongside customer invoices, including the new
/match-supplier-invoice endpoint and Swedish copy variants. Update the sidebar
to point Leverantörsfakturor at /supplier-invoices and hide the legacy entry.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* feat(support): send feedback to both Recapt and email channels

Previously submitFeedback used Recapt when present and only fell back to email
on failure, so feedback captured by the SDK never reached the support inbox.
Always POST to /api/support/contact in parallel with the Recapt call and
return the list of channels that succeeded; feedback is considered delivered
if either channel succeeds.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* chore(skills): add compliance skill references for GDPR, ISO 27001, OSS, OWASP ASVS, SOC 2

Add reference material for five compliance domains alongside the existing
.claude/skills/ set so future audits and CI gating work has a documented
mapping to controls, violation patterns, tool orchestration, and cross-framework
crosswalks.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(supplier-invoices): address review issues in register-and-match flow

- BankTransactionPicker: move createClient() inside the effect and drop the
  supabase client from the deps array. Calling createClient() in the component
  body produced a fresh reference on every render, which combined with the
  setIsLoading(true) inside the effect to fire an infinite re-fetch loop while
  the dialog was open.
- /supplier-invoices/new: replace the submitMode useState with a useRef.
  setSubmitMode() in the button onClick and the read in the form onSubmit run
  in the same React event batch, so onSubmit always saw the previous render's
  value and the bank picker never opened on first use.
- /supplier-invoices/new: route AB companies through the existing review
  dialog before booking the register-and-match flow. handlePickTransaction now
  stores the picked transaction and opens the review dialog for AB; on
  confirm, handleConfirm posts the create and then matches the stored
  transaction. EF retains the one-step create+approve+match path.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

* fix(transactions): populate potential_supplier_invoice so the match flow is reachable

fetchTransactions and loadMoreTransactions previously joined potential_invoice
from the invoices table but never looked up potential_supplier_invoice, so
TransactionInboxCard's hasSupplierInvoiceMatch check was always false and
handleConfirmInvoiceMatch's supplier branch was unreachable. Mirror the
existing customer-invoice pipeline: collect potential_supplier_invoice_id
values, batch-fetch the matching supplier_invoices (with their supplier),
build a map, and spread the result onto each TransactionWithInvoice in
parallel with the customer-invoice fetch.

Update uncategorizedTransactions sort and transactionsWithMatches filter to
also recognize supplier-invoice candidates so they bubble to the top of the
inbox and aren't excluded from match-driven views.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>

---------

Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
Jakob Wennberg
2026-05-07 18:13:43 +02:00
committed by GitHub
co-authored by Claude Opus 4.7
parent 8a1c7f0714
commit 15d4f429f3
52 changed files with 8103 additions and 2043 deletions
+8 -513
View File
@@ -1,515 +1,10 @@
'use client'
import { redirect } from 'next/navigation'
import { useState, useEffect } from 'react'
import { useParams, useRouter } from 'next/navigation'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { ArrowLeft, CheckCircle, CreditCard, FileText, Trash2 } from 'lucide-react'
import Link from 'next/link'
import { AccountNumber } from '@/components/ui/account-number'
import { DestructiveConfirmDialog, useDestructiveConfirm } from '@/components/ui/destructive-confirm-dialog'
import type { SupplierInvoice, SupplierInvoiceItem, SupplierInvoicePayment, EntityType } from '@/types'
const statusConfig: Record<string, { label: string; color: string }> = {
registered: { label: 'Obetald', color: 'bg-blue-100 text-blue-800' },
approved: { label: 'Obetald', color: 'bg-yellow-100 text-yellow-800' },
paid: { label: 'Betald', color: 'bg-success/10 text-success' },
partially_paid: { label: 'Delbetald', color: 'bg-orange-100 text-orange-800' },
overdue: { label: 'Förfallen', color: 'bg-destructive/10 text-destructive' },
disputed: { label: 'Tvist', color: 'bg-purple-100 text-purple-800' },
credited: { label: 'Krediterad', color: 'bg-gray-100 text-gray-800' },
}
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
export default function ExpenseDetailPage() {
const params = useParams()
const router = useRouter()
const { toast } = useToast()
const [invoice, setInvoice] = useState<SupplierInvoice | null>(null)
const [, setEntityType] = useState<EntityType>('enskild_firma')
const [isLoading, setIsLoading] = useState(true)
const [isPayDialogOpen, setIsPayDialogOpen] = useState(false)
const [payAmount, setPayAmount] = useState('')
const [paymentDate, setPaymentDate] = useState(() => new Date().toISOString().split('T')[0])
const [isProcessing, setIsProcessing] = useState(false)
const { dialogProps: confirmDialogProps, confirm: confirmAction } = useDestructiveConfirm()
async function fetchInvoice() {
setIsLoading(true)
const res = await fetch(`/api/supplier-invoices/${params.id}`)
const { data, error } = await res.json()
if (error) {
toast({ title: 'Kunde inte ladda utgift', description: error, variant: 'destructive' })
} else {
setInvoice(data)
setPayAmount(String(data.remaining_amount))
setPaymentDate(new Date().toISOString().split('T')[0])
}
setIsLoading(false)
}
async function fetchEntityType() {
try {
const res = await fetch('/api/settings')
const { data } = await res.json()
if (data?.entity_type) {
setEntityType(data.entity_type)
}
} catch {
// Default to enskild_firma
}
}
useEffect(() => {
fetchInvoice()
fetchEntityType()
}, [params.id])
async function handleApprove() {
setIsProcessing(true)
const res = await fetch(`/api/supplier-invoices/${params.id}/approve`, { method: 'POST' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte godkänna', description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' })
} else {
toast({ title: 'Godkänd', description: 'Utgiften har godkänts' })
fetchInvoice()
}
setIsProcessing(false)
}
async function handleMarkPaid() {
setIsProcessing(true)
const res = await fetch(`/api/supplier-invoices/${params.id}/mark-paid`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ amount: parseFloat(payAmount), payment_date: paymentDate }),
})
const result = await res.json()
if (!res.ok) {
toast({ title: 'Betalning misslyckades', description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' })
} else {
toast({
title: result.status === 'paid' ? 'Betald' : 'Delbetalning registrerad',
description: `${formatAmount(parseFloat(payAmount))} kr registrerat`,
})
setIsPayDialogOpen(false)
fetchInvoice()
}
setIsProcessing(false)
}
async function handleCredit() {
const ok = await confirmAction({
title: 'Registrera kreditfaktura',
description: 'En kreditfaktura skapas som reverserar den ursprungliga fakturan. Denna åtgärd kan inte ångras.',
confirmLabel: 'Registrera kreditfaktura',
variant: 'warning',
})
if (!ok) return
setIsProcessing(true)
const res = await fetch(`/api/supplier-invoices/${params.id}/credit`, { method: 'POST' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte kreditera', description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' })
} else {
toast({ title: 'Kreditfaktura registrerad' })
fetchInvoice()
}
setIsProcessing(false)
}
async function handleDelete() {
const ok = await confirmAction({
title: 'Ta bort utgift',
description: 'Utgiften och tillhörande data tas bort permanent. Denna åtgärd kan inte ångras.',
confirmLabel: 'Ta bort',
variant: 'destructive',
})
if (!ok) return
const res = await fetch(`/api/supplier-invoices/${params.id}`, { method: 'DELETE' })
const result = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte ta bort', description: getErrorMessage(result, { context: 'supplier_invoice' }), variant: 'destructive' })
} else {
toast({ title: 'Borttagen' })
router.push('/expenses')
}
}
if (isLoading) {
return (
<div className="space-y-6 max-w-4xl">
<div className="h-8 bg-muted rounded w-48 animate-pulse" />
<Card className="animate-pulse"><CardContent className="h-48" /></Card>
</div>
)
}
if (!invoice) {
return (
<div className="text-center py-12">
<p className="text-muted-foreground">Utgiften hittades inte</p>
<Button variant="outline" className="mt-4" onClick={() => router.push('/expenses')}>
Tillbaka
</Button>
</div>
)
}
const items = (invoice.items || []) as SupplierInvoiceItem[]
const payments = (invoice.payments || []) as SupplierInvoicePayment[]
const status = statusConfig[invoice.status] || { label: invoice.status, color: '' }
return (
<div className="space-y-6 max-w-4xl">
{/* Header */}
<div className="space-y-4">
<div className="flex items-start gap-4">
<Button variant="ghost" size="icon" className="shrink-0 mt-1" onClick={() => router.push('/expenses')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="min-w-0">
<div className="flex flex-wrap items-center gap-2 sm:gap-3">
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">
Utgift #{invoice.arrival_number}
</h1>
<Badge className={status.color}>
{status.label}
</Badge>
</div>
<p className="text-muted-foreground truncate">
{invoice.supplier?.name} · Faktura {invoice.supplier_invoice_number}
</p>
</div>
</div>
{/* Context-aware actions */}
<div className="flex flex-col sm:flex-row gap-2 sm:justify-end">
{invoice.status === 'registered' && (
<>
<Button className="w-full sm:w-auto" onClick={handleApprove} disabled={isProcessing}>
<CheckCircle className="mr-2 h-4 w-4" />
Godkänn
</Button>
<Button variant="destructive" className="w-full sm:w-auto" onClick={handleDelete} disabled={isProcessing}>
<Trash2 className="mr-2 h-4 w-4 sm:mr-0" />
<span className="sm:hidden">Ta bort</span>
</Button>
</>
)}
{['approved', 'overdue'].includes(invoice.status) && (
<>
<Button className="w-full sm:w-auto" onClick={() => setIsPayDialogOpen(true)} disabled={isProcessing}>
<CreditCard className="mr-2 h-4 w-4" />
Markera betald
</Button>
<Button variant="outline" className="w-full sm:w-auto" onClick={handleCredit} disabled={isProcessing}>
<FileText className="mr-2 h-4 w-4" />
Kreditfaktura
</Button>
</>
)}
{invoice.status === 'partially_paid' && (
<Button className="w-full sm:w-auto" onClick={() => setIsPayDialogOpen(true)} disabled={isProcessing}>
<CreditCard className="mr-2 h-4 w-4" />
Registrera betalning
</Button>
)}
</div>
</div>
{/* Card 1: Fakturadetaljer */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Fakturadetaljer</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Info grid */}
<div className="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 gap-4 text-sm">
<div>
<span className="text-muted-foreground">Leverantör</span>
<p className="font-medium">
{invoice.supplier ? (
<Link href={`/suppliers/${invoice.supplier.id}`} className="text-primary hover:underline">
{invoice.supplier.name}
</Link>
) : '-'}
</p>
{invoice.supplier?.org_number && (
<p className="text-xs text-muted-foreground">Org.nr: {invoice.supplier.org_number}</p>
)}
</div>
<div>
<span className="text-muted-foreground">Fakturanummer</span>
<p className="font-medium">{invoice.supplier_invoice_number}</p>
</div>
<div>
<span className="text-muted-foreground">Ankomstnummer</span>
<p className="font-medium font-mono">{invoice.arrival_number}</p>
</div>
<div>
<span className="text-muted-foreground">Fakturadatum</span>
<p className="font-medium">{invoice.invoice_date}</p>
</div>
<div>
<span className="text-muted-foreground">Förfallodatum</span>
<p className="font-medium">{invoice.due_date}</p>
</div>
{invoice.delivery_date && (
<div>
<span className="text-muted-foreground">Leveransdatum</span>
<p className="font-medium">{invoice.delivery_date}</p>
</div>
)}
{invoice.payment_reference && (
<div>
<span className="text-muted-foreground">OCR/referens</span>
<p className="font-medium font-mono">{invoice.payment_reference}</p>
</div>
)}
</div>
{invoice.reverse_charge && (
<Badge className="bg-purple-100 text-purple-800">Omvänd skattskyldighet</Badge>
)}
{/* Line items */}
{items.length > 0 && (
<div className="border-t pt-4">
{/* Desktop: table */}
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2">Beskrivning</th>
<th className="pb-2 w-20">Konto</th>
<th className="pb-2 w-16 text-right">Moms%</th>
<th className="pb-2 w-28 text-right">Belopp</th>
<th className="pb-2 w-24 text-right">Moms</th>
</tr>
</thead>
<tbody>
{items.map((item) => (
<tr key={item.id} className="border-b last:border-0">
<td className="py-2">{item.description}</td>
<td className="py-2"><AccountNumber number={item.account_number} /></td>
<td className="py-2 text-right">{Math.round(item.vat_rate * 100)}%</td>
<td className="py-2 text-right font-mono">{formatAmount(item.line_total)}</td>
<td className="py-2 text-right font-mono">{formatAmount(item.vat_amount)}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile: stacked cards */}
<div className="sm:hidden space-y-3">
{items.map((item) => (
<div key={item.id} className="border rounded-lg p-3 space-y-2 text-sm">
<div className="flex justify-between items-start gap-2">
<span className="font-medium">{item.description || 'Ingen beskrivning'}</span>
<AccountNumber number={item.account_number} />
</div>
<div className="flex justify-between text-muted-foreground">
<span>Belopp</span>
<span className="font-mono">{formatAmount(item.line_total)}</span>
</div>
<div className="flex justify-between text-muted-foreground">
<span>Moms ({Math.round(item.vat_rate * 100)}%)</span>
<span className="font-mono">{formatAmount(item.vat_amount)}</span>
</div>
</div>
))}
</div>
{/* Amounts summary */}
<div className="mt-4 pt-4 border-t space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Netto (exkl. moms)</span>
<span className="font-mono">{formatAmount(invoice.subtotal)} {invoice.currency}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Moms</span>
<span className="font-mono">{formatAmount(invoice.vat_amount)} {invoice.currency}</span>
</div>
<div className="flex justify-between font-bold text-base pt-2 border-t">
<span>Totalt</span>
<span className="font-mono">{formatAmount(invoice.total)} {invoice.currency}</span>
</div>
<div className="flex justify-between pt-2">
<span className="text-muted-foreground">Betalt</span>
<span className="font-mono text-success">{formatAmount(invoice.paid_amount)} {invoice.currency}</span>
</div>
<div className="flex justify-between font-semibold">
<span>Kvar att betala</span>
<span className="font-mono">{formatAmount(invoice.remaining_amount)} {invoice.currency}</span>
</div>
</div>
</div>
)}
{/* Notes inline */}
{invoice.notes && (
<div className="border-t pt-4">
<p className="text-sm text-muted-foreground">{invoice.notes}</p>
</div>
)}
</CardContent>
</Card>
{/* Card 2: Betalningar & bokföring (only if data exists) */}
{(payments.length > 0 || invoice.registration_journal_entry_id) && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Betalningar & bokföring</CardTitle>
</CardHeader>
<CardContent className="space-y-6">
{/* Payment history */}
{payments.length > 0 && (
<div>
<p className="text-sm font-medium mb-2">Betalningshistorik</p>
{/* Desktop: table */}
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2">Datum</th>
<th className="pb-2 text-right">Belopp</th>
<th className="pb-2">Verifikation</th>
<th className="pb-2">Anteckning</th>
</tr>
</thead>
<tbody>
{payments.map((p) => (
<tr key={p.id} className="border-b last:border-0">
<td className="py-2">{p.payment_date}</td>
<td className="py-2 text-right font-mono">{formatAmount(p.amount)} {p.currency}</td>
<td className="py-2">
{p.journal_entry_id ? (
<Link href={`/bookkeeping/${p.journal_entry_id}`} className="text-primary hover:underline font-mono text-xs">
{p.journal_entry_id.substring(0, 8)}...
</Link>
) : '-'}
</td>
<td className="py-2 text-muted-foreground">{p.notes || '-'}</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile: stacked cards */}
<div className="sm:hidden space-y-3">
{payments.map((p) => (
<div key={p.id} className="border rounded-lg p-3 space-y-1 text-sm">
<div className="flex justify-between items-center">
<span className="text-muted-foreground">{p.payment_date}</span>
<span className="font-mono font-medium">{formatAmount(p.amount)} {p.currency}</span>
</div>
{p.journal_entry_id && (
<div className="flex justify-between items-center">
<span className="text-muted-foreground">Verifikation</span>
<Link href={`/bookkeeping/${p.journal_entry_id}`} className="text-primary hover:underline font-mono text-xs">
{p.journal_entry_id.substring(0, 8)}...
</Link>
</div>
)}
{p.notes && (
<p className="text-muted-foreground text-xs pt-1">{p.notes}</p>
)}
</div>
))}
</div>
</div>
)}
{/* Journal entry links */}
<div className="text-sm space-y-2">
<p className="font-medium">Verifikationer</p>
{invoice.registration_journal_entry_id ? (
<div className="flex justify-between">
<span className="text-muted-foreground">Registreringsverifikation</span>
<Link
href={`/bookkeeping/${invoice.registration_journal_entry_id}`}
className="text-primary hover:underline font-mono"
>
{invoice.registration_journal_entry_id.substring(0, 8)}...
</Link>
</div>
) : (
<p className="text-muted-foreground">Ingen registreringsverifikation</p>
)}
{invoice.payment_journal_entry_id && (
<div className="flex justify-between">
<span className="text-muted-foreground">Betalningsverifikation</span>
<Link
href={`/bookkeeping/${invoice.payment_journal_entry_id}`}
className="text-primary hover:underline font-mono"
>
{invoice.payment_journal_entry_id.substring(0, 8)}...
</Link>
</div>
)}
</div>
</CardContent>
</Card>
)}
<DestructiveConfirmDialog {...confirmDialogProps} />
{/* Pay Dialog */}
<Dialog open={isPayDialogOpen} onOpenChange={setIsPayDialogOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Markera som betald</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label htmlFor="payment-date">Betalningsdatum</Label>
<Input
id="payment-date"
type="date"
value={paymentDate}
max={new Date().toISOString().split('T')[0]}
onChange={(e) => setPaymentDate(e.target.value)}
className="w-full sm:w-48"
/>
</div>
<div className="space-y-2">
<Label htmlFor="payment-amount">Belopp att betala</Label>
<Input
id="payment-amount"
type="number"
step="0.01"
value={payAmount}
onChange={(e) => setPayAmount(e.target.value)}
/>
<p className="text-xs text-muted-foreground">
Kvar att betala: {formatAmount(invoice.remaining_amount)} {invoice.currency}
</p>
</div>
<div className="flex justify-end gap-2">
<Button variant="outline" onClick={() => setIsPayDialogOpen(false)}>
Avbryt
</Button>
<Button onClick={handleMarkPaid} disabled={isProcessing}>
{isProcessing ? 'Bearbetar...' : 'Registrera betalning'}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
</div>
)
export default async function ExpenseDetailRedirectPage({
params,
}: {
params: Promise<{ id: string }>
}) {
const { id } = await params
redirect(`/supplier-invoices/${id}`)
}
+16 -878
View File
@@ -1,884 +1,22 @@
'use client'
import { redirect } from 'next/navigation'
import { useState, useEffect } from 'react'
import { useRouter } from 'next/navigation'
import { useForm, Controller, useFieldArray } from 'react-hook-form'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'
import { Input } from '@/components/ui/input'
import { Label } from '@/components/ui/label'
import { Textarea } from '@/components/ui/textarea'
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '@/components/ui/select'
import { Checkbox } from '@/components/ui/checkbox'
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from '@/components/ui/dialog'
import { useToast } from '@/components/ui/use-toast'
import { ConfirmationDialog } from '@/components/ui/confirmation-dialog'
import { SupplierInvoiceReviewContent } from '@/components/suppliers/SupplierInvoiceReviewContent'
import AccountCombobox from '@/components/bookkeeping/AccountCombobox'
import { getAccountDescription } from '@/lib/bookkeeping/account-descriptions'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { useUnsavedChanges } from '@/lib/hooks/use-unsaved-changes'
import { ArrowLeft, Plus, Trash2, ChevronDown, Loader2, Lock } from 'lucide-react'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { Supplier, BASAccount, VatTreatment, EntityType } from '@/types'
type SearchParams = Record<string, string | string[] | undefined>
interface LineItem {
description: string
amount: number
account_number: string
vat_rate: number
}
interface FormData {
supplier_id: string
supplier_invoice_number: string
invoice_date: string
due_date: string
delivery_date: string
currency: string
exchange_rate: string
reverse_charge: boolean
payment_reference: string
notes: string
items: LineItem[]
}
interface NewSupplierForm {
name: string
supplier_type: string
org_number: string
bankgiro: string
plusgiro: string
default_expense_account: string
}
function formatAmount(amount: number): string {
return amount.toLocaleString('sv-SE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })
}
function inferVatTreatment(items: LineItem[], reverseCharge: boolean): VatTreatment {
if (reverseCharge) return 'reverse_charge'
const rates = new Set(items.map((i) => i.vat_rate))
if (rates.size === 1) {
const rate = rates.values().next().value!
if (rate === 0.25) return 'standard_25'
if (rate === 0.12) return 'reduced_12'
if (rate === 0.06) return 'reduced_6'
if (rate === 0) return 'exempt'
}
return 'standard_25'
}
export default function NewExpensePage() {
const router = useRouter()
const { canWrite } = useCanWrite()
const { toast } = useToast()
const [suppliers, setSuppliers] = useState<Supplier[]>([])
const [accounts, setAccounts] = useState<BASAccount[]>([])
const [entityType, setEntityType] = useState<EntityType>('enskild_firma')
const [isSubmitting, setIsSubmitting] = useState(false)
const [showReview, setShowReview] = useState(false)
const [pendingData, setPendingData] = useState<FormData | null>(null)
const [showNewSupplier, setShowNewSupplier] = useState(false)
const [isCreatingSupplier, setIsCreatingSupplier] = useState(false)
const [pendingSupplierSelect, setPendingSupplierSelect] = useState<string | null>(null)
const [advancedOpen, setAdvancedOpen] = useState(false)
const [newSupplier, setNewSupplier] = useState<NewSupplierForm>({
name: '',
supplier_type: 'swedish_business',
org_number: '',
bankgiro: '',
plusgiro: '',
default_expense_account: '',
})
const { register, control, handleSubmit, watch, setValue, formState: { isDirty } } = useForm<FormData>({
defaultValues: {
supplier_id: '',
supplier_invoice_number: '',
invoice_date: new Date().toISOString().split('T')[0],
due_date: '',
delivery_date: '',
currency: 'SEK',
exchange_rate: '',
reverse_charge: false,
payment_reference: '',
notes: '',
items: [{ description: '', amount: 0, account_number: '5010', vat_rate: 0.25 }],
},
})
useUnsavedChanges(isDirty)
const { fields, append, remove } = useFieldArray({ control, name: 'items' })
const watchedItems = watch('items')
const watchedSupplierId = watch('supplier_id')
const watchedCurrency = watch('currency')
const isEF = entityType === 'enskild_firma'
useEffect(() => {
fetchSuppliers()
fetchAccounts()
fetchEntityType()
}, [])
// Auto-fill due date and defaults when supplier is selected
useEffect(() => {
if (watchedSupplierId) {
const supplier = suppliers.find((s) => s.id === watchedSupplierId)
if (supplier) {
const invoiceDate = watch('invoice_date')
if (invoiceDate) {
const due = new Date(invoiceDate)
due.setDate(due.getDate() + supplier.default_payment_terms)
setValue('due_date', due.toISOString().split('T')[0])
}
if (supplier.default_expense_account && fields.length > 0) {
setValue('items.0.account_number', supplier.default_expense_account)
}
if (supplier.default_currency) {
setValue('currency', supplier.default_currency)
}
if (supplier.supplier_type === 'eu_business') {
setValue('reverse_charge', true)
}
}
}
}, [watchedSupplierId, suppliers])
// Auto-select newly created supplier once it's in the list
useEffect(() => {
if (pendingSupplierSelect && suppliers.find((s) => s.id === pendingSupplierSelect)) {
setValue('supplier_id', pendingSupplierSelect, { shouldDirty: true, shouldValidate: true })
setPendingSupplierSelect(null)
}
}, [suppliers, pendingSupplierSelect, setValue])
async function fetchSuppliers() {
const res = await fetch('/api/suppliers')
const { data } = await res.json()
setSuppliers(data || [])
}
async function fetchAccounts() {
const res = await fetch('/api/bookkeeping/accounts')
const { data } = await res.json()
setAccounts(data || [])
}
async function fetchEntityType() {
try {
const res = await fetch('/api/settings')
const { data } = await res.json()
if (data?.entity_type) {
setEntityType(data.entity_type)
}
} catch {
// Default to enskild_firma
}
}
function handleAccountChange(index: number, accountNumber: string) {
setValue(`items.${index}.account_number`, accountNumber)
const currentDesc = watch(`items.${index}.description`)
if (!currentDesc && accountNumber.length === 4) {
const desc = getAccountDescription(accountNumber)
if (desc) {
setValue(`items.${index}.description`, desc.name)
}
}
}
// Calculate totals
const itemTotals = (watchedItems || []).map((item) => {
const lineTotal = Math.round((item.amount || 0) * 100) / 100
const vatAmount = Math.round(lineTotal * (item.vat_rate || 0) * 100) / 100
return { lineTotal, vatAmount }
})
const subtotal = itemTotals.reduce((sum, t) => sum + t.lineTotal, 0)
const totalVat = itemTotals.reduce((sum, t) => sum + t.vatAmount, 0)
const total = Math.round((subtotal + totalVat) * 100) / 100
async function handleCreateSupplier() {
if (!newSupplier.name.trim()) {
toast({ title: 'Namn saknas', description: 'Ange ett namn för leverantören.', variant: 'destructive' })
return
}
setIsCreatingSupplier(true)
const payload: Record<string, unknown> = {
name: newSupplier.name,
supplier_type: newSupplier.supplier_type,
}
if (newSupplier.org_number) payload.org_number = newSupplier.org_number
if (newSupplier.bankgiro) payload.bankgiro = newSupplier.bankgiro
if (newSupplier.plusgiro) payload.plusgiro = newSupplier.plusgiro
if (newSupplier.default_expense_account) payload.default_expense_account = newSupplier.default_expense_account
const res = await fetch('/api/suppliers', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload),
})
const result = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte skapa leverantör', description: getErrorMessage(result, { context: 'supplier' }), variant: 'destructive' })
export default async function NewExpenseRedirectPage({
searchParams,
}: {
searchParams: Promise<SearchParams>
}) {
const params = await searchParams
const qs = new URLSearchParams()
for (const [key, value] of Object.entries(params)) {
if (value == null) continue
if (Array.isArray(value)) {
for (const v of value) qs.append(key, v)
} else {
const created = result.data as Supplier
setSuppliers((prev) => [...prev, created].sort((a, b) => a.name.localeCompare(b.name)))
setPendingSupplierSelect(created.id)
setShowNewSupplier(false)
setNewSupplier({ name: '', supplier_type: 'swedish_business', org_number: '', bankgiro: '', plusgiro: '', default_expense_account: '' })
toast({ title: 'Leverantör skapad', description: created.name })
}
setIsCreatingSupplier(false)
}
function onSubmit(data: FormData) {
if (!data.supplier_id) {
toast({ title: 'Leverantör saknas', description: 'Välj eller skapa en leverantör.', variant: 'destructive' })
return
}
if (!data.supplier_invoice_number) {
toast({ title: 'Fakturanummer saknas', description: 'Ange leverantörens fakturanummer.', variant: 'destructive' })
return
}
if (isEF) {
// EF: submit directly (auto-approve after create)
setPendingData(data)
handleDirectSubmit(data)
} else {
// AB: show review dialog first
setPendingData(data)
setShowReview(true)
qs.set(key, value)
}
}
function buildPayload(data: FormData) {
const vatTreatment = inferVatTreatment(data.items, data.reverse_charge)
return {
supplier_id: data.supplier_id,
supplier_invoice_number: data.supplier_invoice_number,
invoice_date: data.invoice_date,
due_date: data.due_date,
delivery_date: data.delivery_date || undefined,
currency: data.currency,
exchange_rate: data.exchange_rate ? parseFloat(data.exchange_rate) : undefined,
vat_treatment: vatTreatment,
reverse_charge: data.reverse_charge,
payment_reference: data.payment_reference || undefined,
notes: data.notes || undefined,
items: data.items.map((item) => ({
description: item.description,
amount: item.amount,
account_number: item.account_number,
vat_rate: item.vat_rate,
})),
}
}
// EF flow: create + auto-approve
async function handleDirectSubmit(data: FormData) {
setIsSubmitting(true)
const res = await fetch('/api/supplier-invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildPayload(data)),
})
const result = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte registrera utgift', description: getErrorMessage(result, { context: 'supplier_invoice', statusCode: res.status }), variant: 'destructive' })
setIsSubmitting(false)
return
}
// Auto-approve for EF
const approveRes = await fetch(`/api/supplier-invoices/${result.data.id}/approve`, { method: 'POST' })
if (!approveRes.ok) {
toast({
title: 'Varning',
description: 'Utgiften skapades men kunde inte godkännas automatiskt',
variant: 'destructive',
})
router.push(`/expenses/${result.data.id}`)
} else {
toast({ title: 'Utgift registrerad', description: `Ankomstnummer: ${result.data.arrival_number}` })
router.push('/expenses')
}
setIsSubmitting(false)
}
// AB flow: create after review dialog confirmation
async function handleConfirm() {
if (!pendingData) return
setIsSubmitting(true)
const res = await fetch('/api/supplier-invoices', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(buildPayload(pendingData)),
})
const result = await res.json()
if (!res.ok) {
toast({ title: 'Kunde inte registrera utgift', description: getErrorMessage(result, { context: 'supplier_invoice', statusCode: res.status }), variant: 'destructive' })
} else {
toast({ title: 'Utgift registrerad', description: `Ankomstnummer: ${result.data.arrival_number}` })
setShowReview(false)
router.push(`/expenses/${result.data.id}`)
}
setIsSubmitting(false)
}
return (
<div className="space-y-6 max-w-4xl">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.push('/expenses')}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<h1 className="font-display text-2xl md:text-3xl font-medium tracking-tight">Ny utgift</h1>
<p className="text-muted-foreground">
Registrera en inkommande faktura
</p>
</div>
</div>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-6">
{/* Section 1: Faktura */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Faktura</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Leverantör *</Label>
<Controller
name="supplier_id"
control={control}
render={({ field }) => (
<Select
value={field.value}
onValueChange={(v) => {
if (v === '__new__') {
setShowNewSupplier(true)
} else {
field.onChange(v)
}
}}
>
<SelectTrigger>
<SelectValue placeholder="Välj leverantör" />
</SelectTrigger>
<SelectContent>
{suppliers.map((s) => (
<SelectItem key={s.id} value={s.id}>{s.name}</SelectItem>
))}
<SelectItem value="__new__" className="text-primary font-medium">
+ Lägg till ny leverantör...
</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
<div className="space-y-2">
<Label>Leverantörens fakturanummer *</Label>
<Input
placeholder="Fakturanr från leverantören"
{...register('supplier_invoice_number')}
/>
</div>
</div>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="space-y-2">
<Label>Fakturadatum *</Label>
<Input type="date" {...register('invoice_date')} />
</div>
<div className="space-y-2">
<Label>Förfallodatum *</Label>
<Input type="date" {...register('due_date')} />
</div>
<div className="space-y-2">
<Label>OCR / Betalningsreferens</Label>
<Input
placeholder="OCR-nummer"
{...register('payment_reference')}
/>
</div>
</div>
</CardContent>
</Card>
{/* Section 2: Kontering */}
<Card>
<CardHeader className="flex flex-col sm:flex-row sm:items-center justify-between gap-3">
<CardTitle className="text-lg">Kontering</CardTitle>
<Button
type="button"
variant="outline"
size="sm"
className="w-full sm:w-auto"
onClick={() =>
append({ description: '', amount: 0, account_number: '', vat_rate: 0.25 })
}
>
<Plus className="mr-2 h-4 w-4" />
Lägg till rad
</Button>
</CardHeader>
<CardContent>
{/* Desktop: table layout */}
<div className="hidden sm:block">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left text-muted-foreground">
<th className="pb-2 w-28">Konto</th>
<th className="pb-2">Beskrivning</th>
<th className="pb-2 w-32">Belopp (exkl.)</th>
<th className="pb-2 w-24">Momssats</th>
<th className="pb-2 w-24 text-right">Moms</th>
<th className="pb-2 w-8"></th>
</tr>
</thead>
<tbody>
{fields.map((field, index) => (
<tr key={field.id} className="border-b last:border-0 align-top">
<td className="py-2 pr-2">
<Controller
name={`items.${index}.account_number`}
control={control}
render={({ field: f }) => (
<AccountCombobox
value={f.value}
accounts={accounts}
onChange={(val) => handleAccountChange(index, val)}
/>
)}
/>
</td>
<td className="py-2 pr-2">
<Controller
name={`items.${index}.description`}
control={control}
render={({ field }) => (
<Input
placeholder="Beskrivning"
value={field.value || ''}
onChange={field.onChange}
onBlur={field.onBlur}
/>
)}
/>
</td>
<td className="py-2 pr-2">
<Controller
name={`items.${index}.amount`}
control={control}
render={({ field }) => (
<Input
type="number"
step="0.01"
placeholder="0,00"
value={field.value || ''}
onChange={(e) => {
const parsed = parseFloat(e.target.value)
field.onChange(e.target.value === '' || isNaN(parsed) ? 0 : parsed)
}}
/>
)}
/>
</td>
<td className="py-2 pr-2">
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field: f }) => (
<Select
value={String(f.value)}
onValueChange={(v) => f.onChange(parseFloat(v))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.25">25%</SelectItem>
<SelectItem value="0.12">12%</SelectItem>
<SelectItem value="0.06">6%</SelectItem>
<SelectItem value="0">0%</SelectItem>
</SelectContent>
</Select>
)}
/>
</td>
<td className="py-2 pr-2 text-right font-mono pt-4">
{formatAmount(itemTotals[index]?.vatAmount || 0)}
</td>
<td className="py-2 pt-3">
{fields.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
)}
</td>
</tr>
))}
</tbody>
</table>
</div>
{/* Mobile: stacked card layout */}
<div className="sm:hidden space-y-4">
{fields.map((field, index) => (
<div key={field.id} className="border rounded-lg p-3 space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-muted-foreground">Rad {index + 1}</span>
{fields.length > 1 && (
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={() => remove(index)}
>
<Trash2 className="h-4 w-4 text-muted-foreground" />
</Button>
)}
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Konto</Label>
<Controller
name={`items.${index}.account_number`}
control={control}
render={({ field: f }) => (
<AccountCombobox
value={f.value}
accounts={accounts}
onChange={(val) => handleAccountChange(index, val)}
/>
)}
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Beskrivning</Label>
<Controller
name={`items.${index}.description`}
control={control}
render={({ field }) => (
<Input
placeholder="Beskrivning"
value={field.value || ''}
onChange={field.onChange}
/>
)}
/>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Belopp (exkl.)</Label>
<Controller
name={`items.${index}.amount`}
control={control}
render={({ field }) => (
<Input
type="number"
step="0.01"
placeholder="0,00"
value={field.value || ''}
onChange={(e) => field.onChange(e.target.value === '' ? 0 : parseFloat(e.target.value) || 0)}
/>
)}
/>
</div>
<div className="space-y-2">
<Label className="text-xs text-muted-foreground">Momssats</Label>
<Controller
name={`items.${index}.vat_rate`}
control={control}
render={({ field: f }) => (
<Select
value={String(f.value)}
onValueChange={(v) => f.onChange(parseFloat(v))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="0.25">25%</SelectItem>
<SelectItem value="0.12">12%</SelectItem>
<SelectItem value="0.06">6%</SelectItem>
<SelectItem value="0">0%</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
</div>
<div className="flex justify-between items-center pt-1 border-t">
<span className="text-xs text-muted-foreground">Moms</span>
<span className="font-mono text-sm">{formatAmount(itemTotals[index]?.vatAmount || 0)} kr</span>
</div>
</div>
))}
</div>
{/* Totals */}
<div className="mt-4 pt-4 border-t space-y-2">
<div className="flex justify-between sm:justify-end sm:gap-8">
<span className="text-muted-foreground">Netto (exkl. moms)</span>
<span className="font-mono sm:w-32 text-right">{formatAmount(subtotal)} kr</span>
</div>
<div className="flex justify-between sm:justify-end sm:gap-8">
<span className="text-muted-foreground">Moms</span>
<span className="font-mono sm:w-32 text-right">{formatAmount(totalVat)} kr</span>
</div>
<div className="flex justify-between sm:justify-end sm:gap-8 font-bold text-lg">
<span>Totalt</span>
<span className="font-mono sm:w-32 text-right">{formatAmount(total)} kr</span>
</div>
</div>
</CardContent>
</Card>
{/* Section 3: Övrigt (collapsible) */}
<Card>
<CardHeader
className="cursor-pointer hover:bg-muted/30 transition-colors"
onClick={() => setAdvancedOpen(!advancedOpen)}
>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Övrigt</CardTitle>
<ChevronDown className={`h-4 w-4 text-muted-foreground transition-transform ${advancedOpen ? 'rotate-180' : ''}`} />
</div>
</CardHeader>
{advancedOpen && (
<CardContent className="space-y-4 pt-0">
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Valuta</Label>
<Controller
name="currency"
control={control}
render={({ field }) => (
<Select value={field.value} onValueChange={field.onChange}>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="SEK">SEK</SelectItem>
<SelectItem value="EUR">EUR</SelectItem>
<SelectItem value="USD">USD</SelectItem>
<SelectItem value="GBP">GBP</SelectItem>
<SelectItem value="NOK">NOK</SelectItem>
<SelectItem value="DKK">DKK</SelectItem>
</SelectContent>
</Select>
)}
/>
</div>
{watchedCurrency !== 'SEK' && (
<div className="space-y-2">
<Label>Växelkurs</Label>
<Input
type="number"
step="0.0001"
placeholder="1.0000"
{...register('exchange_rate')}
/>
</div>
)}
</div>
<div className="space-y-2">
<Label>Leveransdatum (ML krav)</Label>
<Input type="date" {...register('delivery_date')} />
</div>
<div className="flex items-center gap-2">
<Controller
name="reverse_charge"
control={control}
render={({ field }) => (
<Checkbox
checked={field.value}
onCheckedChange={field.onChange}
/>
)}
/>
<Label>Omvänd skattskyldighet (reverse charge)</Label>
</div>
<div className="space-y-2">
<Label>Anteckningar</Label>
<Textarea
placeholder="Interna anteckningar om denna utgift..."
{...register('notes')}
/>
</div>
</CardContent>
)}
</Card>
{/* Submit */}
<div className="flex flex-col-reverse sm:flex-row sm:justify-end gap-3 sm:gap-4">
<Button type="button" variant="outline" className="w-full sm:w-auto" onClick={() => router.push('/expenses')}>
Avbryt
</Button>
<Button
type="submit"
disabled={isSubmitting || !canWrite}
className="w-full sm:w-auto"
title={!canWrite ? 'Du har endast läsbehörighet i detta företag' : undefined}
>
{isSubmitting ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Registrerar...
</>
) : !canWrite ? (
<>
<Lock className="mr-2 h-4 w-4" />
{isEF ? 'Registrera utgift' : 'Granska & registrera'}
</>
) : isEF ? (
'Registrera utgift'
) : (
'Granska & registrera'
)}
</Button>
</div>
</form>
{/* Review dialog (AB only) */}
{pendingData && !isEF && (() => {
const selectedSupplier = suppliers.find((s) => s.id === pendingData.supplier_id)
if (!selectedSupplier) return null
return (
<ConfirmationDialog
open={showReview}
onOpenChange={setShowReview}
onConfirm={handleConfirm}
isSubmitting={isSubmitting}
title="Granska utgift"
warningText="Utgiften registreras och en verifikation bokförs. Verifikationen kan inte redigeras direkt, men kan korrigeras via en ändringsverifikation."
confirmLabel="Bekräfta & registrera"
>
<SupplierInvoiceReviewContent
supplier={selectedSupplier}
invoiceNumber={pendingData.supplier_invoice_number}
invoiceDate={pendingData.invoice_date}
dueDate={pendingData.due_date}
deliveryDate={pendingData.delivery_date || undefined}
currency={pendingData.currency}
exchangeRate={pendingData.exchange_rate || undefined}
reverseCharge={pendingData.reverse_charge}
paymentReference={pendingData.payment_reference || undefined}
items={pendingData.items}
subtotal={subtotal}
totalVat={totalVat}
total={total}
/>
</ConfirmationDialog>
)
})()}
{/* New Supplier Dialog */}
<Dialog open={showNewSupplier} onOpenChange={setShowNewSupplier}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>Ny leverantör</DialogTitle>
</DialogHeader>
<div className="space-y-4">
<div className="space-y-2">
<Label>Namn *</Label>
<Input
placeholder="Leverantörens namn"
value={newSupplier.name}
onChange={(e) => setNewSupplier((p) => ({ ...p, name: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Typ</Label>
<Select
value={newSupplier.supplier_type}
onValueChange={(v) => setNewSupplier((p) => ({ ...p, supplier_type: v }))}
>
<SelectTrigger>
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="swedish_business">Svenskt företag</SelectItem>
<SelectItem value="eu_business">EU-företag</SelectItem>
<SelectItem value="non_eu_business">Utomeuropeiskt</SelectItem>
</SelectContent>
</Select>
</div>
<div className="space-y-2">
<Label>Organisationsnummer</Label>
<Input
placeholder="XXXXXX-XXXX"
value={newSupplier.org_number}
onChange={(e) => setNewSupplier((p) => ({ ...p, org_number: e.target.value }))}
/>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label>Bankgiro</Label>
<Input
placeholder="XXX-XXXX"
value={newSupplier.bankgiro}
onChange={(e) => setNewSupplier((p) => ({ ...p, bankgiro: e.target.value }))}
/>
</div>
<div className="space-y-2">
<Label>Plusgiro</Label>
<Input
placeholder="XXXXXX-X"
value={newSupplier.plusgiro}
onChange={(e) => setNewSupplier((p) => ({ ...p, plusgiro: e.target.value }))}
/>
</div>
</div>
<div className="space-y-2">
<Label>Standardkonto (kostnad)</Label>
<Input
placeholder="t.ex. 5010"
value={newSupplier.default_expense_account}
onChange={(e) => setNewSupplier((p) => ({ ...p, default_expense_account: e.target.value }))}
/>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowNewSupplier(false)}>
Avbryt
</Button>
<Button onClick={handleCreateSupplier} disabled={isCreatingSupplier}>
{isCreatingSupplier ? (
<>
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
Skapar...
</>
) : (
'Skapa leverantör'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
)
const suffix = qs.toString()
redirect(`/supplier-invoices/new${suffix ? `?${suffix}` : ''}`)
}
+3 -252
View File
@@ -1,254 +1,5 @@
'use client'
import { redirect } from 'next/navigation'
import { useState, useEffect } from 'react'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Input } from '@/components/ui/input'
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'
import { PageHeader } from '@/components/ui/page-header'
import { EmptyState } from '@/components/ui/empty-state'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency } from '@/lib/utils'
import { Plus, Search, Wallet, Lock } from 'lucide-react'
import { useCompany } from '@/contexts/CompanyContext'
import { useCanWrite } from '@/lib/hooks/use-can-write'
import type { SupplierInvoice } from '@/types'
type ExpenseInvoice = SupplierInvoice & { supplier?: { id: string; name: string } }
const UNPAID_STATUSES = ['registered', 'approved', 'overdue', 'partially_paid']
const PAID_STATUSES = ['paid', 'credited']
function getSimplifiedStatus(status: string): { label: string; variant: 'default' | 'secondary' | 'destructive'; borderColor: string } {
switch (status) {
case 'registered':
case 'approved':
return { label: 'Obetald', variant: 'default', borderColor: 'border-l-warning' }
case 'overdue':
return { label: 'Förfallen', variant: 'destructive', borderColor: 'border-l-destructive' }
case 'partially_paid':
return { label: 'Delbetald', variant: 'default', borderColor: 'border-l-orange-400' }
case 'paid':
return { label: 'Betald', variant: 'secondary', borderColor: 'border-l-success' }
case 'credited':
return { label: 'Krediterad', variant: 'secondary', borderColor: 'border-l-muted-foreground/30' }
default:
return { label: status, variant: 'secondary', borderColor: 'border-l-muted-foreground/30' }
}
}
function getRelativeTimeLabel(dueDateStr: string, status: string): { text: string; color: string } | null {
if (PAID_STATUSES.includes(status)) return null
const today = new Date()
today.setHours(0, 0, 0, 0)
const dueDate = new Date(dueDateStr)
dueDate.setHours(0, 0, 0, 0)
const diffDays = Math.round((dueDate.getTime() - today.getTime()) / (1000 * 60 * 60 * 24))
if (diffDays < 0) {
return { text: `${Math.abs(diffDays)} dagar försenad`, color: 'text-destructive' }
} else if (diffDays === 0) {
return { text: 'Förfaller idag', color: 'text-warning-foreground' }
} else if (diffDays <= 3) {
return { text: `${diffDays} dagar kvar`, color: 'text-warning-foreground' }
} else if (diffDays <= 7) {
return { text: `${diffDays} dagar kvar`, color: 'text-muted-foreground' }
}
return null
}
export default function ExpensesPage() {
const { company } = useCompany()
const { canWrite } = useCanWrite()
const [invoices, setInvoices] = useState<ExpenseInvoice[]>([])
const [isLoading, setIsLoading] = useState(true)
const [searchTerm, setSearchTerm] = useState('')
const [activeTab, setActiveTab] = useState('unpaid')
const { toast } = useToast()
const supabase = createClient()
async function fetchExpenses() {
if (!company) return
setIsLoading(true)
const { data, error } = await supabase
.from('supplier_invoices')
.select('*, supplier:suppliers(id, name)')
.eq('company_id', company.id)
.order('due_date', { ascending: true })
if (error) {
toast({ title: 'Kunde inte ladda utgifter', description: 'Kontrollera din anslutning och försök igen.', variant: 'destructive' })
} else {
setInvoices(data || [])
}
setIsLoading(false)
}
useEffect(() => {
fetchExpenses()
}, [])
const filteredInvoices = invoices.filter((inv) => {
const matchesSearch =
(inv.supplier?.name || '').toLowerCase().includes(searchTerm.toLowerCase()) ||
inv.supplier_invoice_number.toLowerCase().includes(searchTerm.toLowerCase())
const matchesTab =
activeTab === 'unpaid'
? UNPAID_STATUSES.includes(inv.status)
: PAID_STATUSES.includes(inv.status)
return matchesSearch && matchesTab
})
return (
<div className="space-y-6">
<PageHeader
title="Leverantörsfakturor"
description="Registrera och hantera dina utgifter"
action={
canWrite ? (
<Link href="/expenses/new">
<Button>
<Plus className="mr-2 h-4 w-4" />
Ny utgift
</Button>
</Link>
) : (
<Button
disabled
title="Du har endast läsbehörighet i detta företag"
>
<Lock className="mr-2 h-4 w-4" />
Ny utgift
</Button>
)
}
/>
{/* Search and tabs */}
<div className="flex flex-col sm:flex-row gap-4">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="Sök på leverantör eller fakturanummer..."
value={searchTerm}
onChange={(e) => setSearchTerm(e.target.value)}
className="pl-10"
/>
</div>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList>
<TabsTrigger value="unpaid">Att betala</TabsTrigger>
<TabsTrigger value="paid">Betalda</TabsTrigger>
</TabsList>
</Tabs>
</div>
{/* Expense list */}
{isLoading ? (
<div className="space-y-2">
{[1, 2, 3].map((i) => (
<Card key={i} className="animate-pulse">
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="space-y-2">
<div className="h-5 bg-muted rounded w-32" />
<div className="h-4 bg-muted rounded w-48" />
</div>
<div className="h-8 bg-muted rounded w-24" />
</div>
</CardContent>
</Card>
))}
</div>
) : filteredInvoices.length === 0 ? (
<Card>
<CardContent>
{searchTerm ? (
<div className="flex flex-col items-center justify-center py-12">
<Wallet className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">Inga träffar</h3>
<p className="text-muted-foreground text-center mt-1">
Inga utgifter matchar &quot;{searchTerm}&quot;
</p>
</div>
) : invoices.length === 0 ? (
<EmptyState
icon={Wallet}
title="Inga utgifter ännu"
description="Registrera din första utgift. Vi bokför automatiskt och håller koll på betalningar."
actionLabel="Ny utgift"
actionHref="/expenses/new"
/>
) : (
<div className="flex flex-col items-center justify-center py-12">
<Wallet className="h-12 w-12 text-muted-foreground mb-4" />
<h3 className="text-lg font-medium">Inga utgifter i denna kategori</h3>
<p className="text-muted-foreground text-center mt-1">
Prova att byta flik för att se fler utgifter
</p>
</div>
)}
</CardContent>
</Card>
) : (
<div className="space-y-2">
{filteredInvoices.map((inv) => {
const status = getSimplifiedStatus(inv.status)
const relativeTime = inv.due_date ? getRelativeTimeLabel(inv.due_date, inv.status) : null
return (
<Link key={inv.id} href={`/expenses/${inv.id}`}>
<Card className={`hover:border-primary/50 transition-colors cursor-pointer border-l-4 ${status.borderColor} ${inv.status === 'overdue' ? 'ring-1 ring-destructive/20' : ''}`}>
<CardContent className="py-4">
<div className="flex items-center justify-between">
<div className="flex items-center gap-4">
<div className="h-10 w-10 rounded-lg bg-muted flex items-center justify-center">
<Wallet className="h-5 w-5 text-muted-foreground" />
</div>
<div>
<div className="flex items-center gap-2">
<p className="font-medium">{inv.supplier?.name || 'Okänd leverantör'}</p>
<Badge variant={status.variant as 'default' | 'secondary' | 'destructive'}>
{status.label}
</Badge>
</div>
<div className="flex items-center gap-2">
<p className="text-sm text-muted-foreground">
{inv.supplier_invoice_number} · Förfaller {inv.due_date}
</p>
{relativeTime && (
<span className={`text-xs font-medium ${relativeTime.color}`}>
{relativeTime.text}
</span>
)}
</div>
</div>
</div>
<div className="text-right">
<p className="font-medium tabular-nums">
{formatCurrency(inv.total, inv.currency)}
</p>
{inv.remaining_amount !== inv.total && inv.remaining_amount > 0 && (
<p className="text-sm text-muted-foreground tabular-nums">
Kvar: {formatCurrency(inv.remaining_amount, inv.currency)}
</p>
)}
</div>
</div>
</CardContent>
</Card>
</Link>
)
})}
</div>
)}
</div>
)
export default function ExpensesRedirectPage() {
redirect('/supplier-invoices')
}
File diff suppressed because it is too large Load Diff
+106 -45
View File
@@ -33,9 +33,30 @@ import type { TransactionWithInvoice, ViewMode, CategorizeHandler } from '@/comp
import { useCompany } from '@/contexts/CompanyContext'
import { getErrorMessage } from '@/lib/errors/get-error-message'
import { formatCurrency, formatDate } from '@/lib/utils'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, VatTreatment, EntityType, LinePatternEntry } from '@/types'
import type { TransactionCategory, CreateTransactionInput, Invoice, Customer, SupplierInvoice, Supplier, VatTreatment, EntityType, LinePatternEntry } from '@/types'
import type { SuggestedCategory, SuggestedTemplate } from '@/lib/transactions/category-suggestions'
type InvoiceWithCustomer = Invoice & { customer?: Customer }
type SupplierInvoiceWithSupplier = SupplierInvoice & { supplier?: Supplier }
function buildInvoiceMap(rows: InvoiceWithCustomer[] | null): Record<string, InvoiceWithCustomer> {
if (!rows) return {}
return rows.reduce<Record<string, InvoiceWithCustomer>>((acc, inv) => {
acc[inv.id] = inv
return acc
}, {})
}
function buildSupplierInvoiceMap(
rows: SupplierInvoiceWithSupplier[] | null,
): Record<string, SupplierInvoiceWithSupplier> {
if (!rows) return {}
return rows.reduce<Record<string, SupplierInvoiceWithSupplier>>((acc, inv) => {
acc[inv.id] = inv
return acc
}, {})
}
interface QuickReviewState {
transaction: TransactionWithInvoice
category: TransactionCategory
@@ -107,12 +128,16 @@ export default function TransactionsPage() {
const uncategorizedTransactions = transactions
.filter((t) => t.is_business === null && !exitingIds.has(t.id))
.sort((a, b) => {
const aHasMatch = a.potential_invoice ? 1 : 0
const bHasMatch = b.potential_invoice ? 1 : 0
const aHasMatch = a.potential_invoice || a.potential_supplier_invoice ? 1 : 0
const bHasMatch = b.potential_invoice || b.potential_supplier_invoice ? 1 : 0
if (aHasMatch !== bHasMatch) return bHasMatch - aHasMatch
return b.date.localeCompare(a.date)
})
const transactionsWithMatches = transactions.filter((t) => t.potential_invoice && !t.invoice_id)
const transactionsWithMatches = transactions.filter(
(t) =>
(t.potential_invoice && !t.invoice_id) ||
(t.potential_supplier_invoice && !t.supplier_invoice_id),
)
const PAGE_SIZE = 200
@@ -139,30 +164,37 @@ export default function TransactionsPage() {
return
}
const potentialInvoiceIds = (txData || [])
const rows = txData || []
const potentialInvoiceIds = rows
.filter((t) => t.potential_invoice_id)
.map((t) => t.potential_invoice_id)
const potentialSupplierInvoiceIds = rows
.filter((t) => t.potential_supplier_invoice_id)
.map((t) => t.potential_supplier_invoice_id)
const invoiceResult = potentialInvoiceIds.length > 0
? await supabase.from('invoices').select('*, customer:customers(*)').in('id', potentialInvoiceIds)
: { data: null }
const [invoiceResult, supplierInvoiceResult] = await Promise.all([
potentialInvoiceIds.length > 0
? supabase.from('invoices').select('*, customer:customers(*)').in('id', potentialInvoiceIds)
: Promise.resolve({ data: null }),
potentialSupplierInvoiceIds.length > 0
? supabase.from('supplier_invoices').select('*, supplier:suppliers(*)').in('id', potentialSupplierInvoiceIds)
: Promise.resolve({ data: null }),
])
let invoiceMap: Record<string, Invoice & { customer?: Customer }> = {}
if (invoiceResult.data) {
invoiceMap = invoiceResult.data.reduce((acc, inv) => {
acc[inv.id] = inv
return acc
}, {} as Record<string, Invoice & { customer?: Customer }>)
}
const invoiceMap = buildInvoiceMap(invoiceResult.data)
const supplierInvoiceMap = buildSupplierInvoiceMap(supplierInvoiceResult.data)
const transactionsWithInvoices: TransactionWithInvoice[] = (txData || []).map((t) => ({
const transactionsWithInvoices: TransactionWithInvoice[] = rows.map((t) => ({
...t,
potential_invoice: t.potential_invoice_id ? invoiceMap[t.potential_invoice_id] : undefined,
potential_supplier_invoice: t.potential_supplier_invoice_id
? supplierInvoiceMap[t.potential_supplier_invoice_id]
: undefined,
}))
setTransactions(transactionsWithInvoices)
setTotalUncategorizedCount(uncatCount ?? 0)
setHasMore((txData || []).length >= PAGE_SIZE)
setHasMore(rows.length >= PAGE_SIZE)
setIsLoading(false)
}
@@ -187,22 +219,28 @@ export default function TransactionsPage() {
const potentialInvoiceIds = txData
.filter((t) => t.potential_invoice_id)
.map((t) => t.potential_invoice_id)
const potentialSupplierInvoiceIds = txData
.filter((t) => t.potential_supplier_invoice_id)
.map((t) => t.potential_supplier_invoice_id)
const invoiceResult = potentialInvoiceIds.length > 0
? await supabase.from('invoices').select('*, customer:customers(*)').in('id', potentialInvoiceIds)
: { data: null }
const [invoiceResult, supplierInvoiceResult] = await Promise.all([
potentialInvoiceIds.length > 0
? supabase.from('invoices').select('*, customer:customers(*)').in('id', potentialInvoiceIds)
: Promise.resolve({ data: null }),
potentialSupplierInvoiceIds.length > 0
? supabase.from('supplier_invoices').select('*, supplier:suppliers(*)').in('id', potentialSupplierInvoiceIds)
: Promise.resolve({ data: null }),
])
let invoiceMap: Record<string, Invoice & { customer?: Customer }> = {}
if (invoiceResult.data) {
invoiceMap = invoiceResult.data.reduce((acc, inv) => {
acc[inv.id] = inv
return acc
}, {} as Record<string, Invoice & { customer?: Customer }>)
}
const invoiceMap = buildInvoiceMap(invoiceResult.data)
const supplierInvoiceMap = buildSupplierInvoiceMap(supplierInvoiceResult.data)
const newTransactions: TransactionWithInvoice[] = txData.map((t) => ({
...t,
potential_invoice: t.potential_invoice_id ? invoiceMap[t.potential_invoice_id] : undefined,
potential_supplier_invoice: t.potential_supplier_invoice_id
? supplierInvoiceMap[t.potential_supplier_invoice_id]
: undefined,
}))
setTransactions((prev) => [...prev, ...newTransactions])
@@ -369,26 +407,41 @@ export default function TransactionsPage() {
}
async function handleConfirmInvoiceMatch() {
if (!selectedTransaction?.potential_invoice) return
if (!selectedTransaction) return
const isSupplier = !!selectedTransaction.potential_supplier_invoice
const isCustomer = !!selectedTransaction.potential_invoice
if (!isSupplier && !isCustomer) return
setIsConfirmingMatch(true)
try {
const response = await fetch(`/api/transactions/${selectedTransaction.id}/match-invoice`, {
const url = isSupplier
? `/api/transactions/${selectedTransaction.id}/match-supplier-invoice`
: `/api/transactions/${selectedTransaction.id}/match-invoice`
const body = isSupplier
? { supplier_invoice_id: selectedTransaction.potential_supplier_invoice!.id }
: { invoice_id: selectedTransaction.potential_invoice!.id }
const response = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ invoice_id: selectedTransaction.potential_invoice.id }),
body: JSON.stringify(body),
})
const result = await response.json()
if (!response.ok) {
toast({ title: 'Fakturamatchning misslyckades', description: getErrorMessage(result, { context: 'transaction' }), variant: 'destructive' })
toast({
title: isSupplier ? 'Leverantörsfakturamatchning misslyckades' : 'Fakturamatchning misslyckades',
description: getErrorMessage(result, { context: 'transaction' }),
variant: 'destructive',
})
setIsConfirmingMatch(false)
return
}
toast({
title: 'Faktura matchad',
description: `Faktura ${selectedTransaction.potential_invoice.invoice_number} markerad som betald`,
})
const label = isSupplier
? `Leverantörsfaktura ${selectedTransaction.potential_supplier_invoice!.supplier_invoice_number} markerad som betald`
: `Faktura ${selectedTransaction.potential_invoice!.invoice_number} markerad som betald`
toast({ title: isSupplier ? 'Leverantörsfaktura matchad' : 'Faktura matchad', description: label })
setMatchDialogOpen(false)
// Mark as exiting for animation
@@ -397,15 +450,23 @@ export default function TransactionsPage() {
setTransactions((prev) =>
prev.map((t) =>
t.id === selectedTransaction.id
? {
...t,
invoice_id: selectedTransaction.potential_invoice?.id || null,
potential_invoice_id: null,
potential_invoice: undefined,
is_business: true,
category: 'income_services' as TransactionCategory,
journal_entry_id: result.journal_entry_id,
}
? isSupplier
? {
...t,
supplier_invoice_id: selectedTransaction.potential_supplier_invoice?.id || null,
potential_supplier_invoice: undefined,
is_business: true,
journal_entry_id: result.journal_entry_id,
}
: {
...t,
invoice_id: selectedTransaction.potential_invoice?.id || null,
potential_invoice_id: null,
potential_invoice: undefined,
is_business: true,
category: 'income_services' as TransactionCategory,
journal_entry_id: result.journal_entry_id,
}
: t
)
)
@@ -418,7 +479,7 @@ export default function TransactionsPage() {
setIsConfirmingMatch(false)
}, 350)
} catch {
toast({ title: 'Matchning misslyckades', description: 'Transaktionen kunde inte matchas med fakturan. Försök igen.', variant: 'destructive' })
toast({ title: 'Matchning misslyckades', description: 'Transaktionen kunde inte matchas. Försök igen.', variant: 'destructive' })
setIsConfirmingMatch(false)
}
}