Enhance pool management functionality by adding subscription linking and diagnostics features
This commit is contained in:
parent
6864375021
commit
49aee7b7ff
12
ToDo.txt
12
ToDo.txt
@ -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
|
||||
|
||||
@ -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>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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;
|
||||
};
|
||||
|
||||
@ -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),
|
||||
|
||||
@ -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">
|
||||
|
||||
@ -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}
|
||||
|
||||
Loading…
Reference in New Issue
Block a user