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:
co-authored by
Claude Opus 4.7
parent
8a1c7f0714
commit
15d4f429f3
@@ -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}`)
|
||||
}
|
||||
|
||||
@@ -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}` : ''}`)
|
||||
}
|
||||
|
||||
@@ -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 "{searchTerm}"
|
||||
</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
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user