feat: matrix management backend link #1
This commit is contained in:
parent
e7bfe43250
commit
0b325bf44c
@ -0,0 +1,473 @@
|
||||
'use client'
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { createPortal } from 'react-dom'
|
||||
import { MagnifyingGlassIcon, XMarkIcon, BuildingOffice2Icon, UserIcon } from '@heroicons/react/24/outline'
|
||||
import { getUserCandidates } from '../hooks/search-candidate'
|
||||
import { addUserToMatrix } from '../hooks/addUsertoMatrix'
|
||||
import type { MatrixUser, UserType } from '../hooks/getStats'
|
||||
|
||||
type Props = {
|
||||
open: boolean
|
||||
onClose: () => void
|
||||
matrixName: string
|
||||
rootUserId?: number
|
||||
matrixId?: string | number
|
||||
topNodeEmail?: string
|
||||
existingUsers: MatrixUser[]
|
||||
onAdd: (u: { id: number; name: string; email: string; type: UserType }) => void
|
||||
policyMaxDepth?: number | null // NEW
|
||||
}
|
||||
|
||||
export default function SearchModal({
|
||||
open,
|
||||
onClose,
|
||||
matrixName,
|
||||
rootUserId,
|
||||
matrixId,
|
||||
topNodeEmail,
|
||||
existingUsers,
|
||||
onAdd,
|
||||
policyMaxDepth // NEW
|
||||
}: Props) {
|
||||
const [query, setQuery] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState<'all' | UserType>('all')
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<string>('')
|
||||
const [items, setItems] = useState<Array<{ userId: number; name: string; email: string; userType: UserType }>>([])
|
||||
const [total, setTotal] = useState(0)
|
||||
const [limit] = useState(20)
|
||||
|
||||
const [selected, setSelected] = useState<{ userId: number; name: string; email: string; userType: UserType } | null>(null) // NEW
|
||||
const [advanced, setAdvanced] = useState(false) // NEW
|
||||
const [parentId, setParentId] = useState<number | undefined>(undefined) // NEW
|
||||
const [forceFallback, setForceFallback] = useState<boolean>(true) // NEW
|
||||
const [adding, setAdding] = useState(false) // NEW
|
||||
const [addError, setAddError] = useState<string>('') // NEW
|
||||
const [addSuccess, setAddSuccess] = useState<string>('') // NEW
|
||||
const [hasSearched, setHasSearched] = useState(false) // NEW
|
||||
|
||||
const formRef = useRef<HTMLFormElement | null>(null)
|
||||
const reqIdRef = useRef(0) // request guard to avoid applying stale results
|
||||
|
||||
const doSearch = useCallback(async () => {
|
||||
setError('')
|
||||
// Preserve list during refresh to avoid jumpiness
|
||||
const shouldPreserve = hasSearched && items.length > 0
|
||||
if (!shouldPreserve) {
|
||||
setItems([])
|
||||
setTotal(0)
|
||||
}
|
||||
const qTrim = query.trim()
|
||||
if (qTrim.length < 3) {
|
||||
console.warn('[SearchModal] Skip search: need >=3 chars')
|
||||
setHasSearched(false)
|
||||
return
|
||||
}
|
||||
setHasSearched(true)
|
||||
|
||||
const myReqId = ++reqIdRef.current
|
||||
try {
|
||||
setLoading(true)
|
||||
const data = await getUserCandidates({
|
||||
q: qTrim,
|
||||
type: typeFilter === 'all' ? undefined : typeFilter,
|
||||
rootUserId,
|
||||
matrixId,
|
||||
topNodeEmail,
|
||||
limit,
|
||||
offset: 0
|
||||
})
|
||||
// Ignore stale responses
|
||||
if (myReqId !== reqIdRef.current) return
|
||||
|
||||
const existingIds = new Set(existingUsers.map(u => String(u.id)))
|
||||
const filtered = (data.items || []).filter(i => !existingIds.has(String(i.userId)))
|
||||
setItems(filtered)
|
||||
setTotal(data.total || 0)
|
||||
console.info('[SearchModal] Search success', {
|
||||
q: data.q,
|
||||
returned: filtered.length,
|
||||
original: data.items.length,
|
||||
total: data.total,
|
||||
combo: (data as any)?._debug?.combo
|
||||
})
|
||||
if (filtered.length === 0 && data.total > 0) {
|
||||
console.warn('[SearchModal] All backend results filtered out as duplicates')
|
||||
}
|
||||
} catch (e: any) {
|
||||
if (myReqId !== reqIdRef.current) return
|
||||
console.error('[SearchModal] Search error', e)
|
||||
setError(e?.message || 'Search failed')
|
||||
} finally {
|
||||
if (myReqId === reqIdRef.current) setLoading(false)
|
||||
}
|
||||
}, [query, typeFilter, rootUserId, matrixId, topNodeEmail, limit, existingUsers, hasSearched, items])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
setQuery('')
|
||||
setTypeFilter('all')
|
||||
setItems([])
|
||||
setTotal(0)
|
||||
setError('')
|
||||
setLoading(false)
|
||||
setHasSearched(false)
|
||||
reqIdRef.current = 0 // reset guard
|
||||
}
|
||||
}, [open])
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return
|
||||
const prev = document.body.style.overflow
|
||||
document.body.style.overflow = 'hidden'
|
||||
return () => { document.body.style.overflow = prev }
|
||||
}, [open])
|
||||
|
||||
// Auto-prune current results when parent existingUsers changes (keeps modal open)
|
||||
useEffect(() => {
|
||||
if (!open || items.length === 0) return
|
||||
const existingIds = new Set(existingUsers.map(u => String(u.id)))
|
||||
const cleaned = items.filter(i => !existingIds.has(String(i.userId)))
|
||||
if (cleaned.length !== items.length) {
|
||||
console.info('[SearchModal] Pruned results after parent update', { before: items.length, after: cleaned.length })
|
||||
setItems(cleaned)
|
||||
}
|
||||
}, [existingUsers, items, open])
|
||||
|
||||
// Compute children counts per parent (uses parentUserId on existingUsers)
|
||||
const parentUsage = useMemo(() => {
|
||||
const map = new Map<number, number>()
|
||||
existingUsers.forEach(u => {
|
||||
if (u.parentUserId != null) {
|
||||
map.set(u.parentUserId, (map.get(u.parentUserId) || 0) + 1)
|
||||
}
|
||||
})
|
||||
return map
|
||||
}, [existingUsers])
|
||||
|
||||
const potentialParents = useMemo(() => {
|
||||
// All users up to depth 5 can be parents (capacity 5)
|
||||
return existingUsers
|
||||
.filter(u => u.level < 6)
|
||||
.sort((a, b) => a.level - b.level || a.id - b.id)
|
||||
}, [existingUsers])
|
||||
|
||||
const selectedParent = useMemo(() => {
|
||||
if (!parentId) return null
|
||||
return existingUsers.find(u => u.id === parentId) || null
|
||||
}, [parentId, existingUsers])
|
||||
|
||||
const remainingLevels = useMemo(() => {
|
||||
if (!selectedParent) return null
|
||||
if (!policyMaxDepth || policyMaxDepth <= 0) return Infinity
|
||||
return Math.max(0, policyMaxDepth - Number(selectedParent.level ?? 0))
|
||||
}, [selectedParent, policyMaxDepth])
|
||||
|
||||
const addDisabledReason = useMemo(() => {
|
||||
if (!selectedParent) return ''
|
||||
if (!policyMaxDepth || policyMaxDepth <= 0) return ''
|
||||
if (Number(selectedParent.level ?? 0) >= policyMaxDepth) {
|
||||
return `Parent at max depth (${policyMaxDepth}).`
|
||||
}
|
||||
return ''
|
||||
}, [selectedParent, policyMaxDepth])
|
||||
|
||||
const handleAdd = async () => {
|
||||
if (!selected) return
|
||||
setAddError('')
|
||||
setAddSuccess('')
|
||||
setAdding(true)
|
||||
try {
|
||||
const data = await addUserToMatrix({
|
||||
childUserId: selected.userId,
|
||||
parentUserId: advanced ? parentId : undefined,
|
||||
forceParentFallback: forceFallback,
|
||||
rootUserId,
|
||||
matrixId,
|
||||
topNodeEmail
|
||||
})
|
||||
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)
|
||||
// Soft refresh: keep list visible; doSearch won't clear items now
|
||||
setTimeout(() => { void doSearch() }, 200)
|
||||
} catch (e: any) {
|
||||
console.error('[SearchModal] addUserToMatrix error', e)
|
||||
setAddError(e?.message || 'Add failed')
|
||||
} finally {
|
||||
setAdding(false)
|
||||
}
|
||||
}
|
||||
|
||||
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="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"
|
||||
style={{ maxHeight: '90vh' }}
|
||||
>
|
||||
{/* Header */}
|
||||
<div className="relative px-6 py-5 border-b border-blue-900/40 bg-gradient-to-r from-[#142b52] via-[#13365f] to-[#154270]">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-lg font-semibold text-white">
|
||||
Add users to “{matrixName}”
|
||||
</h3>
|
||||
{/* Close button improved hover/focus */}
|
||||
<button
|
||||
onClick={onClose}
|
||||
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]
|
||||
active:scale-95"
|
||||
aria-label="Close"
|
||||
>
|
||||
<XMarkIcon className="h-5 w-5" />
|
||||
</button>
|
||||
</div>
|
||||
<p className="mt-1 text-xs text-blue-200">
|
||||
Search by name or email. Minimum 3 characters. Existing matrix members are hidden.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Form */}
|
||||
<form
|
||||
ref={formRef}
|
||||
onSubmit={e => {
|
||||
e.preventDefault()
|
||||
void doSearch()
|
||||
}}
|
||||
className="px-6 py-4 grid grid-cols-1 md:grid-cols-5 gap-3 border-b border-blue-900/40 bg-[#112645]"
|
||||
>
|
||||
{/* Query */}
|
||||
<div className="md:col-span-2">
|
||||
<div className="relative">
|
||||
<MagnifyingGlassIcon className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-blue-300" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => setQuery(e.target.value)}
|
||||
placeholder="Search name or email…"
|
||||
className="w-full rounded-md bg-[#173456] border border-blue-800 text-sm text-blue-100 placeholder-blue-300 pl-8 pr-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
{/* Type */}
|
||||
<div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={e => setTypeFilter(e.target.value as any)}
|
||||
className="w-full rounded-md bg-[#173456] border border-blue-800 text-sm text-blue-100 px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="personal">Personal</option>
|
||||
<option value="company">Company</option>
|
||||
</select>
|
||||
</div>
|
||||
{/* Buttons */}
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || query.trim().length < 3}
|
||||
className="flex-1 rounded-md bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-3 py-2 text-sm font-medium shadow-sm transition"
|
||||
>
|
||||
{loading ? 'Searching…' : 'Search'}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => { setQuery(''); setItems([]); setTotal(0); setError(''); setHasSearched(false); }}
|
||||
className="rounded-md border border-blue-800 bg-[#173456] px-3 py-2 text-sm text-blue-100 hover:bg-blue-800/40 transition"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
{/* Total */}
|
||||
<div className="text-sm text-blue-200 self-center">
|
||||
Total: <span className="font-semibold text-white">{total}</span>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
{/* Results + selection area (scrollable) */}
|
||||
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
|
||||
{/* Results section */}
|
||||
<div className="relative">
|
||||
{error && (
|
||||
<div className="text-sm text-red-400 mb-4">{error}</div>
|
||||
)}
|
||||
{!error && query.trim().length < 3 && (
|
||||
<div className="py-12 text-sm text-blue-300 text-center">
|
||||
Enter at least 3 characters and click Search.
|
||||
</div>
|
||||
)}
|
||||
{!error && query.trim().length >= 3 && !hasSearched && !loading && (
|
||||
<div className="py-12 text-sm text-blue-300 text-center">
|
||||
Ready to search. Click the Search button to fetch candidates.
|
||||
</div>
|
||||
)}
|
||||
{/* Skeleton only for first-time load (when no items yet) */}
|
||||
{!error && query.trim().length >= 3 && loading && items.length === 0 && (
|
||||
<ul className="space-y-0 divide-y divide-blue-900/40 border border-blue-900/40 rounded-md bg-[#132c4e]">
|
||||
{Array.from({ length: 5 }).map((_, i) => (
|
||||
<li key={i} className="animate-pulse px-4 py-3">
|
||||
<div className="h-3.5 w-36 bg-blue-800/40 rounded" />
|
||||
<div className="mt-2 h-3 w-56 bg-blue-800/30 rounded" />
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{!error && hasSearched && !loading && query.trim().length >= 3 && items.length === 0 && (
|
||||
<div className="py-12 text-sm text-blue-300 text-center">
|
||||
No users match your filters.
|
||||
</div>
|
||||
)}
|
||||
{!error && hasSearched && items.length > 0 && (
|
||||
<ul className="divide-y divide-blue-900/40 border border-blue-900/40 rounded-lg bg-[#132c4e]">
|
||||
{items.map(u => (
|
||||
<li
|
||||
key={u.userId}
|
||||
className="px-4 py-3 flex items-center justify-between gap-3 hover:bg-blue-800/40 transition"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
{u.userType === 'company'
|
||||
? <BuildingOffice2Icon className="h-4 w-4 text-indigo-400" />
|
||||
: <UserIcon className="h-4 w-4 text-blue-300" />}
|
||||
<span className="text-sm font-medium text-blue-100 truncate max-w-[160px]">{u.name}</span>
|
||||
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full ${
|
||||
u.userType === 'company'
|
||||
? 'bg-indigo-700/40 text-indigo-200'
|
||||
: 'bg-blue-700/40 text-blue-200'
|
||||
}`}>
|
||||
{u.userType === 'company' ? 'Company' : 'Personal'}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-0.5 text-[11px] text-blue-300 break-all">{u.email}</div>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => {
|
||||
console.log('[SearchModal] Select candidate', { id: u.userId })
|
||||
setSelected(u)
|
||||
setAddError('')
|
||||
setAddSuccess('')
|
||||
}}
|
||||
className="shrink-0 inline-flex items-center rounded-md bg-blue-600 hover:bg-blue-500 text-white px-3 py-1.5 text-xs font-medium shadow-sm transition"
|
||||
>
|
||||
{selected?.userId === u.userId ? 'Selected' : 'Select'}
|
||||
</button>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
{/* Soft-loading overlay over existing list to avoid jumpiness */}
|
||||
{loading && items.length > 0 && (
|
||||
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[#0F1F3A]/30">
|
||||
<span className="h-5 w-5 rounded-full border-2 border-blue-400 border-b-transparent animate-spin" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Selected candidate details (conditional) */}
|
||||
{selected && (
|
||||
<div className="border border-blue-900/40 rounded-lg p-4 bg-[#132c4e] space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-blue-100 font-medium">
|
||||
Candidate: {selected.name} <span className="text-blue-300">({selected.email})</span>
|
||||
</div>
|
||||
<button
|
||||
onClick={() => { setSelected(null); setParentId(undefined); }}
|
||||
className="text-xs text-blue-300 hover:text-white transition"
|
||||
>
|
||||
Clear selection
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-blue-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={advanced}
|
||||
onChange={e => setAdvanced(e.target.checked)}
|
||||
className="h-3 w-3 rounded border-blue-700 bg-blue-900 text-indigo-500 focus:ring-indigo-400"
|
||||
/>
|
||||
Advanced: choose parent manually
|
||||
</label>
|
||||
|
||||
{advanced && (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
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"
|
||||
title={addDisabledReason || undefined} // NEW
|
||||
>
|
||||
<option value="">(Auto referral / root)</option>
|
||||
{potentialParents.map(p => {
|
||||
const used = parentUsage.get(p.id) || 0
|
||||
const full = 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}
|
||||
</option>
|
||||
)
|
||||
})}
|
||||
</select>
|
||||
<p className="text-[11px] text-blue-300">
|
||||
{(!policyMaxDepth || policyMaxDepth <= 0)
|
||||
? 'Unlimited policy: no remaining-level cap.'
|
||||
: `Remaining levels under chosen parent = Max(${policyMaxDepth}) - parent level.`}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-blue-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={forceFallback}
|
||||
onChange={e => setForceFallback(e.target.checked)}
|
||||
className="h-3 w-3 rounded border-blue-700 bg-blue-900 text-indigo-500 focus:ring-indigo-400"
|
||||
/>
|
||||
Fallback to root if referral parent not in matrix
|
||||
</label>
|
||||
|
||||
{addError && <div className="text-xs text-red-400">{addError}</div>}
|
||||
{addSuccess && <div className="text-xs text-green-400">{addSuccess}</div>}
|
||||
|
||||
<div className="flex justify-end">
|
||||
<button
|
||||
onClick={handleAdd}
|
||||
disabled={adding || (!!addDisabledReason && !!advanced)} // NEW
|
||||
title={addDisabledReason || undefined} // NEW
|
||||
className="inline-flex items-center rounded-md bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white px-4 py-2 text-xs font-medium shadow-sm transition"
|
||||
>
|
||||
{adding ? 'Adding…' : 'Add to Matrix'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Footer (hidden when a candidate is selected) */}
|
||||
{!selected && (
|
||||
<div className="px-6 py-3 border-t border-blue-900/40 flex items-center justify-end bg-[#112645]">
|
||||
<button
|
||||
onClick={onClose}
|
||||
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
|
||||
focus:outline-none focus:ring-2 focus:ring-indigo-300 focus:ring-offset-2 focus:ring-offset-[#112645]
|
||||
active:scale-95 transition"
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return createPortal(modal, document.body)
|
||||
}
|
||||
101
src/app/admin/matrix-management/detail/hooks/addUsertoMatrix.ts
Normal file
101
src/app/admin/matrix-management/detail/hooks/addUsertoMatrix.ts
Normal file
@ -0,0 +1,101 @@
|
||||
import { authFetch } from '../../../../utils/authFetch'
|
||||
|
||||
export type AddUserToMatrixParams = {
|
||||
childUserId: number
|
||||
parentUserId?: number
|
||||
forceParentFallback?: boolean
|
||||
rootUserId?: number
|
||||
matrixId?: string | number
|
||||
topNodeEmail?: string
|
||||
}
|
||||
|
||||
export type AddUserToMatrixResponse = {
|
||||
success: boolean
|
||||
data?: {
|
||||
rootUserId: number
|
||||
parentUserId: number
|
||||
childUserId: number
|
||||
position: number
|
||||
remainingFreeSlots: number
|
||||
usersPreview?: Array<{
|
||||
userId: number
|
||||
level?: number
|
||||
name?: string
|
||||
email?: string
|
||||
userType?: string
|
||||
parentUserId?: number | null
|
||||
}>
|
||||
}
|
||||
message?: string
|
||||
}
|
||||
|
||||
export async function addUserToMatrix(params: AddUserToMatrixParams) {
|
||||
const {
|
||||
childUserId,
|
||||
parentUserId,
|
||||
forceParentFallback = false,
|
||||
rootUserId,
|
||||
matrixId,
|
||||
topNodeEmail
|
||||
} = params
|
||||
|
||||
if (!childUserId) throw new Error('childUserId required')
|
||||
|
||||
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
|
||||
if (!base) console.warn('[addUserToMatrix] NEXT_PUBLIC_API_BASE_URL missing')
|
||||
|
||||
// Choose exactly one identifier
|
||||
const hasRoot = typeof rootUserId === 'number' && rootUserId > 0
|
||||
const hasMatrix = !!matrixId
|
||||
const hasEmail = !!topNodeEmail
|
||||
if (!hasRoot && !hasMatrix && !hasEmail) {
|
||||
throw new Error('One of rootUserId, matrixId or topNodeEmail is required')
|
||||
}
|
||||
|
||||
const body: any = {
|
||||
childUserId,
|
||||
forceParentFallback: !!forceParentFallback
|
||||
}
|
||||
if (parentUserId) body.parentUserId = parentUserId
|
||||
if (hasRoot) body.rootUserId = rootUserId
|
||||
else if (hasMatrix) body.matrixId = matrixId
|
||||
else body.topNodeEmail = topNodeEmail
|
||||
|
||||
const url = `${base}/api/admin/matrix/add-user`
|
||||
console.info('[addUserToMatrix] POST', { url, body })
|
||||
|
||||
const res = await authFetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Accept: 'application/json'
|
||||
},
|
||||
credentials: 'include',
|
||||
body: JSON.stringify(body)
|
||||
})
|
||||
|
||||
const ct = res.headers.get('content-type') || ''
|
||||
const raw = await res.text()
|
||||
let json: AddUserToMatrixResponse | null = null
|
||||
try {
|
||||
json = ct.includes('application/json') ? JSON.parse(raw) : null
|
||||
} catch {
|
||||
json = null
|
||||
}
|
||||
|
||||
console.debug('[addUserToMatrix] Response', {
|
||||
status: res.status,
|
||||
ok: res.ok,
|
||||
hasJson: !!json,
|
||||
bodyPreview: raw.slice(0, 300)
|
||||
})
|
||||
|
||||
if (!res.ok) {
|
||||
const msg = json?.message || `Request failed: ${res.status}`
|
||||
throw new Error(msg)
|
||||
}
|
||||
if (!json?.success) {
|
||||
throw new Error(json?.message || 'Backend returned non-success')
|
||||
}
|
||||
return json.data!
|
||||
}
|
||||
212
src/app/admin/matrix-management/detail/hooks/getStats.ts
Normal file
212
src/app/admin/matrix-management/detail/hooks/getStats.ts
Normal file
@ -0,0 +1,212 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
|
||||
import { authFetch } from '../../../../utils/authFetch'
|
||||
|
||||
export type UserType = 'personal' | 'company'
|
||||
|
||||
export type MatrixUser = {
|
||||
id: number
|
||||
name: string
|
||||
email: string
|
||||
type: UserType
|
||||
level: number
|
||||
parentUserId?: number | null // NEW
|
||||
}
|
||||
|
||||
type ApiUser = {
|
||||
userId: number
|
||||
level?: number | null
|
||||
depth?: number | null
|
||||
name?: string | null
|
||||
displayName?: string | null
|
||||
email: string
|
||||
userType: string
|
||||
role: string
|
||||
createdAt: string
|
||||
parentUserId: number | null
|
||||
position: number | null
|
||||
}
|
||||
|
||||
type ApiResponse = {
|
||||
success: boolean
|
||||
data?: {
|
||||
rootUserId: number
|
||||
maxDepth: number
|
||||
limit: number
|
||||
offset: number
|
||||
includeRoot: boolean
|
||||
users: ApiUser[]
|
||||
}
|
||||
error?: any
|
||||
}
|
||||
|
||||
export type UseMatrixUsersParams = {
|
||||
depth?: number
|
||||
limit?: number
|
||||
offset?: number
|
||||
includeRoot?: boolean
|
||||
matrixId?: string | number
|
||||
topNodeEmail?: string
|
||||
}
|
||||
|
||||
export function useMatrixUsers(
|
||||
rootUserId: number | undefined,
|
||||
params: UseMatrixUsersParams = {}
|
||||
) {
|
||||
const { depth = 5, limit = 100, offset = 0, includeRoot = true, matrixId, topNodeEmail } = params
|
||||
const [users, setUsers] = useState<MatrixUser[]>([])
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [error, setError] = useState<unknown>(null)
|
||||
const [tick, setTick] = useState(0)
|
||||
const abortRef = useRef<AbortController | null>(null)
|
||||
const [serverMaxDepth, setServerMaxDepth] = useState<number | null>(null) // NEW
|
||||
|
||||
// Include new identifiers in diagnostics
|
||||
const builtParams = useMemo(() => {
|
||||
const p = { rootUserId, matrixId, topNodeEmail, depth, limit, offset, includeRoot }
|
||||
console.debug('[useMatrixUsers] Params built', p)
|
||||
return p
|
||||
}, [rootUserId, matrixId, topNodeEmail, depth, limit, offset, includeRoot])
|
||||
|
||||
const refetch = useCallback(() => {
|
||||
console.info('[useMatrixUsers] refetch() called')
|
||||
setTick(t => t + 1)
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
console.info('[useMatrixUsers] Hook mounted')
|
||||
return () => {
|
||||
console.info('[useMatrixUsers] Hook unmounted, aborting any inflight request')
|
||||
abortRef.current?.abort()
|
||||
}
|
||||
}, [])
|
||||
|
||||
useEffect(() => {
|
||||
// Require at least one acceptable identifier
|
||||
const hasRoot = typeof rootUserId === 'number' && rootUserId > 0
|
||||
const hasMatrix = !!matrixId
|
||||
const hasEmail = !!topNodeEmail
|
||||
if (!hasRoot && !hasMatrix && !hasEmail) {
|
||||
console.error('[useMatrixUsers] Missing identifier. Provide one of: rootUserId, matrixId, topNodeEmail.', { rootUserId, matrixId, topNodeEmail })
|
||||
setUsers([])
|
||||
setError(new Error('One of rootUserId, matrixId or topNodeEmail is required'))
|
||||
setLoading(false)
|
||||
return
|
||||
}
|
||||
|
||||
abortRef.current?.abort()
|
||||
const controller = new AbortController()
|
||||
abortRef.current = controller
|
||||
|
||||
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
|
||||
if (!base) {
|
||||
console.warn('[useMatrixUsers] NEXT_PUBLIC_API_BASE_URL is not set. Falling back to same-origin (may fail in dev).')
|
||||
}
|
||||
|
||||
// Choose exactly ONE identifier to avoid backend confusion
|
||||
const qs = new URLSearchParams()
|
||||
let chosenKey: 'rootUserId' | 'matrixId' | 'topNodeEmail'
|
||||
let chosenValue: string | number
|
||||
|
||||
if (hasRoot) {
|
||||
qs.set('rootUserId', String(rootUserId))
|
||||
chosenKey = 'rootUserId'
|
||||
chosenValue = rootUserId!
|
||||
} else if (hasMatrix) {
|
||||
qs.set('matrixId', String(matrixId))
|
||||
chosenKey = 'matrixId'
|
||||
chosenValue = matrixId as any
|
||||
} else {
|
||||
qs.set('topNodeEmail', String(topNodeEmail))
|
||||
chosenKey = 'topNodeEmail'
|
||||
chosenValue = topNodeEmail as any
|
||||
}
|
||||
|
||||
qs.set('depth', String(depth))
|
||||
qs.set('limit', String(limit))
|
||||
qs.set('offset', String(offset))
|
||||
qs.set('includeRoot', String(includeRoot))
|
||||
|
||||
const url = `${base}/api/admin/matrix/users?${qs.toString()}`
|
||||
console.info('[useMatrixUsers] Fetch start (via authFetch)', {
|
||||
url,
|
||||
method: 'GET',
|
||||
identifiers: { rootUserId, matrixId, topNodeEmail, chosen: { key: chosenKey, value: chosenValue } },
|
||||
params: { depth, limit, offset, includeRoot }
|
||||
})
|
||||
console.log('[useMatrixUsers] REQUEST GET', url)
|
||||
|
||||
const t0 = performance.now()
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
|
||||
authFetch(url, {
|
||||
method: 'GET',
|
||||
credentials: 'include',
|
||||
headers: { Accept: 'application/json' },
|
||||
signal: controller.signal as any
|
||||
})
|
||||
.then(async r => {
|
||||
const t1 = performance.now()
|
||||
const ct = r.headers.get('content-type') || ''
|
||||
console.debug('[useMatrixUsers] Response received', { status: r.status, durationMs: Math.round(t1 - t0), contentType: ct })
|
||||
|
||||
if (!r.ok || !ct.includes('application/json')) {
|
||||
const text = await r.text().catch(() => '')
|
||||
console.error('[useMatrixUsers] Non-OK or non-JSON response', { status: r.status, bodyPreview: text.slice(0, 500) })
|
||||
throw new Error(`Request failed: ${r.status}`)
|
||||
}
|
||||
|
||||
const json: ApiResponse = await r.json()
|
||||
if (!json?.success || !json?.data) {
|
||||
console.warn('[useMatrixUsers] Non-success response', json)
|
||||
throw new Error('Backend returned non-success for /admin/matrix/users')
|
||||
}
|
||||
|
||||
const { data } = json
|
||||
console.debug('[useMatrixUsers] Meta', {
|
||||
rootUserId: data.rootUserId,
|
||||
maxDepth: data.maxDepth,
|
||||
limit: data.limit,
|
||||
offset: data.offset,
|
||||
includeRoot: data.includeRoot,
|
||||
usersCount: data.users?.length ?? 0
|
||||
})
|
||||
setServerMaxDepth(typeof data.maxDepth === 'number' ? data.maxDepth : null) // NEW
|
||||
const mapped: MatrixUser[] = (data.users || []).map(u => {
|
||||
const rawLevel = (typeof u.level === 'number' ? u.level : (typeof u.depth === 'number' ? u.depth : undefined))
|
||||
const level = (typeof rawLevel === 'number' && rawLevel >= 0) ? rawLevel : 0
|
||||
if (rawLevel === undefined || rawLevel === null) {
|
||||
console.warn('[useMatrixUsers] Coerced missing level/depth to 0', { userId: u.userId })
|
||||
}
|
||||
const name = (u.name?.trim()) || (u.displayName?.trim()) || u.email
|
||||
return {
|
||||
id: u.userId,
|
||||
name,
|
||||
email: u.email,
|
||||
type: u.userType === 'company' ? 'company' : 'personal',
|
||||
level,
|
||||
parentUserId: u.parentUserId ?? null
|
||||
}
|
||||
})
|
||||
mapped.sort((a, b) => a.level - b.level || a.id - b.id)
|
||||
setUsers(mapped)
|
||||
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)
|
||||
})
|
||||
.finally(() => {
|
||||
setLoading(false)
|
||||
})
|
||||
|
||||
return () => controller.abort()
|
||||
}, [rootUserId, matrixId, topNodeEmail, depth, limit, offset, includeRoot, tick])
|
||||
|
||||
const meta = { rootUserId, matrixId, topNodeEmail, depth, limit, offset, includeRoot, serverMaxDepth } // NEW
|
||||
return { users, loading, error, meta, refetch, serverMaxDepth } // NEW
|
||||
}
|
||||
224
src/app/admin/matrix-management/detail/hooks/search-candidate.ts
Normal file
224
src/app/admin/matrix-management/detail/hooks/search-candidate.ts
Normal file
@ -0,0 +1,224 @@
|
||||
import { authFetch } from '../../../../utils/authFetch';
|
||||
|
||||
export type CandidateItem = {
|
||||
userId: number;
|
||||
email: string;
|
||||
userType: 'personal' | 'company';
|
||||
name: string;
|
||||
};
|
||||
|
||||
export type UserCandidatesResponse = {
|
||||
success: boolean;
|
||||
data?: {
|
||||
q: string | null;
|
||||
type: 'all' | 'personal' | 'company';
|
||||
rootUserId: number | null;
|
||||
limit: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
items: CandidateItem[];
|
||||
};
|
||||
message?: string;
|
||||
};
|
||||
|
||||
export type UserCandidatesData = {
|
||||
q: string | null;
|
||||
type: 'all' | 'personal' | 'company';
|
||||
rootUserId: number | null;
|
||||
limit: number;
|
||||
offset: number;
|
||||
total: number;
|
||||
items: CandidateItem[];
|
||||
_debug?: {
|
||||
endpoint: string;
|
||||
query: Record<string, string>;
|
||||
combo: string;
|
||||
};
|
||||
};
|
||||
|
||||
export type GetUserCandidatesParams = {
|
||||
q: string;
|
||||
type?: 'all' | 'personal' | 'company';
|
||||
rootUserId?: number;
|
||||
matrixId?: string | number;
|
||||
topNodeEmail?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
};
|
||||
|
||||
export async function getUserCandidates(params: GetUserCandidatesParams): Promise<UserCandidatesData> {
|
||||
const {
|
||||
q,
|
||||
type = 'all',
|
||||
rootUserId,
|
||||
matrixId,
|
||||
topNodeEmail,
|
||||
limit = 20,
|
||||
offset = 0
|
||||
} = params;
|
||||
|
||||
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '');
|
||||
if (!base) {
|
||||
console.warn('[getUserCandidates] NEXT_PUBLIC_API_BASE_URL not set. Falling back to same-origin.');
|
||||
}
|
||||
|
||||
const qTrimmed = q.trim();
|
||||
console.info('[getUserCandidates] Building candidate request', {
|
||||
base,
|
||||
q: qTrimmed,
|
||||
typeSent: type !== 'all' ? type : undefined,
|
||||
identifiers: { rootUserId, matrixId, topNodeEmail },
|
||||
pagination: { limit, offset }
|
||||
});
|
||||
|
||||
// Build identifier combinations: all -> root-only -> matrix-only -> email-only
|
||||
const combos: Array<{ label: string; apply: (qs: URLSearchParams) => void }> = [];
|
||||
const hasRoot = typeof rootUserId === 'number' && rootUserId > 0;
|
||||
const hasMatrix = !!matrixId;
|
||||
const hasEmail = !!topNodeEmail;
|
||||
|
||||
if (hasRoot || hasMatrix || hasEmail) {
|
||||
combos.push({
|
||||
label: 'all-identifiers',
|
||||
apply: (qs) => {
|
||||
if (hasRoot) qs.set('rootUserId', String(rootUserId));
|
||||
if (hasMatrix) qs.set('matrixId', String(matrixId));
|
||||
if (hasEmail) qs.set('topNodeEmail', String(topNodeEmail));
|
||||
}
|
||||
});
|
||||
}
|
||||
if (hasRoot) combos.push({ label: 'root-only', apply: (qs) => { qs.set('rootUserId', String(rootUserId)); } });
|
||||
if (hasMatrix) combos.push({ label: 'matrix-only', apply: (qs) => { qs.set('matrixId', String(matrixId)); } });
|
||||
if (hasEmail) combos.push({ label: 'email-only', apply: (qs) => { qs.set('topNodeEmail', String(topNodeEmail)); } });
|
||||
if (combos.length === 0) combos.push({ label: 'no-identifiers', apply: () => {} });
|
||||
|
||||
const endpointVariants = [
|
||||
(qs: string) => `${base}/api/admin/matrix/users/candidates?${qs}`,
|
||||
(qs: string) => `${base}/api/admin/matrix/user-candidates?${qs}`
|
||||
];
|
||||
console.debug('[getUserCandidates] Endpoint variants', endpointVariants.map(f => f('...')));
|
||||
|
||||
let lastError: any = null;
|
||||
let lastZeroData: UserCandidatesData | null = null;
|
||||
|
||||
// Try each identifier combo against both endpoint variants
|
||||
for (const combo of combos) {
|
||||
const qs = new URLSearchParams();
|
||||
qs.set('q', qTrimmed);
|
||||
qs.set('limit', String(limit));
|
||||
qs.set('offset', String(offset));
|
||||
if (type !== 'all') qs.set('type', type);
|
||||
combo.apply(qs);
|
||||
|
||||
const qsObj = Object.fromEntries(qs.entries());
|
||||
console.debug('[getUserCandidates] Final query params', { combo: combo.label, qs: qsObj });
|
||||
|
||||
for (let i = 0; i < endpointVariants.length; i++) {
|
||||
const url = endpointVariants[i](qs.toString());
|
||||
const fetchOpts = { method: 'GET', headers: { Accept: 'application/json' } as const };
|
||||
console.info('[getUserCandidates] REQUEST GET', {
|
||||
url,
|
||||
attempt: i + 1,
|
||||
combo: combo.label,
|
||||
identifiers: { rootUserId, matrixId, topNodeEmail },
|
||||
params: { q: qTrimmed, type: type !== 'all' ? type : undefined, limit, offset },
|
||||
fetchOpts
|
||||
});
|
||||
|
||||
const t0 = performance.now();
|
||||
const res = await authFetch(url, fetchOpts);
|
||||
const t1 = performance.now();
|
||||
const ct = res.headers.get('content-type') || '';
|
||||
console.debug('[getUserCandidates] Response meta', {
|
||||
status: res.status,
|
||||
ok: res.ok,
|
||||
durationMs: Math.round(t1 - t0),
|
||||
contentType: ct
|
||||
});
|
||||
|
||||
// Preview raw body (first 300 chars)
|
||||
let rawPreview = '';
|
||||
try {
|
||||
rawPreview = await res.clone().text();
|
||||
} catch {}
|
||||
if (rawPreview) {
|
||||
console.trace('[getUserCandidates] Raw body preview (trimmed)', rawPreview.slice(0, 300));
|
||||
}
|
||||
|
||||
if (res.status === 404 && i < endpointVariants.length - 1) {
|
||||
try {
|
||||
const preview = ct.includes('application/json') ? await res.json() : await res.text();
|
||||
console.warn('[getUserCandidates] 404 on endpoint variant, trying fallback', {
|
||||
tried: url,
|
||||
combo: combo.label,
|
||||
preview: typeof preview === 'string' ? preview.slice(0, 200) : preview
|
||||
});
|
||||
} catch {}
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!ct.includes('application/json')) {
|
||||
const text = await res.text().catch(() => '');
|
||||
console.error('[getUserCandidates] Non-JSON response', { status: res.status, bodyPreview: text.slice(0, 500) });
|
||||
lastError = new Error(`Request failed: ${res.status}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const json: UserCandidatesResponse = await res.json().catch(() => ({ success: false, message: 'Invalid JSON' } as any));
|
||||
console.debug('[getUserCandidates] Parsed JSON', {
|
||||
success: json?.success,
|
||||
message: json?.message,
|
||||
dataMeta: json?.data && {
|
||||
q: json.data.q,
|
||||
type: json.data.type,
|
||||
total: json.data.total,
|
||||
itemsCount: json.data.items?.length
|
||||
}
|
||||
});
|
||||
|
||||
if (!res.ok || !json?.success) {
|
||||
console.error('[getUserCandidates] Backend reported failure', {
|
||||
status: res.status,
|
||||
successFlag: json?.success,
|
||||
message: json?.message
|
||||
});
|
||||
lastError = new Error(json?.message || `Request failed: ${res.status}`);
|
||||
break;
|
||||
}
|
||||
|
||||
const dataWithDebug: UserCandidatesData = {
|
||||
...json.data!,
|
||||
_debug: { endpoint: url, query: qsObj, combo: combo.label }
|
||||
};
|
||||
|
||||
if ((dataWithDebug.total || 0) > 0) {
|
||||
console.info('[getUserCandidates] Success (non-empty)', {
|
||||
total: dataWithDebug.total,
|
||||
itemsCount: dataWithDebug.items.length,
|
||||
combo: combo.label,
|
||||
endpoint: url
|
||||
});
|
||||
return dataWithDebug;
|
||||
}
|
||||
|
||||
// Keep last zero result but continue trying other combos/endpoints
|
||||
lastZeroData = dataWithDebug;
|
||||
console.info('[getUserCandidates] Success (empty)', { combo: combo.label, endpoint: url });
|
||||
}
|
||||
|
||||
if (lastError) break; // stop on hard error
|
||||
}
|
||||
|
||||
if (lastError) {
|
||||
console.error('[getUserCandidates] Exhausted endpoint variants with error', { lastError: lastError?.message });
|
||||
throw lastError;
|
||||
}
|
||||
|
||||
// Return the last empty response (with _debug info) if everything was empty
|
||||
if (lastZeroData) {
|
||||
console.warn('[getUserCandidates] All combos returned empty results', { lastCombo: lastZeroData._debug });
|
||||
return lastZeroData;
|
||||
}
|
||||
|
||||
throw new Error('Request failed');
|
||||
}
|
||||
@ -4,32 +4,13 @@ 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'
|
||||
|
||||
type UserType = 'personal' | 'company'
|
||||
type MatrixUser = {
|
||||
id: string | number
|
||||
name: string
|
||||
email: string
|
||||
type: UserType
|
||||
level: number // 0 for top-node, then 1..N
|
||||
}
|
||||
import { useMatrixUsers, MatrixUser, UserType } from './hooks/getStats'
|
||||
import useAuthStore from '../../../store/authStore'
|
||||
import { getMatrixStats } from '../hooks/getMatrixStats'
|
||||
import SearchModal from './components/searchModal'
|
||||
|
||||
const LEVEL_CAP = (level: number) => Math.pow(5, level) // L1=5, L2=25, ...
|
||||
|
||||
// Dummy users to pick from in the modal
|
||||
const DUMMY_POOL: Array<Omit<MatrixUser, 'level'>> = [
|
||||
{ id: 101, name: 'Alice Johnson', email: 'alice@example.com', type: 'personal' },
|
||||
{ id: 102, name: 'Beta GmbH', email: 'office@beta-gmbh.de', type: 'company' },
|
||||
{ id: 103, name: 'Carlos Diaz', email: 'carlos@sample.io', type: 'personal' },
|
||||
{ id: 104, name: 'Delta Solutions AG', email: 'contact@delta.ag', type: 'company' },
|
||||
{ id: 105, name: 'Emily Nguyen', email: 'emily.ng@ex.com', type: 'personal' },
|
||||
{ id: 106, name: 'Foxtrot LLC', email: 'hello@foxtrot.llc', type: 'company' },
|
||||
{ id: 107, name: 'Grace Blake', email: 'grace@ex.com', type: 'personal' },
|
||||
{ id: 108, name: 'Hestia Corp', email: 'hq@hestia.io', type: 'company' },
|
||||
{ id: 109, name: 'Ivan Petrov', email: 'ivan@ex.com', type: 'personal' },
|
||||
{ id: 110, name: 'Juno Partners', email: 'team@juno.partners', type: 'company' },
|
||||
]
|
||||
|
||||
export default function MatrixDetailPage() {
|
||||
const sp = useSearchParams()
|
||||
const router = useRouter()
|
||||
@ -37,59 +18,123 @@ export default function MatrixDetailPage() {
|
||||
const matrixId = sp.get('id') || 'm-1'
|
||||
const matrixName = sp.get('name') || 'Unnamed Matrix'
|
||||
const topNodeEmail = sp.get('top') || 'top@example.com'
|
||||
const rootUserIdParam = sp.get('rootUserId')
|
||||
const rootUserId = rootUserIdParam ? Number(rootUserIdParam) : undefined
|
||||
console.info('[MatrixDetailPage] Params', { matrixId, matrixName, topNodeEmail, rootUserId })
|
||||
|
||||
// Build initial dummy matrix users
|
||||
const [users, setUsers] = useState<MatrixUser[]>(() => {
|
||||
// Level 0 = top node from URL
|
||||
const initial: MatrixUser[] = [
|
||||
{ id: 'top', name: 'Top Node', email: topNodeEmail, type: 'personal', level: 0 },
|
||||
]
|
||||
// Fill some demo users across levels
|
||||
const seed: Omit<MatrixUser, 'level'>[] = DUMMY_POOL.slice(0, 12)
|
||||
let remaining = [...seed]
|
||||
let level = 1
|
||||
while (remaining.length > 0 && level <= 3) {
|
||||
const cap = LEVEL_CAP(level)
|
||||
const take = Math.min(remaining.length, Math.max(1, Math.min(cap, 6))) // keep demo compact
|
||||
initial.push(...remaining.splice(0, take).map(u => ({ ...u, level })))
|
||||
level++
|
||||
// Resolve rootUserId when missing by looking it up via stats
|
||||
const accessToken = useAuthStore(s => s.accessToken)
|
||||
const [resolvedRootUserId, setResolvedRootUserId] = useState<number | undefined>(rootUserId)
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false
|
||||
async function resolveRoot() {
|
||||
if (rootUserId && rootUserId > 0) {
|
||||
console.info('[MatrixDetailPage] Using rootUserId from URL', { rootUserId })
|
||||
setResolvedRootUserId(rootUserId)
|
||||
return
|
||||
}
|
||||
if (!accessToken) {
|
||||
console.warn('[MatrixDetailPage] No accessToken; cannot resolve rootUserId from stats')
|
||||
setResolvedRootUserId(undefined)
|
||||
return
|
||||
}
|
||||
if (!matrixId) {
|
||||
console.warn('[MatrixDetailPage] No matrixId; cannot resolve rootUserId from stats')
|
||||
setResolvedRootUserId(undefined)
|
||||
return
|
||||
}
|
||||
console.info('[MatrixDetailPage] Resolving rootUserId via stats for matrixId', { matrixId })
|
||||
const res = await getMatrixStats({ token: accessToken, baseUrl: process.env.NEXT_PUBLIC_API_BASE_URL })
|
||||
console.debug('[MatrixDetailPage] getMatrixStats result', res)
|
||||
if (!res.ok) {
|
||||
console.error('[MatrixDetailPage] getMatrixStats failed', { status: res.status, message: res.message })
|
||||
setResolvedRootUserId(undefined)
|
||||
return
|
||||
}
|
||||
const body = res.body || {}
|
||||
const matrices = (body?.data?.matrices ?? body?.matrices ?? []) as any[]
|
||||
console.debug('[MatrixDetailPage] Stats matrices overview', {
|
||||
count: matrices.length,
|
||||
ids: matrices.map((m: any) => m?.id ?? m?.matrixId),
|
||||
matrixIds: matrices.map((m: any) => m?.matrixId ?? m?.id),
|
||||
rootUserIds: matrices.map((m: any) => m?.rootUserId ?? m?.root_user_id),
|
||||
emails: matrices.map((m: any) => m?.topNodeEmail ?? m?.email)
|
||||
})
|
||||
const found = matrices.find((m: any) =>
|
||||
String(m?.id) === String(matrixId) || String(m?.matrixId) === String(matrixId)
|
||||
)
|
||||
const ru = Number(found?.rootUserId ?? found?.root_user_id)
|
||||
if (ru > 0 && !cancelled) {
|
||||
console.info('[MatrixDetailPage] Resolved rootUserId from stats', { matrixId, rootUserId: ru })
|
||||
setResolvedRootUserId(ru)
|
||||
} else {
|
||||
console.warn('[MatrixDetailPage] Could not resolve rootUserId from stats', { matrixId, found })
|
||||
setResolvedRootUserId(undefined)
|
||||
}
|
||||
}
|
||||
return initial
|
||||
resolveRoot()
|
||||
return () => { cancelled = true }
|
||||
}, [matrixId, rootUserId, accessToken])
|
||||
|
||||
// Backend users
|
||||
const { users: fetchedUsers, loading: usersLoading, error: usersError, meta, refetch, serverMaxDepth } = useMatrixUsers(resolvedRootUserId, {
|
||||
depth: 5,
|
||||
includeRoot: true,
|
||||
limit: 100,
|
||||
offset: 0,
|
||||
matrixId,
|
||||
topNodeEmail
|
||||
})
|
||||
|
||||
// Prepare for backend fetches
|
||||
const [users, setUsers] = useState<MatrixUser[]>([])
|
||||
|
||||
useEffect(() => {
|
||||
console.info('[MatrixDetailPage] useMatrixUsers state', {
|
||||
loading: usersLoading,
|
||||
error: !!usersError,
|
||||
fetchedCount: fetchedUsers.length,
|
||||
meta
|
||||
})
|
||||
setUsers(fetchedUsers)
|
||||
}, [fetchedUsers, usersLoading, usersError, meta])
|
||||
|
||||
// Modal state
|
||||
const [open, setOpen] = useState(false)
|
||||
const [query, setQuery] = useState('')
|
||||
const [typeFilter, setTypeFilter] = useState<'all' | UserType>('all')
|
||||
// ADD: global search state (was removed)
|
||||
const [globalSearch, setGlobalSearch] = useState('')
|
||||
|
||||
// Available candidates = pool minus already added ids
|
||||
const availableUsers = useMemo(() => {
|
||||
const picked = new Set(users.map(u => String(u.id)))
|
||||
return DUMMY_POOL.filter(u => !picked.has(String(u.id)))
|
||||
}, [users])
|
||||
// Refresh overlay state
|
||||
const [refreshing, setRefreshing] = useState(false)
|
||||
|
||||
const filteredCandidates = useMemo(() => {
|
||||
const q = query.trim().toLowerCase()
|
||||
return availableUsers.filter(u => {
|
||||
if (typeFilter !== 'all' && u.type !== typeFilter) return false
|
||||
if (!q) return true
|
||||
return u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
|
||||
})
|
||||
}, [availableUsers, query, typeFilter])
|
||||
// Collapsed state for each level
|
||||
const [collapsedLevels, setCollapsedLevels] = useState<{ [level: number]: boolean }>({
|
||||
0: true, 1: true, 2: true, 3: true, 4: true, 5: true
|
||||
})
|
||||
|
||||
// Per-level search
|
||||
const [levelSearch, setLevelSearch] = useState<{ [level: number]: string }>({
|
||||
0: '', 1: '', 2: '', 3: '', 4: '', 5: ''
|
||||
})
|
||||
|
||||
// Counts per level and next available level logic
|
||||
const byLevel = useMemo(() => {
|
||||
const map = new Map<number, MatrixUser[]>()
|
||||
users.forEach(u => {
|
||||
const arr = map.get(u.level) || []
|
||||
arr.push(u)
|
||||
map.set(u.level, arr)
|
||||
if (!u.name) {
|
||||
console.warn('[MatrixDetailPage] User missing name, fallback email used', { id: u.id, email: u.email })
|
||||
}
|
||||
const lvl = (typeof u.level === 'number' && u.level >= 0) ? u.level : 0
|
||||
const arr = map.get(lvl) || []
|
||||
arr.push({ ...u, level: lvl })
|
||||
map.set(lvl, arr)
|
||||
})
|
||||
console.debug('[MatrixDetailPage] byLevel computed', { levels: Array.from(map.keys()), total: users.length })
|
||||
return map
|
||||
}, [users])
|
||||
|
||||
const nextAvailableLevel = () => {
|
||||
// Start from level 1 upwards; find first with space; else go to next new level
|
||||
let lvl = 1
|
||||
while (true) {
|
||||
const current = byLevel.get(lvl)?.length || 0
|
||||
@ -100,7 +145,9 @@ export default function MatrixDetailPage() {
|
||||
}
|
||||
|
||||
const addToMatrix = (u: Omit<MatrixUser, 'level'>) => {
|
||||
setUsers(prev => [...prev, { ...u, level: nextAvailableLevel() }])
|
||||
const level = nextAvailableLevel()
|
||||
console.info('[MatrixDetailPage] addToMatrix', { userId: u.id, nextLevel: level })
|
||||
setUsers(prev => [...prev, { ...u, level }])
|
||||
}
|
||||
|
||||
// Simple chip for user
|
||||
@ -113,195 +160,337 @@ export default function MatrixDetailPage() {
|
||||
|
||||
// Compact grid for a level
|
||||
const LevelSection = ({ level }: { level: number }) => {
|
||||
const cap = level === 0 ? 1 : LEVEL_CAP(level)
|
||||
const list = byLevel.get(level) || []
|
||||
const pct = Math.min(100, Math.round((list.length / cap) * 100))
|
||||
const title =
|
||||
level === 0 ? 'Level 0 (Top node)' : `Level ${level} — ${list.length} / ${cap}`
|
||||
|
||||
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)
|
||||
)
|
||||
const list = listAll.slice(0, pageFor(level) * levelPageSize)
|
||||
const title = `Level ${level} — ${listAll.length} users`
|
||||
const showLoadMore = listAll.length > list.length
|
||||
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>
|
||||
<span className="text-[11px] font-semibold text-indigo-700">{pct}%</span>
|
||||
</div>
|
||||
|
||||
{/* Progress */}
|
||||
<div className="px-4 sm:px-5 py-3">
|
||||
<div className="h-2 w-full rounded-full bg-gray-100 ring-1 ring-gray-200 overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-gradient-to-r from-indigo-500 via-violet-500 to-fuchsia-500 transition-[width] duration-500"
|
||||
style={{ width: `${pct}%` }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Users */}
|
||||
<div className="px-4 sm:px-5 pb-4">
|
||||
{list.length === 0 ? (
|
||||
<div className="text-xs text-gray-500 italic">No users in this level yet.</div>
|
||||
) : (
|
||||
<div className="mt-2 flex flex-wrap gap-2">
|
||||
{list.slice(0, 30).map(u => <UserChip key={`${level}-${u.id}`} u={u} />)}
|
||||
{list.length > 30 && (
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-[11px] text-gray-700">
|
||||
+{list.length - 30} more
|
||||
</span>
|
||||
)}
|
||||
{!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>
|
||||
)}
|
||||
</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>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
|
||||
// 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
|
||||
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 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.click()
|
||||
URL.revokeObjectURL(a.href)
|
||||
}
|
||||
|
||||
// When modal closes, refetch backend to sync page data
|
||||
const handleModalClose = () => {
|
||||
setOpen(false)
|
||||
setRefreshing(true)
|
||||
refetch() // triggers hook reload
|
||||
}
|
||||
|
||||
// Stop spinner when hook finishes loading
|
||||
useEffect(() => {
|
||||
if (!usersLoading && refreshing) {
|
||||
setRefreshing(false)
|
||||
}
|
||||
}, [usersLoading, refreshing])
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<div className="min-h-screen w-full px-4 sm:px-6 py-8 bg-white">
|
||||
<div className="max-w-7xl mx-auto">
|
||||
{/* Header / Overview */}
|
||||
<div className="flex items-start justify-between gap-4 mb-6">
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => router.push('/admin/matrix-management')}
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
Back to matrices
|
||||
</button>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">{matrixName}</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
Top node: <span className="font-medium text-gray-900">{topNodeEmail}</span>
|
||||
</p>
|
||||
{/* Smooth refresh overlay */}
|
||||
{refreshing && (
|
||||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-white/50 backdrop-blur-sm transition-opacity">
|
||||
<div className="flex items-center gap-3 rounded-lg bg-white shadow-md border border-gray-200 px-4 py-3">
|
||||
<span className="h-5 w-5 rounded-full border-2 border-indigo-600 border-b-transparent animate-spin" />
|
||||
<span className="text-sm text-gray-700">Refreshing…</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Centered page container to avoid full-width stretch */}
|
||||
<div className="min-h-screen w-full bg-white">
|
||||
<div className="mx-auto max-w-6xl px-4 sm:px-6 py-6">
|
||||
{/* Modern header card with action */}
|
||||
<div className="mb-6 rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden">
|
||||
<div className="px-5 py-4 flex items-start justify-between gap-4">
|
||||
<div className="space-y-1">
|
||||
<button
|
||||
onClick={() => router.push('/admin/matrix-management')}
|
||||
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-800"
|
||||
>
|
||||
<ArrowLeftIcon className="h-4 w-4" />
|
||||
Back to matrices
|
||||
</button>
|
||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">{matrixName}</h1>
|
||||
<p className="text-sm text-gray-600">
|
||||
Top node: <span className="font-medium text-gray-900">{topNodeEmail}</span>
|
||||
</p>
|
||||
<div className="mt-2 flex flex-wrap items-center gap-2">
|
||||
<span className="inline-flex items-center rounded-full bg-gray-100 border border-gray-200 px-2.5 py-1 text-[11px] text-gray-800">
|
||||
Children/node: 5
|
||||
</span>
|
||||
<span className="inline-flex items-center rounded-full bg-indigo-50 border border-indigo-200 px-2.5 py-1 text-[11px] text-indigo-800">
|
||||
Max depth: {(!serverMaxDepth || serverMaxDepth <= 0) ? 'Unlimited' : serverMaxDepth}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => { setOpen(true) }}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-blue-700 hover:bg-blue-600 text-white px-4 py-2 shadow-sm"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
Add users to matrix
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 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.
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* sticky depth controls */}
|
||||
<div className="sticky top-0 z-10 bg-white/85 backdrop-blur px-4 sm:px-6 py-3 border-b border-gray-100 flex items-center gap-3 rounded-md">
|
||||
<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 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 border border-gray-300 px-2 py-1 text-xs" />
|
||||
</div>
|
||||
<button
|
||||
onClick={() => setOpen(true)}
|
||||
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 shadow-sm"
|
||||
onClick={() => { setDepthA(Math.max(0, depthA - 5)); setDepthB(Math.max(5, depthB - 5)); }}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-300"
|
||||
>
|
||||
<PlusIcon className="h-5 w-5" />
|
||||
Add users to matrix
|
||||
‹ Load previous 5 levels
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { setDepthA(depthA + 5); setDepthB(depthB + 5); }}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-300"
|
||||
>
|
||||
Load next 5 levels ›
|
||||
</button>
|
||||
{/* NEW: include root toggle */}
|
||||
<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-indigo-600 focus:ring-indigo-500"
|
||||
/>
|
||||
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-indigo-700 hover:text-indigo-900 underline">Export CSV (levels {depthA}–{depthB})</button>
|
||||
</div>
|
||||
|
||||
{/* Levels display */}
|
||||
{/* small stats */}
|
||||
<div className="mt-3 mb-4 grid grid-cols-1 sm:grid-cols-3 gap-3">
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-3">
|
||||
<div className="text-[11px] text-gray-500">Users (current slice)</div>
|
||||
<div className="text-base font-semibold text-gray-900">{usersInSlice.length}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-3">
|
||||
<div className="text-[11px] text-gray-500">Total descendants</div>
|
||||
<div className="text-base font-semibold text-gray-900">{isUnlimited ? 'All descendants so far' : totalDescendants}</div>
|
||||
</div>
|
||||
<div className="rounded-lg border border-gray-200 bg-white p-3">
|
||||
<div className="text-[11px] text-gray-500">Active levels loaded</div>
|
||||
<div className="text-base font-semibold text-gray-900">{depthA}–{depthB}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* dynamic levels */}
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||
<LevelSection level={0} />
|
||||
<LevelSection level={1} />
|
||||
<LevelSection level={2} />
|
||||
<LevelSection level={3} />
|
||||
{/* Add more levels visually if needed */}
|
||||
{Array
|
||||
.from(byLevel.keys())
|
||||
.filter(l => l >= depthA && l <= depthB)
|
||||
.filter(l => includeRoot || l !== 0) // NEW: hide level 0 when unchecked
|
||||
.sort((a,b)=>a-b)
|
||||
.map(l => (
|
||||
<LevelSection key={l} level={l} />
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/* Full users list by level */}
|
||||
<div className="mt-8 rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
|
||||
{/* jump to level */}
|
||||
<div className="mt-4 mb-8">
|
||||
<label className="text-xs text-gray-600 mr-2">Jump to level</label>
|
||||
<select className="rounded border border-gray-300 px-2 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-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-100">
|
||||
<h2 className="text-base font-semibold text-gray-900">All users in this matrix</h2>
|
||||
<p className="text-xs text-gray-600">Grouped by levels (power of five structure).</p>
|
||||
</div>
|
||||
<div className="divide-y divide-gray-100">
|
||||
{[...byLevel.keys()].sort((a, b) => a - b).map(lvl => {
|
||||
{[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-5 py-4">
|
||||
<div className="mb-2 text-sm font-semibold text-gray-800">
|
||||
{lvl === 0 ? 'Level 0 (Top node)' : `Level ${lvl}`} • {list.length} user(s)
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{list.map(u => (
|
||||
<div key={`${lvl}-${u.id}`} className="rounded-lg border border-gray-200 p-3 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-gray-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>
|
||||
<button
|
||||
className="flex items-center justify-between w-full text-left"
|
||||
onClick={() => {
|
||||
console.log('[MatrixDetailPage] toggle level', { level: lvl, to: !collapsedLevels[lvl] })
|
||||
setCollapsedLevels(prev => ({ ...prev, [lvl]: !prev[lvl] }))
|
||||
}}
|
||||
>
|
||||
<span className="mb-2 text-sm font-semibold text-gray-800 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-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
value={levelSearch[lvl]}
|
||||
onChange={e => {
|
||||
console.log('[MatrixDetailPage] levelSearch', { level: lvl, q: e.target.value })
|
||||
setLevelSearch(prev => ({ ...prev, [lvl]: e.target.value }))
|
||||
}}
|
||||
placeholder="Search in level..."
|
||||
className="pl-8 pr-2 py-1 rounded-md border border-gray-200 text-xs focus:ring-1 focus:ring-indigo-500 focus:border-transparent w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-gray-600">{u.email}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
||||
{filteredList.length === 0 && (
|
||||
<div className="col-span-full text-xs text-gray-500 italic">No users found.</div>
|
||||
)}
|
||||
{filteredList.length > 0 && filteredList.map(u => (
|
||||
<div key={`${lvl}-${u.id}`} className="rounded-lg border border-gray-200 p-3 bg-gray-50">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm font-medium text-gray-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-gray-600">{u.email}</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Users Modal */}
|
||||
<SearchModal
|
||||
open={open}
|
||||
onClose={handleModalClose}
|
||||
matrixName={matrixName}
|
||||
rootUserId={resolvedRootUserId}
|
||||
matrixId={matrixId}
|
||||
topNodeEmail={topNodeEmail}
|
||||
existingUsers={users}
|
||||
policyMaxDepth={serverMaxDepth ?? null}
|
||||
onAdd={(u) => { addToMatrix(u) }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Add Users Modal */}
|
||||
{open && (
|
||||
<div className="fixed inset-0 z-50">
|
||||
<div className="absolute inset-0 bg-black/40" onClick={() => setOpen(false)} />
|
||||
<div className="absolute inset-0 flex items-center justify-center p-4">
|
||||
<div className="w-full max-w-2xl rounded-xl bg-white shadow-2xl ring-1 ring-black/10 overflow-hidden">
|
||||
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between">
|
||||
<h3 className="text-base font-semibold text-gray-900">Add users to “{matrixName}”</h3>
|
||||
<button onClick={() => setOpen(false)} className="text-sm text-gray-600 hover:text-gray-800">Close</button>
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-4 grid grid-cols-1 md:grid-cols-4 gap-3 border-b border-gray-100">
|
||||
<div className="md:col-span-2 relative">
|
||||
<MagnifyingGlassIcon className="h-4 w-4 text-gray-500 absolute left-2.5 top-2.5" />
|
||||
<input
|
||||
value={query}
|
||||
onChange={e => { setQuery(e.target.value) }}
|
||||
placeholder="Search name or email…"
|
||||
className="w-full rounded-md border border-gray-300 pl-8 pr-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 placeholder:text-gray-700 placeholder:opacity-100"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={typeFilter}
|
||||
onChange={e => setTypeFilter(e.target.value as any)}
|
||||
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900"
|
||||
>
|
||||
<option value="all">All Types</option>
|
||||
<option value="personal">Personal</option>
|
||||
<option value="company">Company</option>
|
||||
</select>
|
||||
<div className="text-sm text-gray-600 self-center">
|
||||
Candidates: <span className="font-semibold text-gray-900">{filteredCandidates.length}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[50vh] overflow-auto">
|
||||
{filteredCandidates.length === 0 ? (
|
||||
<div className="px-5 py-8 text-sm text-gray-500">No users match your filters.</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-gray-100">
|
||||
{filteredCandidates.map(u => (
|
||||
<li key={u.id} className="px-5 py-3 flex items-center justify-between gap-3">
|
||||
<div className="min-w-0">
|
||||
<div className="text-sm font-medium text-gray-900">{u.name}</div>
|
||||
<div className="text-xs text-gray-600">{u.email}</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<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>
|
||||
<button
|
||||
onClick={() => addToMatrix(u)}
|
||||
className="inline-flex items-center rounded-md bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-1.5 text-xs font-medium"
|
||||
>
|
||||
Add
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="px-5 py-3 border-t border-gray-100 flex items-center justify-end">
|
||||
<button onClick={() => setOpen(false)} className="text-sm text-gray-700 hover:text-gray-900">Done</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</PageLayout>
|
||||
)
|
||||
}
|
||||
|
||||
@ -12,7 +12,9 @@ export async function getMatrixStats(params: {
|
||||
const { token, baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || '' } = params
|
||||
if (!token) return { ok: false, status: 401, message: 'Missing token' }
|
||||
|
||||
const url = `${baseUrl}/api/matrix/stats`
|
||||
const base = (baseUrl || '').replace(/\/+$/, '')
|
||||
const url = `${base}/api/matrix/stats`
|
||||
console.info('[getMatrixStats] REQUEST GET', url)
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
@ -21,11 +23,13 @@ export async function getMatrixStats(params: {
|
||||
})
|
||||
let body: any = null
|
||||
try { body = await res.json() } catch {}
|
||||
console.debug('[getMatrixStats] Response', { status: res.status, hasBody: !!body, keys: body ? Object.keys(body) : [] })
|
||||
if (!res.ok) {
|
||||
return { ok: false, status: res.status, body, message: body?.message || `Fetch stats failed (${res.status})` }
|
||||
}
|
||||
return { ok: true, status: res.status, body }
|
||||
} catch {
|
||||
} catch (e) {
|
||||
console.error('[getMatrixStats] Network error', e)
|
||||
return { ok: false, status: 0, message: 'Network error' }
|
||||
}
|
||||
}
|
||||
|
||||
@ -22,6 +22,8 @@ type Matrix = {
|
||||
usersCount: number
|
||||
createdAt: string
|
||||
topNodeEmail: string
|
||||
rootUserId: number // added
|
||||
policyMaxDepth?: number | null // NEW
|
||||
}
|
||||
|
||||
export default function MatrixManagementPage() {
|
||||
@ -59,6 +61,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 [sortByUsers, setSortByUsers] = useState<'asc'|'desc'>('desc') // NEW
|
||||
|
||||
const loadStats = async () => {
|
||||
if (!token) return
|
||||
setStatsLoading(true)
|
||||
@ -73,13 +78,18 @@ export default function MatrixManagementPage() {
|
||||
const isActive = !!m?.isActive
|
||||
const createdAt = m?.createdAt || m?.ego_activated_at || m?.activatedAt || new Date().toISOString()
|
||||
const topNodeEmail = m?.topNodeEmail || m?.masterTopUserEmail || m?.email || ''
|
||||
const rootUserId = Number(m?.rootUserId ?? m?.root_user_id ?? 0)
|
||||
const matrixId = m?.matrixId ?? m?.id // prefer matrixId for routing
|
||||
const maxDepth = (m?.maxDepth ?? m?.policyMaxDepth) // backend optional
|
||||
return {
|
||||
id: String(m?.rootUserId ?? m?.id ?? `m-${idx}`),
|
||||
id: String(matrixId ?? `m-${idx}`),
|
||||
name: String(m?.name ?? 'Unnamed Matrix'),
|
||||
status: isActive ? 'active' : 'inactive',
|
||||
usersCount: Number(m?.usersCount ?? 0),
|
||||
createdAt: String(createdAt),
|
||||
topNodeEmail: String(topNodeEmail),
|
||||
rootUserId,
|
||||
policyMaxDepth: typeof maxDepth === 'number' ? maxDepth : null // NEW
|
||||
}
|
||||
})
|
||||
setMatrices(mapped)
|
||||
@ -190,6 +200,19 @@ export default function MatrixManagementPage() {
|
||||
)
|
||||
}
|
||||
|
||||
// derived list with filter/sort
|
||||
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])
|
||||
|
||||
const StatCard = ({
|
||||
icon: Icon,
|
||||
label,
|
||||
@ -260,6 +283,35 @@ 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 items-center gap-3">
|
||||
<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 border border-gray-300 px-2 py-1 text-sm">
|
||||
<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 border border-gray-300 px-2 py-1 text-sm">
|
||||
<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.">
|
||||
ℹ️ Tooltip: 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 gap-6 sm:grid-cols-2 xl:grid-cols-3">
|
||||
{statsLoading ? (
|
||||
@ -274,19 +326,23 @@ export default function MatrixManagementPage() {
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : matrices.length === 0 ? (
|
||||
) : matricesView.length === 0 ? (
|
||||
<div className="text-sm text-gray-600">No matrices found.</div>
|
||||
) : (
|
||||
matrices.map(m => (
|
||||
matricesView.map(m => (
|
||||
<article key={m.id} className="rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
|
||||
<div className="p-5">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<h3 className="text-lg font-semibold text-gray-900">{m.name}</h3>
|
||||
<StatusBadge status={m.status} />
|
||||
</div>
|
||||
|
||||
<div className="mt-2">
|
||||
<span className="inline-flex items-center rounded-full bg-indigo-50 border border-indigo-200 px-2 py-0.5 text-[11px] text-indigo-800">
|
||||
Max depth: {(!m.policyMaxDepth || m.policyMaxDepth <= 0) ? 'Unlimited' : m.policyMaxDepth}
|
||||
</span>
|
||||
</div>
|
||||
<div className="mt-4 grid grid-cols-1 gap-3 text-sm text-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="flex items-center gap-2" title="Users count respects each matrix’s max depth policy.">
|
||||
<UsersIcon className="h-5 w-5 text-gray-500" />
|
||||
<span className="font-medium">{m.usersCount}</span>
|
||||
<span className="text-gray-500">users</span>
|
||||
@ -302,7 +358,6 @@ export default function MatrixManagementPage() {
|
||||
<span className="text-gray-700 truncate">{m.topNodeEmail}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-5 flex items-center justify-between">
|
||||
<button
|
||||
onClick={() => toggleStatus(m.id)}
|
||||
@ -317,10 +372,16 @@ export default function MatrixManagementPage() {
|
||||
<button
|
||||
className="text-sm font-medium text-indigo-600 hover:text-indigo-500"
|
||||
onClick={() => {
|
||||
// get default depth range from localStorage per matrix
|
||||
const defA = Number(localStorage.getItem(`matrixDepthA:${m.id}`) ?? 0)
|
||||
const defB = Number(localStorage.getItem(`matrixDepthB:${m.id}`) ?? 5)
|
||||
const params = new URLSearchParams({
|
||||
id: String(m.id),
|
||||
name: m.name,
|
||||
top: m.topNodeEmail,
|
||||
rootUserId: String(m.rootUserId),
|
||||
a: String(Number.isFinite(defA) ? defA : 0),
|
||||
b: String(Number.isFinite(defB) ? defB : 5)
|
||||
})
|
||||
router.push(`/admin/matrix-management/detail?${params.toString()}`)
|
||||
}}
|
||||
@ -328,6 +389,25 @@ export default function MatrixManagementPage() {
|
||||
View details →
|
||||
</button>
|
||||
</div>
|
||||
{/* Quick default A–B editor */}
|
||||
<div className="mt-3 flex items-center gap-2">
|
||||
<span className="text-[11px] 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 border border-gray-300 px-2 py-1 text-[11px]"
|
||||
/>
|
||||
<span className="text-[11px] 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 border border-gray-300 px-2 py-1 text-[11px]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
))
|
||||
|
||||
@ -113,10 +113,11 @@ export default function LoginForm() {
|
||||
<div
|
||||
className="w-full flex justify-center items-start relative"
|
||||
style={{
|
||||
// Removed fixed 100vh + overflow hidden to allow scrolling
|
||||
minHeight: 'auto',
|
||||
// Ensure the background fills the viewport height
|
||||
minHeight: '100vh',
|
||||
// Reduce bottom padding to avoid extra white space
|
||||
paddingTop: isMobile ? '0.75rem' : '4rem',
|
||||
paddingBottom: '3rem',
|
||||
paddingBottom: isMobile ? '1rem' : '1.5rem',
|
||||
backgroundImage: 'url(/images/misc/marble_bluegoldwhite_BG.jpg)',
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center'
|
||||
|
||||
@ -43,19 +43,19 @@ export default function LoginPage() {
|
||||
|
||||
return (
|
||||
<PageLayout showFooter={false}>
|
||||
<div className="relative w-full flex flex-col min-h-screen bg-[#FAF9F6]">
|
||||
<div className="relative w-full flex flex-col min-h-screen">
|
||||
<div className="relative z-10 flex-1 flex items-start justify-center">
|
||||
<div className="w-full">
|
||||
<LoginForm />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Footer for mobile */}
|
||||
<div className="relative z-10 md:hidden">
|
||||
{/* Removed mobile footer to avoid bottom white container */}
|
||||
{/* <div className="relative z-10 md:hidden">
|
||||
<div className="text-center py-4 text-sm text-slate-700">
|
||||
© 2024 Profit Planet. Alle Rechte vorbehalten.
|
||||
</div>
|
||||
</div>
|
||||
</div> */}
|
||||
</div>
|
||||
</PageLayout>
|
||||
)
|
||||
|
||||
@ -61,7 +61,13 @@ export async function authFetch(input: RequestInfo | URL, init: CustomRequestIni
|
||||
|
||||
// Always send credentials so refresh cookie is included when server-side refresh is needed
|
||||
const fetchWithAuth = async (hdrs: Record<string, string>): Promise<Response> => {
|
||||
log("📡 authFetch: Sending request", { url, headers: Object.keys(hdrs), credentials: "include" });
|
||||
log("📡 authFetch: Sending request", {
|
||||
url,
|
||||
headers: Object.keys(hdrs),
|
||||
hasAuth: !!hdrs.Authorization,
|
||||
authPrefix: hdrs.Authorization ? `${hdrs.Authorization.substring(0, 24)}...` : null,
|
||||
credentials: "include",
|
||||
});
|
||||
return fetch(url, { ...init, headers: hdrs, credentials: "include" });
|
||||
};
|
||||
|
||||
|
||||
Loading…
Reference in New Issue
Block a user