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:
seaznCode 2026-03-08 16:29:01 +01:00
parent 97596720f5
commit de290cd9ef
4 changed files with 677 additions and 312 deletions

View File

@ -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>
)
}

View File

@ -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>

View File

@ -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">

View File

@ -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}