feat: add matrix managemet backend
This commit is contained in:
parent
6bf1ca006e
commit
18a873ffe3
@ -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,18 +452,22 @@ 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.'
|
||||||
|
: (!policyMaxDepth || policyMaxDepth <= 0)
|
||||||
|
? 'Unlimited policy: no remaining-level cap for subtree.'
|
||||||
: `Remaining levels under chosen parent = Max(${policyMaxDepth}) - parent level.`}
|
: `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
|
||||||
|
|||||||
@ -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)
|
||||||
|
|||||||
@ -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 m = new Map<number, MatrixUser[]>()
|
||||||
|
users.forEach(u => {
|
||||||
|
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]
|
||||||
)
|
)
|
||||||
const list = listAll.slice(0, pageFor(level) * levelPageSize)
|
|
||||||
const title = `Level ${level} — ${listAll.length} users`
|
// NEW: immediate children count for root (unlimited capacity display)
|
||||||
const showLoadMore = listAll.length > list.length
|
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">
|
|
||||||
{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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</li>
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Depth range A–B 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: non‑root 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,162 +399,68 @@ 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">
|
|
||||||
<label className="text-xs text-gray-600">From</label>
|
|
||||||
<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
|
|
||||||
type="checkbox"
|
|
||||||
checked={includeRoot}
|
|
||||||
onChange={e => setIncludeRoot(e.target.checked)}
|
|
||||||
className="h-3 w-3 rounded border-gray-300 text-blue-900 focus:ring-blue-900"
|
|
||||||
/>
|
|
||||||
Include root
|
|
||||||
</label>
|
|
||||||
<div className="ml-auto text-xs text-gray-600">
|
|
||||||
Showing levels {depthA}–{depthB} of {isUnlimited ? 'Unlimited' : serverMaxDepth}
|
|
||||||
</div>
|
|
||||||
<button onClick={exportCsv} className="ml-3 text-xs text-blue-900 hover:text-blue-700 underline">Export CSV (levels {depthA}–{depthB})</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Small stats */}
|
|
||||||
<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">Users (current slice)</div>
|
|
||||||
<div className="text-xl font-semibold text-blue-900">{usersInSlice.length}</div>
|
|
||||||
</div>
|
|
||||||
<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-xl font-semibold text-blue-900">{isUnlimited ? 'All descendants so far' : totalDescendants}</div>
|
|
||||||
</div>
|
|
||||||
<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-xl font-semibold text-blue-900">{depthA}–{depthB}</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Dynamic levels */}
|
|
||||||
<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="px-8 py-6 border-b border-gray-100">
|
|
||||||
<h2 className="text-xl font-semibold text-blue-900">All users in this matrix</h2>
|
|
||||||
<p className="text-xs text-blue-700">Grouped by levels (power of five structure).</p>
|
|
||||||
</div>
|
|
||||||
<div className="divide-y divide-gray-100">
|
|
||||||
{[0,1,2,3,4,5].map(lvl => {
|
|
||||||
const list = byLevel.get(lvl) || []
|
|
||||||
const filteredList = list.filter(u => {
|
|
||||||
const globalMatch = !globalSearch || (
|
|
||||||
u.name.toLowerCase().includes(globalSearch.toLowerCase()) ||
|
|
||||||
u.email.toLowerCase().includes(globalSearch.toLowerCase())
|
|
||||||
)
|
|
||||||
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">▶</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-block w-4 h-4 text-gray-400">▼</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">
|
<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" />
|
<MagnifyingGlassIcon className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-blue-300" />
|
||||||
<input
|
<input
|
||||||
type="text"
|
value={globalSearch}
|
||||||
value={levelSearch[lvl]}
|
onChange={e => setGlobalSearch(e.target.value)}
|
||||||
onChange={e => setLevelSearch(prev => ({ ...prev, [lvl]: e.target.value }))}
|
placeholder="Global search..."
|
||||||
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"
|
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>
|
||||||
|
<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 className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-4">
|
</div>
|
||||||
{filteredList.length === 0 && (
|
|
||||||
<div className="col-span-full text-xs text-gray-500 italic">No users found.</div>
|
{/* Small stats (CHANGED wording) */}
|
||||||
|
<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 fetched</div>
|
||||||
|
<div className="text-xl font-semibold text-blue-900">{users.length}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||||
|
<div className="text-xs text-gray-500 mb-1">Rogue users</div>
|
||||||
|
<div className="text-xl font-semibold text-blue-900">{rogueCount}</div>
|
||||||
|
</div>
|
||||||
|
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||||||
|
<div className="text-xs text-gray-500 mb-1">Structure</div>
|
||||||
|
<div className="text-xl font-semibold text-blue-900">5‑ary 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>
|
||||||
|
|
||||||
|
{/* Unlimited hierarchical tree (replaces dynamic levels + grouped level list) */}
|
||||||
|
<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">Matrix Tree (Unlimited Depth)</h2>
|
||||||
|
<p className="text-xs text-blue-700">Each node can hold up to 5 direct children. Depth unbounded.</p>
|
||||||
|
</div>
|
||||||
|
<div className="px-8 py-6">
|
||||||
|
{!rootNode && (
|
||||||
|
<div className="text-xs text-gray-500 italic">Root not yet loaded.</div>
|
||||||
)}
|
)}
|
||||||
{filteredList.length > 0 && filteredList.map(u => (
|
{rootNode && (
|
||||||
<div key={`${lvl}-${u.id}`} className="rounded-lg border border-gray-100 p-4 bg-blue-50">
|
<ul className="flex flex-col gap-1">
|
||||||
<div className="flex items-center justify-between">
|
{renderNode(rootNode, 0)}
|
||||||
<div className="text-sm font-medium text-blue-900">{u.name}</div>
|
</ul>
|
||||||
<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>
|
||||||
|
|
||||||
{/* Add Users Modal */}
|
{/* Add Users Modal */}
|
||||||
@ -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>
|
||||||
|
|||||||
45
src/app/admin/matrix-management/hooks/changeMatrixState.ts
Normal file
45
src/app/admin/matrix-management/hooks/changeMatrixState.ts
Normal 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`)
|
||||||
|
}
|
||||||
@ -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 matrix’s max depth policy.">
|
|
||||||
ℹ️ Users count respects each matrix’s 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>
|
||||||
))
|
))
|
||||||
|
|||||||
125
src/app/admin/pool-management/components/createNewPoolModal.tsx
Normal file
125
src/app/admin/pool-management/components/createNewPoolModal.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@ -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>
|
||||||
|
|||||||
111
src/app/personal-matrix/hooks/getStats.ts
Normal file
111
src/app/personal-matrix/hooks/getStats.ts
Normal 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 }
|
||||||
|
}
|
||||||
122
src/app/personal-matrix/page.tsx
Normal file
122
src/app/personal-matrix/page.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Loading…
Reference in New Issue
Block a user