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 { useSearchParams, useRouter } from 'next/navigation'
|
||||||
import PageLayout from '../../../components/PageLayout'
|
import PageLayout from '../../../components/PageLayout'
|
||||||
import { ArrowLeftIcon, MagnifyingGlassIcon, PlusIcon, UserIcon, BuildingOffice2Icon } from '@heroicons/react/24/outline'
|
import { ArrowLeftIcon, MagnifyingGlassIcon, PlusIcon, UserIcon, BuildingOffice2Icon } from '@heroicons/react/24/outline'
|
||||||
|
import { useMatrixUsers, MatrixUser, UserType } from './hooks/getStats'
|
||||||
type UserType = 'personal' | 'company'
|
import useAuthStore from '../../../store/authStore'
|
||||||
type MatrixUser = {
|
import { getMatrixStats } from '../hooks/getMatrixStats'
|
||||||
id: string | number
|
import SearchModal from './components/searchModal'
|
||||||
name: string
|
|
||||||
email: string
|
|
||||||
type: UserType
|
|
||||||
level: number // 0 for top-node, then 1..N
|
|
||||||
}
|
|
||||||
|
|
||||||
const LEVEL_CAP = (level: number) => Math.pow(5, level) // L1=5, L2=25, ...
|
const LEVEL_CAP = (level: number) => Math.pow(5, level) // L1=5, L2=25, ...
|
||||||
|
|
||||||
// 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() {
|
export default function MatrixDetailPage() {
|
||||||
const sp = useSearchParams()
|
const sp = useSearchParams()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
@ -37,59 +18,123 @@ export default function MatrixDetailPage() {
|
|||||||
const matrixId = sp.get('id') || 'm-1'
|
const matrixId = sp.get('id') || 'm-1'
|
||||||
const matrixName = sp.get('name') || 'Unnamed Matrix'
|
const matrixName = sp.get('name') || 'Unnamed Matrix'
|
||||||
const topNodeEmail = sp.get('top') || 'top@example.com'
|
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
|
// Resolve rootUserId when missing by looking it up via stats
|
||||||
const [users, setUsers] = useState<MatrixUser[]>(() => {
|
const accessToken = useAuthStore(s => s.accessToken)
|
||||||
// Level 0 = top node from URL
|
const [resolvedRootUserId, setResolvedRootUserId] = useState<number | undefined>(rootUserId)
|
||||||
const initial: MatrixUser[] = [
|
|
||||||
{ id: 'top', name: 'Top Node', email: topNodeEmail, type: 'personal', level: 0 },
|
useEffect(() => {
|
||||||
]
|
let cancelled = false
|
||||||
// Fill some demo users across levels
|
async function resolveRoot() {
|
||||||
const seed: Omit<MatrixUser, 'level'>[] = DUMMY_POOL.slice(0, 12)
|
if (rootUserId && rootUserId > 0) {
|
||||||
let remaining = [...seed]
|
console.info('[MatrixDetailPage] Using rootUserId from URL', { rootUserId })
|
||||||
let level = 1
|
setResolvedRootUserId(rootUserId)
|
||||||
while (remaining.length > 0 && level <= 3) {
|
return
|
||||||
const cap = LEVEL_CAP(level)
|
}
|
||||||
const take = Math.min(remaining.length, Math.max(1, Math.min(cap, 6))) // keep demo compact
|
if (!accessToken) {
|
||||||
initial.push(...remaining.splice(0, take).map(u => ({ ...u, level })))
|
console.warn('[MatrixDetailPage] No accessToken; cannot resolve rootUserId from stats')
|
||||||
level++
|
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
|
// Modal state
|
||||||
const [open, setOpen] = useState(false)
|
const [open, setOpen] = useState(false)
|
||||||
const [query, setQuery] = useState('')
|
// ADD: global search state (was removed)
|
||||||
const [typeFilter, setTypeFilter] = useState<'all' | UserType>('all')
|
const [globalSearch, setGlobalSearch] = useState('')
|
||||||
|
|
||||||
// Available candidates = pool minus already added ids
|
// Refresh overlay state
|
||||||
const availableUsers = useMemo(() => {
|
const [refreshing, setRefreshing] = useState(false)
|
||||||
const picked = new Set(users.map(u => String(u.id)))
|
|
||||||
return DUMMY_POOL.filter(u => !picked.has(String(u.id)))
|
|
||||||
}, [users])
|
|
||||||
|
|
||||||
const filteredCandidates = useMemo(() => {
|
// Collapsed state for each level
|
||||||
const q = query.trim().toLowerCase()
|
const [collapsedLevels, setCollapsedLevels] = useState<{ [level: number]: boolean }>({
|
||||||
return availableUsers.filter(u => {
|
0: true, 1: true, 2: true, 3: true, 4: true, 5: true
|
||||||
if (typeFilter !== 'all' && u.type !== typeFilter) return false
|
})
|
||||||
if (!q) return true
|
|
||||||
return u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q)
|
// Per-level search
|
||||||
})
|
const [levelSearch, setLevelSearch] = useState<{ [level: number]: string }>({
|
||||||
}, [availableUsers, query, typeFilter])
|
0: '', 1: '', 2: '', 3: '', 4: '', 5: ''
|
||||||
|
})
|
||||||
|
|
||||||
// Counts per level and next available level logic
|
// Counts per level and next available level logic
|
||||||
const byLevel = useMemo(() => {
|
const byLevel = useMemo(() => {
|
||||||
const map = new Map<number, MatrixUser[]>()
|
const map = new Map<number, MatrixUser[]>()
|
||||||
users.forEach(u => {
|
users.forEach(u => {
|
||||||
const arr = map.get(u.level) || []
|
if (!u.name) {
|
||||||
arr.push(u)
|
console.warn('[MatrixDetailPage] User missing name, fallback email used', { id: u.id, email: u.email })
|
||||||
map.set(u.level, arr)
|
}
|
||||||
|
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
|
return map
|
||||||
}, [users])
|
}, [users])
|
||||||
|
|
||||||
const nextAvailableLevel = () => {
|
const nextAvailableLevel = () => {
|
||||||
// Start from level 1 upwards; find first with space; else go to next new level
|
|
||||||
let lvl = 1
|
let lvl = 1
|
||||||
while (true) {
|
while (true) {
|
||||||
const current = byLevel.get(lvl)?.length || 0
|
const current = byLevel.get(lvl)?.length || 0
|
||||||
@ -100,7 +145,9 @@ export default function MatrixDetailPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const addToMatrix = (u: Omit<MatrixUser, 'level'>) => {
|
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
|
// Simple chip for user
|
||||||
@ -113,195 +160,337 @@ export default function MatrixDetailPage() {
|
|||||||
|
|
||||||
// Compact grid for a level
|
// Compact grid for a level
|
||||||
const LevelSection = ({ level }: { level: number }) => {
|
const LevelSection = ({ level }: { level: number }) => {
|
||||||
const cap = level === 0 ? 1 : LEVEL_CAP(level)
|
const cap = isUnlimited ? undefined : (level === 0 ? 1 : LEVEL_CAP(level))
|
||||||
const list = byLevel.get(level) || []
|
const listAll = (byLevel.get(level) || []).filter(u =>
|
||||||
const pct = Math.min(100, Math.round((list.length / cap) * 100))
|
u.level >= depthA && u.level <= depthB && (includeRoot || u.level !== 0)
|
||||||
const title =
|
)
|
||||||
level === 0 ? 'Level 0 (Top node)' : `Level ${level} — ${list.length} / ${cap}`
|
const list = listAll.slice(0, pageFor(level) * levelPageSize)
|
||||||
|
const title = `Level ${level} — ${listAll.length} users`
|
||||||
|
const showLoadMore = listAll.length > list.length
|
||||||
return (
|
return (
|
||||||
<section className="rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden">
|
<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">
|
<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>
|
<h3 className="text-sm font-semibold text-gray-900">{title}</h3>
|
||||||
<span className="text-[11px] font-semibold text-indigo-700">{pct}%</span>
|
{!isUnlimited && level > 0 && (
|
||||||
</div>
|
<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>
|
||||||
{/* 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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</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>
|
</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 (
|
return (
|
||||||
<PageLayout>
|
<PageLayout>
|
||||||
<div className="min-h-screen w-full px-4 sm:px-6 py-8 bg-white">
|
{/* Smooth refresh overlay */}
|
||||||
<div className="max-w-7xl mx-auto">
|
{refreshing && (
|
||||||
{/* Header / Overview */}
|
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-white/50 backdrop-blur-sm transition-opacity">
|
||||||
<div className="flex items-start justify-between gap-4 mb-6">
|
<div className="flex items-center gap-3 rounded-lg bg-white shadow-md border border-gray-200 px-4 py-3">
|
||||||
<div className="space-y-1">
|
<span className="h-5 w-5 rounded-full border-2 border-indigo-600 border-b-transparent animate-spin" />
|
||||||
<button
|
<span className="text-sm text-gray-700">Refreshing…</span>
|
||||||
onClick={() => router.push('/admin/matrix-management')}
|
</div>
|
||||||
className="inline-flex items-center gap-2 text-sm text-gray-600 hover:text-gray-800"
|
</div>
|
||||||
>
|
)}
|
||||||
<ArrowLeftIcon className="h-4 w-4" />
|
|
||||||
Back to matrices
|
{/* Centered page container to avoid full-width stretch */}
|
||||||
</button>
|
<div className="min-h-screen w-full bg-white">
|
||||||
<h1 className="text-2xl sm:text-3xl font-bold text-gray-900">{matrixName}</h1>
|
<div className="mx-auto max-w-6xl px-4 sm:px-6 py-6">
|
||||||
<p className="text-sm text-gray-600">
|
{/* Modern header card with action */}
|
||||||
Top node: <span className="font-medium text-gray-900">{topNodeEmail}</span>
|
<div className="mb-6 rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden">
|
||||||
</p>
|
<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>
|
||||||
|
</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
|
<button
|
||||||
onClick={() => setOpen(true)}
|
onClick={() => { setDepthA(Math.max(0, depthA - 5)); setDepthB(Math.max(5, depthB - 5)); }}
|
||||||
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 shadow-sm"
|
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" />
|
‹ Load previous 5 levels
|
||||||
Add users to matrix
|
|
||||||
</button>
|
</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>
|
</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">
|
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
|
||||||
<LevelSection level={0} />
|
{Array
|
||||||
<LevelSection level={1} />
|
.from(byLevel.keys())
|
||||||
<LevelSection level={2} />
|
.filter(l => l >= depthA && l <= depthB)
|
||||||
<LevelSection level={3} />
|
.filter(l => includeRoot || l !== 0) // NEW: hide level 0 when unchecked
|
||||||
{/* Add more levels visually if needed */}
|
.sort((a,b)=>a-b)
|
||||||
|
.map(l => (
|
||||||
|
<LevelSection key={l} level={l} />
|
||||||
|
))}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Full users list by level */}
|
{/* jump to level */}
|
||||||
<div className="mt-8 rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
|
<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">
|
<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>
|
<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>
|
<p className="text-xs text-gray-600">Grouped by levels (power of five structure).</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="divide-y divide-gray-100">
|
<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 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 (
|
return (
|
||||||
<div key={lvl} className="px-5 py-4">
|
<div key={lvl} className="px-5 py-4">
|
||||||
<div className="mb-2 text-sm font-semibold text-gray-800">
|
<button
|
||||||
{lvl === 0 ? 'Level 0 (Top node)' : `Level ${lvl}`} • {list.length} user(s)
|
className="flex items-center justify-between w-full text-left"
|
||||||
</div>
|
onClick={() => {
|
||||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
|
console.log('[MatrixDetailPage] toggle level', { level: lvl, to: !collapsedLevels[lvl] })
|
||||||
{list.map(u => (
|
setCollapsedLevels(prev => ({ ...prev, [lvl]: !prev[lvl] }))
|
||||||
<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="mb-2 text-sm font-semibold text-gray-800 flex items-center gap-2">
|
||||||
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full ${
|
{lvl === 0 ? 'Level 0 (Top node)' : `Level ${lvl}`} • {list.length} user(s)
|
||||||
u.type === 'company' ? 'bg-indigo-100 text-indigo-800' : 'bg-blue-100 text-blue-800'
|
</span>
|
||||||
}`}>
|
<span className="ml-2">
|
||||||
{u.type === 'company' ? 'Company' : 'Personal'}
|
{collapsedLevels[lvl] ? (
|
||||||
</span>
|
<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>
|
||||||
<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">
|
||||||
</div>
|
{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>
|
</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>
|
||||||
</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>
|
</PageLayout>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,9 @@ export async function getMatrixStats(params: {
|
|||||||
const { token, baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || '' } = params
|
const { token, baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || '' } = params
|
||||||
if (!token) return { ok: false, status: 401, message: 'Missing token' }
|
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 {
|
try {
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
method: 'GET',
|
method: 'GET',
|
||||||
@ -21,11 +23,13 @@ export async function getMatrixStats(params: {
|
|||||||
})
|
})
|
||||||
let body: any = null
|
let body: any = null
|
||||||
try { body = await res.json() } catch {}
|
try { body = await res.json() } catch {}
|
||||||
|
console.debug('[getMatrixStats] Response', { status: res.status, hasBody: !!body, keys: body ? Object.keys(body) : [] })
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
return { ok: false, status: res.status, body, message: body?.message || `Fetch stats failed (${res.status})` }
|
return { ok: false, status: res.status, body, message: body?.message || `Fetch stats failed (${res.status})` }
|
||||||
}
|
}
|
||||||
return { ok: true, status: res.status, body }
|
return { ok: true, status: res.status, body }
|
||||||
} catch {
|
} catch (e) {
|
||||||
|
console.error('[getMatrixStats] Network error', e)
|
||||||
return { ok: false, status: 0, message: 'Network error' }
|
return { ok: false, status: 0, message: 'Network error' }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -22,6 +22,8 @@ type Matrix = {
|
|||||||
usersCount: number
|
usersCount: number
|
||||||
createdAt: string
|
createdAt: string
|
||||||
topNodeEmail: string
|
topNodeEmail: string
|
||||||
|
rootUserId: number // added
|
||||||
|
policyMaxDepth?: number | null // NEW
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function MatrixManagementPage() {
|
export default function MatrixManagementPage() {
|
||||||
@ -59,6 +61,9 @@ export default function MatrixManagementPage() {
|
|||||||
const [forcePrompt, setForcePrompt] = useState<{ name: string; email: string } | null>(null)
|
const [forcePrompt, setForcePrompt] = useState<{ name: string; email: string } | null>(null)
|
||||||
const [createSuccess, setCreateSuccess] = useState<{ name: string; email: string } | null>(null)
|
const [createSuccess, setCreateSuccess] = useState<{ name: string; email: string } | null>(null)
|
||||||
|
|
||||||
|
const [policyFilter, setPolicyFilter] = useState<'all'|'unlimited'|'five'>('all') // NEW
|
||||||
|
const [sortByUsers, setSortByUsers] = useState<'asc'|'desc'>('desc') // NEW
|
||||||
|
|
||||||
const loadStats = async () => {
|
const loadStats = async () => {
|
||||||
if (!token) return
|
if (!token) return
|
||||||
setStatsLoading(true)
|
setStatsLoading(true)
|
||||||
@ -73,13 +78,18 @@ export default function MatrixManagementPage() {
|
|||||||
const isActive = !!m?.isActive
|
const isActive = !!m?.isActive
|
||||||
const createdAt = m?.createdAt || m?.ego_activated_at || m?.activatedAt || new Date().toISOString()
|
const createdAt = m?.createdAt || m?.ego_activated_at || m?.activatedAt || new Date().toISOString()
|
||||||
const topNodeEmail = m?.topNodeEmail || m?.masterTopUserEmail || m?.email || ''
|
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 {
|
return {
|
||||||
id: String(m?.rootUserId ?? m?.id ?? `m-${idx}`),
|
id: String(matrixId ?? `m-${idx}`),
|
||||||
name: String(m?.name ?? 'Unnamed Matrix'),
|
name: String(m?.name ?? 'Unnamed Matrix'),
|
||||||
status: isActive ? 'active' : 'inactive',
|
status: isActive ? 'active' : 'inactive',
|
||||||
usersCount: Number(m?.usersCount ?? 0),
|
usersCount: Number(m?.usersCount ?? 0),
|
||||||
createdAt: String(createdAt),
|
createdAt: String(createdAt),
|
||||||
topNodeEmail: String(topNodeEmail),
|
topNodeEmail: String(topNodeEmail),
|
||||||
|
rootUserId,
|
||||||
|
policyMaxDepth: typeof maxDepth === 'number' ? maxDepth : null // NEW
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
setMatrices(mapped)
|
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 = ({
|
const StatCard = ({
|
||||||
icon: Icon,
|
icon: Icon,
|
||||||
label,
|
label,
|
||||||
@ -260,6 +283,35 @@ export default function MatrixManagementPage() {
|
|||||||
<StatCard icon={UsersIcon} label="Total Users Subscribed" value={stats.totalUsers} color="bg-amber-600" />
|
<StatCard icon={UsersIcon} label="Total Users Subscribed" value={stats.totalUsers} color="bg-amber-600" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<div className="mb-6 flex 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 */}
|
{/* Matrix cards */}
|
||||||
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
|
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
|
||||||
{statsLoading ? (
|
{statsLoading ? (
|
||||||
@ -274,19 +326,23 @@ export default function MatrixManagementPage() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
))
|
))
|
||||||
) : matrices.length === 0 ? (
|
) : matricesView.length === 0 ? (
|
||||||
<div className="text-sm text-gray-600">No matrices found.</div>
|
<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">
|
<article key={m.id} className="rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
|
||||||
<div className="p-5">
|
<div className="p-5">
|
||||||
<div className="flex items-start justify-between gap-3">
|
<div className="flex items-start justify-between gap-3">
|
||||||
<h3 className="text-lg font-semibold text-gray-900">{m.name}</h3>
|
<h3 className="text-lg font-semibold text-gray-900">{m.name}</h3>
|
||||||
<StatusBadge status={m.status} />
|
<StatusBadge status={m.status} />
|
||||||
</div>
|
</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="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" />
|
<UsersIcon className="h-5 w-5 text-gray-500" />
|
||||||
<span className="font-medium">{m.usersCount}</span>
|
<span className="font-medium">{m.usersCount}</span>
|
||||||
<span className="text-gray-500">users</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>
|
<span className="text-gray-700 truncate">{m.topNodeEmail}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-5 flex items-center justify-between">
|
<div className="mt-5 flex items-center justify-between">
|
||||||
<button
|
<button
|
||||||
onClick={() => toggleStatus(m.id)}
|
onClick={() => toggleStatus(m.id)}
|
||||||
@ -317,10 +372,16 @@ export default function MatrixManagementPage() {
|
|||||||
<button
|
<button
|
||||||
className="text-sm font-medium text-indigo-600 hover:text-indigo-500"
|
className="text-sm font-medium text-indigo-600 hover:text-indigo-500"
|
||||||
onClick={() => {
|
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({
|
const params = new URLSearchParams({
|
||||||
id: String(m.id),
|
id: String(m.id),
|
||||||
name: m.name,
|
name: m.name,
|
||||||
top: m.topNodeEmail,
|
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()}`)
|
router.push(`/admin/matrix-management/detail?${params.toString()}`)
|
||||||
}}
|
}}
|
||||||
@ -328,6 +389,25 @@ export default function MatrixManagementPage() {
|
|||||||
View details →
|
View details →
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
))
|
))
|
||||||
|
|||||||
@ -113,10 +113,11 @@ export default function LoginForm() {
|
|||||||
<div
|
<div
|
||||||
className="w-full flex justify-center items-start relative"
|
className="w-full flex justify-center items-start relative"
|
||||||
style={{
|
style={{
|
||||||
// Removed fixed 100vh + overflow hidden to allow scrolling
|
// Ensure the background fills the viewport height
|
||||||
minHeight: 'auto',
|
minHeight: '100vh',
|
||||||
|
// Reduce bottom padding to avoid extra white space
|
||||||
paddingTop: isMobile ? '0.75rem' : '4rem',
|
paddingTop: isMobile ? '0.75rem' : '4rem',
|
||||||
paddingBottom: '3rem',
|
paddingBottom: isMobile ? '1rem' : '1.5rem',
|
||||||
backgroundImage: 'url(/images/misc/marble_bluegoldwhite_BG.jpg)',
|
backgroundImage: 'url(/images/misc/marble_bluegoldwhite_BG.jpg)',
|
||||||
backgroundSize: 'cover',
|
backgroundSize: 'cover',
|
||||||
backgroundPosition: 'center'
|
backgroundPosition: 'center'
|
||||||
|
|||||||
@ -43,19 +43,19 @@ export default function LoginPage() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<PageLayout showFooter={false}>
|
<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="relative z-10 flex-1 flex items-start justify-center">
|
||||||
<div className="w-full">
|
<div className="w-full">
|
||||||
<LoginForm />
|
<LoginForm />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Footer for mobile */}
|
{/* Removed mobile footer to avoid bottom white container */}
|
||||||
<div className="relative z-10 md:hidden">
|
{/* <div className="relative z-10 md:hidden">
|
||||||
<div className="text-center py-4 text-sm text-slate-700">
|
<div className="text-center py-4 text-sm text-slate-700">
|
||||||
© 2024 Profit Planet. Alle Rechte vorbehalten.
|
© 2024 Profit Planet. Alle Rechte vorbehalten.
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div> */}
|
||||||
</div>
|
</div>
|
||||||
</PageLayout>
|
</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
|
// Always send credentials so refresh cookie is included when server-side refresh is needed
|
||||||
const fetchWithAuth = async (hdrs: Record<string, string>): Promise<Response> => {
|
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" });
|
return fetch(url, { ...init, headers: hdrs, credentials: "include" });
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user