'use client' import React, { Fragment, useEffect, useState, useCallback } from 'react' import { Dialog, Transition } from '@headlessui/react' import { XMarkIcon, DocumentTextIcon, UserIcon, BanknotesIcon, CalendarDaysIcon, ArrowDownTrayIcon, ArrowPathIcon, CheckCircleIcon, ExclamationCircleIcon, ClockIcon, NoSymbolIcon, PencilSquareIcon, ShieldCheckIcon, } from '@heroicons/react/24/outline' import useAuthStore from '../../../store/authStore' /* ---------- types ---------- */ export type AdminInvoice = { id: string | number invoice_number?: string | null user_id?: string | number | null buyer_name?: string | null buyer_email?: string | null buyer_street?: string | null buyer_postal_code?: string | null buyer_city?: string | null buyer_country?: string | null currency?: string | null total_net?: number | null total_tax?: number | null total_gross?: number | null vat_rate?: number | null status?: string issued_at?: string | null due_at?: string | null pdf_storage_key?: string | null context?: any | null created_at?: string | null updated_at?: string | null } type InvoiceItem = { id?: number invoice_id?: number product_id?: number | null sku?: string | null description?: string | null quantity?: number unit_price?: number tax_rate?: number | null line_net?: number line_tax?: number line_gross?: number created_at?: string } type InvoicePayment = { id?: number invoice_id?: number payment_method?: string | null transaction_id?: string | null amount?: number | null paid_at?: string | null status?: string | null details?: string | null created_at?: string } interface InvoiceDetailModalProps { invoice: AdminInvoice | null open: boolean onClose: () => void onStatusChanged?: () => void onRunPoolCheck?: (invoiceId: string | number) => void onExport?: (invoice: AdminInvoice) => void } /* ---------- constants ---------- */ const STATUSES = ['draft', 'issued', 'paid', 'overdue', 'canceled'] as const type InvoiceStatus = (typeof STATUSES)[number] const STATUS_CONFIG: Record = { draft: { label: 'Draft', bg: 'bg-gray-100', text: 'text-gray-700', icon: PencilSquareIcon }, issued: { label: 'Issued', bg: 'bg-indigo-100', text: 'text-indigo-700', icon: DocumentTextIcon }, paid: { label: 'Paid', bg: 'bg-green-100', text: 'text-green-700', icon: CheckCircleIcon }, overdue: { label: 'Overdue', bg: 'bg-red-100', text: 'text-red-700', icon: ExclamationCircleIcon }, canceled: { label: 'Canceled', bg: 'bg-yellow-100', text: 'text-yellow-700', icon: NoSymbolIcon }, } function fmtDate(d?: string | null) { if (!d) return '—' return new Date(d).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' }) } function fmtDateTime(d?: string | null) { if (!d) return '—' return new Date(d).toLocaleString('de-DE', { year: 'numeric', month: 'short', day: '2-digit', hour: '2-digit', minute: '2-digit' }) } function fmtMoney(v?: number | null, currency = 'EUR') { return `€ ${Number(v ?? 0).toFixed(2)}` } /* ---------- component ---------- */ export default function InvoiceDetailModal({ invoice, open, onClose, onStatusChanged, onRunPoolCheck, onExport, }: InvoiceDetailModalProps) { const token = useAuthStore((s) => s.accessToken) // detail data const [items, setItems] = useState([]) const [payments, setPayments] = useState([]) const [detailLoading, setDetailLoading] = useState(false) const [detailError, setDetailError] = useState('') // status change const [changingStatus, setChangingStatus] = useState(false) const [statusMsg, setStatusMsg] = useState('') const [statusErr, setStatusErr] = useState('') const [currentStatus, setCurrentStatus] = useState(invoice?.status ?? 'draft') // keep current status in sync with prop useEffect(() => { if (invoice) setCurrentStatus(invoice.status ?? 'draft') }, [invoice]) // fetch detail (items + payments) when opened const fetchDetail = useCallback(async () => { if (!invoice?.id || !token) return setDetailLoading(true) setDetailError('') try { const base = process.env.NEXT_PUBLIC_API_BASE_URL || '' const res = await fetch(`${base}/api/admin/invoices/${encodeURIComponent(String(invoice.id))}/detail`, { method: 'GET', credentials: 'include', headers: { Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, }) const body = await res.json().catch(() => ({})) if (!res.ok || body?.success === false) { setDetailError(body?.message || `Failed to load details (${res.status})`) return } setItems(Array.isArray(body?.data?.items) ? body.data.items : []) setPayments(Array.isArray(body?.data?.payments) ? body.data.payments : []) } catch (e: any) { setDetailError(e?.message || 'Network error') } finally { setDetailLoading(false) } }, [invoice?.id, token]) useEffect(() => { if (open && invoice) { void fetchDetail() } else { setItems([]) setPayments([]) setDetailError('') setStatusMsg('') setStatusErr('') } }, [open, invoice, fetchDetail]) // change status async function handleStatusChange(newStatus: string) { if (!invoice?.id || !token || newStatus === currentStatus) return setChangingStatus(true) setStatusErr('') setStatusMsg('') try { const base = process.env.NEXT_PUBLIC_API_BASE_URL || '' const res = await fetch(`${base}/api/admin/invoices/${encodeURIComponent(String(invoice.id))}/status`, { method: 'PATCH', credentials: 'include', headers: { 'Content-Type': 'application/json', Accept: 'application/json', ...(token ? { Authorization: `Bearer ${token}` } : {}), }, body: JSON.stringify({ status: newStatus }), }) const body = await res.json().catch(() => ({})) if (!res.ok || body?.success === false) { setStatusErr(body?.message || `Failed to update status (${res.status})`) return } setCurrentStatus(newStatus) // Build status message including pool booking info let msg = `Status updated to "${newStatus}"` if (body?.poolResult) { const pr = body.poolResult if (pr.error) { msg += ` — Pool booking error: ${pr.error}` } else if (pr.inserted > 0) { msg += ` — ${pr.inserted} pool inflow(s) booked` } else if (pr.reason && pr.reason !== 'ok') { const reasonLabels: Record = { invalid_invoice_id: 'Invalid invoice ID', invoice_not_found: 'Invoice not found for pool booking', invoice_not_paid: 'Invoice not marked as paid', unsupported_source_type: 'Not a subscription invoice — no pool booking', missing_abonement_relation: 'No linked subscription — no pool booking', abonement_not_found: 'Linked subscription not found', no_breakdown_lines: 'Subscription has no capsule breakdown — no pool booking', no_active_system_pools: 'No active system pools found', } msg += ` — ${reasonLabels[pr.reason] || pr.reason}` } } setStatusMsg(msg) // Re-fetch detail to pick up any payment records (e.g. after marking paid) void fetchDetail() onStatusChanged?.() } catch (e: any) { setStatusErr(e?.message || 'Network error') } finally { setChangingStatus(false) } } if (!invoice) return null const statusConf = STATUS_CONFIG[(currentStatus as InvoiceStatus)] ?? STATUS_CONFIG.draft const StatusIcon = statusConf.icon as React.ComponentType> return ( {/* backdrop */}
{/* panel */}
{/* ─── header ─── */}
Invoice {invoice.invoice_number ?? `#${invoice.id}`}

Created {fmtDateTime(invoice.created_at)}

{/* ─── body ─── */}
{/* status badge + status switcher */}
{statusConf.label}
Change status: {STATUSES.map((s) => { const sc = STATUS_CONFIG[s] const active = s === currentStatus return ( ) })}
{/* status feedback */} {changingStatus && (
Updating status…
)} {statusMsg && (
{statusMsg}
)} {statusErr && (
{statusErr}
)} {/* ── two-column info cards ── */}
{/* Customer info */}
Customer
{/* Financial info */}
Financials
{/* Dates */}
Dates
{/* Line items */}
Line Items
{detailLoading ? (
) : detailError ? (
{detailError}
) : items.length === 0 ? (
No line items found.
) : (
{items.map((item, i) => ( ))}
Description Qty Unit Price Tax Gross
{item.description || '—'} {item.quantity ?? 0} {fmtMoney(item.unit_price)} {item.tax_rate != null ? `${item.tax_rate}%` : '—'} {fmtMoney(item.line_gross)}
Total {fmtMoney(invoice.total_gross)}
)}
{/* Payments */} {payments.length > 0 && (
Payments
{payments.map((p, i) => ( ))}
Method Transaction Amount Paid At Status
{p.payment_method ?? '—'} {p.transaction_id ?? '—'} {p.amount != null ? fmtMoney(p.amount) : '—'} {fmtDateTime(p.paid_at)} {p.status ?? '—'}
)} {/* Context (raw JSON if present) */} {invoice.context && (
Context / Metadata (click to expand)
                        {typeof invoice.context === 'string' ? invoice.context : JSON.stringify(invoice.context, null, 2)}
                      
)}
{/* ─── footer actions ─── */}
) } /* ---------- sub-components ---------- */ function InfoRow({ label, value, highlight = false }: { label: string; value?: string | null; highlight?: boolean }) { return (
{label} {value || '—'}
) } function DateChip({ label, value }: { label: string; value?: string | null }) { return (
{label}
{fmtDate(value)}
) }