Delete invoice feature (#64)

* I mplement delete API for draft invoices with ownership verification

* Color change dark mode
This commit is contained in:
Mattsson
2026-03-20 14:42:43 +01:00
committed by GitHub
parent 0ba1db1c21
commit db528c3652
3 changed files with 131 additions and 3 deletions
+66 -2
View File
@@ -32,6 +32,14 @@ import {
Trash2,
} from 'lucide-react'
import PaymentBookingDialog from '@/components/invoices/PaymentBookingDialog'
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog'
import type { Invoice, InvoiceItem, Customer, InvoiceStatus, InvoiceReminder, InvoiceDocumentType } from '@/types'
const statusConfig: Record<InvoiceStatus, { label: string; variant: 'default' | 'secondary' | 'success' | 'warning' | 'destructive'; icon: React.ElementType }> = {
@@ -72,6 +80,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
const [isUpdating, setIsUpdating] = useState(false)
const [isDownloading, setIsDownloading] = useState(false)
const [isSendingEmail, setIsSendingEmail] = useState(false)
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isDeleting, setIsDeleting] = useState(false)
useEffect(() => {
fetchInvoice()
@@ -326,6 +336,39 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
setIsDownloading(false)
}
async function deleteInvoice() {
if (!invoice) return
setIsDeleting(true)
try {
const response = await fetch(`/api/invoices/${invoice.id}`, {
method: 'DELETE',
})
if (!response.ok) {
const data = await response.json()
throw new Error(data.error || 'Kunde inte ta bort fakturan')
}
toast({
title: 'Faktura borttagen',
description: `Utkast ${invoice.invoice_number} har tagits bort`,
})
router.push('/invoices')
} catch (error) {
toast({
title: 'Kunde inte ta bort fakturan',
description: error instanceof Error ? error.message : 'Försök igen.',
variant: 'destructive',
})
}
setIsDeleting(false)
setShowDeleteDialog(false)
}
if (isLoading) {
return (
<div className="flex items-center justify-center h-64">
@@ -885,8 +928,8 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
<Button
variant="outline"
className="w-full text-destructive hover:text-destructive"
onClick={() => updateStatus('cancelled')}
disabled={isUpdating}
onClick={() => setShowDeleteDialog(true)}
disabled={isDeleting}
>
<Trash2 className="mr-2 h-4 w-4" />
Ta bort utkast
@@ -925,6 +968,27 @@ export default function InvoiceDetailPage({ params }: { params: Promise<{ id: st
</div>
</div>
{/* Delete confirmation dialog */}
<Dialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Ta bort fakturautkast</DialogTitle>
<DialogDescription>
Är du säker att du vill ta bort utkast {invoice.invoice_number}? Detta kan inte ångras.
</DialogDescription>
</DialogHeader>
<DialogFooter>
<Button variant="outline" onClick={() => setShowDeleteDialog(false)} disabled={isDeleting}>
Avbryt
</Button>
<Button variant="destructive" onClick={deleteInvoice} disabled={isDeleting}>
{isDeleting && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Ta bort
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
<PaymentBookingDialog
open={showPaymentDialog}
onOpenChange={setShowPaymentDialog}
+64
View File
@@ -0,0 +1,64 @@
import { createClient } from '@/lib/supabase/server'
import { NextResponse } from 'next/server'
/**
* DELETE /api/invoices/[id]
*
* Permanently deletes a draft invoice and its items.
* Only invoices with status 'draft' can be deleted — committed invoices
* are immutable per BFL and must be reversed via credit note instead.
*/
export async function DELETE(
request: Request,
{ params }: { params: Promise<{ id: string }> }
) {
const { id } = await params
const supabase = await createClient()
const { data: { user } } = await supabase.auth.getUser()
if (!user) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
// Fetch invoice to verify ownership and status
const { data: invoice, error: fetchError } = await supabase
.from('invoices')
.select('id, status, user_id')
.eq('id', id)
.eq('user_id', user.id)
.single()
if (fetchError || !invoice) {
return NextResponse.json({ error: 'Invoice not found' }, { status: 404 })
}
if (invoice.status !== 'draft') {
return NextResponse.json(
{ error: 'Endast utkast kan tas bort. Bokförda fakturor måste krediteras istället.' },
{ status: 400 }
)
}
// Delete items first (FK constraint), then the invoice
const { error: itemsError } = await supabase
.from('invoice_items')
.delete()
.eq('invoice_id', id)
if (itemsError) {
return NextResponse.json({ error: itemsError.message }, { status: 500 })
}
const { error: deleteError } = await supabase
.from('invoices')
.delete()
.eq('id', id)
.eq('user_id', user.id)
if (deleteError) {
return NextResponse.json({ error: deleteError.message }, { status: 500 })
}
return NextResponse.json({ data: { deleted: true } })
}
+1 -1
View File
@@ -105,7 +105,7 @@
--success-foreground: 0 0% 100%;
--warning: 38 50% 55%;
--warning-foreground: 0 0% 9%;
--warning-foreground: 38 50% 90%;
--warm-accent: 38 42% 58%;