Enhance pool management functionality by adding subscription linking and diagnostics features

This commit is contained in:
seaznCode 2026-02-17 18:13:23 +01:00
parent 6864375021
commit 49aee7b7ff
7 changed files with 342 additions and 20 deletions

View File

@ -22,20 +22,11 @@ Last updated: 2026-01-20
=== SEAZN TODOS ===
(Compromised User / Pool )
• [x] Compromised User Fix (SAT)
• [x] Pools Complete Setup check and refactor -- Implementing Logging Layout from Alex -- Talk with him (SAT)
• [x] Adjust and add Functionality for Download Acc Data and Delete Acc (SAT)
• [X] News Management (own pages for news) + Adjust the Dashboard to Display Latest news
• [ ] Unified Modal Design
• [ ] Autorefresh of Site??
• [ ] UserMgmt table refactor with actions and filter options (SAT?)
• [x] Remove irrelevant statuses in userverify filter
• [ ] User Status 1 Feld das wir nicht benutzen
• [ ] Pool mulit user actions (select 5 -> add to pool)
• [x] reset edit templates
• [x] "Suspended" status should actually do something
• [] Matrix shit (tiefe dynamisch einstellen)
• [x] Git
• [] Switching status -> confirmation modal
• [] mobile scroll bug with double page on top
• [] search modal unify -> only return userId(s)
@ -43,10 +34,13 @@ Last updated: 2026-01-20
================================================================================
QUICK SHARED / CROSSOVER ITEMS
================================================================================
• [] Summary & Details bei abo abschluss - Country nur DE + Delivery Interval richtig? oder nur monthly
• [] Wieviele Abos können auf einen pool verlinkt werden?
• [ ] Security Headers
• [ ] Dependency Management
• [ ] SYSTEM ABSCHALTUNG VERHINDERN -- Exoscale Role? / Security Konzept

View File

@ -4,12 +4,18 @@ import PageLayout from '../../components/PageLayout'
import { useRouter } from 'next/navigation'
import { useVatRates } from './hooks/getTaxes'
import { useAdminInvoices } from './hooks/getInvoices'
import useAuthStore from '../../store/authStore'
export default function FinanceManagementPage() {
const router = useRouter()
const accessToken = useAuthStore(s => s.accessToken)
const { rates, loading: vatLoading, error: vatError } = useVatRates()
const [timeframe, setTimeframe] = useState<'7d' | '30d' | '90d' | 'ytd'>('30d')
const [billFilter, setBillFilter] = useState({ query: '', status: 'all', from: '', to: '' })
const [diagLoading, setDiagLoading] = useState(false)
const [diagError, setDiagError] = useState('')
const [diagData, setDiagData] = useState<any | null>(null)
const [selectedInvoice, setSelectedInvoice] = useState<AdminInvoice | null>(null)
// NEW: fetch invoices from backend
const {
@ -67,6 +73,47 @@ export default function FinanceManagementPage() {
alert(`Export ${format.toUpperCase()} (dummy) for ${filteredBills.length} invoices`)
}
const runPoolCheck = async (invoiceId: string | number) => {
setDiagLoading(true)
setDiagError('')
setDiagData(null)
try {
const base = process.env.NEXT_PUBLIC_API_BASE_URL || ''
const url = `${base}/api/admin/pools/inflow-diagnostics?invoiceId=${encodeURIComponent(String(invoiceId))}`
const res = await fetch(url, {
method: 'GET',
credentials: 'include',
headers: {
Accept: 'application/json',
...(accessToken ? { Authorization: `Bearer ${accessToken}` } : {}),
},
})
const body = await res.json().catch(() => ({}))
if (!res.ok || body?.success === false) {
setDiagError(body?.message || `Check failed (${res.status})`)
return
}
setDiagData(body?.data || null)
} catch (e: any) {
setDiagError(e?.message || 'Network error')
} finally {
setDiagLoading(false)
}
}
const exportInvoice = (inv: AdminInvoice) => {
const pretty = JSON.stringify(inv, null, 2)
const blob = new Blob([pretty], { type: 'application/json;charset=utf-8' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url
a.download = `invoice-${inv.invoice_number || inv.id}.json`
document.body.appendChild(a)
a.click()
a.remove()
URL.revokeObjectURL(url)
}
return (
<PageLayout>
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
@ -178,6 +225,51 @@ export default function FinanceManagementPage() {
{invError}
</div>
)}
{(diagLoading || diagError || diagData) && (
<div className="rounded-md border border-blue-100 bg-blue-50/60 px-3 py-3 text-sm mb-3">
{diagLoading && <div className="text-blue-800">Checking pool inflow...</div>}
{!diagLoading && diagError && <div className="text-red-700">{diagError}</div>}
{!diagLoading && !diagError && diagData && (
<div className="space-y-2">
<div className="text-blue-900 font-semibold">Pool inflow diagnostic for invoice #{diagData.invoice_id ?? '—'}</div>
<div className="text-gray-700">
Status: <span className="font-medium">{diagData.ok ? 'OK' : 'Blocked'}</span> Reason: <span className="font-mono">{diagData.reason}</span>
</div>
{diagData.ok && (
<div className="text-gray-700">
Abonement: <span className="font-medium">{diagData.abonement_id}</span> Will book: <span className="font-medium">{diagData.will_book_count}</span> Already booked: <span className="font-medium">{diagData.already_booked_count}</span>
</div>
)}
{Array.isArray(diagData.candidates) && diagData.candidates.length > 0 && (
<div className="overflow-x-auto">
<table className="min-w-full text-xs">
<thead>
<tr className="text-left text-blue-900">
<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">Booked</th>
</tr>
</thead>
<tbody>
{diagData.candidates.map((c: any) => (
<tr key={`${c.pool_id}-${c.coffee_table_id}`}>
<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">{c.already_booked ? 'yes' : 'no'}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)}
</div>
)}
<table className="min-w-full text-sm">
<thead>
<tr className="bg-blue-50 text-left text-blue-900">
@ -229,8 +321,24 @@ export default function FinanceManagementPage() {
</span>
</td>
<td className="px-3 py-2 space-x-2">
<button className="text-xs rounded border px-2 py-1 hover:bg-gray-50">View</button>
<button className="text-xs rounded border px-2 py-1 hover:bg-gray-50">Export</button>
<button
onClick={() => setSelectedInvoice(inv)}
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>
))
@ -238,6 +346,25 @@ export default function FinanceManagementPage() {
</tbody>
</table>
</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>
)}
</section>
</div>
</div>

View File

@ -4,7 +4,8 @@ import React from 'react'
interface Props {
isOpen: boolean
onClose: () => void
onCreate: (data: { pool_name: string; description: string; price: number; pool_type: 'coffee' | 'other' }) => void | Promise<void>
onCreate: (data: { pool_name: string; description: string; price: number; pool_type: 'coffee' | 'other'; subscription_coffee_id: number | null }) => void | Promise<void>
subscriptions: Array<{ id: number; title: string }>
creating: boolean
error?: string
success?: string
@ -15,6 +16,7 @@ export default function CreateNewPoolModal({
isOpen,
onClose,
onCreate,
subscriptions,
creating,
error,
success,
@ -24,6 +26,7 @@ export default function CreateNewPoolModal({
const [description, setDescription] = React.useState('')
const [price, setPrice] = React.useState('0.00')
const [poolType, setPoolType] = React.useState<'coffee' | 'other'>('other')
const [subscriptionCoffeeId, setSubscriptionCoffeeId] = React.useState<string>('')
const isDisabled = creating || !!success
@ -33,6 +36,7 @@ export default function CreateNewPoolModal({
setDescription('')
setPrice('0.00')
setPoolType('other')
setSubscriptionCoffeeId('')
}
}, [isOpen])
@ -73,7 +77,13 @@ export default function CreateNewPoolModal({
onSubmit={e => {
e.preventDefault()
clearMessages()
onCreate({ pool_name: poolName, description, price: parseFloat(price) || 0, pool_type: poolType })
onCreate({
pool_name: poolName,
description,
price: parseFloat(price) || 0,
pool_type: poolType,
subscription_coffee_id: subscriptionCoffeeId ? Number(subscriptionCoffeeId) : null,
})
}}
className="space-y-4"
>
@ -100,7 +110,7 @@ export default function CreateNewPoolModal({
/>
</div>
<div>
<label className="block text-sm font-medium text-blue-900 mb-1">Price (per capsule)</label>
<label className="block text-sm font-medium text-blue-900 mb-1">Price per capsule (net)</label>
<input
type="number"
step="0.01"
@ -112,6 +122,7 @@ export default function CreateNewPoolModal({
disabled={isDisabled}
required
/>
<p className="mt-1 text-xs text-gray-500">This value is stored as net price.</p>
</div>
<div>
<label className="block text-sm font-medium text-blue-900 mb-1">Pool Type</label>
@ -125,6 +136,20 @@ export default function CreateNewPoolModal({
<option value="coffee">Coffee</option>
</select>
</div>
<div>
<label className="block text-sm font-medium text-blue-900 mb-1">Linked 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={subscriptionCoffeeId}
onChange={e => setSubscriptionCoffeeId(e.target.value)}
disabled={isDisabled}
>
<option value="">No subscription selected (set later)</option>
{subscriptions.map((s) => (
<option key={s.id} value={String(s.id)}>{s.title}</option>
))}
</select>
</div>
<div className="flex gap-2">
<button
type="submit"
@ -137,7 +162,7 @@ export default function CreateNewPoolModal({
<button
type="button"
className="px-4 py-2 text-sm font-medium text-gray-700 bg-gray-200 rounded-lg hover:bg-gray-300 transition"
onClick={() => { setPoolName(''); setDescription(''); setPrice('0.00'); setPoolType('other'); clearMessages(); }}
onClick={() => { setPoolName(''); setDescription(''); setPrice('0.00'); setPoolType('other'); setSubscriptionCoffeeId(''); clearMessages(); }}
disabled={isDisabled}
>
Reset

View File

@ -4,6 +4,7 @@ export type AddPoolPayload = {
pool_name: string;
description?: string;
price: number;
subscription_coffee_id?: number | null;
pool_type: 'coffee' | 'other';
is_active?: boolean;
};

View File

@ -7,6 +7,8 @@ export type AdminPool = {
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;
@ -62,7 +64,9 @@ export function useAdminPools() {
id: String(item.id),
pool_name: String(item.pool_name ?? 'Unnamed Pool'),
description: String(item.description ?? ''),
price: Number(item.price ?? 0),
price: Number(item.price_net ?? item.price ?? 0),
subscription_coffee_id: item.subscription_coffee_id != null ? Number(item.subscription_coffee_id) : null,
subscription_title: item.subscription_title ?? null,
pool_type: item.pool_type === 'coffee' ? 'coffee' : 'other',
is_active: Boolean(item.is_active),
membersCount: Number(item.members_count ?? item.membersCount ?? 0),
@ -100,7 +104,9 @@ export function useAdminPools() {
id: String(item.id),
pool_name: String(item.pool_name ?? 'Unnamed Pool'),
description: String(item.description ?? ''),
price: Number(item.price ?? 0),
price: Number(item.price_net ?? item.price ?? 0),
subscription_coffee_id: item.subscription_coffee_id != null ? Number(item.subscription_coffee_id) : null,
subscription_title: item.subscription_title ?? null,
pool_type: item.pool_type === 'coffee' ? 'coffee' : 'other',
is_active: Boolean(item.is_active),
membersCount: Number(item.members_count ?? item.membersCount ?? 0),

View File

@ -8,6 +8,7 @@ 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'
type PoolUser = {
id: string
@ -50,6 +51,8 @@ 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()
@ -75,6 +78,12 @@ function PoolManagePageInner() {
const [savingMembers, setSavingMembers] = React.useState(false)
const [removingMemberId, setRemovingMemberId] = React.useState<string | null>(null)
const [removeError, setRemoveError] = React.useState<string>('')
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
@ -107,6 +116,76 @@ function PoolManagePageInner() {
void fetchMembers()
}, [token, poolId])
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])
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)
}
}
// Early return AFTER all hooks are declared to keep consistent order
if (!authChecked) return null
@ -255,6 +334,10 @@ function PoolManagePageInner() {
{!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></span>
<span>Created {new Date(poolCreatedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}</span>
<span></span>
<span className="text-gray-500">ID: {poolId}</span>
@ -272,6 +355,39 @@ 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">

View File

@ -11,12 +11,15 @@ 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'
type Pool = {
id: string
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
@ -34,13 +37,14 @@ export default function PoolManagementPage() {
const [archiveError, setArchiveError] = React.useState<string>('')
// Token and API URL
const token = useAuthStore.getState().accessToken
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) {
@ -48,10 +52,46 @@ 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' }) {
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()
@ -62,7 +102,14 @@ export default function PoolManagementPage() {
}
setCreating(true)
try {
const res = await addPool({ pool_name, description: description || undefined, price: data.price, pool_type: data.pool_type, is_active: true })
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?.()
@ -219,6 +266,9 @@ 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>
@ -241,6 +291,8 @@ 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 ?? '',
})
@ -285,6 +337,7 @@ export default function PoolManagementPage() {
isOpen={createModalOpen}
onClose={() => { setCreateModalOpen(false); setCreateError(''); setCreateSuccess(''); }}
onCreate={handleCreatePool}
subscriptions={subscriptions}
creating={creating}
error={createError}
success={createSuccess}