feat: add matrix managemet backend

This commit is contained in:
DeathKaioken 2025-11-30 12:21:10 +01:00
parent 6bf1ca006e
commit 18a873ffe3
9 changed files with 741 additions and 399 deletions

View File

@ -45,6 +45,7 @@ export default function SearchModal({
const [addError, setAddError] = useState<string>('') // NEW const [addError, setAddError] = useState<string>('') // NEW
const [addSuccess, setAddSuccess] = useState<string>('') // NEW const [addSuccess, setAddSuccess] = useState<string>('') // NEW
const [hasSearched, setHasSearched] = useState(false) // NEW const [hasSearched, setHasSearched] = useState(false) // NEW
const [closing, setClosing] = useState(false) // NEW: animated closing state
const formRef = useRef<HTMLFormElement | null>(null) const formRef = useRef<HTMLFormElement | null>(null)
const reqIdRef = useRef(0) // request guard to avoid applying stale results const reqIdRef = useRef(0) // request guard to avoid applying stale results
@ -134,6 +135,9 @@ export default function SearchModal({
} }
}, [existingUsers, items, open]) }, [existingUsers, items, open])
// Track a revision to force remount of parent dropdown when existingUsers changes
const [parentsRevision, setParentsRevision] = useState(0) // NEW
// Compute children counts per parent (uses parentUserId on existingUsers) // Compute children counts per parent (uses parentUserId on existingUsers)
const parentUsage = useMemo(() => { const parentUsage = useMemo(() => {
const map = new Map<number, number>() const map = new Map<number, number>()
@ -157,6 +161,19 @@ export default function SearchModal({
return existingUsers.find(u => u.id === parentId) || null return existingUsers.find(u => u.id === parentId) || null
}, [parentId, existingUsers]) }, [parentId, existingUsers])
// NEW: when existingUsers changes, refresh dropdown and clear invalid/now-full parent selection
useEffect(() => {
setParentsRevision(r => r + 1)
if (!selectedParent) return
const used = parentUsage.get(selectedParent.id) || 0
const isRoot = (selectedParent.level ?? 0) === 0
const isFull = !isRoot && used >= 5
const stillExists = !!existingUsers.find(u => u.id === selectedParent.id)
if (!stillExists || isFull) {
setParentId(undefined)
}
}, [existingUsers, parentUsage, selectedParent])
const remainingLevels = useMemo(() => { const remainingLevels = useMemo(() => {
if (!selectedParent) return null if (!selectedParent) return null
if (!policyMaxDepth || policyMaxDepth <= 0) return Infinity if (!policyMaxDepth || policyMaxDepth <= 0) return Infinity
@ -172,6 +189,28 @@ export default function SearchModal({
return '' return ''
}, [selectedParent, policyMaxDepth]) }, [selectedParent, policyMaxDepth])
// Helper: is root selected
const isRootSelected = useMemo(() => {
if (!selectedParent) return false
return (selectedParent.level ?? 0) === 0
}, [selectedParent])
const closeWithAnimation = useCallback(() => {
// guard: if already closing, ignore
if (closing) return
setClosing(true)
// allow CSS transitions to play
setTimeout(() => {
setClosing(false)
onClose()
}, 200) // keep brief for responsiveness
}, [closing, onClose])
useEffect(() => {
// reset closing flag when reopened
if (open) setClosing(false)
}, [open])
const handleAdd = async () => { const handleAdd = async () => {
if (!selected) return if (!selected) return
setAddError('') setAddError('')
@ -189,10 +228,13 @@ export default function SearchModal({
console.info('[SearchModal] addUserToMatrix success', data) console.info('[SearchModal] addUserToMatrix success', data)
setAddSuccess(`Added at position ${data.position} under parent ${data.parentUserId}`) setAddSuccess(`Added at position ${data.position} under parent ${data.parentUserId}`)
onAdd({ id: selected.userId, name: selected.name, email: selected.email, type: selected.userType }) onAdd({ id: selected.userId, name: selected.name, email: selected.email, type: selected.userType })
setSelected(null) // NEW: animated close instead of abrupt onClose
setParentId(undefined) closeWithAnimation()
return
// setSelected(null)
// setParentId(undefined)
// Soft refresh: keep list visible; doSearch won't clear items now // Soft refresh: keep list visible; doSearch won't clear items now
setTimeout(() => { void doSearch() }, 200) // setTimeout(() => { void doSearch() }, 200)
} catch (e: any) { } catch (e: any) {
console.error('[SearchModal] addUserToMatrix error', e) console.error('[SearchModal] addUserToMatrix error', e)
setAddError(e?.message || 'Add failed') setAddError(e?.message || 'Add failed')
@ -204,11 +246,16 @@ export default function SearchModal({
if (!open) return null if (!open) return null
const modal = ( const modal = (
<div className="fixed inset-0 z-[10000]"> {/* elevated z-index */} <div className="fixed inset-0 z-[10000]">
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} /> {/* Backdrop: animate opacity */}
<div
className={`absolute inset-0 backdrop-blur-sm transition-opacity duration-200 ${closing ? 'opacity-0' : 'opacity-100'} bg-black/60`}
onClick={closeWithAnimation} // CHANGED: use animated close
/>
<div className="absolute inset-0 flex items-center justify-center p-4 sm:p-6"> <div className="absolute inset-0 flex items-center justify-center p-4 sm:p-6">
<div <div
className="w-full max-w-full sm:max-w-xl md:max-w-3xl lg:max-w-4xl rounded-2xl overflow-hidden bg-[#0F1F3A] shadow-2xl ring-1 ring-black/40 flex flex-col" className={`w-full max-w-full sm:max-w-xl md:max-w-3xl lg:max-w-4xl rounded-2xl overflow-hidden bg-[#0F1F3A] shadow-2xl ring-1 ring-black/40 flex flex-col
transition-all duration-200 ${closing ? 'opacity-0 scale-95 translate-y-1' : 'opacity-100 scale-100 translate-y-0'}`}
style={{ maxHeight: '90vh' }} style={{ maxHeight: '90vh' }}
> >
{/* Header */} {/* Header */}
@ -217,9 +264,8 @@ export default function SearchModal({
<h3 className="text-lg font-semibold text-white"> <h3 className="text-lg font-semibold text-white">
Add users to {matrixName} Add users to {matrixName}
</h3> </h3>
{/* Close button improved hover/focus */}
<button <button
onClick={onClose} onClick={closeWithAnimation} // CHANGED: animated close
className="p-1.5 rounded-md text-blue-200 transition className="p-1.5 rounded-md text-blue-200 transition
hover:bg-white/15 hover:text-white hover:bg-white/15 hover:text-white
focus:outline-none focus:ring-2 focus:ring-white/60 focus:ring-offset-2 focus:ring-offset-[#13365f] focus:outline-none focus:ring-2 focus:ring-white/60 focus:ring-offset-2 focus:ring-offset-[#13365f]
@ -397,6 +443,7 @@ export default function SearchModal({
{advanced && ( {advanced && (
<div className="space-y-2"> <div className="space-y-2">
<select <select
key={parentsRevision} // NEW: force remount to refresh options
value={parentId ?? ''} value={parentId ?? ''}
onChange={e => setParentId(e.target.value ? Number(e.target.value) : undefined)} onChange={e => setParentId(e.target.value ? Number(e.target.value) : undefined)}
className="w-full rounded-md bg-[#173456] border border-blue-800 text-xs text-blue-100 px-2 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500" className="w-full rounded-md bg-[#173456] border border-blue-800 text-xs text-blue-100 px-2 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
@ -405,19 +452,23 @@ export default function SearchModal({
<option value="">(Auto referral / root)</option> <option value="">(Auto referral / root)</option>
{potentialParents.map(p => { {potentialParents.map(p => {
const used = parentUsage.get(p.id) || 0 const used = parentUsage.get(p.id) || 0
const full = used >= 5 const isRoot = (p.level ?? 0) === 0
const full = !isRoot && used >= 5
const rem = !policyMaxDepth || policyMaxDepth <= 0 ? '∞' : Math.max(0, policyMaxDepth - p.level) const rem = !policyMaxDepth || policyMaxDepth <= 0 ? '∞' : Math.max(0, policyMaxDepth - p.level)
return ( return (
<option key={p.id} value={p.id} disabled={full}> <option key={p.id} value={p.id} disabled={full}>
{p.name} L{p.level} Slots {used}/5 Rem levels: {rem} {p.name} L{p.level} Slots {isRoot ? `${used} (root ∞)` : `${used}/5`} Rem levels: {rem}
</option> </option>
) )
})} })}
</select> </select>
{/* CHANGED: clarify root unlimited and rogue behavior */}
<p className="text-[11px] text-blue-300"> <p className="text-[11px] text-blue-300">
{(!policyMaxDepth || policyMaxDepth <= 0) {isRootSelected
? 'Unlimited policy: no remaining-level cap.' ? 'Root has unlimited capacity; placing under root does not mark the user as rogue.'
: `Remaining levels under chosen parent = Max(${policyMaxDepth}) - parent level.`} : (!policyMaxDepth || policyMaxDepth <= 0)
? 'Unlimited policy: no remaining-level cap for subtree.'
: `Remaining levels under chosen parent = Max(${policyMaxDepth}) - parent level.`}
</p> </p>
</div> </div>
)} )}
@ -453,7 +504,7 @@ export default function SearchModal({
{!selected && ( {!selected && (
<div className="px-6 py-3 border-t border-blue-900/40 flex items-center justify-end bg-[#112645]"> <div className="px-6 py-3 border-t border-blue-900/40 flex items-center justify-end bg-[#112645]">
<button <button
onClick={onClose} onClick={closeWithAnimation} // CHANGED: animated close
className="text-sm rounded-md px-4 py-2 font-medium className="text-sm rounded-md px-4 py-2 font-medium
bg-white/10 text-blue-200 backdrop-blur bg-white/10 text-blue-200 backdrop-blur
hover:bg-indigo-500/20 hover:text-white hover:shadow-sm hover:bg-indigo-500/20 hover:text-white hover:shadow-sm

View File

@ -10,6 +10,7 @@ export type MatrixUser = {
type: UserType type: UserType
level: number level: number
parentUserId?: number | null // NEW parentUserId?: number | null // NEW
position?: number | null // NEW
} }
type ApiUser = { type ApiUser = {
@ -185,7 +186,8 @@ export function useMatrixUsers(
email: u.email, email: u.email,
type: u.userType === 'company' ? 'company' : 'personal', type: u.userType === 'company' ? 'company' : 'personal',
level, level,
parentUserId: u.parentUserId ?? null parentUserId: u.parentUserId ?? null,
position: u.position ?? null // NEW
} }
}) })
mapped.sort((a, b) => a.level - b.level || a.id - b.id) mapped.sort((a, b) => a.level - b.level || a.id - b.id)
@ -193,12 +195,9 @@ export function useMatrixUsers(
console.info('[useMatrixUsers] Users mapped', { count: mapped.length, sample: mapped.slice(0, 3) }) console.info('[useMatrixUsers] Users mapped', { count: mapped.length, sample: mapped.slice(0, 3) })
}) })
.catch(err => { .catch(err => {
if (controller.signal.aborted) {
console.log('[useMatrixUsers] Fetch aborted')
return
}
console.error('[useMatrixUsers] Fetch error', err) console.error('[useMatrixUsers] Fetch error', err)
setError(err) setError(err)
setUsers([])
}) })
.finally(() => { .finally(() => {
setLoading(false) setLoading(false)

View File

@ -4,11 +4,12 @@ import React, { useEffect, useMemo, useState } from 'react'
import { useSearchParams, useRouter } from 'next/navigation' import { useSearchParams, useRouter } from 'next/navigation'
import PageLayout from '../../../components/PageLayout' import PageLayout from '../../../components/PageLayout'
import { ArrowLeftIcon, MagnifyingGlassIcon, PlusIcon, UserIcon, BuildingOffice2Icon } from '@heroicons/react/24/outline' import { ArrowLeftIcon, MagnifyingGlassIcon, PlusIcon, UserIcon, BuildingOffice2Icon } from '@heroicons/react/24/outline'
import { useMatrixUsers, MatrixUser, UserType } from './hooks/getStats' import { useMatrixUsers, MatrixUser } from './hooks/getStats'
import useAuthStore from '../../../store/authStore' import useAuthStore from '../../../store/authStore'
import { getMatrixStats } from '../hooks/getMatrixStats' import { getMatrixStats } from '../hooks/getMatrixStats'
import SearchModal from './components/searchModal' import SearchModal from './components/searchModal'
const DEFAULT_FETCH_DEPTH = 50 // provisional large depth to approximate unlimited
const LEVEL_CAP = (level: number) => Math.pow(5, level) // L1=5, L2=25, ... const LEVEL_CAP = (level: number) => Math.pow(5, level) // L1=5, L2=25, ...
export default function MatrixDetailPage() { export default function MatrixDetailPage() {
@ -25,6 +26,8 @@ export default function MatrixDetailPage() {
// Resolve rootUserId when missing by looking it up via stats // Resolve rootUserId when missing by looking it up via stats
const accessToken = useAuthStore(s => s.accessToken) const accessToken = useAuthStore(s => s.accessToken)
const [resolvedRootUserId, setResolvedRootUserId] = useState<number | undefined>(rootUserId) const [resolvedRootUserId, setResolvedRootUserId] = useState<number | undefined>(rootUserId)
// NEW: track policy (DB) max depth (null => unlimited)
const [policyMaxDepth, setPolicyMaxDepth] = useState<number | null>(null)
useEffect(() => { useEffect(() => {
let cancelled = false let cancelled = false
@ -64,6 +67,11 @@ export default function MatrixDetailPage() {
const found = matrices.find((m: any) => const found = matrices.find((m: any) =>
String(m?.id) === String(matrixId) || String(m?.matrixId) === String(matrixId) String(m?.id) === String(matrixId) || String(m?.matrixId) === String(matrixId)
) )
// NEW: extract policy maxDepth (may be null)
const pmRaw = found?.maxDepth ?? found?.max_depth ?? null
if (!cancelled) {
setPolicyMaxDepth(pmRaw == null ? null : Number(pmRaw))
}
const ru = Number(found?.rootUserId ?? found?.root_user_id) const ru = Number(found?.rootUserId ?? found?.root_user_id)
if (ru > 0 && !cancelled) { if (ru > 0 && !cancelled) {
console.info('[MatrixDetailPage] Resolved rootUserId from stats', { matrixId, rootUserId: ru }) console.info('[MatrixDetailPage] Resolved rootUserId from stats', { matrixId, rootUserId: ru })
@ -77,11 +85,11 @@ export default function MatrixDetailPage() {
return () => { cancelled = true } return () => { cancelled = true }
}, [matrixId, rootUserId, accessToken]) }, [matrixId, rootUserId, accessToken])
// Backend users // Backend users (changed depth from 5 to DEFAULT_FETCH_DEPTH)
const { users: fetchedUsers, loading: usersLoading, error: usersError, meta, refetch, serverMaxDepth } = useMatrixUsers(resolvedRootUserId, { const { users: fetchedUsers, loading: usersLoading, error: usersError, meta, refetch, serverMaxDepth } = useMatrixUsers(resolvedRootUserId, {
depth: 5, depth: DEFAULT_FETCH_DEPTH,
includeRoot: true, includeRoot: true,
limit: 100, limit: 2000,
offset: 0, offset: 0,
matrixId, matrixId,
topNodeEmail topNodeEmail
@ -158,90 +166,160 @@ export default function MatrixDetailPage() {
</div> </div>
) )
// Compact grid for a level // Global search (already present) + node collapse state
const LevelSection = ({ level }: { level: number }) => { const [collapsedNodes, setCollapsedNodes] = useState<Record<number, boolean>>({})
const cap = isUnlimited ? undefined : (level === 0 ? 1 : LEVEL_CAP(level)) const toggleNode = (id: number) => setCollapsedNodes(p => ({ ...p, [id]: !p[id] }))
const listAll = (byLevel.get(level) || []).filter(u =>
u.level >= depthA && u.level <= depthB && (includeRoot || u.level !== 0) // Build children adjacency map
) const childrenMap = useMemo(() => {
const list = listAll.slice(0, pageFor(level) * levelPageSize) const m = new Map<number, MatrixUser[]>()
const title = `Level ${level}${listAll.length} users` users.forEach(u => {
const showLoadMore = listAll.length > list.length if (u.parentUserId != null) {
const arr = m.get(u.parentUserId) || []
arr.push(u)
m.set(u.parentUserId, arr)
}
})
// sort children by optional position then id
m.forEach(arr => arr.sort((a,b) => {
const pa = (a as any).position ?? 0
const pb = (b as any).position ?? 0
return pa - pb || a.id - b.id
}))
return m
}, [users])
// Root node
const rootNode = useMemo(
() => users.find(u => u.level === 0 || u.id === resolvedRootUserId),
[users, resolvedRootUserId]
)
// NEW: immediate children count for root (unlimited capacity display)
const rootChildrenCount = useMemo(() => {
if (!rootNode) return 0
return (childrenMap.get(rootNode.id) || []).length
}, [rootNode, childrenMap])
// Rogue count if flags exist
const rogueCount = useMemo(
() => users.filter(u => (u as any).rogueUser || (u as any).rogue_user || (u as any).rogue).length,
[users]
)
// Filter match helper
const searchLower = globalSearch.trim().toLowerCase()
const matchesSearch = (u: MatrixUser) =>
!searchLower ||
u.name.toLowerCase().includes(searchLower) ||
u.email.toLowerCase().includes(searchLower)
// Determine which nodes should be visible when searching:
// Show all ancestors of matching nodes so the path is visible.
const visibleIds = useMemo(() => {
if (!searchLower) return new Set(users.map(u => u.id))
const matchIds = new Set<number>()
const parentMap = new Map<number, number | undefined>()
users.forEach(u => parentMap.set(u.id, u.parentUserId == null ? undefined : u.parentUserId))
users.forEach(u => {
if (matchesSearch(u)) {
let cur: number | undefined = u.id
while (cur != null) {
if (!matchIds.has(cur)) matchIds.add(cur)
cur = parentMap.get(cur)
}
}
})
return matchIds
}, [users, searchLower])
// Auto-expand ancestors for search results
useEffect(() => {
if (!searchLower) return
// Expand all visible nodes containing matches
setCollapsedNodes(prev => {
const next = { ...prev }
visibleIds.forEach(id => { next[id] = false })
return next
})
}, [searchLower, visibleIds])
// Tree renderer (inside component to access scope)
const renderNode = (node: MatrixUser, depth: number) => {
const children = childrenMap.get(node.id) || []
const hasChildren = children.length > 0
const collapsed = collapsedNodes[node.id]
const highlight = matchesSearch(node) && searchLower.length > 0
if (!visibleIds.has(node.id)) return null
const isRoot = node.level === 0
const pos = node.position ?? null
return ( return (
<section className="rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden"> <li key={node.id} className="relative">
<div className="px-4 sm:px-5 py-4 border-b border-gray-100 flex items-center justify-between"> <div
<h3 className="text-sm font-semibold text-gray-900">{title}</h3> className={`flex items-center gap-2 rounded-md border px-2 py-1 text-xs ${
{!isUnlimited && level > 0 && ( highlight ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white'
<div className="flex items-center gap-2"> }`}
<span className="text-[11px] font-semibold text-indigo-700">{Math.min(100, Math.round((listAll.length / (cap || 1)) * 100))}%</span> >
</div> {hasChildren && (
<button
onClick={() => toggleNode(node.id)}
className="h-4 w-4 rounded border border-gray-300 text-[10px] flex items-center justify-center bg-gray-50 hover:bg-gray-100"
aria-label={collapsed ? 'Expand' : 'Collapse'}
>
{collapsed ? '+' : ''}
</button>
)}
{!hasChildren && <span className="h-4 w-4" />}
{node.type === 'company'
? <BuildingOffice2Icon className="h-4 w-4 text-indigo-600" />
: <UserIcon className="h-4 w-4 text-blue-600" />}
<span className="font-medium truncate max-w-[160px]">{node.name}</span>
<span className="text-[10px] px-1 rounded bg-gray-100 text-gray-600">L{node.level}</span>
{(node as any).rogueUser || (node as any).rogue_user || (node as any).rogue ? (
<span className="text-[10px] px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-800">Rogue</span>
) : null}
{pos != null && (
<span className="text-[10px] px-1 rounded bg-gray-100 text-gray-600">
pos {pos}
</span>
)}
{isRoot && (
<span className="ml-auto text-[10px] text-gray-500">
{children.length} children (Unlimited)
</span>
)}
{!isRoot && hasChildren && (
<span className="ml-auto text-[10px] text-gray-500">
{children.length}/5
</span>
)} )}
</div> </div>
<div className="px-4 sm:px-5 pb-4"> {hasChildren && !collapsed && (
{listAll.length === 0 ? ( <ul className="ml-6 mt-1 flex flex-col gap-1">
<div className="text-xs text-gray-500 italic">{isUnlimited ? 'No users at this level yet.' : 'No users in this level yet.'}</div> {children.map(c => renderNode(c, depth + 1))}
) : ( </ul>
<> )}
<div className="mt-2 flex flex-wrap gap-2"> </li>
{list.map(u => <UserChip key={`${level}-${u.id}`} u={u} />)}
</div>
{showLoadMore && (
<div className="mt-3">
<button onClick={() => nextPage(level)} className="text-xs text-indigo-700 hover:text-indigo-900 underline">Load more users</button>
</div>
)}
</>
)}
</div>
</section>
) )
} }
// Depth range AB state with persistence // CSV export (now all users fetched)
const initialA = Number(sp.get('a') ?? (typeof window !== 'undefined' ? localStorage.getItem(`matrixDepthA:${matrixId}`) : null) ?? 0)
const initialB = Number(sp.get('b') ?? (typeof window !== 'undefined' ? localStorage.getItem(`matrixDepthB:${matrixId}`) : null) ?? 5)
const [depthA, setDepthA] = useState(Number.isFinite(initialA) ? initialA : 0)
const [depthB, setDepthB] = useState(Number.isFinite(initialB) ? initialB : 5)
const isUnlimited = !serverMaxDepth || serverMaxDepth <= 0
// includeRoot + fetch depth (affects hook)
const [includeRoot, setIncludeRoot] = useState(true)
const [fetchDepth, setFetchDepth] = useState(depthB)
useEffect(() => {
// persist selection
try {
localStorage.setItem(`matrixDepthA:${matrixId}`, String(depthA))
localStorage.setItem(`matrixDepthB:${matrixId}`, String(depthB))
} catch {}
setFetchDepth(depthB)
}, [matrixId, depthA, depthB])
// refetch when fetchDepth/includeRoot change
useEffect(() => {
// naive: change only logs; the hook takes initial params; a full re-mount would be needed to change depth.
// For simplicity, filter client-side and keep backend depth as initial; a production version should plumb depth into the hook deps.
}, [fetchDepth, includeRoot])
// Per-level paging (page size)
const [levelPageSize, setLevelPageSize] = useState(30)
const [levelPage, setLevelPage] = useState<Record<number, number>>({})
const pageFor = (lvl: number) => levelPage[lvl] ?? 1
const nextPage = (lvl: number) => setLevelPage(p => ({ ...p, [lvl]: pageFor(lvl) + 1 }))
// Filter to current slice
const usersInSlice = useMemo(
() => users.filter(u =>
u.level >= depthA && u.level <= depthB && (includeRoot || u.level !== 0)
),
[users, depthA, depthB, includeRoot]
)
const totalDescendants = users.length > 0 ? users.length - 1 : 0
// CSV export
const exportCsv = () => { const exportCsv = () => {
const rows = [['id','name','email','type','level','parentUserId']] const rows = [['id','name','email','type','level','parentUserId','rogue']]
usersInSlice.forEach(u => rows.push([u.id,u.name,u.email,u.type,u.level,u.parentUserId ?? ''] as any)) users.forEach(u => rows.push([
u.id,
u.name,
u.email,
u.type,
u.level,
u.parentUserId ?? '',
((u as any).rogueUser || (u as any).rogue_user || (u as any).rogue) ? 'true' : 'false'
] as any))
const csv = rows.map(r => r.map(v => `"${String(v).replace(/"/g,'""')}"`).join(',')).join('\n') const csv = rows.map(r => r.map(v => `"${String(v).replace(/"/g,'""')}"`).join(',')).join('\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' }) const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
const a = document.createElement('a') const a = document.createElement('a')
a.href = URL.createObjectURL(blob) a.href = URL.createObjectURL(blob)
a.download = `matrix-${matrixId}-levels-${depthA}-${depthB}.csv` a.download = `matrix-${matrixId}-unlimited.csv`
a.click() a.click()
URL.revokeObjectURL(a.href) URL.revokeObjectURL(a.href)
} }
@ -260,6 +338,10 @@ export default function MatrixDetailPage() {
} }
}, [usersLoading, refreshing]) }, [usersLoading, refreshing])
// REMOVE old isUnlimited derivation using serverMaxDepth; REPLACE with policy-based
// const isUnlimited = !serverMaxDepth || serverMaxDepth <= 0;
const isUnlimited = policyMaxDepth == null || policyMaxDepth <= 0 // NEW
return ( return (
<PageLayout> <PageLayout>
{/* Smooth refresh overlay */} {/* Smooth refresh overlay */}
@ -290,11 +372,15 @@ export default function MatrixDetailPage() {
Top node: <span className="font-semibold text-blue-900">{topNodeEmail}</span> Top node: <span className="font-semibold text-blue-900">{topNodeEmail}</span>
</p> </p>
<div className="mt-2 flex flex-wrap items-center gap-2"> <div className="mt-2 flex flex-wrap items-center gap-2">
{/* CHANGED: capacity clarification */}
<span className="inline-flex items-center rounded-full bg-gray-100 border border-gray-200 px-3 py-1 text-xs text-gray-800"> <span className="inline-flex items-center rounded-full bg-gray-100 border border-gray-200 px-3 py-1 text-xs text-gray-800">
Children/node: 5 Children/node: nonroot 5, root unlimited
</span> </span>
<span className="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-3 py-1 text-xs text-blue-900"> <span className="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-3 py-1 text-xs text-blue-900">
Max depth: {(!serverMaxDepth || serverMaxDepth <= 0) ? 'Unlimited' : serverMaxDepth} Max depth: {isUnlimited ? 'Unlimited' : policyMaxDepth}
</span>
<span className="inline-flex items-center rounded-full bg-gray-100 border border-gray-200 px-3 py-1 text-xs text-gray-800">
Root children: {rootChildrenCount} (Unlimited)
</span> </span>
</div> </div>
</div> </div>
@ -313,161 +399,67 @@ export default function MatrixDetailPage() {
{/* Banner for unlimited */} {/* Banner for unlimited */}
{isUnlimited && ( {isUnlimited && (
<div className="mb-4 rounded-md px-4 py-2 text-xs text-blue-900 bg-blue-50 border border-blue-200"> <div className="mb-4 rounded-md px-4 py-2 text-xs text-blue-900 bg-blue-50 border border-blue-200">
Large structure. Results are paginated by depth and count. Unlimited matrix: depth grows without a configured cap. Display limited by fetch slice ({DEFAULT_FETCH_DEPTH} levels requested).
</div> </div>
)} )}
{/* Sticky depth controls */} {/* Sticky controls (CHANGED depth display) */}
<div className="sticky top-0 z-10 bg-white/90 backdrop-blur px-6 py-4 border-b border-blue-100 flex flex-wrap items-center gap-4 rounded-xl mb-6 shadow"> <div className="sticky top-0 z-10 bg-white/90 backdrop-blur px-6 py-4 border-b border-blue-100 flex flex-wrap items-center gap-4 rounded-xl mb-6 shadow">
<div className="flex items-center gap-2"> <div className="relative w-64">
<label className="text-xs text-gray-600">From</label> <MagnifyingGlassIcon className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-blue-300" />
<input type="number" min={0} value={depthA} onChange={e => setDepthA(Math.max(0, Number(e.target.value) || 0))} className="w-16 rounded-lg border border-gray-300 px-2 py-1 text-xs" />
<label className="text-xs text-gray-600">to</label>
<input type="number" min={depthA} value={depthB} onChange={e => setDepthB(Math.max(depthA, Number(e.target.value) || depthA))} className="w-16 rounded-lg border border-gray-300 px-2 py-1 text-xs" />
</div>
<button
onClick={() => { setDepthA(Math.max(0, depthA - 5)); setDepthB(Math.max(5, depthB - 5)); }}
className="inline-flex items-center gap-1 rounded-lg border border-blue-200 bg-blue-50 px-3 py-1 text-xs font-medium text-blue-900 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-300"
>
Load previous 5 levels
</button>
<button
onClick={() => { setDepthA(depthA + 5); setDepthB(depthB + 5); }}
className="inline-flex items-center gap-1 rounded-lg border border-blue-200 bg-blue-50 px-3 py-1 text-xs font-medium text-blue-900 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-300"
>
Load next 5 levels
</button>
<label className="ml-3 inline-flex items-center gap-2 text-xs text-gray-700">
<input <input
type="checkbox" value={globalSearch}
checked={includeRoot} onChange={e => setGlobalSearch(e.target.value)}
onChange={e => setIncludeRoot(e.target.checked)} placeholder="Global search..."
className="h-3 w-3 rounded border-gray-300 text-blue-900 focus:ring-blue-900" className="pl-8 pr-2 py-2 rounded-lg border border-gray-200 text-xs focus:ring-1 focus:ring-blue-900 focus:border-transparent w-full"
/> />
Include root
</label>
<div className="ml-auto text-xs text-gray-600">
Showing levels {depthA}{depthB} of {isUnlimited ? 'Unlimited' : serverMaxDepth}
</div> </div>
<button onClick={exportCsv} className="ml-3 text-xs text-blue-900 hover:text-blue-700 underline">Export CSV (levels {depthA}{depthB})</button> <button
onClick={() => exportCsv()}
className="text-xs text-blue-900 hover:text-blue-700 underline"
>
Export CSV (all fetched)
</button>
<div className="ml-auto text-[11px] text-gray-600">
Policy depth: {isUnlimited ? 'Unlimited' : policyMaxDepth}
</div>
</div> </div>
{/* Small stats */} {/* Small stats (CHANGED wording) */}
<div className="mb-8 grid grid-cols-1 sm:grid-cols-3 gap-6"> <div className="mb-8 grid grid-cols-1 sm:grid-cols-3 gap-6">
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow"> <div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
<div className="text-xs text-gray-500 mb-1">Users (current slice)</div> <div className="text-xs text-gray-500 mb-1">Total users fetched</div>
<div className="text-xl font-semibold text-blue-900">{usersInSlice.length}</div> <div className="text-xl font-semibold text-blue-900">{users.length}</div>
</div> </div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow"> <div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
<div className="text-xs text-gray-500 mb-1">Total descendants</div> <div className="text-xs text-gray-500 mb-1">Rogue users</div>
<div className="text-xl font-semibold text-blue-900">{isUnlimited ? 'All descendants so far' : totalDescendants}</div> <div className="text-xl font-semibold text-blue-900">{rogueCount}</div>
</div> </div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow"> <div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
<div className="text-xs text-gray-500 mb-1">Active levels loaded</div> <div className="text-xs text-gray-500 mb-1">Structure</div>
<div className="text-xl font-semibold text-blue-900">{depthA}{depthB}</div> <div className="text-xl font-semibold text-blue-900">5ary Tree</div>
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
<div className="text-xs text-gray-500 mb-1">Policy Max Depth</div>
<div className="text-xl font-semibold text-blue-900">{isUnlimited ? 'Unlimited' : policyMaxDepth}</div>
</div> </div>
</div> </div>
{/* Dynamic levels */} {/* Unlimited hierarchical tree (replaces dynamic levels + grouped level list) */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8 mb-8">
{Array
.from(byLevel.keys())
.filter(l => l >= depthA && l <= depthB)
.filter(l => includeRoot || l !== 0)
.sort((a,b)=>a-b)
.map(l => (
<LevelSection key={l} level={l} />
))}
</div>
{/* Jump to level */}
<div className="mb-8">
<label className="text-xs text-gray-600 mr-2">Jump to level</label>
<select className="rounded-lg border border-gray-300 px-3 py-1 text-xs" onChange={e => {
const lv = Number(e.target.value) || 0
setDepthA(lv); setDepthB(Math.max(lv, lv + 5))
}}>
{Array.from(byLevel.keys()).sort((a,b)=>a-b).map(l => <option key={l} value={l}>Level {l}</option>)}
</select>
</div>
{/* Collapsible All users list by level */}
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden mb-8"> <div className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden mb-8">
<div className="px-8 py-6 border-b border-gray-100"> <div className="px-8 py-6 border-b border-gray-100">
<h2 className="text-xl font-semibold text-blue-900">All users in this matrix</h2> <h2 className="text-xl font-semibold text-blue-900">Matrix Tree (Unlimited Depth)</h2>
<p className="text-xs text-blue-700">Grouped by levels (power of five structure).</p> <p className="text-xs text-blue-700">Each node can hold up to 5 direct children. Depth unbounded.</p>
</div> </div>
<div className="divide-y divide-gray-100"> <div className="px-8 py-6">
{[0,1,2,3,4,5].map(lvl => { {!rootNode && (
const list = byLevel.get(lvl) || [] <div className="text-xs text-gray-500 italic">Root not yet loaded.</div>
const filteredList = list.filter(u => { )}
const globalMatch = !globalSearch || ( {rootNode && (
u.name.toLowerCase().includes(globalSearch.toLowerCase()) || <ul className="flex flex-col gap-1">
u.email.toLowerCase().includes(globalSearch.toLowerCase()) {renderNode(rootNode, 0)}
) </ul>
const levelMatch = !levelSearch[lvl] || ( )}
u.name.toLowerCase().includes(levelSearch[lvl].toLowerCase()) ||
u.email.toLowerCase().includes(levelSearch[lvl].toLowerCase())
)
return globalMatch && levelMatch
})
return (
<div key={lvl} className="px-8 py-6">
<button
className="flex items-center justify-between w-full text-left"
onClick={() => {
setCollapsedLevels(prev => ({ ...prev, [lvl]: !prev[lvl] }))
}}
>
<span className="mb-2 text-base font-semibold text-blue-900 flex items-center gap-2">
{lvl === 0 ? 'Level 0 (Top node)' : `Level ${lvl}`} {list.length} user(s)
</span>
<span className="ml-2">
{collapsedLevels[lvl] ? (
<span className="inline-block w-4 h-4 text-gray-400">&#9654;</span>
) : (
<span className="inline-block w-4 h-4 text-gray-400">&#9660;</span>
)}
</span>
</button>
{/* Per-level search only when expanded */}
{!collapsedLevels[lvl] && (
<>
<div className="flex justify-end mt-2 mb-3">
<div className="relative w-64">
<MagnifyingGlassIcon className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-blue-300" />
<input
type="text"
value={levelSearch[lvl]}
onChange={e => setLevelSearch(prev => ({ ...prev, [lvl]: e.target.value }))}
placeholder="Search in level..."
className="pl-8 pr-2 py-2 rounded-lg border border-gray-200 text-xs focus:ring-1 focus:ring-blue-900 focus:border-transparent w-full"
/>
</div>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
{filteredList.length === 0 && (
<div className="col-span-full text-xs text-gray-500 italic">No users found.</div>
)}
{filteredList.length > 0 && filteredList.map(u => (
<div key={`${lvl}-${u.id}`} className="rounded-lg border border-gray-100 p-4 bg-blue-50">
<div className="flex items-center justify-between">
<div className="text-sm font-medium text-blue-900">{u.name}</div>
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full ${
u.type === 'company' ? 'bg-indigo-100 text-indigo-800' : 'bg-blue-100 text-blue-800'
}`}>
{u.type === 'company' ? 'Company' : 'Personal'}
</span>
</div>
<div className="mt-1 text-xs text-blue-700">{u.email}</div>
</div>
))}
</div>
</>
)}
</div>
)
})}
</div> </div>
</div> </div>
@ -480,7 +472,7 @@ export default function MatrixDetailPage() {
matrixId={matrixId} matrixId={matrixId}
topNodeEmail={topNodeEmail} topNodeEmail={topNodeEmail}
existingUsers={users} existingUsers={users}
policyMaxDepth={serverMaxDepth ?? null} policyMaxDepth={policyMaxDepth} // CHANGED: pass real policy max depth
onAdd={(u) => { addToMatrix(u) }} onAdd={(u) => { addToMatrix(u) }}
/> />
</div> </div>

View File

@ -0,0 +1,45 @@
import { authFetch } from '../../../utils/authFetch'
export type MatrixStateData = {
matrixInstanceId: string | number
wasActive: boolean
isActive: boolean
status: 'deactivated' | 'already_inactive' | 'activated' | 'already_active'
}
type MatrixStateResponse = {
success: boolean
data?: MatrixStateData
message?: string
}
const baseUrl = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
async function patch(endpoint: string) {
const url = `${baseUrl}${endpoint}`
const res = await authFetch(url, {
method: 'PATCH',
headers: { Accept: 'application/json' },
credentials: 'include'
})
const ct = res.headers.get('content-type') || ''
const raw = await res.text()
const json: MatrixStateResponse | null = ct.includes('application/json') ? JSON.parse(raw) : null
if (!res.ok || !json?.success) {
const msg = json?.message || `Request failed: ${res.status}`
throw new Error(msg)
}
return json.data!
}
export async function deactivateMatrix(id: string | number) {
if (!id && id !== 0) throw new Error('matrix id required')
return patch(`/api/admin/matrix/${id}/deactivate`)
}
export async function activateMatrix(id: string | number) {
if (!id && id !== 0) throw new Error('matrix id required')
// Assuming symmetrical endpoint; backend may expose this path.
return patch(`/api/admin/matrix/${id}/activate`)
}

View File

@ -14,6 +14,7 @@ import { useRouter } from 'next/navigation'
import useAuthStore from '../../store/authStore' import useAuthStore from '../../store/authStore'
import { createMatrix } from './hooks/createMatrix' import { createMatrix } from './hooks/createMatrix'
import { getMatrixStats } from './hooks/getMatrixStats' import { getMatrixStats } from './hooks/getMatrixStats'
import { deactivateMatrix, activateMatrix } from './hooks/changeMatrixState' // NEW
type Matrix = { type Matrix = {
id: string id: string
@ -61,8 +62,9 @@ export default function MatrixManagementPage() {
const [forcePrompt, setForcePrompt] = useState<{ name: string; email: string } | null>(null) const [forcePrompt, setForcePrompt] = useState<{ name: string; email: string } | null>(null)
const [createSuccess, setCreateSuccess] = 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') // NEW const [policyFilter, setPolicyFilter] = useState<'unlimited'|'five'>('unlimited') // NEW
const [sortByUsers, setSortByUsers] = useState<'asc'|'desc'>('desc') // NEW const [sortByUsers, setSortByUsers] = useState<'asc'|'desc'>('desc') // NEW
const [mutatingId, setMutatingId] = useState<string | null>(null) // NEW
const loadStats = async () => { const loadStats = async () => {
if (!token) return if (!token) return
@ -194,21 +196,32 @@ export default function MatrixManagementPage() {
} }
} }
const toggleStatus = (id: string) => { const toggleStatus = async (id: string) => {
setMatrices(prev => try {
prev.map(m => (m.id === id ? { ...m, status: m.status === 'active' ? 'inactive' : 'active' } : m)) 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 // derived list with filter/sort (always apply selected filter)
const matricesView = useMemo(() => { const matricesView = useMemo(() => {
let list = [...matrices] let list = [...matrices]
if (policyFilter !== 'all') { list = list.filter(m => {
list = list.filter(m => { const unlimited = !m.policyMaxDepth || m.policyMaxDepth <= 0
const unlimited = !m.policyMaxDepth || m.policyMaxDepth <= 0 return policyFilter === 'unlimited' ? unlimited : (!unlimited && m.policyMaxDepth === 5)
return policyFilter === 'unlimited' ? unlimited : (!unlimited && m.policyMaxDepth === 5) })
})
}
list.sort((a,b) => sortByUsers === 'asc' ? (a.usersCount - b.usersCount) : (b.usersCount - a.usersCount)) list.sort((a,b) => sortByUsers === 'asc' ? (a.usersCount - b.usersCount) : (b.usersCount - a.usersCount))
return list return list
}, [matrices, policyFilter, sortByUsers]) }, [matrices, policyFilter, sortByUsers])
@ -285,35 +298,6 @@ export default function MatrixManagementPage() {
<StatCard icon={UsersIcon} label="Total Users Subscribed" value={stats.totalUsers} color="bg-amber-600" /> <StatCard icon={UsersIcon} label="Total Users Subscribed" value={stats.totalUsers} color="bg-amber-600" />
</div> </div>
{/* Filters */}
<div className="mb-6 flex flex-wrap items-center gap-4">
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">Policy</label>
<select value={policyFilter} onChange={e => setPolicyFilter(e.target.value as any)} className="rounded-lg border border-gray-300 px-3 py-2 text-sm bg-white shadow">
<option value="all">All</option>
<option value="unlimited">Unlimited</option>
<option value="five">5-level</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">Sort by users</label>
<select value={sortByUsers} onChange={e => setSortByUsers(e.target.value as any)} className="rounded-lg border border-gray-300 px-3 py-2 text-sm bg-white shadow">
<option value="desc">Desc</option>
<option value="asc">Asc</option>
</select>
</div>
<div className="text-xs text-gray-500" title="Users count respects each matrixs max depth policy.">
Users count respects each matrixs max depth policy.
</div>
</div>
{/* Optional health hint */}
{policyFilter !== 'five' && matricesView.some(m => !m.policyMaxDepth || m.policyMaxDepth <= 0) && (
<div className="mb-4 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-900">
Large tree matrices may require pagination by levels. <a className="underline" href="https://example.com/docs/matrix-pagination" target="_blank">Learn more</a>
</div>
)}
{/* Matrix cards */} {/* Matrix cards */}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
{statsLoading ? ( {statsLoading ? (
@ -362,12 +346,15 @@ export default function MatrixManagementPage() {
<div className="mt-5 flex items-center justify-between"> <div className="mt-5 flex items-center justify-between">
<button <button
onClick={() => toggleStatus(m.id)} onClick={() => toggleStatus(m.id)}
disabled={mutatingId === m.id}
className={`rounded-lg px-4 py-2 text-sm font-medium border shadow transition className={`rounded-lg px-4 py-2 text-sm font-medium border shadow transition
${m.status === 'active' ${m.status === 'active'
? 'border-red-300 text-red-700 hover:bg-red-50' ? 'border-red-300 text-red-700 hover:bg-red-50 disabled:opacity-60'
: 'border-green-300 text-green-700 hover:bg-green-50'}`} : 'border-green-300 text-green-700 hover:bg-green-50 disabled:opacity-60'}`}
> >
{m.status === 'active' ? 'Deactivate' : 'Activate'} {mutatingId === m.id
? (m.status === 'active' ? 'Deactivating…' : 'Activating…')
: (m.status === 'active' ? 'Deactivate' : 'Activate')}
</button> </button>
<button <button
className="text-sm font-medium text-blue-900 hover:text-blue-700" className="text-sm font-medium text-blue-900 hover:text-blue-700"
@ -388,24 +375,6 @@ export default function MatrixManagementPage() {
View details View details
</button> </button>
</div> </div>
<div className="mt-3 flex items-center gap-2">
<span className="text-xs text-gray-500">Default depth slice:</span>
<input
type="number"
min={0}
defaultValue={Number(localStorage.getItem(`matrixDepthA:${m.id}`) ?? 0)}
onBlur={e => localStorage.setItem(`matrixDepthA:${m.id}`, String(Math.max(0, Number(e.target.value) || 0)))}
className="w-16 rounded-lg border border-gray-300 px-2 py-1 text-xs"
/>
<span className="text-xs text-gray-500">to</span>
<input
type="number"
min={0}
defaultValue={Number(localStorage.getItem(`matrixDepthB:${m.id}`) ?? 5)}
onBlur={e => localStorage.setItem(`matrixDepthB:${m.id}`, String(Math.max(0, Number(e.target.value) || 0)))}
className="w-16 rounded-lg border border-gray-300 px-2 py-1 text-xs"
/>
</div>
</div> </div>
</article> </article>
)) ))

View File

@ -0,0 +1,125 @@
'use client'
import React from 'react'
interface Props {
isOpen: boolean
onClose: () => void
onCreate: (data: { name: string; description: string }) => void | Promise<void>
creating: boolean
error?: string
success?: string
clearMessages: () => void
}
export default function CreateNewPoolModal({
isOpen,
onClose,
onCreate,
creating,
error,
success,
clearMessages
}: Props) {
const [name, setName] = React.useState('')
const [description, setDescription] = React.useState('')
React.useEffect(() => {
if (!isOpen) {
setName('')
setDescription('')
}
}, [isOpen])
if (!isOpen) return null
return (
<div className="fixed inset-0 z-50 flex items-center justify-center">
{/* Overlay */}
<div
className="absolute inset-0 bg-black/40 backdrop-blur-sm"
onClick={onClose}
/>
{/* Modal */}
<div className="relative w-full max-w-lg mx-4 rounded-2xl bg-white shadow-xl border border-blue-100 p-6">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-semibold text-blue-900">Create New Pool</h2>
<button
onClick={() => { clearMessages(); onClose(); }}
className="text-gray-500 hover:text-gray-700 transition text-sm"
aria-label="Close"
>
</button>
</div>
{success && (
<div className="mb-4 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">
{success}
</div>
)}
{error && (
<div className="mb-4 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{error}
</div>
)}
<form
onSubmit={e => {
e.preventDefault()
clearMessages()
onCreate({ name, description })
}}
className="space-y-4"
>
<div>
<label className="block text-sm font-medium text-blue-900 mb-1">Pool Name</label>
<input
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"
placeholder="e.g., VIP Members"
value={name}
onChange={e => setName(e.target.value)}
disabled={creating}
/>
</div>
<div>
<label className="block text-sm font-medium text-blue-900 mb-1">Description</label>
<textarea
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"
rows={3}
placeholder="Short description of the pool"
value={description}
onChange={e => setDescription(e.target.value)}
disabled={creating}
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={creating}
className="px-5 py-3 text-sm font-semibold text-blue-50 rounded-lg bg-blue-900 hover:bg-blue-800 shadow inline-flex items-center gap-2 disabled:opacity-60"
>
{creating && <span className="h-4 w-4 rounded-full border-2 border-white/30 border-t-white animate-spin" />}
{creating ? 'Creating...' : 'Create Pool'}
</button>
<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={() => { setName(''); setDescription(''); clearMessages(); }}
disabled={creating}
>
Reset
</button>
<button
type="button"
className="ml-auto px-4 py-2 text-sm font-medium text-gray-600 hover:text-gray-800 transition"
onClick={() => { clearMessages(); onClose(); }}
disabled={creating}
>
Close
</button>
</div>
</form>
</div>
</div>
)
}

View File

@ -10,6 +10,7 @@ import { addPool } from './hooks/addPool'
import { useRouter } from 'next/navigation' import { useRouter } from 'next/navigation'
import { archivePoolById, setPoolState } from './hooks/archivePool' import { archivePoolById, setPoolState } from './hooks/archivePool'
import PageTransitionEffect from '../../components/animation/pageTransitionEffect' import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
import CreateNewPoolModal from './components/createNewPoolModal'
type Pool = { type Pool = {
id: string id: string
@ -23,27 +24,13 @@ type Pool = {
export default function PoolManagementPage() { export default function PoolManagementPage() {
const router = useRouter() const router = useRouter()
// Form state, dropdown, errors // Modal state
const [form, setForm] = React.useState({ name: '', description: '' })
const [creating, setCreating] = React.useState(false) const [creating, setCreating] = React.useState(false)
const [formError, setFormError] = React.useState<string>('') const [createError, setCreateError] = React.useState<string>('')
const [formSuccess, setFormSuccess] = React.useState<string>('') const [createSuccess, setCreateSuccess] = React.useState<string>('')
const [createOpen, setCreateOpen] = React.useState(false) const [createModalOpen, setCreateModalOpen] = React.useState(false)
const [archiveError, setArchiveError] = React.useState<string>('') const [archiveError, setArchiveError] = React.useState<string>('')
// Initialize dropdown open state from localStorage and persist on changes
React.useEffect(() => {
const stored = typeof window !== 'undefined' ? localStorage.getItem('admin:pools:createOpen') : null;
if (stored != null) setCreateOpen(stored === 'true');
}, []);
const setCreateOpenPersist = (val: boolean | ((v: boolean) => boolean)) => {
setCreateOpen(prev => {
const next = typeof val === 'function' ? (val as (v: boolean) => boolean)(prev) : val;
if (typeof window !== 'undefined') localStorage.setItem('admin:pools:createOpen', String(next));
return next;
});
};
// Token and API URL // Token and API URL
const token = useAuthStore.getState().accessToken const token = useAuthStore.getState().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'
@ -58,31 +45,29 @@ export default function PoolManagementPage() {
} }
}, [initialPools, loading, error]) }, [initialPools, loading, error])
async function handleCreatePool(e: React.FormEvent) { // REPLACED: handleCreatePool to accept data from modal
e.preventDefault() async function handleCreatePool(data: { name: string; description: string }) {
setFormError('') setCreateError('')
setFormSuccess('') setCreateSuccess('')
const name = form.name.trim() const name = data.name.trim()
const description = form.description.trim() const description = data.description.trim()
if (!name) { if (!name) {
setFormError('Please provide a pool name.') setCreateError('Please provide a pool name.')
return return
} }
setCreating(true) setCreating(true)
try { try {
const res = await addPool({ name, description: description || undefined, state: 'active' }) const res = await addPool({ name, description: description || undefined, state: 'active' })
if (res.ok && res.body?.data) { if (res.ok && res.body?.data) {
setFormSuccess('Pool created successfully.') setCreateSuccess('Pool created successfully.')
setForm({ name: '', description: '' })
// Refresh list from backend to include the new pool
await refresh?.() await refresh?.()
// Do NOT close; keep dropdown open across refresh // keep modal open so user sees success; optional close:
// setCreateOpenPersist(false) // removed to keep it open // setCreateModalOpen(false)
} else { } else {
setFormError(res.message || 'Failed to create pool.') setCreateError(res.message || 'Failed to create pool.')
} }
} catch { } catch {
setFormError('Network error while creating pool.') setCreateError('Network error while creating pool.')
} finally { } finally {
setCreating(false) setCreating(false)
} }
@ -142,10 +127,8 @@ export default function PoolManagementPage() {
<PageTransitionEffect> <PageTransitionEffect>
<div className="min-h-screen flex flex-col bg-gradient-to-tr from-blue-50 via-white to-blue-100"> <div className="min-h-screen flex flex-col bg-gradient-to-tr from-blue-50 via-white to-blue-100">
<Header /> <Header />
{/* main wrapper: avoid high z-index stacking */}
<main className="flex-1 py-8 px-4 sm:px-6 lg:px-8 relative z-0"> <main className="flex-1 py-8 px-4 sm:px-6 lg:px-8 relative z-0">
<div className="max-w-7xl mx-auto relative z-0"> <div className="max-w-7xl mx-auto relative z-0">
{/* Page Header: remove sticky and high z-index */}
<header className="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 relative z-0"> <header className="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 relative z-0">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
@ -153,81 +136,14 @@ export default function PoolManagementPage() {
<p className="text-lg text-blue-700 mt-2">Create and manage user pools.</p> <p className="text-lg text-blue-700 mt-2">Create and manage user pools.</p>
</div> </div>
<button <button
onClick={() => setCreateOpenPersist(o => !o)} onClick={() => { setCreateModalOpen(true); createError && setCreateError(''); }}
className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow transition" className="inline-flex items-center gap-2 rounded-lg bg-blue-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-sm font-semibold shadow transition"
aria-expanded={createOpen}
aria-controls="create-pool-section"
> >
{createOpen ? 'Close' : 'Create New Pool'} Create New Pool
</button> </button>
</div> </div>
</header> </header>
{/* Create Pool dropdown */}
<div id="create-pool-section" className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-visible mb-8 relative z-0">
<button
onClick={() => setCreateOpenPersist(o => !o)}
className="w-full flex items-center justify-between px-6 py-4 text-left"
aria-expanded={createOpen}
>
<span className="text-lg font-semibold text-blue-900">Create New Pool</span>
<span className={`transition-transform ${createOpen ? 'rotate-180' : 'rotate-0'} text-gray-500`} aria-hidden="true"></span>
</button>
{createOpen && (
<div className="px-6 pb-6">
{/* Success/Error banners */}
{formSuccess && (
<div className="mb-3 rounded-md border border-green-200 bg-green-50 px-3 py-2 text-sm text-green-700">
{formSuccess}
</div>
)}
{formError && (
<div className="mb-3 rounded-md border border-red-200 bg-red-50 px-3 py-2 text-sm text-red-700">
{formError}
</div>
)}
<form onSubmit={handleCreatePool} className="space-y-4">
<div>
<label className="block text-sm font-medium text-blue-900 mb-1">Pool Name</label>
<input
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"
placeholder="e.g., VIP Members"
value={form.name}
onChange={e => setForm(f => ({ ...f, name: e.target.value }))}
/>
</div>
<div>
<label className="block text-sm font-medium text-blue-900 mb-1">Description</label>
<textarea
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"
rows={3}
placeholder="Short description of the pool"
value={form.description}
onChange={e => setForm(f => ({ ...f, description: e.target.value }))}
/>
</div>
<div className="flex gap-2">
<button
type="submit"
disabled={creating}
className="px-5 py-3 text-sm font-semibold text-blue-50 rounded-lg bg-blue-900 hover:bg-blue-800 shadow inline-flex items-center gap-2 disabled:opacity-60"
>
{creating && <span className="h-4 w-4 rounded-full border-2 border-white/30 border-t-white animate-spin" />}
{creating ? 'Creating...' : 'Create Pool'}
</button>
<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={() => { setForm({ name: '', description: '' }); setFormError(''); setFormSuccess(''); }}
>
Reset
</button>
</div>
</form>
</div>
)}
</div>
{/* Pools List card */} {/* Pools List card */}
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6 relative z-0"> <div className="rounded-2xl bg-white border border-gray-100 shadow-lg p-6 relative z-0">
<div className="flex items-center justify-between mb-4"> <div className="flex items-center justify-between mb-4">
@ -335,6 +251,18 @@ export default function PoolManagementPage() {
</div> </div>
</div> </div>
</main> </main>
{/* Modal for creating a new pool */}
<CreateNewPoolModal
isOpen={createModalOpen}
onClose={() => { setCreateModalOpen(false); setCreateError(''); setCreateSuccess(''); }}
onCreate={handleCreatePool}
creating={creating}
error={createError}
success={createSuccess}
clearMessages={() => { setCreateError(''); setCreateSuccess(''); }}
/>
<Footer /> <Footer />
</div> </div>
</PageTransitionEffect> </PageTransitionEffect>

View File

@ -0,0 +1,111 @@
import { useEffect, useMemo, useRef, useState } from 'react'
import { authFetch } from '../../utils/authFetch'
export type Level1Child = {
userId: number
email: string
name: string
position: number | null
}
export type Level2PlusItem = {
userId: number
depth: number
nameMasked: string
}
export type PersonalOverview = {
matrixInstanceId: string | number
totalUsersUnderMe: number
levelsFilled: number
immediateChildrenCount: number
rootSlotsRemaining: number | null
level1: Level1Child[]
level2Plus: Level2PlusItem[]
}
export function usePersonalMatrixOverview() {
const [data, setData] = useState<PersonalOverview | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string | null>(null)
const abortRef = useRef<AbortController | null>(null)
useEffect(() => {
// Abort any inflight when re-running
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
const url = `${base}/api/matrix/me/overview`
setLoading(true)
// Do not clear error if we already have valid data; only clear when starting fresh
setError(null)
authFetch(url, {
method: 'GET',
headers: { Accept: 'application/json' },
credentials: 'include',
signal: controller.signal as any
})
.then(async r => {
const ct = r.headers.get('content-type') || ''
if (!r.ok || !ct.includes('application/json')) {
const txt = await r.text().catch(() => '')
throw new Error(`Request failed: ${r.status} ${txt.slice(0, 120)}`)
}
const json = await r.json()
const d = json?.data || json
const mapped: PersonalOverview = {
matrixInstanceId: d.matrixInstanceId,
totalUsersUnderMe: Number(d.totalUsersUnderMe ?? 0),
levelsFilled: Number(d.levelsFilled ?? 0),
immediateChildrenCount: Number(d.immediateChildrenCount ?? 0),
rootSlotsRemaining: d.rootSlotsRemaining == null ? null : Number(d.rootSlotsRemaining),
level1: Array.isArray(d.level1) ? d.level1.map((c: any) => ({
userId: Number(c.userId),
email: String(c.email || ''),
name: String(c.name || c.email || ''),
position: c.position == null ? null : Number(c.position)
})) : [],
level2Plus: Array.isArray(d.level2Plus) ? d.level2Plus.map((x: any) => ({
userId: Number(x.userId),
depth: Number(x.depth ?? 0),
nameMasked: String(x.name ?? '')
})) : []
}
setData(mapped)
})
.catch((e: any) => {
// Ignore aborts (during navigation/unmount or re-run)
if (controller.signal.aborted) {
// Do not set error; just exit quietly
return
}
// Some environments throw DOMException name 'AbortError'
if (e?.name === 'AbortError') {
return
}
setError(e?.message || 'Failed to load overview')
setData(null)
})
.finally(() => {
if (!controller.signal.aborted) {
setLoading(false)
}
})
// Cleanup: abort on unmount
return () => {
controller.abort()
}
}, [])
const meta = useMemo(() => ({
countL1: data?.level1.length ?? 0,
countL2Plus: data?.level2Plus.length ?? 0
}), [data])
return { data, loading, error, meta }
}

View File

@ -0,0 +1,122 @@
'use client'
import React, { useEffect } from 'react'
import useAuthStore from '../store/authStore'
import { useRouter } from 'next/navigation'
import PageLayout from '../components/PageLayout'
import { UsersIcon, CalendarDaysIcon, ArrowLeftIcon } from '@heroicons/react/24/outline'
import { usePersonalMatrixOverview } from './hooks/getStats'
export default function PersonalMatrixPage() {
const router = useRouter()
const user = useAuthStore(s => s.user)
const { data, loading, error, meta } = usePersonalMatrixOverview()
useEffect(() => {
if (user === null) router.replace('/login')
}, [user, router])
if (user === null) return null
return (
<PageLayout>
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen">
<div className="max-w-6xl mx-auto px-4 sm:px-6 py-8">
<header className="mb-8 rounded-2xl border border-gray-100 bg-white shadow-lg px-8 py-8 flex flex-col gap-4">
<div className="flex items-center justify-between">
<div className="space-y-2">
<button
onClick={() => router.push('/')}
className="inline-flex items-center gap-2 text-sm text-blue-900 hover:text-blue-700"
>
<ArrowLeftIcon className="h-4 w-4" />
Back
</button>
<h1 className="text-3xl font-extrabold text-blue-900">My Matrix Overview</h1>
<p className="text-base text-blue-700">Personal subtree, privacy-preserving beyond level 1.</p>
</div>
</div>
</header>
{error && (
<div className="mb-6 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
{error}
</div>
)}
<div className="mb-8 grid grid-cols-1 sm:grid-cols-3 gap-6">
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
<div className="text-xs text-gray-500 mb-1">Total users under me</div>
<div className="text-xl font-semibold text-blue-900">{data?.totalUsersUnderMe ?? (loading ? '…' : 0)}</div>
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
<div className="text-xs text-gray-500 mb-1">Immediate children</div>
<div className="text-xl font-semibold text-blue-900">{data?.immediateChildrenCount ?? (loading ? '…' : 0)}</div>
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
<div className="text-xs text-gray-500 mb-1">Levels filled</div>
<div className="text-xl font-semibold text-blue-900">{data?.levelsFilled ?? (loading ? '…' : 0)}</div>
</div>
</div>
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden mb-8">
<div className="px-8 py-6 border-b border-gray-100">
<h2 className="text-xl font-semibold text-blue-900">Level 1 (Direct Children)</h2>
<p className="text-xs text-blue-700">
Up to the first five direct children. {data?.rootSlotsRemaining != null ? `Root slots remaining: ${data.rootSlotsRemaining}` : ''}
</p>
</div>
<div className="px-8 py-6">
{loading && <div className="text-xs text-gray-500">Loading</div>}
{!loading && (data?.level1?.length ?? 0) === 0 && (
<div className="text-xs text-gray-500 italic">No direct children.</div>
)}
<ul className="grid grid-cols-1 md:grid-cols-2 gap-4">
{data?.level1.map((c) => (
<li key={c.userId} className="rounded-lg border border-gray-100 p-4 bg-blue-50">
<div className="flex items-center justify-between">
<div className="text-sm font-medium text-blue-900">{c.name}</div>
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-gray-100 text-gray-700">
{c.position != null ? `pos ${c.position}` : 'pos —'}
</span>
</div>
<div className="mt-1 text-xs text-blue-700 break-all">{c.email}</div>
</li>
))}
</ul>
</div>
</div>
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden mb-8">
<div className="px-8 py-6 border-b border-gray-100">
<h2 className="text-xl font-semibold text-blue-900">Level 2+</h2>
<p className="text-xs text-blue-700">Masked names for deeper descendants.</p>
</div>
<div className="px-8 py-6">
{loading && <div className="text-xs text-gray-500">Loading</div>}
{!loading && (data?.level2Plus?.length ?? 0) === 0 && (
<div className="text-xs text-gray-500 italic">No deeper descendants.</div>
)}
<ul className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{data?.level2Plus.map((x) => (
<li key={x.userId} className="rounded-lg border border-gray-100 p-3 bg-gray-50">
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-gray-800">{x.nameMasked}</span>
<span className="text-[10px] font-semibold px-2 py-0.5 rounded-full bg-gray-100 text-gray-700">L{x.depth}</span>
</div>
</li>
))}
</ul>
</div>
</div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
<div className="text-xs text-gray-500 mb-1">Overview meta</div>
<div className="text-xs text-gray-700">
Matrix instance: {data?.matrixInstanceId ?? (loading ? '…' : '—')}{' '}
Level1: {meta.countL1} Level2+: {meta.countL2Plus}
</div>
</div>
</div>
</div>
</PageLayout>
)
}