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.
This commit is contained in:
parent
97596720f5
commit
de290cd9ef
@ -0,0 +1,535 @@
|
||||
'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>
|
||||
)
|
||||
}
|
||||
@ -3,8 +3,9 @@ import React, { useMemo, useState } from 'react'
|
||||
import PageLayout from '../../components/PageLayout'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { useVatRates } from './hooks/getTaxes'
|
||||
import { useAdminInvoices } from './hooks/getInvoices'
|
||||
import { useAdminInvoices, type AdminInvoice } from './hooks/getInvoices'
|
||||
import useAuthStore from '../../store/authStore'
|
||||
import InvoiceDetailModal from './components/InvoiceDetailModal'
|
||||
|
||||
export default function FinanceManagementPage() {
|
||||
const router = useRouter()
|
||||
@ -16,6 +17,7 @@ export default function FinanceManagementPage() {
|
||||
const [diagError, setDiagError] = useState('')
|
||||
const [diagData, setDiagData] = useState<any | null>(null)
|
||||
const [selectedInvoice, setSelectedInvoice] = useState<AdminInvoice | null>(null)
|
||||
const [detailModalOpen, setDetailModalOpen] = useState(false)
|
||||
|
||||
// NEW: fetch invoices from backend
|
||||
const {
|
||||
@ -248,7 +250,7 @@ export default function FinanceManagementPage() {
|
||||
<th className="pr-3 py-1">Pool</th>
|
||||
<th className="pr-3 py-1">Coffee</th>
|
||||
<th className="pr-3 py-1">Capsules</th>
|
||||
<th className="pr-3 py-1">Net Amount</th>
|
||||
<th className="pr-3 py-1">Amount (gross)</th>
|
||||
<th className="pr-3 py-1">Booked</th>
|
||||
</tr>
|
||||
</thead>
|
||||
@ -258,7 +260,7 @@ export default function FinanceManagementPage() {
|
||||
<td className="pr-3 py-1">{c.pool_name}</td>
|
||||
<td className="pr-3 py-1">#{c.coffee_table_id}</td>
|
||||
<td className="pr-3 py-1">{c.capsules_count}</td>
|
||||
<td className="pr-3 py-1">€{Number(c.amount_net ?? 0).toFixed(2)}</td>
|
||||
<td className="pr-3 py-1">€{Number(c.amount_gross ?? c.amount_net ?? 0).toFixed(2)}</td>
|
||||
<td className="pr-3 py-1">{c.already_booked ? 'yes' : 'no'}</td>
|
||||
</tr>
|
||||
))}
|
||||
@ -322,23 +324,11 @@ export default function FinanceManagementPage() {
|
||||
</td>
|
||||
<td className="px-3 py-2 space-x-2">
|
||||
<button
|
||||
onClick={() => setSelectedInvoice(inv)}
|
||||
onClick={() => { setSelectedInvoice(inv); setDetailModalOpen(true) }}
|
||||
className="text-xs rounded border px-2 py-1 hover:bg-gray-50"
|
||||
>
|
||||
View
|
||||
</button>
|
||||
<button
|
||||
onClick={() => exportInvoice(inv)}
|
||||
className="text-xs rounded border px-2 py-1 hover:bg-gray-50"
|
||||
>
|
||||
Export
|
||||
</button>
|
||||
<button
|
||||
onClick={() => runPoolCheck(inv.id)}
|
||||
className="text-xs rounded border px-2 py-1 hover:bg-gray-50"
|
||||
>
|
||||
Pool check
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
@ -348,22 +338,14 @@ export default function FinanceManagementPage() {
|
||||
</div>
|
||||
|
||||
{selectedInvoice && (
|
||||
<div className="mt-4 rounded-md border border-gray-200 bg-gray-50 p-3">
|
||||
<div className="flex items-center justify-between mb-2">
|
||||
<div className="text-sm font-semibold text-[#1C2B4A]">
|
||||
Invoice details: {selectedInvoice.invoice_number ?? selectedInvoice.id}
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setSelectedInvoice(null)}
|
||||
className="text-xs rounded border px-2 py-1 hover:bg-white"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<pre className="text-xs overflow-x-auto whitespace-pre-wrap break-all">
|
||||
{JSON.stringify(selectedInvoice, null, 2)}
|
||||
</pre>
|
||||
</div>
|
||||
<InvoiceDetailModal
|
||||
invoice={selectedInvoice}
|
||||
open={detailModalOpen}
|
||||
onClose={() => { setDetailModalOpen(false); setTimeout(() => setSelectedInvoice(null), 200) }}
|
||||
onStatusChanged={reload}
|
||||
onRunPoolCheck={(id) => { setDetailModalOpen(false); runPoolCheck(id) }}
|
||||
onExport={(inv) => exportInvoice(inv)}
|
||||
/>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
|
||||
@ -8,15 +8,14 @@ import { useRouter, useSearchParams } from 'next/navigation'
|
||||
import useAuthStore from '../../../store/authStore'
|
||||
import PageTransitionEffect from '../../../components/animation/pageTransitionEffect'
|
||||
import { AdminAPI } from '../../../utils/api'
|
||||
import { authFetch } from '../../../utils/authFetch'
|
||||
import ConfirmActionModal from '../../../components/modals/ConfirmActionModal'
|
||||
|
||||
type PoolUser = {
|
||||
id: string
|
||||
name: string
|
||||
email: string
|
||||
contributed: number
|
||||
joinedAt: string // NEW: member since
|
||||
share: number
|
||||
joinedAt: string
|
||||
}
|
||||
|
||||
function PoolManagePageInner() {
|
||||
@ -52,8 +51,6 @@ function PoolManagePageInner() {
|
||||
const poolName = searchParams.get('pool_name') ?? 'Unnamed Pool'
|
||||
const poolDescription = searchParams.get('description') ?? ''
|
||||
const poolPrice = parseFloat(searchParams.get('price') ?? '0')
|
||||
const initialSubscriptionId = searchParams.get('subscription_coffee_id') ?? ''
|
||||
const subscriptionTitle = searchParams.get('subscription_title') ?? ''
|
||||
const poolType = searchParams.get('pool_type') as 'coffee' | 'other' || 'other'
|
||||
const poolIsActive = searchParams.get('is_active') === 'true'
|
||||
const poolCreatedAt = searchParams.get('createdAt') ?? new Date().toISOString()
|
||||
@ -80,12 +77,6 @@ function PoolManagePageInner() {
|
||||
const [removingMemberId, setRemovingMemberId] = React.useState<string | null>(null)
|
||||
const [removeError, setRemoveError] = React.useState<string>('')
|
||||
const [removeConfirm, setRemoveConfirm] = React.useState<{ userId: string; label: string } | null>(null)
|
||||
const [subscriptions, setSubscriptions] = React.useState<Array<{ id: number; title: string }>>([])
|
||||
const [linkedSubscriptionId, setLinkedSubscriptionId] = React.useState<string>(initialSubscriptionId)
|
||||
const [savingSubscription, setSavingSubscription] = React.useState(false)
|
||||
const [subscriptionMessage, setSubscriptionMessage] = React.useState('')
|
||||
const [subscriptionError, setSubscriptionError] = React.useState('')
|
||||
const [currentSubscriptionTitle, setCurrentSubscriptionTitle] = React.useState(subscriptionTitle)
|
||||
|
||||
async function fetchMembers() {
|
||||
if (!token || !poolId || poolId === 'pool-unknown') return
|
||||
@ -102,7 +93,7 @@ function PoolManagePageInner() {
|
||||
id: String(row.id),
|
||||
name: name || String(row.email || '').trim() || 'Unnamed user',
|
||||
email: String(row.email || '').trim(),
|
||||
contributed: 0,
|
||||
share: Number(row.share ?? 0),
|
||||
joinedAt: row.joined_at || new Date().toISOString()
|
||||
}
|
||||
})
|
||||
@ -118,12 +109,14 @@ function PoolManagePageInner() {
|
||||
void fetchMembers()
|
||||
}, [token, poolId])
|
||||
|
||||
// Fetch pool inflow stats
|
||||
React.useEffect(() => {
|
||||
if (!token || !poolId || poolId === 'pool-unknown') return
|
||||
let cancelled = false
|
||||
async function loadSubscriptions() {
|
||||
async function loadStats() {
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||
const response = await authFetch(`${base}/api/admin/coffee`, {
|
||||
const res = await fetch(`${base}/api/admin/pools/${encodeURIComponent(poolId)}/stats`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
@ -131,62 +124,19 @@ function PoolManagePageInner() {
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
})
|
||||
if (!response.ok) {
|
||||
if (!cancelled) setSubscriptions([])
|
||||
return
|
||||
const body = await res.json().catch(() => ({}))
|
||||
if (!cancelled && res.ok && body?.success) {
|
||||
setTotalAmount(Number(body.data?.total_amount ?? 0))
|
||||
setAmountThisYear(Number(body.data?.amount_this_year ?? 0))
|
||||
setAmountThisMonth(Number(body.data?.amount_this_month ?? 0))
|
||||
}
|
||||
const rows = await response.json().catch(() => [])
|
||||
const mapped = Array.isArray(rows)
|
||||
? rows
|
||||
.map((r: any) => ({ id: Number(r?.id), title: String(r?.title || '').trim() }))
|
||||
.filter((r: { id: number; title: string }) => Number.isFinite(r.id) && r.id > 0 && !!r.title)
|
||||
: []
|
||||
if (!cancelled) setSubscriptions(mapped)
|
||||
} catch {
|
||||
if (!cancelled) setSubscriptions([])
|
||||
}
|
||||
}
|
||||
void loadSubscriptions()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [token])
|
||||
|
||||
async function saveLinkedSubscription() {
|
||||
if (!poolId || poolId === 'pool-unknown') return
|
||||
setSavingSubscription(true)
|
||||
setSubscriptionError('')
|
||||
setSubscriptionMessage('')
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||
const payload = {
|
||||
subscription_coffee_id: linkedSubscriptionId ? Number(linkedSubscriptionId) : null,
|
||||
}
|
||||
const response = await authFetch(`${base}/api/admin/pools/${encodeURIComponent(String(poolId))}/subscription`, {
|
||||
method: 'PATCH',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
})
|
||||
|
||||
const body = await response.json().catch(() => ({}))
|
||||
if (!response.ok || body?.success === false) {
|
||||
setSubscriptionError(body?.message || `Failed to update (${response.status})`)
|
||||
return
|
||||
}
|
||||
const selectedTitle = subscriptions.find(s => String(s.id) === String(linkedSubscriptionId))?.title || ''
|
||||
setCurrentSubscriptionTitle(selectedTitle)
|
||||
setSubscriptionMessage(`Linked subscription updated${selectedTitle ? `: ${selectedTitle}` : ' (not linked)'}.`)
|
||||
} catch (e: any) {
|
||||
setSubscriptionError(e?.message || 'Failed to update linked subscription.')
|
||||
} finally {
|
||||
setSavingSubscription(false)
|
||||
// ignore — stats are non-critical
|
||||
}
|
||||
}
|
||||
void loadStats()
|
||||
return () => { cancelled = true }
|
||||
}, [token, poolId])
|
||||
|
||||
// Early return AFTER all hooks are declared to keep consistent order
|
||||
if (!authChecked) return null
|
||||
@ -316,34 +266,44 @@ function PoolManagePageInner() {
|
||||
}
|
||||
}
|
||||
|
||||
const isCore = poolName === 'Core'
|
||||
|
||||
return (
|
||||
<PageTransitionEffect>
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-tr from-blue-50 via-white to-blue-100">
|
||||
<div className={`min-h-screen flex flex-col ${isCore ? 'bg-gradient-to-tr from-amber-50 via-white to-amber-100' : 'bg-gradient-to-tr from-blue-50 via-white to-blue-100'}`}>
|
||||
<Header />
|
||||
{/* main wrapper: avoid high z-index stacking */}
|
||||
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8 relative z-0">
|
||||
<div className="max-w-7xl mx-auto relative z-0">
|
||||
{/* Header (remove sticky/z-10) */}
|
||||
<header className="bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-3 mb-8 relative z-0">
|
||||
<header className={`backdrop-blur border-b py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-3 mb-8 relative z-0 ${
|
||||
isCore ? 'bg-gradient-to-r from-amber-50/90 to-white/90 border-amber-200' : 'bg-white/90 border-blue-100'
|
||||
}`}>
|
||||
{isCore && (
|
||||
<div className="inline-flex items-center gap-1.5 self-start rounded-full bg-amber-500 px-3 py-1 text-xs font-bold text-white uppercase tracking-wider shadow-sm mb-2">
|
||||
<svg className="h-3.5 w-3.5" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.957a1 1 0 00.95.69h4.162c.969 0 1.371 1.24.588 1.81l-3.37 2.448a1 1 0 00-.364 1.118l1.287 3.957c.3.921-.755 1.688-1.54 1.118l-3.37-2.448a1 1 0 00-1.176 0l-3.37 2.448c-.784.57-1.838-.197-1.539-1.118l1.287-3.957a1 1 0 00-.364-1.118L2.063 9.384c-.783-.57-.38-1.81.588-1.81h4.162a1 1 0 00.95-.69l1.286-3.957z" /></svg>
|
||||
Core Pool — 1¢ per capsule per member
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-10 w-10 rounded-lg bg-blue-50 border border-blue-200 flex items-center justify-center">
|
||||
<UsersIcon className="h-5 w-5 text-blue-900" />
|
||||
<div className={`h-10 w-10 rounded-lg border flex items-center justify-center ${
|
||||
isCore ? 'bg-amber-100 border-amber-300' : 'bg-blue-50 border-blue-200'
|
||||
}`}>
|
||||
<UsersIcon className={`h-5 w-5 ${isCore ? 'text-amber-700' : 'text-blue-900'}`} />
|
||||
</div>
|
||||
<div>
|
||||
<h1 className="text-3xl font-extrabold text-blue-900 tracking-tight">{poolName}</h1>
|
||||
<p className="text-sm text-blue-700">
|
||||
<h1 className={`text-3xl font-extrabold tracking-tight ${isCore ? 'text-amber-900' : 'text-blue-900'}`}>{poolName}</h1>
|
||||
<p className={`text-sm ${isCore ? 'text-amber-700' : 'text-blue-700'}`}>
|
||||
{poolDescription ? poolDescription : 'Manage users and track pool funds'}
|
||||
</p>
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-gray-600">
|
||||
<div className="mt-1 flex items-center gap-2 text-xs text-gray-600 flex-wrap">
|
||||
<span className={`inline-flex items-center rounded-full px-2 py-0.5 font-medium ${!poolIsActive ? 'bg-gray-100 text-gray-700' : 'bg-green-100 text-green-800'}`}>
|
||||
<span className={`mr-1.5 h-1.5 w-1.5 rounded-full ${!poolIsActive ? 'bg-gray-400' : 'bg-green-500'}`} />
|
||||
{!poolIsActive ? 'Inactive' : 'Active'}
|
||||
</span>
|
||||
<span>•</span>
|
||||
<span>Price/capsule (net): € {Number(poolPrice || 0).toFixed(2)}</span>
|
||||
<span>•</span>
|
||||
<span>Subscription: {currentSubscriptionTitle || 'Not linked'}</span>
|
||||
<span>Price/capsule (gross): € {Number(poolPrice || 0).toFixed(2)}{isCore ? ' × each member' : ''}</span>
|
||||
<span>•</span>
|
||||
<span>Created {new Date(poolCreatedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}</span>
|
||||
<span>•</span>
|
||||
@ -362,39 +322,6 @@ function PoolManagePageInner() {
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6 mb-8 relative z-0">
|
||||
<h2 className="text-lg font-semibold text-blue-900 mb-3">Linked Subscription</h2>
|
||||
<div className="grid grid-cols-1 md:grid-cols-[1fr_auto] gap-3 items-end">
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-blue-900 mb-1">Subscription</label>
|
||||
<select
|
||||
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm bg-gray-50 text-gray-900 focus:ring-2 focus:ring-blue-900 focus:border-transparent"
|
||||
value={linkedSubscriptionId}
|
||||
onChange={e => setLinkedSubscriptionId(e.target.value)}
|
||||
>
|
||||
<option value="">No subscription linked</option>
|
||||
{subscriptions.map((s) => (
|
||||
<option key={s.id} value={String(s.id)}>{s.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<p className="mt-1 text-xs text-gray-500">Current: {currentSubscriptionTitle || 'Not linked'}</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={saveLinkedSubscription}
|
||||
disabled={savingSubscription}
|
||||
className="inline-flex items-center justify-center rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow transition disabled:opacity-60"
|
||||
>
|
||||
{savingSubscription ? 'Saving…' : 'Save Link'}
|
||||
</button>
|
||||
</div>
|
||||
{subscriptionMessage && (
|
||||
<div className="mt-3 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">{subscriptionMessage}</div>
|
||||
)}
|
||||
{subscriptionError && (
|
||||
<div className="mt-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">{subscriptionError}</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Stats (now zero until backend wired) */}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-3 gap-6 mb-8 relative z-0">
|
||||
<div className="relative overflow-hidden rounded-2xl bg-white px-6 py-5 shadow-lg border border-gray-100">
|
||||
@ -435,7 +362,12 @@ function PoolManagePageInner() {
|
||||
{/* Unified Members card: add button + list */}
|
||||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6 relative z-0">
|
||||
<div className="flex items-center justify-between mb-4">
|
||||
<div className="flex items-center gap-3">
|
||||
<h2 className="text-lg font-semibold text-blue-900">Members</h2>
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 px-2.5 py-0.5 text-xs font-medium text-gray-700">
|
||||
{users.length}
|
||||
</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setSearchOpen(true); setQuery(''); setCandidates([]); setHasSearched(false); setError(''); }}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow transition"
|
||||
@ -449,56 +381,68 @@ function PoolManagePageInner() {
|
||||
{removeError}
|
||||
</div>
|
||||
)}
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
|
||||
{membersLoading && (
|
||||
<div className="text-center text-gray-500 italic py-8">Loading members...</div>
|
||||
)}
|
||||
{membersError && !membersLoading && (
|
||||
<div className="text-center text-red-600 py-8">{membersError}</div>
|
||||
)}
|
||||
{users.length === 0 && !membersLoading && !membersError && (
|
||||
<div className="text-center text-gray-500 italic py-8">No users in this pool yet.</div>
|
||||
)}
|
||||
|
||||
{users.length > 0 && !membersLoading && (
|
||||
<div className="overflow-x-auto rounded-lg border border-gray-200">
|
||||
<table className="min-w-full divide-y divide-gray-200 text-sm">
|
||||
<thead className="bg-gray-50">
|
||||
<tr>
|
||||
<th className="px-4 py-3 text-left font-semibold text-gray-700">Name</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-gray-700">Email</th>
|
||||
<th className="px-4 py-3 text-left font-semibold text-gray-700">Member Since</th>
|
||||
<th className="px-4 py-3 text-right font-semibold text-gray-700">{isCore ? 'Total Earned' : 'Share'}</th>
|
||||
<th className="px-4 py-3 text-right font-semibold text-gray-700" />
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-gray-100 bg-white">
|
||||
{users.map(u => (
|
||||
<article key={u.id} className="rounded-2xl bg-white border border-gray-100 shadow p-5 flex flex-col">
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-lg bg-blue-50 border border-blue-200 flex items-center justify-center">
|
||||
<UsersIcon className="h-5 w-5 text-blue-900" />
|
||||
<tr key={u.id} className="hover:bg-gray-50 transition">
|
||||
<td className="px-4 py-3 whitespace-nowrap">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="h-7 w-7 rounded-full bg-blue-100 border border-blue-200 flex items-center justify-center text-xs font-bold text-blue-800">
|
||||
{(u.name?.[0] || '?').toUpperCase()}
|
||||
</div>
|
||||
<div>
|
||||
<h3 className="text-sm font-semibold text-blue-900">{u.name}</h3>
|
||||
<p className="text-xs text-gray-600">{u.email}</p>
|
||||
<span className="font-medium text-gray-900">{u.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-2 py-0.5 text-xs text-blue-900">
|
||||
€ {u.contributed.toLocaleString()}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-3 text-xs text-gray-600">
|
||||
Member since:{' '}
|
||||
<span className="font-medium text-gray-900">
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-gray-600">{u.email}</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-gray-600">
|
||||
{new Date(u.joinedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-right">
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold ${
|
||||
u.share > 0
|
||||
? 'bg-green-50 text-green-700 border border-green-200'
|
||||
: 'bg-gray-50 text-gray-500 border border-gray-200'
|
||||
}`}>
|
||||
€ {u.share.toLocaleString('de-DE', { minimumFractionDigits: 2, maximumFractionDigits: 2 })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 flex justify-end">
|
||||
</td>
|
||||
<td className="px-4 py-3 whitespace-nowrap text-right">
|
||||
<button
|
||||
onClick={() => removeMember(u.id)}
|
||||
disabled={removingMemberId === u.id}
|
||||
className="px-3 py-2 text-xs font-medium rounded-lg border border-red-200 bg-red-50 text-red-700 hover:bg-red-100 transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
className="px-3 py-1.5 text-xs font-medium rounded-md border border-red-200 bg-red-50 text-red-700 hover:bg-red-100 transition disabled:opacity-60 disabled:cursor-not-allowed"
|
||||
>
|
||||
{removingMemberId === u.id ? 'Removing…' : 'Remove'}
|
||||
</button>
|
||||
</div>
|
||||
</article>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
{membersLoading && (
|
||||
<div className="col-span-full text-center text-gray-500 italic py-6">
|
||||
Loading members...
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
{membersError && !membersLoading && (
|
||||
<div className="col-span-full text-center text-red-600 py-6">
|
||||
{membersError}
|
||||
</div>
|
||||
)}
|
||||
{users.length === 0 && !membersLoading && !membersError && (
|
||||
<div className="col-span-full text-center text-gray-500 italic py-6">
|
||||
No users in this pool yet.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
@ -509,7 +453,7 @@ function PoolManagePageInner() {
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => setSearchOpen(false)} />
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4 sm:p-6">
|
||||
<div className="w-full max-w-2xl rounded-2xl overflow-hidden bg-white shadow-2xl ring-1 ring-black/10 flex flex-col">
|
||||
<div className="w-full max-w-2xl max-h-[90vh] rounded-2xl overflow-hidden bg-white shadow-2xl ring-1 ring-black/10 flex flex-col">
|
||||
{/* Header */}
|
||||
<div className="px-6 py-5 border-b border-gray-100 flex items-center justify-between">
|
||||
<h4 className="text-lg font-semibold text-blue-900">Add user to pool</h4>
|
||||
@ -560,7 +504,7 @@ function PoolManagePageInner() {
|
||||
</div>
|
||||
|
||||
{/* Results */}
|
||||
<div className="px-6 py-4">
|
||||
<div className="px-6 py-4 overflow-y-auto min-h-0 flex-1">
|
||||
{error && <div className="text-sm text-red-600 mb-3">{error}</div>}
|
||||
{!error && query.trim().length < 3 && (
|
||||
<div className="py-8 text-sm text-gray-500 text-center">
|
||||
|
||||
@ -6,12 +6,9 @@ import Footer from '../../components/Footer'
|
||||
import { UsersIcon } from '@heroicons/react/24/outline'
|
||||
import { useAdminPools } from './hooks/getlist'
|
||||
import useAuthStore from '../../store/authStore'
|
||||
import { addPool } from './hooks/addPool'
|
||||
import { useRouter } from 'next/navigation'
|
||||
import { setPoolInactive, setPoolActive } from './hooks/poolStatus'
|
||||
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
|
||||
import CreateNewPoolModal from './components/createNewPoolModal'
|
||||
import { authFetch } from '../../utils/authFetch'
|
||||
import ConfirmActionModal from '../../components/modals/ConfirmActionModal'
|
||||
|
||||
type Pool = {
|
||||
@ -19,8 +16,6 @@ type Pool = {
|
||||
pool_name: string
|
||||
description?: string
|
||||
price?: number
|
||||
subscription_coffee_id?: number | null
|
||||
subscription_title?: string | null
|
||||
pool_type?: 'coffee' | 'other'
|
||||
is_active?: boolean
|
||||
membersCount: number
|
||||
@ -30,24 +25,14 @@ type Pool = {
|
||||
export default function PoolManagementPage() {
|
||||
const router = useRouter()
|
||||
|
||||
// Modal state
|
||||
const [creating, setCreating] = React.useState(false)
|
||||
const [createError, setCreateError] = React.useState<string>('')
|
||||
const [createSuccess, setCreateSuccess] = React.useState<string>('')
|
||||
const [createModalOpen, setCreateModalOpen] = React.useState(false)
|
||||
const [archiveError, setArchiveError] = React.useState<string>('')
|
||||
const [poolStatusConfirm, setPoolStatusConfirm] = React.useState<{ poolId: string; action: 'archive' | 'activate' } | null>(null)
|
||||
const [poolStatusPending, setPoolStatusPending] = React.useState(false)
|
||||
|
||||
// Token and API URL
|
||||
const token = useAuthStore(s => s.accessToken)
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||
|
||||
// Replace local fetch with hook
|
||||
const { pools: initialPools, loading, error, refresh } = useAdminPools()
|
||||
const [pools, setPools] = React.useState<Pool[]>([])
|
||||
const [showInactive, setShowInactive] = React.useState(false)
|
||||
const [subscriptions, setSubscriptions] = React.useState<Array<{ id: number; title: string }>>([])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!loading && !error) {
|
||||
@ -55,81 +40,8 @@ export default function PoolManagementPage() {
|
||||
}
|
||||
}, [initialPools, loading, error])
|
||||
|
||||
React.useEffect(() => {
|
||||
let cancelled = false
|
||||
async function loadSubscriptions() {
|
||||
try {
|
||||
const base = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||
const response = await authFetch(`${base}/api/admin/coffee`, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: {
|
||||
Accept: 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
if (!cancelled) setSubscriptions([])
|
||||
return
|
||||
}
|
||||
|
||||
const rows = await response.json().catch(() => [])
|
||||
const mapped = Array.isArray(rows)
|
||||
? rows
|
||||
.map((r: any) => ({ id: Number(r?.id), title: String(r?.title || '').trim() }))
|
||||
.filter((r: { id: number; title: string }) => Number.isFinite(r.id) && r.id > 0 && !!r.title)
|
||||
: []
|
||||
if (!cancelled) setSubscriptions(mapped)
|
||||
} catch {
|
||||
if (!cancelled) setSubscriptions([])
|
||||
}
|
||||
}
|
||||
void loadSubscriptions()
|
||||
return () => {
|
||||
cancelled = true
|
||||
}
|
||||
}, [token])
|
||||
|
||||
const filteredPools = pools.filter(p => showInactive ? !p.is_active : p.is_active)
|
||||
|
||||
// REPLACED: handleCreatePool to accept data from modal with new schema fields
|
||||
async function handleCreatePool(data: { pool_name: string; description: string; price: number; pool_type: 'coffee' | 'other'; subscription_coffee_id: number | null }) {
|
||||
setCreateError('')
|
||||
setCreateSuccess('')
|
||||
const pool_name = data.pool_name.trim()
|
||||
const description = data.description.trim()
|
||||
if (!pool_name) {
|
||||
setCreateError('Please provide a pool name.')
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
try {
|
||||
const res = await addPool({
|
||||
pool_name,
|
||||
description: description || undefined,
|
||||
price: data.price,
|
||||
subscription_coffee_id: data.subscription_coffee_id,
|
||||
pool_type: data.pool_type,
|
||||
is_active: true,
|
||||
})
|
||||
if (res.ok && res.body?.data) {
|
||||
setCreateSuccess('Pool created successfully.')
|
||||
await refresh?.()
|
||||
setTimeout(() => {
|
||||
setCreateModalOpen(false)
|
||||
setCreateSuccess('')
|
||||
}, 1500)
|
||||
} else {
|
||||
setCreateError(res.message || 'Failed to create pool.')
|
||||
}
|
||||
} catch {
|
||||
setCreateError('Network error while creating pool.')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
}
|
||||
|
||||
async function handleArchive(poolId: string) {
|
||||
setPoolStatusConfirm({ poolId, action: 'archive' })
|
||||
}
|
||||
@ -196,14 +108,8 @@ export default function PoolManagementPage() {
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Pool Management</h1>
|
||||
<p className="text-lg text-blue-700 mt-2">Create and manage user pools.</p>
|
||||
<p className="text-lg text-blue-700 mt-2">Manage system pools and members.</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setCreateModalOpen(true); createError && setCreateError(''); }}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow transition"
|
||||
>
|
||||
Create New Pool
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">Show:</span>
|
||||
@ -256,14 +162,28 @@ export default function PoolManagementPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-6">
|
||||
{filteredPools.map(pool => (
|
||||
<article key={pool.id} className="rounded-2xl bg-white border border-gray-100 shadow p-5 flex flex-col relative z-0">
|
||||
{filteredPools.map(pool => {
|
||||
const isCore = pool.pool_name === 'Core'
|
||||
return (
|
||||
<article key={pool.id} className={`rounded-2xl border shadow p-5 flex flex-col relative z-0 ${
|
||||
isCore
|
||||
? 'bg-gradient-to-br from-amber-50 via-white to-amber-50 border-amber-300 ring-1 ring-amber-200'
|
||||
: 'bg-white border-gray-100'
|
||||
}`}>
|
||||
{isCore && (
|
||||
<div className="absolute -top-2.5 left-4 inline-flex items-center gap-1 rounded-full bg-amber-500 px-2.5 py-0.5 text-[10px] font-bold text-white uppercase tracking-wider shadow-sm">
|
||||
<svg className="h-3 w-3" fill="currentColor" viewBox="0 0 20 20"><path d="M9.049 2.927c.3-.921 1.603-.921 1.902 0l1.286 3.957a1 1 0 00.95.69h4.162c.969 0 1.371 1.24.588 1.81l-3.37 2.448a1 1 0 00-.364 1.118l1.287 3.957c.3.921-.755 1.688-1.54 1.118l-3.37-2.448a1 1 0 00-1.176 0l-3.37 2.448c-.784.57-1.838-.197-1.539-1.118l1.287-3.957a1 1 0 00-.364-1.118L2.063 9.384c-.783-.57-.38-1.81.588-1.81h4.162a1 1 0 00.95-.69l1.286-3.957z" /></svg>
|
||||
Core Pool
|
||||
</div>
|
||||
)}
|
||||
<div className="flex items-start justify-between">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="h-9 w-9 rounded-lg bg-blue-50 border border-blue-200 flex items-center justify-center">
|
||||
<UsersIcon className="h-5 w-5 text-blue-900" />
|
||||
<div className={`h-9 w-9 rounded-lg border flex items-center justify-center ${
|
||||
isCore ? 'bg-amber-100 border-amber-300' : 'bg-blue-50 border-blue-200'
|
||||
}`}>
|
||||
<UsersIcon className={`h-5 w-5 ${isCore ? 'text-amber-700' : 'text-blue-900'}`} />
|
||||
</div>
|
||||
<h3 className="text-lg font-semibold text-blue-900">{pool.pool_name}</h3>
|
||||
<h3 className={`text-lg font-semibold ${isCore ? 'text-amber-900' : 'text-blue-900'}`}>{pool.pool_name}</h3>
|
||||
</div>
|
||||
<span className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${!pool.is_active ? 'bg-gray-100 text-gray-700' : 'bg-green-100 text-green-800'}`}>
|
||||
<span className={`mr-1.5 h-1.5 w-1.5 rounded-full ${!pool.is_active ? 'bg-gray-400' : 'bg-green-500'}`} />
|
||||
@ -271,9 +191,6 @@ export default function PoolManagementPage() {
|
||||
</span>
|
||||
</div>
|
||||
<p className="mt-2 text-sm text-gray-700">{pool.description || '-'}</p>
|
||||
<p className="mt-1 text-xs text-gray-600">
|
||||
Subscription: {pool.subscription_title || 'Not linked'}
|
||||
</p>
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 text-sm text-gray-600">
|
||||
<div>
|
||||
<span className="text-gray-500">Members</span>
|
||||
@ -296,8 +213,6 @@ export default function PoolManagementPage() {
|
||||
description: pool.description ?? '',
|
||||
price: String(pool.price ?? 0),
|
||||
pool_type: pool.pool_type ?? 'other',
|
||||
subscription_coffee_id: pool.subscription_coffee_id != null ? String(pool.subscription_coffee_id) : '',
|
||||
subscription_title: pool.subscription_title ?? '',
|
||||
is_active: pool.is_active ? 'true' : 'false',
|
||||
createdAt: pool.createdAt ?? '',
|
||||
})
|
||||
@ -325,7 +240,8 @@ export default function PoolManagementPage() {
|
||||
)}
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
)
|
||||
})}
|
||||
{filteredPools.length === 0 && !loading && !error && (
|
||||
<div className="col-span-full text-center text-gray-500 italic py-6">
|
||||
{showInactive ? 'No inactive pools found.' : 'No active pools found.'}
|
||||
@ -337,18 +253,6 @@ export default function PoolManagementPage() {
|
||||
</div>
|
||||
</main>
|
||||
|
||||
{/* Modal for creating a new pool */}
|
||||
<CreateNewPoolModal
|
||||
isOpen={createModalOpen}
|
||||
onClose={() => { setCreateModalOpen(false); setCreateError(''); setCreateSuccess(''); }}
|
||||
onCreate={handleCreatePool}
|
||||
subscriptions={subscriptions}
|
||||
creating={creating}
|
||||
error={createError}
|
||||
success={createSuccess}
|
||||
clearMessages={() => { setCreateError(''); setCreateSuccess(''); }}
|
||||
/>
|
||||
|
||||
<ConfirmActionModal
|
||||
open={Boolean(poolStatusConfirm)}
|
||||
pending={poolStatusPending}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user