Files
accounted/app/(dashboard)/invoices/[id]/page.tsx
T
2026-02-13 14:00:46 +01:00

737 lines
26 KiB
TypeScript

'use client'
import { useState, useEffect, use } from 'react'
import { useRouter } from 'next/navigation'
import Link from 'next/link'
import { createClient } from '@/lib/supabase/client'
import { Button } from '@/components/ui/button'
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
import { Badge } from '@/components/ui/badge'
import { Separator } from '@/components/ui/separator'
import { useToast } from '@/components/ui/use-toast'
import { formatCurrency, formatDate } from '@/lib/utils'
import { getVatTreatmentLabel } from '@/lib/invoice/vat-rules'
import {
Loader2,
ArrowLeft,
Send,
CheckCircle,
FileText,
Download,
XCircle,
Clock,
Building,
Mail,
Phone,
MapPin,
ReceiptText,
ExternalLink,
Bell,
AlertTriangle,
MessageSquare,
} from 'lucide-react'
import type { Invoice, InvoiceItem, Customer, InvoiceStatus, InvoiceReminder } from '@/types'
const statusConfig: Record<InvoiceStatus, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive'; icon: React.ElementType }> = {
draft: { label: 'Utkast', variant: 'secondary', icon: FileText },
sent: { label: 'Skickad', variant: 'default', icon: Send },
paid: { label: 'Betald', variant: 'success', icon: CheckCircle },
overdue: { label: 'Förfallen', variant: 'destructive', icon: Clock },
cancelled: { label: 'Makulerad', variant: 'secondary', icon: XCircle },
credited: { label: 'Krediterad', variant: 'secondary', icon: XCircle },
}
const reminderLevelLabels: Record<1 | 2 | 3, string> = {
1: 'Vänlig påminnelse',
2: 'Andra påminnelsen',
3: 'Slutlig påminnelse'
}
interface InvoiceWithRelations extends Invoice {
customer: Customer
items: InvoiceItem[]
sent_at?: string
}
export default function InvoiceDetailPage({ params }: { params: Promise<{ id: string }> }) {
const { id } = use(params)
const router = useRouter()
const { toast } = useToast()
const supabase = createClient()
const [invoice, setInvoice] = useState<InvoiceWithRelations | null>(null)
const [reminders, setReminders] = useState<InvoiceReminder[]>([])
const [creditNote, setCreditNote] = useState<Invoice | null>(null)
const [originalInvoice, setOriginalInvoice] = useState<Invoice | null>(null)
const [isLoading, setIsLoading] = useState(true)
const [isUpdating, setIsUpdating] = useState(false)
const [isDownloading, setIsDownloading] = useState(false)
const [isSendingEmail, setIsSendingEmail] = useState(false)
useEffect(() => {
fetchInvoice()
}, [id])
async function fetchInvoice() {
setIsLoading(true)
const { data, error } = await supabase
.from('invoices')
.select(`
*,
customer:customers(*),
items:invoice_items(*)
`)
.eq('id', id)
.single()
if (error || !data) {
toast({
title: 'Fel',
description: 'Kunde inte hämta faktura',
variant: 'destructive',
})
router.push('/invoices')
return
}
// Sort items by sort_order
if (data.items) {
data.items.sort((a: InvoiceItem, b: InvoiceItem) => a.sort_order - b.sort_order)
}
setInvoice(data as InvoiceWithRelations)
// Fetch reminders for this invoice
const { data: reminderData } = await supabase
.from('invoice_reminders')
.select('*')
.eq('invoice_id', id)
.order('sent_at', { ascending: false })
if (reminderData) {
setReminders(reminderData as InvoiceReminder[])
}
// If this invoice is credited, find the credit note
if (data.status === 'credited') {
const { data: creditNoteData } = await supabase
.from('invoices')
.select('id, invoice_number')
.eq('credited_invoice_id', id)
.single()
if (creditNoteData) {
setCreditNote(creditNoteData as Invoice)
}
}
// If this is a credit note, fetch the original invoice
if (data.credited_invoice_id) {
const { data: originalData } = await supabase
.from('invoices')
.select('id, invoice_number')
.eq('id', data.credited_invoice_id)
.single()
if (originalData) {
setOriginalInvoice(originalData as Invoice)
}
}
setIsLoading(false)
}
async function updateStatus(status: InvoiceStatus) {
if (!invoice) return
setIsUpdating(true)
const updates: Partial<Invoice> = { status }
if (status === 'paid') {
updates.paid_at = new Date().toISOString()
updates.paid_amount = invoice.total
}
const { error } = await supabase
.from('invoices')
.update(updates)
.eq('id', invoice.id)
if (error) {
toast({
title: 'Fel',
description: 'Kunde inte uppdatera status',
variant: 'destructive',
})
} else {
toast({
title: 'Uppdaterad',
description: `Fakturan är nu markerad som ${statusConfig[status].label.toLowerCase()}`,
})
fetchInvoice()
}
setIsUpdating(false)
}
async function sendInvoiceEmail() {
if (!invoice) return
// Check if customer has email
if (!invoice.customer.email) {
toast({
title: 'E-post saknas',
description: 'Kunden saknar e-postadress. Uppdatera kunduppgifterna först.',
variant: 'destructive',
})
return
}
setIsSendingEmail(true)
try {
const response = await fetch(`/api/invoices/${invoice.id}/send`, {
method: 'POST',
})
const data = await response.json()
if (!response.ok) {
throw new Error(data.error || 'Kunde inte skicka fakturan')
}
toast({
title: 'Faktura skickad',
description: data.message,
})
// Refresh to get updated status
fetchInvoice()
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte skicka fakturan',
variant: 'destructive',
})
}
setIsSendingEmail(false)
}
async function downloadPDF() {
if (!invoice) return
setIsDownloading(true)
try {
const response = await fetch(`/api/invoices/${invoice.id}/pdf`)
if (!response.ok) {
throw new Error('Kunde inte generera PDF')
}
const blob = await response.blob()
const url = window.URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `faktura-${invoice.invoice_number}.pdf`
document.body.appendChild(a)
a.click()
window.URL.revokeObjectURL(url)
document.body.removeChild(a)
toast({
title: 'PDF nedladdad',
description: `Faktura ${invoice.invoice_number} har laddats ner`,
})
} catch (error) {
toast({
title: 'Fel',
description: error instanceof Error ? error.message : 'Kunde inte ladda ner PDF',
variant: 'destructive',
})
}
setIsDownloading(false)
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
<Loader2 className="h-8 w-8 animate-spin text-primary" />
</div>
)
}
if (!invoice) {
return null
}
const status = statusConfig[invoice.status]
const StatusIcon = status.icon
const customer = invoice.customer
const customerHasEmail = !!customer.email
return (
<div className="space-y-6">
{/* Header */}
<div className="flex items-start justify-between">
<div className="flex items-center gap-4">
<Button variant="ghost" size="icon" onClick={() => router.back()}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div>
<div className="flex items-center gap-3">
<h1 className="text-3xl font-bold tracking-tight">{invoice.invoice_number}</h1>
<Badge variant={status.variant as 'default' | 'secondary' | 'destructive'}>
<StatusIcon className="mr-1 h-3 w-3" />
{status.label}
</Badge>
</div>
<p className="text-muted-foreground">
Skapad {formatDate(invoice.created_at)}
{invoice.sent_at && ` • Skickad ${formatDate(invoice.sent_at)}`}
</p>
</div>
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{invoice.status === 'draft' && (
customerHasEmail ? (
<Button onClick={sendInvoiceEmail} disabled={isSendingEmail}>
{isSendingEmail ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
Skicka via e-post
</Button>
) : (
<Button onClick={() => updateStatus('sent')} disabled={isUpdating}>
<Send className="mr-2 h-4 w-4" />
Markera som skickad
</Button>
)
)}
{(invoice.status === 'sent' || invoice.status === 'overdue') && (
<Button onClick={() => updateStatus('paid')} disabled={isUpdating}>
<CheckCircle className="mr-2 h-4 w-4" />
Markera som betald
</Button>
)}
<Button variant="outline" onClick={downloadPDF} disabled={isDownloading}>
{isDownloading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Download className="mr-2 h-4 w-4" />
)}
Ladda ner PDF
</Button>
</div>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="lg:col-span-2 space-y-6">
{/* Customer info */}
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Building className="h-5 w-5" />
Kund
</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2">
<p className="font-medium text-lg">{customer.name}</p>
{customer.org_number && (
<p className="text-muted-foreground">Org.nr: {customer.org_number}</p>
)}
{customer.vat_number && (
<p className="text-muted-foreground">VAT: {customer.vat_number}</p>
)}
<div className="flex flex-wrap gap-4 pt-2">
{customer.email && (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Mail className="h-4 w-4" />
{customer.email}
</div>
)}
{customer.phone && (
<div className="flex items-center gap-1 text-sm text-muted-foreground">
<Phone className="h-4 w-4" />
{customer.phone}
</div>
)}
</div>
{(customer.address_line1 || customer.city) && (
<div className="flex items-start gap-1 text-sm text-muted-foreground pt-1">
<MapPin className="h-4 w-4 mt-0.5" />
<div>
{customer.address_line1 && <p>{customer.address_line1}</p>}
{customer.address_line2 && <p>{customer.address_line2}</p>}
<p>
{customer.postal_code} {customer.city}
{customer.country !== 'SE' && `, ${customer.country}`}
</p>
</div>
</div>
)}
</div>
</CardContent>
</Card>
{/* Invoice items */}
<Card>
<CardHeader>
<CardTitle>Fakturarader</CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-4">
{/* Header */}
<div className="grid grid-cols-12 gap-4 text-sm font-medium text-muted-foreground border-b pb-2">
<div className="col-span-5">Beskrivning</div>
<div className="col-span-2 text-right">Antal</div>
<div className="col-span-1 text-center">Enhet</div>
<div className="col-span-2 text-right">à-pris</div>
<div className="col-span-2 text-right">Summa</div>
</div>
{/* Items */}
{invoice.items.map((item) => (
<div key={item.id} className="grid grid-cols-12 gap-4 text-sm">
<div className="col-span-5">{item.description}</div>
<div className="col-span-2 text-right">{item.quantity}</div>
<div className="col-span-1 text-center">{item.unit}</div>
<div className="col-span-2 text-right">
{formatCurrency(item.unit_price, invoice.currency)}
</div>
<div className="col-span-2 text-right font-medium">
{formatCurrency(item.line_total, invoice.currency)}
</div>
</div>
))}
<Separator />
{/* Totals */}
<div className="space-y-2">
<div className="flex justify-between">
<span className="text-muted-foreground">Delsumma</span>
<span>{formatCurrency(invoice.subtotal, invoice.currency)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Moms ({invoice.vat_rate}%)</span>
<span>{formatCurrency(invoice.vat_amount, invoice.currency)}</span>
</div>
<Separator />
<div className="flex justify-between font-bold text-lg">
<span>Totalt</span>
<span>{formatCurrency(invoice.total, invoice.currency)}</span>
</div>
{invoice.currency !== 'SEK' && invoice.total_sek && (
<div className="flex justify-between text-sm text-muted-foreground">
<span>I SEK (kurs {invoice.exchange_rate})</span>
<span>{formatCurrency(invoice.total_sek)}</span>
</div>
)}
</div>
</div>
</CardContent>
</Card>
{/* Notes */}
{(invoice.notes || invoice.reverse_charge_text) && (
<Card>
<CardHeader>
<CardTitle>Anteckningar</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
{invoice.reverse_charge_text && (
<div className="p-3 bg-muted rounded-lg">
<p className="text-sm font-medium">Omvänd skattskyldighet</p>
<p className="text-sm text-muted-foreground">{invoice.reverse_charge_text}</p>
</div>
)}
{invoice.notes && <p className="text-sm">{invoice.notes}</p>}
</CardContent>
</Card>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Invoice details */}
<Card>
<CardHeader>
<CardTitle>Detaljer</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex justify-between">
<span className="text-muted-foreground">Fakturanummer</span>
<span className="font-medium">{invoice.invoice_number}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Fakturadatum</span>
<span>{formatDate(invoice.invoice_date)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Förfallodatum</span>
<span>{formatDate(invoice.due_date)}</span>
</div>
<Separator />
<div className="flex justify-between">
<span className="text-muted-foreground">Valuta</span>
<span>{invoice.currency}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Momsbehandling</span>
<span className="text-right text-sm">
{getVatTreatmentLabel(invoice.vat_treatment)}
</span>
</div>
{invoice.your_reference && (
<div className="flex justify-between">
<span className="text-muted-foreground">Er referens</span>
<span>{invoice.your_reference}</span>
</div>
)}
{invoice.our_reference && (
<div className="flex justify-between">
<span className="text-muted-foreground">Vår referens</span>
<span>{invoice.our_reference}</span>
</div>
)}
</CardContent>
</Card>
{/* Payment info */}
{invoice.status === 'paid' && invoice.paid_at && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2 text-success">
<CheckCircle className="h-5 w-5" />
Betald
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">
Betalning mottagen {formatDate(invoice.paid_at)}
</p>
{invoice.paid_amount && (
<p className="text-lg font-bold mt-2">
{formatCurrency(invoice.paid_amount, invoice.currency)}
</p>
)}
</CardContent>
</Card>
)}
{/* Reminders Card */}
{(invoice.status === 'sent' || invoice.status === 'overdue' || reminders.length > 0) && (
<Card>
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Bell className="h-5 w-5" />
Påminnelser
</CardTitle>
{reminders.length === 0 && (
<CardDescription>
Automatiska påminnelser skickas vid 15, 30 och 45 dagars förfallen betalning
</CardDescription>
)}
</CardHeader>
<CardContent>
{reminders.length > 0 ? (
<div className="space-y-3">
{reminders.map((reminder) => (
<div
key={reminder.id}
className="flex items-start justify-between p-3 bg-muted rounded-lg"
>
<div className="space-y-1">
<div className="flex items-center gap-2">
<Badge
variant={reminder.reminder_level === 3 ? 'destructive' : reminder.reminder_level === 2 ? 'default' : 'secondary'}
className="text-xs"
>
Nivå {reminder.reminder_level}
</Badge>
<span className="text-sm font-medium">
{reminderLevelLabels[reminder.reminder_level as 1 | 2 | 3]}
</span>
</div>
<p className="text-xs text-muted-foreground">
Skickad {formatDate(reminder.sent_at)} till {reminder.email_to}
</p>
{reminder.response_type && (
<div className="flex items-center gap-1 mt-1">
{reminder.response_type === 'marked_paid' ? (
<>
<CheckCircle className="h-3 w-3 text-green-600" />
<span className="text-xs text-green-600">Kunden markerat som betald</span>
</>
) : (
<>
<MessageSquare className="h-3 w-3 text-orange-600" />
<span className="text-xs text-orange-600">Kunden har invändningar</span>
</>
)}
</div>
)}
</div>
</div>
))}
</div>
) : (
<p className="text-sm text-muted-foreground">
Inga påminnelser har skickats ännu.
</p>
)}
</CardContent>
</Card>
)}
{/* Credit note reference (if this invoice was credited) */}
{invoice.status === 'credited' && creditNote && (
<Card className="border-warning/50">
<CardHeader>
<CardTitle className="flex items-center gap-2 text-warning">
<ReceiptText className="h-5 w-5" />
Krediterad
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-2">
Denna faktura har krediterats
</p>
<Link href={`/invoices/${creditNote.id}`}>
<Button variant="outline" size="sm" className="w-full">
<ExternalLink className="mr-2 h-4 w-4" />
Se kreditfaktura {creditNote.invoice_number}
</Button>
</Link>
</CardContent>
</Card>
)}
{/* Original invoice reference (if this is a credit note) */}
{invoice.credited_invoice_id && originalInvoice && (
<Card className="border-primary/50">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<ReceiptText className="h-5 w-5" />
Kreditfaktura
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground mb-2">
Denna kreditfaktura krediterar
</p>
<Link href={`/invoices/${originalInvoice.id}`}>
<Button variant="outline" size="sm" className="w-full">
<ExternalLink className="mr-2 h-4 w-4" />
Se faktura {originalInvoice.invoice_number}
</Button>
</Link>
</CardContent>
</Card>
)}
{/* Status actions */}
{invoice.status !== 'cancelled' && invoice.status !== 'credited' && !invoice.credited_invoice_id && (
<Card>
<CardHeader>
<CardTitle>Åtgärder</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{invoice.status === 'draft' && (
<>
{customerHasEmail ? (
<Button
className="w-full"
onClick={sendInvoiceEmail}
disabled={isSendingEmail}
>
{isSendingEmail ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<Mail className="mr-2 h-4 w-4" />
)}
Skicka via e-post
</Button>
) : (
<>
<div className="flex items-start gap-2 p-3 bg-yellow-50 border border-yellow-200 rounded-lg mb-2">
<AlertTriangle className="h-4 w-4 text-yellow-600 mt-0.5 flex-shrink-0" />
<p className="text-xs text-yellow-700">
Kunden saknar e-postadress. Lägg till e-post för att kunna skicka fakturan digitalt.
</p>
</div>
<Button
className="w-full"
onClick={() => updateStatus('sent')}
disabled={isUpdating}
>
<Send className="mr-2 h-4 w-4" />
Markera som skickad
</Button>
</>
)}
<Button
variant="outline"
className="w-full"
onClick={() => updateStatus('cancelled')}
disabled={isUpdating}
>
<XCircle className="mr-2 h-4 w-4" />
Makulera
</Button>
</>
)}
{(invoice.status === 'sent' || invoice.status === 'overdue') && (
<>
<Button
className="w-full"
onClick={() => updateStatus('paid')}
disabled={isUpdating}
>
<CheckCircle className="mr-2 h-4 w-4" />
Markera som betald
</Button>
<Link href={`/invoices/${invoice.id}/credit`} className="block">
<Button variant="outline" className="w-full">
<ReceiptText className="mr-2 h-4 w-4" />
Skapa kreditfaktura
</Button>
</Link>
<Button
variant="outline"
className="w-full"
onClick={() => updateStatus('cancelled')}
disabled={isUpdating}
>
<XCircle className="mr-2 h-4 w-4" />
Makulera
</Button>
</>
)}
{invoice.status === 'paid' && (
<Link href={`/invoices/${invoice.id}/credit`} className="block">
<Button variant="outline" className="w-full">
<ReceiptText className="mr-2 h-4 w-4" />
Skapa kreditfaktura
</Button>
</Link>
)}
</CardContent>
</Card>
)}
</div>
</div>
</div>
)
}