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 === === SEAZN TODOS ===
(Compromised User / Pool ) (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 • [ ] Unified Modal Design
• [ ] Autorefresh of Site?? • [ ] Autorefresh of Site??
• [ ] UserMgmt table refactor with actions and filter options (SAT?) • [ ] UserMgmt table refactor with actions and filter options (SAT?)
• [x] Remove irrelevant statuses in userverify filter
• [ ] User Status 1 Feld das wir nicht benutzen • [ ] User Status 1 Feld das wir nicht benutzen
• [ ] Pool mulit user actions (select 5 -> add to pool) • [ ] 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 • [] Switching status -> confirmation modal
• [] mobile scroll bug with double page on top • [] mobile scroll bug with double page on top
• [] search modal unify -> only return userId(s) • [] search modal unify -> only return userId(s)
@ -43,10 +34,13 @@ Last updated: 2026-01-20
================================================================================ ================================================================================
QUICK SHARED / CROSSOVER ITEMS 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 • [ ] Security Headers
• [ ] Dependency Management • [ ] Dependency Management
• [ ] SYSTEM ABSCHALTUNG VERHINDERN -- Exoscale Role? / Security Konzept • [ ] SYSTEM ABSCHALTUNG VERHINDERN -- Exoscale Role? / Security Konzept

View File

@ -4,12 +4,18 @@ import PageLayout from '../../components/PageLayout'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { useVatRates } from './hooks/getTaxes' import { useVatRates } from './hooks/getTaxes'
import { useAdminInvoices } from './hooks/getInvoices' import { useAdminInvoices } from './hooks/getInvoices'
import useAuthStore from '../../store/authStore'
export default function FinanceManagementPage() { export default function FinanceManagementPage() {
const router = useRouter() const router = useRouter()
const accessToken = useAuthStore(s => s.accessToken)
const { rates, loading: vatLoading, error: vatError } = useVatRates() const { rates, loading: vatLoading, error: vatError } = useVatRates()
const [timeframe, setTimeframe] = useState<'7d' | '30d' | '90d' | 'ytd'>('30d') const [timeframe, setTimeframe] = useState<'7d' | '30d' | '90d' | 'ytd'>('30d')
const [billFilter, setBillFilter] = useState({ query: '', status: 'all', from: '', to: '' }) 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 // NEW: fetch invoices from backend
const { const {
@ -67,6 +73,47 @@ export default function FinanceManagementPage() {
alert(`Export ${format.toUpperCase()} (dummy) for ${filteredBills.length} invoices`) 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 ( return (
<PageLayout> <PageLayout>
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen"> <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} {invError}
</div> </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"> <table className="min-w-full text-sm">
<thead> <thead>
<tr className="bg-blue-50 text-left text-blue-900"> <tr className="bg-blue-50 text-left text-blue-900">
@ -229,8 +321,24 @@ export default function FinanceManagementPage() {
</span> </span>
</td> </td>
<td className="px-3 py-2 space-x-2"> <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
<button className="text-xs rounded border px-2 py-1 hover:bg-gray-50">Export</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> </td>
</tr> </tr>
)) ))
@ -238,6 +346,25 @@ export default function FinanceManagementPage() {
</tbody> </tbody>
</table> </table>
</div> </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> </section>
</div> </div>
</div> </div>

View File

@ -4,7 +4,8 @@ import React from 'react'
interface Props { interface Props {
isOpen: boolean isOpen: boolean
onClose: () => void 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 creating: boolean
error?: string error?: string
success?: string success?: string
@ -15,6 +16,7 @@ export default function CreateNewPoolModal({
isOpen, isOpen,
onClose, onClose,
onCreate, onCreate,
subscriptions,
creating, creating,
error, error,
success, success,
@ -24,6 +26,7 @@ export default function CreateNewPoolModal({
const [description, setDescription] = React.useState('') const [description, setDescription] = React.useState('')
const [price, setPrice] = React.useState('0.00') const [price, setPrice] = React.useState('0.00')
const [poolType, setPoolType] = React.useState<'coffee' | 'other'>('other') const [poolType, setPoolType] = React.useState<'coffee' | 'other'>('other')
const [subscriptionCoffeeId, setSubscriptionCoffeeId] = React.useState<string>('')
const isDisabled = creating || !!success const isDisabled = creating || !!success
@ -33,6 +36,7 @@ export default function CreateNewPoolModal({
setDescription('') setDescription('')
setPrice('0.00') setPrice('0.00')
setPoolType('other') setPoolType('other')
setSubscriptionCoffeeId('')
} }
}, [isOpen]) }, [isOpen])
@ -73,7 +77,13 @@ export default function CreateNewPoolModal({
onSubmit={e => { onSubmit={e => {
e.preventDefault() e.preventDefault()
clearMessages() 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" className="space-y-4"
> >
@ -100,7 +110,7 @@ export default function CreateNewPoolModal({
/> />
</div> </div>
<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 <input
type="number" type="number"
step="0.01" step="0.01"
@ -112,6 +122,7 @@ export default function CreateNewPoolModal({
disabled={isDisabled} disabled={isDisabled}
required required
/> />
<p className="mt-1 text-xs text-gray-500">This value is stored as net price.</p>
</div> </div>
<div> <div>
<label className="block text-sm font-medium text-blue-900 mb-1">Pool Type</label> <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> <option value="coffee">Coffee</option>
</select> </select>
</div> </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"> <div className="flex gap-2">
<button <button
type="submit" type="submit"
@ -137,7 +162,7 @@ export default function CreateNewPoolModal({
<button <button
type="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" 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} disabled={isDisabled}
> >
Reset Reset

View File

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

View File

@ -7,6 +7,8 @@ export type AdminPool = {
pool_name: string; pool_name: string;
description?: string; description?: string;
price?: number; price?: number;
subscription_coffee_id?: number | null;
subscription_title?: string | null;
pool_type?: 'coffee' | 'other'; pool_type?: 'coffee' | 'other';
is_active?: boolean; is_active?: boolean;
membersCount: number; membersCount: number;
@ -62,7 +64,9 @@ export function useAdminPools() {
id: String(item.id), id: String(item.id),
pool_name: String(item.pool_name ?? 'Unnamed Pool'), pool_name: String(item.pool_name ?? 'Unnamed Pool'),
description: String(item.description ?? ''), 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', pool_type: item.pool_type === 'coffee' ? 'coffee' : 'other',
is_active: Boolean(item.is_active), is_active: Boolean(item.is_active),
membersCount: Number(item.members_count ?? item.membersCount ?? 0), membersCount: Number(item.members_count ?? item.membersCount ?? 0),
@ -100,7 +104,9 @@ export function useAdminPools() {
id: String(item.id), id: String(item.id),
pool_name: String(item.pool_name ?? 'Unnamed Pool'), pool_name: String(item.pool_name ?? 'Unnamed Pool'),
description: String(item.description ?? ''), 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', pool_type: item.pool_type === 'coffee' ? 'coffee' : 'other',
is_active: Boolean(item.is_active), is_active: Boolean(item.is_active),
membersCount: Number(item.members_count ?? item.membersCount ?? 0), 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 useAuthStore from '../../../store/authStore'
import PageTransitionEffect from '../../../components/animation/pageTransitionEffect' import PageTransitionEffect from '../../../components/animation/pageTransitionEffect'
import { AdminAPI } from '../../../utils/api' import { AdminAPI } from '../../../utils/api'
import { authFetch } from '../../../utils/authFetch'
type PoolUser = { type PoolUser = {
id: string id: string
@ -50,6 +51,8 @@ function PoolManagePageInner() {
const poolName = searchParams.get('pool_name') ?? 'Unnamed Pool' const poolName = searchParams.get('pool_name') ?? 'Unnamed Pool'
const poolDescription = searchParams.get('description') ?? '' const poolDescription = searchParams.get('description') ?? ''
const poolPrice = parseFloat(searchParams.get('price') ?? '0') 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 poolType = searchParams.get('pool_type') as 'coffee' | 'other' || 'other'
const poolIsActive = searchParams.get('is_active') === 'true' const poolIsActive = searchParams.get('is_active') === 'true'
const poolCreatedAt = searchParams.get('createdAt') ?? new Date().toISOString() const poolCreatedAt = searchParams.get('createdAt') ?? new Date().toISOString()
@ -75,6 +78,12 @@ function PoolManagePageInner() {
const [savingMembers, setSavingMembers] = React.useState(false) const [savingMembers, setSavingMembers] = React.useState(false)
const [removingMemberId, setRemovingMemberId] = React.useState<string | null>(null) const [removingMemberId, setRemovingMemberId] = React.useState<string | null>(null)
const [removeError, setRemoveError] = React.useState<string>('') 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() { async function fetchMembers() {
if (!token || !poolId || poolId === 'pool-unknown') return if (!token || !poolId || poolId === 'pool-unknown') return
@ -107,6 +116,76 @@ function PoolManagePageInner() {
void fetchMembers() void fetchMembers()
}, [token, poolId]) }, [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 // Early return AFTER all hooks are declared to keep consistent order
if (!authChecked) return null if (!authChecked) return null
@ -255,6 +334,10 @@ function PoolManagePageInner() {
{!poolIsActive ? 'Inactive' : 'Active'} {!poolIsActive ? 'Inactive' : 'Active'}
</span> </span>
<span></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>Created {new Date(poolCreatedAt).toLocaleDateString('de-DE', { year: 'numeric', month: 'short', day: '2-digit' })}</span>
<span></span> <span></span>
<span className="text-gray-500">ID: {poolId}</span> <span className="text-gray-500">ID: {poolId}</span>
@ -272,6 +355,39 @@ function PoolManagePageInner() {
</div> </div>
</header> </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) */} {/* 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="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"> <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 { setPoolInactive, setPoolActive } from './hooks/poolStatus'
import PageTransitionEffect from '../../components/animation/pageTransitionEffect' import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
import CreateNewPoolModal from './components/createNewPoolModal' import CreateNewPoolModal from './components/createNewPoolModal'
import { authFetch } from '../../utils/authFetch'
type Pool = { type Pool = {
id: string id: string
pool_name: string pool_name: string
description?: string description?: string
price?: number price?: number
subscription_coffee_id?: number | null
subscription_title?: string | null
pool_type?: 'coffee' | 'other' pool_type?: 'coffee' | 'other'
is_active?: boolean is_active?: boolean
membersCount: number membersCount: number
@ -34,13 +37,14 @@ export default function PoolManagementPage() {
const [archiveError, setArchiveError] = React.useState<string>('') const [archiveError, setArchiveError] = React.useState<string>('')
// Token and API URL // 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' const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
// Replace local fetch with hook // Replace local fetch with hook
const { pools: initialPools, loading, error, refresh } = useAdminPools() const { pools: initialPools, loading, error, refresh } = useAdminPools()
const [pools, setPools] = React.useState<Pool[]>([]) const [pools, setPools] = React.useState<Pool[]>([])
const [showInactive, setShowInactive] = React.useState(false) const [showInactive, setShowInactive] = React.useState(false)
const [subscriptions, setSubscriptions] = React.useState<Array<{ id: number; title: string }>>([])
React.useEffect(() => { React.useEffect(() => {
if (!loading && !error) { if (!loading && !error) {
@ -48,10 +52,46 @@ export default function PoolManagementPage() {
} }
}, [initialPools, loading, error]) }, [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) const filteredPools = pools.filter(p => showInactive ? !p.is_active : p.is_active)
// REPLACED: handleCreatePool to accept data from modal with new schema fields // 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('') setCreateError('')
setCreateSuccess('') setCreateSuccess('')
const pool_name = data.pool_name.trim() const pool_name = data.pool_name.trim()
@ -62,7 +102,14 @@ export default function PoolManagementPage() {
} }
setCreating(true) setCreating(true)
try { 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) { if (res.ok && res.body?.data) {
setCreateSuccess('Pool created successfully.') setCreateSuccess('Pool created successfully.')
await refresh?.() await refresh?.()
@ -219,6 +266,9 @@ export default function PoolManagementPage() {
</span> </span>
</div> </div>
<p className="mt-2 text-sm text-gray-700">{pool.description || '-'}</p> <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 className="mt-4 grid grid-cols-2 gap-3 text-sm text-gray-600">
<div> <div>
<span className="text-gray-500">Members</span> <span className="text-gray-500">Members</span>
@ -241,6 +291,8 @@ export default function PoolManagementPage() {
description: pool.description ?? '', description: pool.description ?? '',
price: String(pool.price ?? 0), price: String(pool.price ?? 0),
pool_type: pool.pool_type ?? 'other', 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', is_active: pool.is_active ? 'true' : 'false',
createdAt: pool.createdAt ?? '', createdAt: pool.createdAt ?? '',
}) })
@ -285,6 +337,7 @@ export default function PoolManagementPage() {
isOpen={createModalOpen} isOpen={createModalOpen}
onClose={() => { setCreateModalOpen(false); setCreateError(''); setCreateSuccess(''); }} onClose={() => { setCreateModalOpen(false); setCreateError(''); setCreateSuccess(''); }}
onCreate={handleCreatePool} onCreate={handleCreatePool}
subscriptions={subscriptions}
creating={creating} creating={creating}
error={createError} error={createError}
success={createSuccess} success={createSuccess}