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 ===
|
=== 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
|
||||||
|
|||||||
@ -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>
|
||||||
|
|||||||
@ -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
|
||||||
|
|||||||
@ -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;
|
||||||
};
|
};
|
||||||
|
|||||||
@ -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),
|
||||||
|
|||||||
@ -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">
|
||||||
|
|||||||
@ -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}
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user