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 [addSuccess, setAddSuccess] = useState<string>('') // NEW
|
||||
const [hasSearched, setHasSearched] = useState(false) // NEW
|
||||
const [closing, setClosing] = useState(false) // NEW: animated closing state
|
||||
|
||||
const formRef = useRef<HTMLFormElement | null>(null)
|
||||
const reqIdRef = useRef(0) // request guard to avoid applying stale results
|
||||
@ -134,6 +135,9 @@ export default function SearchModal({
|
||||
}
|
||||
}, [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)
|
||||
const parentUsage = useMemo(() => {
|
||||
const map = new Map<number, number>()
|
||||
@ -157,6 +161,19 @@ export default function SearchModal({
|
||||
return existingUsers.find(u => u.id === parentId) || null
|
||||
}, [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(() => {
|
||||
if (!selectedParent) return null
|
||||
if (!policyMaxDepth || policyMaxDepth <= 0) return Infinity
|
||||
@ -172,6 +189,28 @@ export default function SearchModal({
|
||||
return ''
|
||||
}, [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 () => {
|
||||
if (!selected) return
|
||||
setAddError('')
|
||||
@ -189,10 +228,13 @@ export default function SearchModal({
|
||||
console.info('[SearchModal] addUserToMatrix success', data)
|
||||
setAddSuccess(`Added at position ${data.position} under parent ${data.parentUserId}`)
|
||||
onAdd({ id: selected.userId, name: selected.name, email: selected.email, type: selected.userType })
|
||||
setSelected(null)
|
||||
setParentId(undefined)
|
||||
// NEW: animated close instead of abrupt onClose
|
||||
closeWithAnimation()
|
||||
return
|
||||
// setSelected(null)
|
||||
// setParentId(undefined)
|
||||
// Soft refresh: keep list visible; doSearch won't clear items now
|
||||
setTimeout(() => { void doSearch() }, 200)
|
||||
// setTimeout(() => { void doSearch() }, 200)
|
||||
} catch (e: any) {
|
||||
console.error('[SearchModal] addUserToMatrix error', e)
|
||||
setAddError(e?.message || 'Add failed')
|
||||
@ -204,11 +246,16 @@ export default function SearchModal({
|
||||
if (!open) return null
|
||||
|
||||
const modal = (
|
||||
<div className="fixed inset-0 z-[10000]"> {/* elevated z-index */}
|
||||
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
|
||||
<div className="fixed inset-0 z-[10000]">
|
||||
{/* 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="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' }}
|
||||
>
|
||||
{/* Header */}
|
||||
@ -217,9 +264,8 @@ export default function SearchModal({
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
Add users to “{matrixName}”
|
||||
</h3>
|
||||
{/* Close button improved hover/focus */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
onClick={closeWithAnimation} // CHANGED: animated close
|
||||
className="p-1.5 rounded-md text-blue-200 transition
|
||||
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]
|
||||
@ -397,6 +443,7 @@ export default function SearchModal({
|
||||
{advanced && (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
key={parentsRevision} // NEW: force remount to refresh options
|
||||
value={parentId ?? ''}
|
||||
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"
|
||||
@ -405,18 +452,22 @@ export default function SearchModal({
|
||||
<option value="">(Auto referral / root)</option>
|
||||
{potentialParents.map(p => {
|
||||
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)
|
||||
return (
|
||||
<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>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
{/* CHANGED: clarify root unlimited and rogue behavior */}
|
||||
<p className="text-[11px] text-blue-300">
|
||||
{(!policyMaxDepth || policyMaxDepth <= 0)
|
||||
? 'Unlimited policy: no remaining-level cap.'
|
||||
{isRootSelected
|
||||
? '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.`}
|
||||
</p>
|
||||
</div>
|
||||
@ -453,7 +504,7 @@ export default function SearchModal({
|
||||
{!selected && (
|
||||
<div className="px-6 py-3 border-t border-blue-900/40 flex items-center justify-end bg-[#112645]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
onClick={closeWithAnimation} // CHANGED: animated close
|
||||
className="text-sm rounded-md px-4 py-2 font-medium
|
||||
bg-white/10 text-blue-200 backdrop-blur
|
||||
hover:bg-indigo-500/20 hover:text-white hover:shadow-sm
|
||||
|
||||
@ -10,6 +10,7 @@ export type MatrixUser = {
|
||||
type: UserType
|
||||
level: number
|
||||
parentUserId?: number | null // NEW
|
||||
position?: number | null // NEW
|
||||
}
|
||||
|
||||
type ApiUser = {
|
||||
@ -185,7 +186,8 @@ export function useMatrixUsers(
|
||||
email: u.email,
|
||||
type: u.userType === 'company' ? 'company' : 'personal',
|
||||
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)
|
||||
@ -193,12 +195,9 @@ export function useMatrixUsers(
|
||||
console.info('[useMatrixUsers] Users mapped', { count: mapped.length, sample: mapped.slice(0, 3) })
|
||||
})
|
||||
.catch(err => {
|
||||
if (controller.signal.aborted) {
|
||||
console.log('[useMatrixUsers] Fetch aborted')
|
||||
return
|
||||
}
|
||||
console.error('[useMatrixUsers] Fetch error', err)
|
||||
setError(err)
|
||||
setUsers([])
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
|
||||
@ -4,11 +4,12 @@ import React, { useEffect, useMemo, useState } from 'react'
|
||||
import { useSearchParams, useRouter } from 'next/navigation'
|
||||
import PageLayout from '../../../components/PageLayout'
|
||||
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 { getMatrixStats } from '../hooks/getMatrixStats'
|
||||
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, ...
|
||||
|
||||
export default function MatrixDetailPage() {
|
||||
@ -25,6 +26,8 @@ export default function MatrixDetailPage() {
|
||||
// Resolve rootUserId when missing by looking it up via stats
|
||||
const accessToken = useAuthStore(s => s.accessToken)
|
||||
const [resolvedRootUserId, setResolvedRootUserId] = useState<number | undefined>(rootUserId)
|
||||
// NEW: track policy (DB) max depth (null => unlimited)
|
||||
const [policyMaxDepth, setPolicyMaxDepth] = useState<number | null>(null)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
@ -64,6 +67,11 @@ export default function MatrixDetailPage() {
|
||||
const found = matrices.find((m: any) =>
|
||||
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)
|
||||
if (ru > 0 && !cancelled) {
|
||||
console.info('[MatrixDetailPage] Resolved rootUserId from stats', { matrixId, rootUserId: ru })
|
||||
@ -77,11 +85,11 @@ export default function MatrixDetailPage() {
|
||||
return () => { cancelled = true }
|
||||
}, [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, {
|
||||
depth: 5,
|
||||
depth: DEFAULT_FETCH_DEPTH,
|
||||
includeRoot: true,
|
||||
limit: 100,
|
||||
limit: 2000,
|
||||
offset: 0,
|
||||
matrixId,
|
||||
topNodeEmail
|
||||
@ -158,90 +166,160 @@ export default function MatrixDetailPage() {
|
||||
</div>
|
||||
)
|
||||
|
||||
// Compact grid for a level
|
||||
const LevelSection = ({ level }: { level: number }) => {
|
||||
const cap = isUnlimited ? undefined : (level === 0 ? 1 : LEVEL_CAP(level))
|
||||
const listAll = (byLevel.get(level) || []).filter(u =>
|
||||
u.level >= depthA && u.level <= depthB && (includeRoot || u.level !== 0)
|
||||
// Global search (already present) + node collapse state
|
||||
const [collapsedNodes, setCollapsedNodes] = useState<Record<number, boolean>>({})
|
||||
const toggleNode = (id: number) => setCollapsedNodes(p => ({ ...p, [id]: !p[id] }))
|
||||
|
||||
// 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`
|
||||
const showLoadMore = listAll.length > list.length
|
||||
|
||||
// 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 (
|
||||
<section className="rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden">
|
||||
<div className="px-4 sm:px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||
<h3 className="text-sm font-semibold text-gray-900">{title}</h3>
|
||||
{!isUnlimited && level > 0 && (
|
||||
<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>
|
||||
<li key={node.id} className="relative">
|
||||
<div
|
||||
className={`flex items-center gap-2 rounded-md border px-2 py-1 text-xs ${
|
||||
highlight ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white'
|
||||
}`}
|
||||
>
|
||||
{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 className="px-4 sm:px-5 pb-4">
|
||||
{listAll.length === 0 ? (
|
||||
<div className="text-xs text-gray-500 italic">{isUnlimited ? 'No users at this level yet.' : 'No users in this level yet.'}</div>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
{hasChildren && !collapsed && (
|
||||
<ul className="ml-6 mt-1 flex flex-col gap-1">
|
||||
{children.map(c => renderNode(c, depth + 1))}
|
||||
</ul>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
// Depth range A–B state with persistence
|
||||
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
|
||||
// CSV export (now all users fetched)
|
||||
const exportCsv = () => {
|
||||
const rows = [['id','name','email','type','level','parentUserId']]
|
||||
usersInSlice.forEach(u => rows.push([u.id,u.name,u.email,u.type,u.level,u.parentUserId ?? ''] as any))
|
||||
const rows = [['id','name','email','type','level','parentUserId','rogue']]
|
||||
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 blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||||
const a = document.createElement('a')
|
||||
a.href = URL.createObjectURL(blob)
|
||||
a.download = `matrix-${matrixId}-levels-${depthA}-${depthB}.csv`
|
||||
a.download = `matrix-${matrixId}-unlimited.csv`
|
||||
a.click()
|
||||
URL.revokeObjectURL(a.href)
|
||||
}
|
||||
@ -260,6 +338,10 @@ export default function MatrixDetailPage() {
|
||||
}
|
||||
}, [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 (
|
||||
<PageLayout>
|
||||
{/* Smooth refresh overlay */}
|
||||
@ -290,11 +372,15 @@ export default function MatrixDetailPage() {
|
||||
Top node: <span className="font-semibold text-blue-900">{topNodeEmail}</span>
|
||||
</p>
|
||||
<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">
|
||||
Children/node: 5
|
||||
Children/node: non‑root 5, root unlimited
|
||||
</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">
|
||||
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>
|
||||
</div>
|
||||
</div>
|
||||
@ -313,162 +399,68 @@ export default function MatrixDetailPage() {
|
||||
{/* Banner for unlimited */}
|
||||
{isUnlimited && (
|
||||
<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>
|
||||
)}
|
||||
|
||||
{/* 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="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">
|
||||
<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..."
|
||||
value={globalSearch}
|
||||
onChange={e => setGlobalSearch(e.target.value)}
|
||||
placeholder="Global search..."
|
||||
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>
|
||||
<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 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>
|
||||
</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 => (
|
||||
<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>
|
||||
</>
|
||||
{rootNode && (
|
||||
<ul className="flex flex-col gap-1">
|
||||
{renderNode(rootNode, 0)}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Users Modal */}
|
||||
@ -480,7 +472,7 @@ export default function MatrixDetailPage() {
|
||||
matrixId={matrixId}
|
||||
topNodeEmail={topNodeEmail}
|
||||
existingUsers={users}
|
||||
policyMaxDepth={serverMaxDepth ?? null}
|
||||
policyMaxDepth={policyMaxDepth} // CHANGED: pass real policy max depth
|
||||
onAdd={(u) => { addToMatrix(u) }}
|
||||
/>
|
||||
</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 { createMatrix } from './hooks/createMatrix'
|
||||
import { getMatrixStats } from './hooks/getMatrixStats'
|
||||
import { deactivateMatrix, activateMatrix } from './hooks/changeMatrixState' // NEW
|
||||
|
||||
type Matrix = {
|
||||
id: string
|
||||
@ -61,8 +62,9 @@ export default function MatrixManagementPage() {
|
||||
const [forcePrompt, setForcePrompt] = useState<{ name: string; email: string } | null>(null)
|
||||
const [createSuccess, setCreateSuccess] = useState<{ name: string; email: string } | null>(null)
|
||||
|
||||
const [policyFilter, setPolicyFilter] = useState<'all'|'unlimited'|'five'>('all') // NEW
|
||||
const [policyFilter, setPolicyFilter] = useState<'unlimited'|'five'>('unlimited') // NEW
|
||||
const [sortByUsers, setSortByUsers] = useState<'asc'|'desc'>('desc') // NEW
|
||||
const [mutatingId, setMutatingId] = useState<string | null>(null) // NEW
|
||||
|
||||
const loadStats = async () => {
|
||||
if (!token) return
|
||||
@ -194,21 +196,32 @@ export default function MatrixManagementPage() {
|
||||
}
|
||||
}
|
||||
|
||||
const toggleStatus = (id: string) => {
|
||||
setMatrices(prev =>
|
||||
prev.map(m => (m.id === id ? { ...m, status: m.status === 'active' ? 'inactive' : 'active' } : m))
|
||||
)
|
||||
const toggleStatus = async (id: string) => {
|
||||
try {
|
||||
const target = matrices.find(m => m.id === id)
|
||||
if (!target) return
|
||||
setStatsError('')
|
||||
setMutatingId(id)
|
||||
if (target.status === 'active') {
|
||||
await deactivateMatrix(id)
|
||||
} else {
|
||||
await activateMatrix(id)
|
||||
}
|
||||
await loadStats()
|
||||
} catch (e: any) {
|
||||
setStatsError(e?.message || 'Failed to change matrix state.')
|
||||
} finally {
|
||||
setMutatingId(null)
|
||||
}
|
||||
}
|
||||
|
||||
// derived list with filter/sort
|
||||
// derived list with filter/sort (always apply selected filter)
|
||||
const matricesView = useMemo(() => {
|
||||
let list = [...matrices]
|
||||
if (policyFilter !== 'all') {
|
||||
list = list.filter(m => {
|
||||
const unlimited = !m.policyMaxDepth || m.policyMaxDepth <= 0
|
||||
return policyFilter === 'unlimited' ? unlimited : (!unlimited && m.policyMaxDepth === 5)
|
||||
})
|
||||
}
|
||||
list.sort((a,b) => sortByUsers === 'asc' ? (a.usersCount - b.usersCount) : (b.usersCount - a.usersCount))
|
||||
return list
|
||||
}, [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" />
|
||||
</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 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-8">
|
||||
{statsLoading ? (
|
||||
@ -362,12 +346,15 @@ export default function MatrixManagementPage() {
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => toggleStatus(m.id)}
|
||||
disabled={mutatingId === m.id}
|
||||
className={`rounded-lg px-4 py-2 text-sm font-medium border shadow transition
|
||||
${m.status === 'active'
|
||||
? 'border-red-300 text-red-700 hover:bg-red-50'
|
||||
: 'border-green-300 text-green-700 hover:bg-green-50'}`}
|
||||
? 'border-red-300 text-red-700 hover:bg-red-50 disabled:opacity-60'
|
||||
: 'border-green-300 text-green-700 hover:bg-green-50 disabled:opacity-60'}`}
|
||||
>
|
||||
{m.status === 'active' ? 'Deactivate' : 'Activate'}
|
||||
{mutatingId === m.id
|
||||
? (m.status === 'active' ? 'Deactivating…' : 'Activating…')
|
||||
: (m.status === 'active' ? 'Deactivate' : 'Activate')}
|
||||
</button>
|
||||
<button
|
||||
className="text-sm font-medium text-blue-900 hover:text-blue-700"
|
||||
@ -388,24 +375,6 @@ export default function MatrixManagementPage() {
|
||||
View details →
|
||||
</button>
|
||||
</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>
|
||||
</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 { archivePoolById, setPoolState } from './hooks/archivePool'
|
||||
import PageTransitionEffect from '../../components/animation/pageTransitionEffect'
|
||||
import CreateNewPoolModal from './components/createNewPoolModal'
|
||||
|
||||
type Pool = {
|
||||
id: string
|
||||
@ -23,27 +24,13 @@ type Pool = {
|
||||
export default function PoolManagementPage() {
|
||||
const router = useRouter()
|
||||
|
||||
// Form state, dropdown, errors
|
||||
const [form, setForm] = React.useState({ name: '', description: '' })
|
||||
// Modal state
|
||||
const [creating, setCreating] = React.useState(false)
|
||||
const [formError, setFormError] = React.useState<string>('')
|
||||
const [formSuccess, setFormSuccess] = React.useState<string>('')
|
||||
const [createOpen, setCreateOpen] = React.useState(false)
|
||||
const [createError, setCreateError] = React.useState<string>('')
|
||||
const [createSuccess, setCreateSuccess] = React.useState<string>('')
|
||||
const [createModalOpen, setCreateModalOpen] = React.useState(false)
|
||||
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
|
||||
const token = useAuthStore.getState().accessToken
|
||||
const BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:3001'
|
||||
@ -58,31 +45,29 @@ export default function PoolManagementPage() {
|
||||
}
|
||||
}, [initialPools, loading, error])
|
||||
|
||||
async function handleCreatePool(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setFormError('')
|
||||
setFormSuccess('')
|
||||
const name = form.name.trim()
|
||||
const description = form.description.trim()
|
||||
// REPLACED: handleCreatePool to accept data from modal
|
||||
async function handleCreatePool(data: { name: string; description: string }) {
|
||||
setCreateError('')
|
||||
setCreateSuccess('')
|
||||
const name = data.name.trim()
|
||||
const description = data.description.trim()
|
||||
if (!name) {
|
||||
setFormError('Please provide a pool name.')
|
||||
setCreateError('Please provide a pool name.')
|
||||
return
|
||||
}
|
||||
setCreating(true)
|
||||
try {
|
||||
const res = await addPool({ name, description: description || undefined, state: 'active' })
|
||||
if (res.ok && res.body?.data) {
|
||||
setFormSuccess('Pool created successfully.')
|
||||
setForm({ name: '', description: '' })
|
||||
// Refresh list from backend to include the new pool
|
||||
setCreateSuccess('Pool created successfully.')
|
||||
await refresh?.()
|
||||
// Do NOT close; keep dropdown open across refresh
|
||||
// setCreateOpenPersist(false) // removed to keep it open
|
||||
// keep modal open so user sees success; optional close:
|
||||
// setCreateModalOpen(false)
|
||||
} else {
|
||||
setFormError(res.message || 'Failed to create pool.')
|
||||
setCreateError(res.message || 'Failed to create pool.')
|
||||
}
|
||||
} catch {
|
||||
setFormError('Network error while creating pool.')
|
||||
setCreateError('Network error while creating pool.')
|
||||
} finally {
|
||||
setCreating(false)
|
||||
}
|
||||
@ -142,10 +127,8 @@ export default function PoolManagementPage() {
|
||||
<PageTransitionEffect>
|
||||
<div className="min-h-screen flex flex-col bg-gradient-to-tr from-blue-50 via-white to-blue-100">
|
||||
<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">
|
||||
<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">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
@ -153,81 +136,14 @@ export default function PoolManagementPage() {
|
||||
<p className="text-lg text-blue-700 mt-2">Create and manage user pools.</p>
|
||||
</div>
|
||||
<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"
|
||||
aria-expanded={createOpen}
|
||||
aria-controls="create-pool-section"
|
||||
>
|
||||
{createOpen ? 'Close' : 'Create New Pool'}
|
||||
Create New Pool
|
||||
</button>
|
||||
</div>
|
||||
</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 */}
|
||||
<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">
|
||||
@ -335,6 +251,18 @@ export default function PoolManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
</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 />
|
||||
</div>
|
||||
</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