- 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.
536 lines
24 KiB
TypeScript
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>
|
|
)
|
|
}
|