profit-planet-frontend/src/app/admin/matrix-management/page.tsx
2025-12-06 12:34:04 +01:00

532 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'use client'
import React, { useMemo, useState, useEffect } from 'react'
import {
ChartBarIcon,
CheckCircleIcon,
UsersIcon,
PlusIcon,
EnvelopeIcon,
CalendarDaysIcon,
} from '@heroicons/react/24/outline'
import PageLayout from '../../components/PageLayout'
import { useRouter } from 'next/navigation'
import useAuthStore from '../../store/authStore'
import { createMatrix } from './hooks/createMatrix'
import { getMatrixStats } from './hooks/getMatrixStats'
import { deactivateMatrix, activateMatrix } from './hooks/changeMatrixState' // NEW
type Matrix = {
id: string
name: string
status: 'active' | 'inactive'
usersCount: number
createdAt: string
topNodeEmail: string
rootUserId: number // added
policyMaxDepth?: number | null // NEW
}
export default function MatrixManagementPage() {
const router = useRouter()
const user = useAuthStore(s => s.user)
const token = useAuthStore(s => s.accessToken)
const isAdmin =
!!user &&
(
(user as any)?.role === 'admin' ||
(user as any)?.userType === 'admin' ||
(user as any)?.isAdmin === true ||
((user as any)?.roles?.includes?.('admin'))
)
useEffect(() => {
if (user === null) {
router.push('/login')
} else if (user && !isAdmin) {
router.push('/')
}
}, [user, isAdmin, router])
const [matrices, setMatrices] = useState<Matrix[]>([])
const [stats, setStats] = useState({ total: 0, active: 0, totalUsers: 0 })
const [statsLoading, setStatsLoading] = useState(false)
const [statsError, setStatsError] = useState<string>('')
const [createOpen, setCreateOpen] = useState(false)
const [createName, setCreateName] = useState('')
const [createEmail, setCreateEmail] = useState('')
const [formError, setFormError] = useState<string>('')
const [createLoading, setCreateLoading] = useState(false)
const [forcePrompt, setForcePrompt] = useState<{ name: string; email: string } | null>(null)
const [createSuccess, setCreateSuccess] = useState<{ name: string; email: string } | null>(null)
const [policyFilter, setPolicyFilter] = useState<'all'|'unlimited'|'five'>('all') // CHANGED
const [sortByUsers, setSortByUsers] = useState<'asc'|'desc'>('desc')
const [sortByPolicy, setSortByPolicy] = useState<'none'|'asc'|'desc'>('none') // NEW
const [mutatingId, setMutatingId] = useState<string | null>(null) // NEW
const loadStats = async () => {
if (!token) return
setStatsLoading(true)
setStatsError('')
try {
const res = await getMatrixStats({ token })
console.log('📊 MatrixManagement: GET /matrix/stats ->', res.status, res.body)
if (res.ok) {
const payload = res.body?.data || res.body || {}
const apiMatrices: any[] = Array.isArray(payload.matrices) ? payload.matrices : []
const mapped: Matrix[] = apiMatrices.map((m: any, idx: number) => {
const isActive = !!m?.isActive
const createdAt = m?.createdAt || m?.ego_activated_at || m?.activatedAt || new Date().toISOString()
const topNodeEmail = m?.topNodeEmail || m?.masterTopUserEmail || m?.email || ''
const rootUserId = Number(m?.rootUserId ?? m?.root_user_id ?? 0)
const matrixId = m?.matrixId ?? m?.id // prefer matrixId for routing
const maxDepth = (m?.maxDepth ?? m?.policyMaxDepth) // backend optional
return {
id: String(matrixId ?? `m-${idx}`),
name: String(m?.name ?? 'Unnamed Matrix'),
status: isActive ? 'active' : 'inactive',
usersCount: Number(m?.usersCount ?? 0),
createdAt: String(createdAt),
topNodeEmail: String(topNodeEmail),
rootUserId,
policyMaxDepth: typeof maxDepth === 'number' ? maxDepth : null // NEW
}
})
setMatrices(mapped)
const activeMatrices = Number(payload.activeMatrices ?? mapped.filter(m => m.status === 'active').length)
const totalMatrices = Number(payload.totalMatrices ?? mapped.length)
const totalUsersSubscribed = Number(payload.totalUsersSubscribed ?? 0)
setStats({ total: totalMatrices, active: activeMatrices, totalUsers: totalUsersSubscribed })
console.log('✅ MatrixManagement: mapped stats:', { total: totalMatrices, active: activeMatrices, totalUsers: totalUsersSubscribed })
console.log('✅ MatrixManagement: mapped matrices sample:', mapped.slice(0, 3))
} else {
setStatsError(res.message || 'Failed to load matrix stats.')
}
} catch (e) {
console.error('❌ MatrixManagement: stats load error', e)
setStatsError('Network error while loading matrix stats.')
} finally {
setStatsLoading(false)
}
}
useEffect(() => {
loadStats()
}, [token])
const resetForm = () => {
setCreateName('')
setCreateEmail('')
setFormError('')
setForcePrompt(null)
setCreateSuccess(null)
}
const validateEmail = (email: string) =>
/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.trim())
const handleCreate = async (e: React.FormEvent) => {
e.preventDefault()
const name = createName.trim()
const email = createEmail.trim()
setFormError('')
setCreateSuccess(null)
setForcePrompt(null)
if (!name) {
setFormError('Please provide a matrix name.')
return
}
if (!email || !validateEmail(email)) {
setFormError('Please provide a valid top-node email.')
return
}
if (!token) {
setFormError('Not authenticated. Please log in again.')
return
}
setCreateLoading(true)
try {
const res = await createMatrix({ token, name, email })
console.log('🧱 MatrixManagement: create result ->', res.status, res.body)
if (res.ok && res.body?.success) {
const createdName = res.body?.data?.name || name
const createdEmail = res.body?.data?.masterTopUserEmail || email
setCreateSuccess({ name: createdName, email: createdEmail })
await loadStats()
setCreateName('')
setCreateEmail('')
} else if (res.status === 409) {
setForcePrompt({ name, email })
} else {
setFormError(res.message || 'Failed to create matrix.')
}
} catch (err) {
setFormError('Network error while creating the matrix.')
} finally {
setCreateLoading(false)
}
}
const confirmForce = async () => {
if (!forcePrompt || !token) return
setFormError('')
setCreateLoading(true)
try {
const res = await createMatrix({ token, name: forcePrompt.name, email: forcePrompt.email, force: true })
console.log('🧱 MatrixManagement: force-create result ->', res.status, res.body)
if (res.ok && res.body?.success) {
const createdName = res.body?.data?.name || forcePrompt.name
const createdEmail = res.body?.data?.masterTopUserEmail || forcePrompt.email
setCreateSuccess({ name: createdName, email: createdEmail })
setForcePrompt(null)
setCreateName('')
setCreateEmail('')
await loadStats()
} else {
setFormError(res.message || 'Failed to create matrix (force).')
}
} catch {
setFormError('Network error while forcing the matrix creation.')
} finally {
setCreateLoading(false)
}
}
const toggleStatus = async (id: string) => {
try {
const target = matrices.find(m => m.id === id)
if (!target) return
setStatsError('')
setMutatingId(id)
if (target.status === 'active') {
await deactivateMatrix(id)
} else {
await activateMatrix(id)
}
await loadStats()
} catch (e: any) {
setStatsError(e?.message || 'Failed to change matrix state.')
} finally {
setMutatingId(null)
}
}
// derived list with filter/sort (always apply selected filter)
const matricesView = useMemo(() => {
let list = [...matrices]
list = list.filter(m => {
const unlimited = !m.policyMaxDepth || m.policyMaxDepth <= 0
if (policyFilter === 'all') return true
return policyFilter === 'unlimited' ? unlimited : (!unlimited && m.policyMaxDepth === 5)
})
list.sort((a,b) => {
if (sortByPolicy !== 'none') {
const pa = (!a.policyMaxDepth || a.policyMaxDepth <= 0) ? Infinity : a.policyMaxDepth
const pb = (!b.policyMaxDepth || b.policyMaxDepth <= 0) ? Infinity : b.policyMaxDepth
const diff = sortByPolicy === 'asc' ? pa - pb : pb - pa
if (diff !== 0) return diff
}
return sortByUsers === 'asc' ? (a.usersCount - b.usersCount) : (b.usersCount - a.usersCount)
})
return list
}, [matrices, policyFilter, sortByUsers, sortByPolicy])
const StatCard = ({
icon: Icon,
label,
value,
color,
}: {
icon: any
label: string
value: number
color: string
}) => (
<div className="relative overflow-hidden rounded-lg bg-white px-4 pb-6 pt-5 shadow-sm border border-gray-200 sm:px-6 sm:pt-6">
<dt>
<div className={`absolute rounded-md ${color} p-3`}>
<Icon className="h-6 w-6 text-white" aria-hidden="true" />
</div>
<p className="ml-16 truncate text-sm font-medium text-gray-500">{label}</p>
</dt>
<dd className="ml-16 mt-2 text-2xl font-semibold text-gray-900">{value}</dd>
</div>
)
const StatusBadge = ({ status }: { status: Matrix['status'] }) => (
<span
className={`inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-medium ${
status === 'active' ? 'bg-green-100 text-green-800' : 'bg-gray-100 text-gray-700'
}`}
>
<span
className={`mr-1.5 h-1.5 w-1.5 rounded-full ${
status === 'active' ? 'bg-green-500' : 'bg-gray-400'
}`}
/>
{status.charAt(0).toUpperCase() + status.slice(1)}
</span>
)
return (
<PageLayout>
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen w-full">
<div className="max-w-7xl mx-auto px-2 sm:px-6 lg:px-8 py-8">
{/* Header + Create */}
<header className="sticky top-0 z-10 bg-white/90 backdrop-blur border-b border-blue-100 py-10 px-8 rounded-2xl shadow-lg flex flex-col gap-4 mb-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-4xl font-extrabold text-blue-900 tracking-tight">Matrix Management</h1>
<p className="text-lg text-blue-700 mt-2">Manage matrices, see stats, and create new ones.</p>
</div>
<button
onClick={() => setCreateOpen(true)}
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition"
>
<PlusIcon className="h-5 w-5" />
Create Matrix
</button>
</div>
</header>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3 mb-6">
<div className="flex items-center gap-2 text-xs text-blue-900">
<span className="font-semibold">Policy filter:</span>
<button onClick={() => setPolicyFilter('all')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='all'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>All</button>
<button onClick={() => setPolicyFilter('unlimited')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='unlimited'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>Unlimited</button>
<button onClick={() => setPolicyFilter('five')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='five'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>Depth 5</button>
</div>
<div className="flex items-center gap-2 text-xs text-blue-900">
<span className="font-semibold">Sort:</span>
<select value={sortByPolicy} onChange={e => setSortByPolicy(e.target.value as any)} className="border rounded px-2 py-1">
<option value="none">None</option>
<option value="asc">Policy </option>
<option value="desc">Policy </option>
</select>
<select value={sortByUsers} onChange={e => setSortByUsers(e.target.value as any)} className="border rounded px-2 py-1">
<option value="desc">Users </option>
<option value="asc">Users </option>
</select>
</div>
</div>
{/* Error banner for stats */}
{statsError && (
<div className="mb-6 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{statsError}
</div>
)}
{/* Stats */}
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8 mb-8">
<StatCard icon={CheckCircleIcon} label="Active Matrices" value={stats.active} color="bg-green-500" />
<StatCard icon={ChartBarIcon} label="Total Matrices" value={stats.total} color="bg-blue-900" />
<StatCard icon={UsersIcon} label="Total Users Subscribed" value={stats.totalUsers} color="bg-amber-600" />
</div>
{/* Matrix cards */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
{statsLoading ? (
Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden">
<div className="p-6 animate-pulse space-y-4">
<div className="h-5 w-1/2 bg-gray-200 rounded" />
<div className="h-4 w-1/3 bg-gray-200 rounded" />
<div className="h-4 w-2/3 bg-gray-200 rounded" />
<div className="h-9 w-full bg-gray-100 rounded" />
</div>
</div>
))
) : matricesView.length === 0 ? (
<div className="text-sm text-gray-600">No matrices found.</div>
) : (
matricesView.map(m => (
<article key={m.id} className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden flex flex-col">
<div className="p-6 flex-1 flex flex-col">
<div className="flex items-start justify-between gap-3">
<h3 className="text-xl font-semibold text-blue-900">{m.name}</h3>
<StatusBadge status={m.status} />
</div>
<div className="mt-2 flex flex-wrap gap-2 text-xs">
<span className={`inline-flex items-center rounded-full px-2 py-0.5 border ${m.status==='inactive'?'border-gray-200 bg-gray-50 text-gray-500':'border-blue-200 bg-blue-50 text-blue-900'}`}>
Policy: {(!m.policyMaxDepth || m.policyMaxDepth <= 0) ? 'Unlimited' : m.policyMaxDepth}
</span>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 border ${m.status==='inactive'?'border-gray-200 bg-gray-50 text-gray-500':'border-gray-200 bg-gray-100 text-gray-800'}`}>
Root: unlimited immediate children (sequential), non-root: 5 children (positions 15)
</span>
</div>
<div className="mt-4 grid grid-cols-1 gap-3 text-sm text-gray-700">
<div className="flex items-center gap-2" title="Users count respects each matrixs max depth policy.">
<UsersIcon className="h-5 w-5 text-gray-500" />
<span className="font-medium">{m.usersCount}</span>
<span className="text-gray-500">users</span>
</div>
<div className="flex items-center gap-2">
<CalendarDaysIcon className="h-5 w-5 text-gray-500" />
<span className="text-gray-600">
{new Date(m.createdAt).toLocaleDateString()}
</span>
</div>
<div className="flex items-center gap-2">
<EnvelopeIcon className="h-5 w-5 text-gray-500" />
<span className="text-gray-700 truncate">{m.topNodeEmail}</span>
</div>
</div>
<div className="mt-5 flex items-center justify-between">
<button
onClick={() => toggleStatus(m.id)}
disabled={mutatingId === m.id}
className={`rounded-lg px-4 py-2 text-sm font-medium border shadow transition
${m.status === 'active'
? 'border-red-300 text-red-700 hover:bg-red-50 disabled:opacity-60'
: 'border-green-300 text-green-700 hover:bg-green-50 disabled:opacity-60'}`}
>
{mutatingId === m.id
? (m.status === 'active' ? 'Deactivating…' : 'Activating…')
: (m.status === 'active' ? 'Deactivate' : 'Activate')}
</button>
<span className="text-[11px] text-gray-500">
State change will affect add/remove operations.
</span>
<button
className="text-sm font-medium text-blue-900 hover:text-blue-700"
onClick={() => {
const defA = Number(localStorage.getItem(`matrixDepthA:${m.id}`) ?? 0)
const defB = Number(localStorage.getItem(`matrixDepthB:${m.id}`) ?? 5)
const params = new URLSearchParams({
id: String(m.id),
name: m.name,
top: m.topNodeEmail,
rootUserId: String(m.rootUserId),
a: String(Number.isFinite(defA) ? defA : 0),
b: String(Number.isFinite(defB) ? defB : 5)
})
router.push(`/admin/matrix-management/detail?${params.toString()}`)
}}
>
View details
</button>
</div>
</div>
</article>
))
)}
</div>
</div>
{/* Create Matrix Modal */}
{createOpen && (
<div className="fixed inset-0 z-50">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={() => { setCreateOpen(false); resetForm() }} />
<div className="absolute inset-0 flex items-center justify-center p-4">
<div className="w-full max-w-md rounded-2xl bg-white shadow-2xl ring-1 ring-black/10">
<div className="px-6 py-5 border-b border-gray-100 flex items-center justify-between">
<h4 className="text-lg font-semibold text-blue-900">Create Matrix</h4>
<button
onClick={() => { setCreateOpen(false); resetForm() }}
className="text-sm text-gray-500 hover:text-gray-700"
>
Close
</button>
</div>
<form onSubmit={handleCreate} className="p-6 space-y-5">
{/* Success banner */}
{createSuccess && (
<div className="rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">
Matrix created successfully.
<div className="mt-1 text-green-800">
<span className="font-semibold">Name:</span> {createSuccess.name}{' '}
<span className="font-semibold ml-3">Top node:</span> {createSuccess.email}
</div>
</div>
)}
{/* 409 force prompt */}
{forcePrompt && (
<div className="rounded-md border border-amber-200 bg-amber-50 px-3 py-2 text-sm text-amber-800">
A matrix configuration already exists for this selection.
<div className="mt-2 flex items-center gap-2">
<button
type="button"
onClick={confirmForce}
disabled={createLoading}
className="rounded-lg bg-amber-600 hover:bg-amber-500 text-white px-4 py-2 text-xs font-medium disabled:opacity-50"
>
Replace (force)
</button>
<button
type="button"
onClick={() => setForcePrompt(null)}
disabled={createLoading}
className="rounded-lg border border-amber-300 px-4 py-2 text-xs font-medium text-amber-800 hover:bg-amber-100 disabled:opacity-50"
>
Cancel
</button>
</div>
</div>
)}
{/* Form fields */}
<div>
<label className="block text-sm font-medium text-blue-900 mb-1">Matrix Name</label>
<input
type="text"
value={createName}
onChange={e => setCreateName(e.target.value)}
disabled={createLoading}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent disabled:bg-gray-100"
placeholder="e.g., Platinum Matrix"
/>
</div>
<div>
<label className="block text-sm font-medium text-blue-900 mb-1">Top-node Email</label>
<input
type="email"
value={createEmail}
onChange={e => setCreateEmail(e.target.value)}
disabled={createLoading}
className="w-full rounded-lg border border-gray-300 px-4 py-3 text-sm focus:ring-2 focus:ring-blue-900 focus:border-transparent disabled:bg-gray-100"
placeholder="owner@example.com"
/>
</div>
{formError && (
<div className="rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{formError}
</div>
)}
<div className="pt-2 flex items-center justify-end gap-3">
<button
type="button"
onClick={() => { setCreateOpen(false); resetForm() }}
disabled={createLoading}
className="rounded-lg border border-gray-300 px-4 py-2 text-sm text-gray-700 hover:bg-gray-50 disabled:opacity-50"
>
Cancel
</button>
<button
type="submit"
disabled={createLoading}
className="rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow disabled:opacity-50 inline-flex items-center gap-2"
>
{createLoading && <span className="h-4 w-4 rounded-full border-2 border-white border-b-transparent animate-spin" />}
{createLoading ? 'Creating...' : 'Create Matrix'}
</button>
</div>
</form>
</div>
</div>
</div>
)}
</div>
</PageLayout>
)
}