profit-planet-frontend/src/app/admin/finance-management/components/InvoiceDetailModal.tsx
seaznCode de290cd9ef feat: enhance finance management and pool management features
- Added InvoiceDetailModal to display invoice details in a modal on the Finance Management page.
- Updated invoice amount display to show gross amount instead of net amount.
- Refactored invoice selection logic to open the detail modal.
- Removed unused subscription handling in Pool Management page.
- Simplified pool management UI by removing the create pool modal and related state management.
- Enhanced pool display with visual indicators for core pools and improved styling.
- Updated member display to show share instead of contributed amount in Pool Management.
2026-03-08 16:29:01 +01:00

536 lines
24 KiB
TypeScript

'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<InvoiceStatus, { label: string; bg: string; text: string; icon: React.ElementType }> = {
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<InvoiceItem[]>([])
const [payments, setPayments] = useState<InvoicePayment[]>([])
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<string>(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<string, string> = {
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<React.SVGProps<SVGSVGElement>>
return (
<Transition show={open} as={Fragment}>
<Dialog onClose={onClose} className="relative z-[1100]">
{/* backdrop */}
<Transition.Child
as={Fragment}
enter="transition-opacity ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="transition-opacity ease-in duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/40 backdrop-blur-sm" />
</Transition.Child>
{/* panel */}
<div className="fixed inset-0 overflow-y-auto">
<div className="flex min-h-full items-center justify-center p-4">
<Transition.Child
as={Fragment}
enter="transition-all ease-out duration-200"
enterFrom="opacity-0 translate-y-2 sm:scale-95"
enterTo="opacity-100 translate-y-0 sm:scale-100"
leave="transition-all ease-in duration-150"
leaveFrom="opacity-100 translate-y-0 sm:scale-100"
leaveTo="opacity-0 translate-y-2 sm:scale-95"
>
<Dialog.Panel className="w-full max-w-3xl rounded-2xl bg-white shadow-2xl ring-1 ring-black/5 overflow-hidden">
{/* ─── header ─── */}
<div className="bg-gradient-to-r from-[#1C2B4A] to-[#2d3f66] px-6 py-5 flex items-center justify-between">
<div className="flex items-center gap-3">
<div className="h-10 w-10 rounded-lg bg-white/10 flex items-center justify-center">
<DocumentTextIcon className="h-5 w-5 text-white" />
</div>
<div>
<Dialog.Title className="text-lg font-bold text-white">
Invoice {invoice.invoice_number ?? `#${invoice.id}`}
</Dialog.Title>
<p className="text-sm text-blue-200/80">
Created {fmtDateTime(invoice.created_at)}
</p>
</div>
</div>
<button
onClick={onClose}
className="rounded-lg p-1.5 text-blue-200 hover:text-white hover:bg-white/10 transition"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
{/* ─── body ─── */}
<div className="px-6 py-5 space-y-6 max-h-[75vh] overflow-y-auto">
{/* status badge + status switcher */}
<div className="flex flex-col sm:flex-row sm:items-center gap-4">
<div className="flex items-center gap-2">
<span className={`inline-flex items-center gap-1.5 rounded-full px-3 py-1 text-sm font-semibold ${statusConf.bg} ${statusConf.text}`}>
<StatusIcon className="h-4 w-4" />
{statusConf.label}
</span>
</div>
<div className="flex-1" />
<div className="flex items-center gap-2 flex-wrap">
<span className="text-xs text-gray-500 mr-1">Change status:</span>
{STATUSES.map((s) => {
const sc = STATUS_CONFIG[s]
const active = s === currentStatus
return (
<button
key={s}
disabled={active || changingStatus}
onClick={() => handleStatusChange(s)}
className={`rounded-lg px-3 py-1.5 text-xs font-medium border transition ${
active
? `${sc.bg} ${sc.text} border-transparent cursor-default`
: 'border-gray-200 text-gray-600 hover:bg-gray-50 disabled:opacity-40'
}`}
>
{sc.label}
</button>
)
})}
</div>
</div>
{/* status feedback */}
{changingStatus && (
<div className="rounded-lg bg-blue-50 border border-blue-100 px-3 py-2 text-sm text-blue-700 flex items-center gap-2">
<ArrowPathIcon className="h-4 w-4 animate-spin" /> Updating status
</div>
)}
{statusMsg && (
<div className="rounded-lg bg-green-50 border border-green-200 px-3 py-2 text-sm text-green-700 flex items-center gap-2">
<CheckCircleIcon className="h-4 w-4" /> {statusMsg}
</div>
)}
{statusErr && (
<div className="rounded-lg bg-red-50 border border-red-200 px-3 py-2 text-sm text-red-700 flex items-center gap-2">
<ExclamationCircleIcon className="h-4 w-4" /> {statusErr}
</div>
)}
{/* ── two-column info cards ── */}
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* Customer info */}
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4 space-y-2">
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-1">
<UserIcon className="h-4 w-4" /> Customer
</div>
<InfoRow label="Name" value={invoice.buyer_name} />
<InfoRow label="Email" value={invoice.buyer_email} />
<InfoRow label="Street" value={invoice.buyer_street} />
<InfoRow label="City" value={[invoice.buyer_postal_code, invoice.buyer_city].filter(Boolean).join(' ')} />
<InfoRow label="Country" value={invoice.buyer_country} />
<InfoRow label="User ID" value={invoice.user_id != null ? String(invoice.user_id) : null} />
</div>
{/* Financial info */}
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4 space-y-2">
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-1">
<BanknotesIcon className="h-4 w-4" /> Financials
</div>
<InfoRow label="Net" value={fmtMoney(invoice.total_net, invoice.currency ?? 'EUR')} />
<InfoRow label="Tax" value={fmtMoney(invoice.total_tax, invoice.currency ?? 'EUR')} />
<InfoRow label="Gross" value={fmtMoney(invoice.total_gross, invoice.currency ?? 'EUR')} highlight />
<InfoRow label="VAT Rate" value={invoice.vat_rate != null ? `${invoice.vat_rate}%` : '—'} />
<InfoRow label="Currency" value={invoice.currency ?? 'EUR'} />
</div>
</div>
{/* Dates */}
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-2">
<CalendarDaysIcon className="h-4 w-4" /> Dates
</div>
<div className="grid grid-cols-2 md:grid-cols-4 gap-3 text-sm">
<DateChip label="Issued" value={invoice.issued_at} />
<DateChip label="Due" value={invoice.due_at} />
<DateChip label="Created" value={invoice.created_at} />
<DateChip label="Updated" value={invoice.updated_at} />
</div>
</div>
{/* Line items */}
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-3">
<DocumentTextIcon className="h-4 w-4" /> Line Items
</div>
{detailLoading ? (
<div className="space-y-2">
<div className="h-4 w-3/4 bg-gray-200 animate-pulse rounded" />
<div className="h-4 w-1/2 bg-gray-200 animate-pulse rounded" />
</div>
) : detailError ? (
<div className="text-sm text-red-600">{detailError}</div>
) : items.length === 0 ? (
<div className="text-sm text-gray-500">No line items found.</div>
) : (
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase text-gray-500 border-b border-gray-200">
<th className="pb-2 pr-4 font-medium">Description</th>
<th className="pb-2 pr-4 font-medium">Qty</th>
<th className="pb-2 pr-4 font-medium">Unit Price</th>
<th className="pb-2 pr-4 font-medium">Tax</th>
<th className="pb-2 pr-4 font-medium text-right">Gross</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{items.map((item, i) => (
<tr key={item.id ?? i}>
<td className="py-2 pr-4 text-gray-800">{item.description || '—'}</td>
<td className="py-2 pr-4 text-gray-600">{item.quantity ?? 0}</td>
<td className="py-2 pr-4 text-gray-600">{fmtMoney(item.unit_price)}</td>
<td className="py-2 pr-4 text-gray-600">{item.tax_rate != null ? `${item.tax_rate}%` : '—'}</td>
<td className="py-2 pr-4 text-right font-medium text-gray-800">{fmtMoney(item.line_gross)}</td>
</tr>
))}
</tbody>
<tfoot>
<tr className="border-t border-gray-200">
<td colSpan={4} className="pt-2 text-right font-semibold text-gray-700">Total</td>
<td className="pt-2 text-right font-bold text-[#1C2B4A]">{fmtMoney(invoice.total_gross)}</td>
</tr>
</tfoot>
</table>
</div>
)}
</div>
{/* Payments */}
{payments.length > 0 && (
<div className="rounded-xl border border-gray-100 bg-gray-50/50 p-4">
<div className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] mb-3">
<ShieldCheckIcon className="h-4 w-4" /> Payments
</div>
<div className="overflow-x-auto">
<table className="min-w-full text-sm">
<thead>
<tr className="text-left text-xs uppercase text-gray-500 border-b border-gray-200">
<th className="pb-2 pr-4 font-medium">Method</th>
<th className="pb-2 pr-4 font-medium">Transaction</th>
<th className="pb-2 pr-4 font-medium">Amount</th>
<th className="pb-2 pr-4 font-medium">Paid At</th>
<th className="pb-2 pr-4 font-medium">Status</th>
</tr>
</thead>
<tbody className="divide-y divide-gray-100">
{payments.map((p, i) => (
<tr key={p.id ?? i}>
<td className="py-2 pr-4 text-gray-700">{p.payment_method ?? '—'}</td>
<td className="py-2 pr-4 text-gray-600 font-mono text-xs">{p.transaction_id ?? '—'}</td>
<td className="py-2 pr-4 text-gray-700">{p.amount != null ? fmtMoney(p.amount) : '—'}</td>
<td className="py-2 pr-4 text-gray-600">{fmtDateTime(p.paid_at)}</td>
<td className="py-2 pr-4">
<span className="inline-flex rounded-full px-2 py-0.5 text-xs font-medium bg-green-50 text-green-700">
{p.status ?? '—'}
</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
{/* Context (raw JSON if present) */}
{invoice.context && (
<details className="rounded-xl border border-gray-100 bg-gray-50/50 p-4 group">
<summary className="flex items-center gap-2 text-sm font-semibold text-[#1C2B4A] cursor-pointer select-none">
<ClockIcon className="h-4 w-4" /> Context / Metadata
<span className="text-xs font-normal text-gray-400 ml-1">(click to expand)</span>
</summary>
<pre className="mt-3 text-xs text-gray-600 overflow-x-auto whitespace-pre-wrap break-all max-h-48">
{typeof invoice.context === 'string' ? invoice.context : JSON.stringify(invoice.context, null, 2)}
</pre>
</details>
)}
</div>
{/* ─── footer actions ─── */}
<div className="bg-gray-50 border-t border-gray-100 px-6 py-4 flex items-center justify-between gap-3">
<div className="flex items-center gap-2">
<button
onClick={() => onExport?.(invoice)}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
>
<ArrowDownTrayIcon className="h-4 w-4" /> Export JSON
</button>
<button
onClick={() => onRunPoolCheck?.(invoice.id)}
className="inline-flex items-center gap-1.5 rounded-lg border border-gray-200 bg-white px-3 py-2 text-sm font-medium text-gray-700 hover:bg-gray-50 transition"
>
<ArrowPathIcon className="h-4 w-4" /> Pool Check
</button>
</div>
<button
onClick={onClose}
className="inline-flex items-center rounded-lg bg-[#1C2B4A] px-4 py-2 text-sm font-semibold text-white hover:bg-[#1C2B4A]/90 transition"
>
Close
</button>
</div>
</Dialog.Panel>
</Transition.Child>
</div>
</div>
</Dialog>
</Transition>
)
}
/* ---------- sub-components ---------- */
function InfoRow({ label, value, highlight = false }: { label: string; value?: string | null; highlight?: boolean }) {
return (
<div className="flex items-baseline justify-between gap-2 text-sm">
<span className="text-gray-500 shrink-0">{label}</span>
<span className={`text-right truncate ${highlight ? 'font-semibold text-[#1C2B4A]' : 'text-gray-800'}`}>
{value || '—'}
</span>
</div>
)
}
function DateChip({ label, value }: { label: string; value?: string | null }) {
return (
<div>
<div className="text-xs text-gray-500">{label}</div>
<div className="text-sm text-gray-800 font-medium">{fmtDate(value)}</div>
</div>
)
}