diff --git a/src/app/admin/matrix-management/detail/components/searchModal.tsx b/src/app/admin/matrix-management/detail/components/searchModal.tsx new file mode 100644 index 0000000..b12e0b0 --- /dev/null +++ b/src/app/admin/matrix-management/detail/components/searchModal.tsx @@ -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('') + const [items, setItems] = useState>([]) + 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(undefined) // NEW + const [forceFallback, setForceFallback] = useState(true) // NEW + const [adding, setAdding] = useState(false) // NEW + const [addError, setAddError] = useState('') // NEW + const [addSuccess, setAddSuccess] = useState('') // NEW + const [hasSearched, setHasSearched] = useState(false) // NEW + + const formRef = useRef(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() + 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 = ( +
{/* elevated z-index */} +
+
+
+ {/* Header */} +
+
+

+ Add users to “{matrixName}” +

+ {/* Close button improved hover/focus */} + +
+

+ Search by name or email. Minimum 3 characters. Existing matrix members are hidden. +

+
+ + {/* Form */} +
{ + 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 */} +
+
+ + 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" + /> +
+
+ {/* Type */} +
+ +
+ {/* Buttons */} +
+ + +
+ {/* Total */} +
+ Total: {total} +
+
+ + {/* Results + selection area (scrollable) */} +
+ {/* Results section */} +
+ {error && ( +
{error}
+ )} + {!error && query.trim().length < 3 && ( +
+ Enter at least 3 characters and click Search. +
+ )} + {!error && query.trim().length >= 3 && !hasSearched && !loading && ( +
+ Ready to search. Click the Search button to fetch candidates. +
+ )} + {/* Skeleton only for first-time load (when no items yet) */} + {!error && query.trim().length >= 3 && loading && items.length === 0 && ( +
    + {Array.from({ length: 5 }).map((_, i) => ( +
  • +
    +
    +
  • + ))} +
+ )} + {!error && hasSearched && !loading && query.trim().length >= 3 && items.length === 0 && ( +
+ No users match your filters. +
+ )} + {!error && hasSearched && items.length > 0 && ( +
    + {items.map(u => ( +
  • +
    +
    + {u.userType === 'company' + ? + : } + {u.name} + + {u.userType === 'company' ? 'Company' : 'Personal'} + +
    +
    {u.email}
    +
    + +
  • + ))} +
+ )} + {/* Soft-loading overlay over existing list to avoid jumpiness */} + {loading && items.length > 0 && ( +
+ +
+ )} +
+ + {/* Selected candidate details (conditional) */} + {selected && ( +
+
+
+ Candidate: {selected.name} ({selected.email}) +
+ +
+ + + + {advanced && ( +
+ +

+ {(!policyMaxDepth || policyMaxDepth <= 0) + ? 'Unlimited policy: no remaining-level cap.' + : `Remaining levels under chosen parent = Max(${policyMaxDepth}) - parent level.`} +

+
+ )} + + + + {addError &&
{addError}
} + {addSuccess &&
{addSuccess}
} + +
+ +
+
+ )} +
+ + {/* Footer (hidden when a candidate is selected) */} + {!selected && ( +
+ +
+ )} +
+
+
+ ) + + return createPortal(modal, document.body) +} diff --git a/src/app/admin/matrix-management/detail/hooks/addUsertoMatrix.ts b/src/app/admin/matrix-management/detail/hooks/addUsertoMatrix.ts new file mode 100644 index 0000000..f1c73ee --- /dev/null +++ b/src/app/admin/matrix-management/detail/hooks/addUsertoMatrix.ts @@ -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! +} diff --git a/src/app/admin/matrix-management/detail/hooks/getStats.ts b/src/app/admin/matrix-management/detail/hooks/getStats.ts new file mode 100644 index 0000000..3292b89 --- /dev/null +++ b/src/app/admin/matrix-management/detail/hooks/getStats.ts @@ -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([]) + const [loading, setLoading] = useState(false) + const [error, setError] = useState(null) + const [tick, setTick] = useState(0) + const abortRef = useRef(null) + const [serverMaxDepth, setServerMaxDepth] = useState(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 +} diff --git a/src/app/admin/matrix-management/detail/hooks/search-candidate.ts b/src/app/admin/matrix-management/detail/hooks/search-candidate.ts new file mode 100644 index 0000000..ab51d78 --- /dev/null +++ b/src/app/admin/matrix-management/detail/hooks/search-candidate.ts @@ -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; + 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 { + 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'); +} diff --git a/src/app/admin/matrix-management/detail/page.tsx b/src/app/admin/matrix-management/detail/page.tsx index 29627da..e852783 100644 --- a/src/app/admin/matrix-management/detail/page.tsx +++ b/src/app/admin/matrix-management/detail/page.tsx @@ -4,32 +4,13 @@ import React, { useEffect, useMemo, useState } from 'react' import { useSearchParams, useRouter } from 'next/navigation' import PageLayout from '../../../components/PageLayout' import { ArrowLeftIcon, MagnifyingGlassIcon, PlusIcon, UserIcon, BuildingOffice2Icon } from '@heroicons/react/24/outline' - -type UserType = 'personal' | 'company' -type MatrixUser = { - id: string | number - name: string - email: string - type: UserType - level: number // 0 for top-node, then 1..N -} +import { useMatrixUsers, MatrixUser, UserType } from './hooks/getStats' +import useAuthStore from '../../../store/authStore' +import { getMatrixStats } from '../hooks/getMatrixStats' +import SearchModal from './components/searchModal' const LEVEL_CAP = (level: number) => Math.pow(5, level) // L1=5, L2=25, ... -// Dummy users to pick from in the modal -const DUMMY_POOL: Array> = [ - { id: 101, name: 'Alice Johnson', email: 'alice@example.com', type: 'personal' }, - { id: 102, name: 'Beta GmbH', email: 'office@beta-gmbh.de', type: 'company' }, - { id: 103, name: 'Carlos Diaz', email: 'carlos@sample.io', type: 'personal' }, - { id: 104, name: 'Delta Solutions AG', email: 'contact@delta.ag', type: 'company' }, - { id: 105, name: 'Emily Nguyen', email: 'emily.ng@ex.com', type: 'personal' }, - { id: 106, name: 'Foxtrot LLC', email: 'hello@foxtrot.llc', type: 'company' }, - { id: 107, name: 'Grace Blake', email: 'grace@ex.com', type: 'personal' }, - { id: 108, name: 'Hestia Corp', email: 'hq@hestia.io', type: 'company' }, - { id: 109, name: 'Ivan Petrov', email: 'ivan@ex.com', type: 'personal' }, - { id: 110, name: 'Juno Partners', email: 'team@juno.partners', type: 'company' }, -] - export default function MatrixDetailPage() { const sp = useSearchParams() const router = useRouter() @@ -37,59 +18,123 @@ export default function MatrixDetailPage() { const matrixId = sp.get('id') || 'm-1' const matrixName = sp.get('name') || 'Unnamed Matrix' const topNodeEmail = sp.get('top') || 'top@example.com' + const rootUserIdParam = sp.get('rootUserId') + const rootUserId = rootUserIdParam ? Number(rootUserIdParam) : undefined + console.info('[MatrixDetailPage] Params', { matrixId, matrixName, topNodeEmail, rootUserId }) - // Build initial dummy matrix users - const [users, setUsers] = useState(() => { - // Level 0 = top node from URL - const initial: MatrixUser[] = [ - { id: 'top', name: 'Top Node', email: topNodeEmail, type: 'personal', level: 0 }, - ] - // Fill some demo users across levels - const seed: Omit[] = DUMMY_POOL.slice(0, 12) - let remaining = [...seed] - let level = 1 - while (remaining.length > 0 && level <= 3) { - const cap = LEVEL_CAP(level) - const take = Math.min(remaining.length, Math.max(1, Math.min(cap, 6))) // keep demo compact - initial.push(...remaining.splice(0, take).map(u => ({ ...u, level }))) - level++ + // Resolve rootUserId when missing by looking it up via stats + const accessToken = useAuthStore(s => s.accessToken) + const [resolvedRootUserId, setResolvedRootUserId] = useState(rootUserId) + + useEffect(() => { + let cancelled = false + async function resolveRoot() { + if (rootUserId && rootUserId > 0) { + console.info('[MatrixDetailPage] Using rootUserId from URL', { rootUserId }) + setResolvedRootUserId(rootUserId) + return + } + if (!accessToken) { + console.warn('[MatrixDetailPage] No accessToken; cannot resolve rootUserId from stats') + setResolvedRootUserId(undefined) + return + } + if (!matrixId) { + console.warn('[MatrixDetailPage] No matrixId; cannot resolve rootUserId from stats') + setResolvedRootUserId(undefined) + return + } + console.info('[MatrixDetailPage] Resolving rootUserId via stats for matrixId', { matrixId }) + const res = await getMatrixStats({ token: accessToken, baseUrl: process.env.NEXT_PUBLIC_API_BASE_URL }) + console.debug('[MatrixDetailPage] getMatrixStats result', res) + if (!res.ok) { + console.error('[MatrixDetailPage] getMatrixStats failed', { status: res.status, message: res.message }) + setResolvedRootUserId(undefined) + return + } + const body = res.body || {} + const matrices = (body?.data?.matrices ?? body?.matrices ?? []) as any[] + console.debug('[MatrixDetailPage] Stats matrices overview', { + count: matrices.length, + ids: matrices.map((m: any) => m?.id ?? m?.matrixId), + matrixIds: matrices.map((m: any) => m?.matrixId ?? m?.id), + rootUserIds: matrices.map((m: any) => m?.rootUserId ?? m?.root_user_id), + emails: matrices.map((m: any) => m?.topNodeEmail ?? m?.email) + }) + const found = matrices.find((m: any) => + String(m?.id) === String(matrixId) || String(m?.matrixId) === String(matrixId) + ) + const ru = Number(found?.rootUserId ?? found?.root_user_id) + if (ru > 0 && !cancelled) { + console.info('[MatrixDetailPage] Resolved rootUserId from stats', { matrixId, rootUserId: ru }) + setResolvedRootUserId(ru) + } else { + console.warn('[MatrixDetailPage] Could not resolve rootUserId from stats', { matrixId, found }) + setResolvedRootUserId(undefined) + } } - return initial + resolveRoot() + return () => { cancelled = true } + }, [matrixId, rootUserId, accessToken]) + + // Backend users + const { users: fetchedUsers, loading: usersLoading, error: usersError, meta, refetch, serverMaxDepth } = useMatrixUsers(resolvedRootUserId, { + depth: 5, + includeRoot: true, + limit: 100, + offset: 0, + matrixId, + topNodeEmail }) + // Prepare for backend fetches + const [users, setUsers] = useState([]) + + useEffect(() => { + console.info('[MatrixDetailPage] useMatrixUsers state', { + loading: usersLoading, + error: !!usersError, + fetchedCount: fetchedUsers.length, + meta + }) + setUsers(fetchedUsers) + }, [fetchedUsers, usersLoading, usersError, meta]) + // Modal state const [open, setOpen] = useState(false) - const [query, setQuery] = useState('') - const [typeFilter, setTypeFilter] = useState<'all' | UserType>('all') + // ADD: global search state (was removed) + const [globalSearch, setGlobalSearch] = useState('') - // Available candidates = pool minus already added ids - const availableUsers = useMemo(() => { - const picked = new Set(users.map(u => String(u.id))) - return DUMMY_POOL.filter(u => !picked.has(String(u.id))) - }, [users]) + // Refresh overlay state + const [refreshing, setRefreshing] = useState(false) - const filteredCandidates = useMemo(() => { - const q = query.trim().toLowerCase() - return availableUsers.filter(u => { - if (typeFilter !== 'all' && u.type !== typeFilter) return false - if (!q) return true - return u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q) - }) - }, [availableUsers, query, typeFilter]) + // Collapsed state for each level + const [collapsedLevels, setCollapsedLevels] = useState<{ [level: number]: boolean }>({ + 0: true, 1: true, 2: true, 3: true, 4: true, 5: true + }) + + // Per-level search + const [levelSearch, setLevelSearch] = useState<{ [level: number]: string }>({ + 0: '', 1: '', 2: '', 3: '', 4: '', 5: '' + }) // Counts per level and next available level logic const byLevel = useMemo(() => { const map = new Map() users.forEach(u => { - const arr = map.get(u.level) || [] - arr.push(u) - map.set(u.level, arr) + if (!u.name) { + console.warn('[MatrixDetailPage] User missing name, fallback email used', { id: u.id, email: u.email }) + } + const lvl = (typeof u.level === 'number' && u.level >= 0) ? u.level : 0 + const arr = map.get(lvl) || [] + arr.push({ ...u, level: lvl }) + map.set(lvl, arr) }) + console.debug('[MatrixDetailPage] byLevel computed', { levels: Array.from(map.keys()), total: users.length }) return map }, [users]) const nextAvailableLevel = () => { - // Start from level 1 upwards; find first with space; else go to next new level let lvl = 1 while (true) { const current = byLevel.get(lvl)?.length || 0 @@ -100,7 +145,9 @@ export default function MatrixDetailPage() { } const addToMatrix = (u: Omit) => { - setUsers(prev => [...prev, { ...u, level: nextAvailableLevel() }]) + const level = nextAvailableLevel() + console.info('[MatrixDetailPage] addToMatrix', { userId: u.id, nextLevel: level }) + setUsers(prev => [...prev, { ...u, level }]) } // Simple chip for user @@ -113,195 +160,337 @@ export default function MatrixDetailPage() { // Compact grid for a level const LevelSection = ({ level }: { level: number }) => { - const cap = level === 0 ? 1 : LEVEL_CAP(level) - const list = byLevel.get(level) || [] - const pct = Math.min(100, Math.round((list.length / cap) * 100)) - const title = - level === 0 ? 'Level 0 (Top node)' : `Level ${level} — ${list.length} / ${cap}` - + const cap = isUnlimited ? undefined : (level === 0 ? 1 : LEVEL_CAP(level)) + const listAll = (byLevel.get(level) || []).filter(u => + u.level >= depthA && u.level <= depthB && (includeRoot || u.level !== 0) + ) + const list = listAll.slice(0, pageFor(level) * levelPageSize) + const title = `Level ${level} — ${listAll.length} users` + const showLoadMore = listAll.length > list.length return (

{title}

- {pct}% -
- - {/* Progress */} -
-
-
-
-
- - {/* Users */} -
- {list.length === 0 ? ( -
No users in this level yet.
- ) : ( -
- {list.slice(0, 30).map(u => )} - {list.length > 30 && ( - - +{list.length - 30} more - - )} + {!isUnlimited && level > 0 && ( +
+ {Math.min(100, Math.round((listAll.length / (cap || 1)) * 100))}%
)}
+
+ {listAll.length === 0 ? ( +
{isUnlimited ? 'No users at this level yet.' : 'No users in this level yet.'}
+ ) : ( + <> +
+ {list.map(u => )} +
+ {showLoadMore && ( +
+ +
+ )} + + )} +
) } + // 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>({}) + 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 ( -
-
- {/* Header / Overview */} -
-
- -

{matrixName}

-

- Top node: {topNodeEmail} -

+ {/* Smooth refresh overlay */} + {refreshing && ( +
+
+ + Refreshing… +
+
+ )} + + {/* Centered page container to avoid full-width stretch */} +
+
+ {/* Modern header card with action */} +
+
+
+ +

{matrixName}

+

+ Top node: {topNodeEmail} +

+
+ + Children/node: 5 + + + Max depth: {(!serverMaxDepth || serverMaxDepth <= 0) ? 'Unlimited' : serverMaxDepth} + +
+
+
+ +
+
+ {/* banner for unlimited */} + {isUnlimited && ( +
+ Large structure. Results are paginated by depth and count. +
+ )} + + {/* sticky depth controls */} +
+
+ + setDepthA(Math.max(0, Number(e.target.value) || 0))} className="w-16 rounded border border-gray-300 px-2 py-1 text-xs" /> + + setDepthB(Math.max(depthA, Number(e.target.value) || depthA))} className="w-16 rounded border border-gray-300 px-2 py-1 text-xs" /> +
+ + {/* NEW: include root toggle */} + +
+ Showing levels {depthA}–{depthB} of {isUnlimited ? 'Unlimited' : serverMaxDepth} +
+
- {/* Levels display */} + {/* small stats */} +
+
+
Users (current slice)
+
{usersInSlice.length}
+
+
+
Total descendants
+
{isUnlimited ? 'All descendants so far' : totalDescendants}
+
+
+
Active levels loaded
+
{depthA}–{depthB}
+
+
+ + {/* dynamic levels */}
- - - - - {/* Add more levels visually if needed */} + {Array + .from(byLevel.keys()) + .filter(l => l >= depthA && l <= depthB) + .filter(l => includeRoot || l !== 0) // NEW: hide level 0 when unchecked + .sort((a,b)=>a-b) + .map(l => ( + + ))}
- {/* Full users list by level */} -
+ {/* jump to level */} +
+ + +
+ + {/* Collapsible All users list by level */} +

All users in this matrix

Grouped by levels (power of five structure).

- {[...byLevel.keys()].sort((a, b) => a - b).map(lvl => { + {[0,1,2,3,4,5].map(lvl => { const list = byLevel.get(lvl) || [] + const filteredList = list.filter(u => { + const globalMatch = !globalSearch || ( + u.name.toLowerCase().includes(globalSearch.toLowerCase()) || + u.email.toLowerCase().includes(globalSearch.toLowerCase()) + ) + const levelMatch = !levelSearch[lvl] || ( + u.name.toLowerCase().includes(levelSearch[lvl].toLowerCase()) || + u.email.toLowerCase().includes(levelSearch[lvl].toLowerCase()) + ) + return globalMatch && levelMatch + }) return (
-
- {lvl === 0 ? 'Level 0 (Top node)' : `Level ${lvl}`} • {list.length} user(s) -
-
- {list.map(u => ( -
-
-
{u.name}
- - {u.type === 'company' ? 'Company' : 'Personal'} - + + {/* Per-level search only when expanded */} + {!collapsedLevels[lvl] && ( + <> +
+
+ + { + 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" + />
-
{u.email}
- ))} -
+
+ {filteredList.length === 0 && ( +
No users found.
+ )} + {filteredList.length > 0 && filteredList.map(u => ( +
+
+
{u.name}
+ + {u.type === 'company' ? 'Company' : 'Personal'} + +
+
{u.email}
+
+ ))} +
+ + )}
) })}
+ + {/* Add Users Modal */} + { addToMatrix(u) }} + />
- - {/* Add Users Modal */} - {open && ( -
-
setOpen(false)} /> -
-
-
-

Add users to “{matrixName}”

- -
- -
-
- - { 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" - /> -
- -
- Candidates: {filteredCandidates.length} -
-
- -
- {filteredCandidates.length === 0 ? ( -
No users match your filters.
- ) : ( -
    - {filteredCandidates.map(u => ( -
  • -
    -
    {u.name}
    -
    {u.email}
    -
    -
    - - {u.type === 'company' ? 'Company' : 'Personal'} - - -
    -
  • - ))} -
- )} -
- -
- -
-
-
-
- )} ) } diff --git a/src/app/admin/matrix-management/hooks/getMatrixStats.ts b/src/app/admin/matrix-management/hooks/getMatrixStats.ts index ed705fe..36e9561 100644 --- a/src/app/admin/matrix-management/hooks/getMatrixStats.ts +++ b/src/app/admin/matrix-management/hooks/getMatrixStats.ts @@ -12,7 +12,9 @@ export async function getMatrixStats(params: { const { token, baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || '' } = params if (!token) return { ok: false, status: 401, message: 'Missing token' } - const url = `${baseUrl}/api/matrix/stats` + const base = (baseUrl || '').replace(/\/+$/, '') + const url = `${base}/api/matrix/stats` + console.info('[getMatrixStats] REQUEST GET', url) try { const res = await fetch(url, { method: 'GET', @@ -21,11 +23,13 @@ export async function getMatrixStats(params: { }) let body: any = null try { body = await res.json() } catch {} + console.debug('[getMatrixStats] Response', { status: res.status, hasBody: !!body, keys: body ? Object.keys(body) : [] }) if (!res.ok) { return { ok: false, status: res.status, body, message: body?.message || `Fetch stats failed (${res.status})` } } return { ok: true, status: res.status, body } - } catch { + } catch (e) { + console.error('[getMatrixStats] Network error', e) return { ok: false, status: 0, message: 'Network error' } } } diff --git a/src/app/admin/matrix-management/page.tsx b/src/app/admin/matrix-management/page.tsx index 5f0ed61..f5d0bf9 100644 --- a/src/app/admin/matrix-management/page.tsx +++ b/src/app/admin/matrix-management/page.tsx @@ -22,6 +22,8 @@ type Matrix = { usersCount: number createdAt: string topNodeEmail: string + rootUserId: number // added + policyMaxDepth?: number | null // NEW } export default function MatrixManagementPage() { @@ -59,6 +61,9 @@ export default function MatrixManagementPage() { const [forcePrompt, setForcePrompt] = useState<{ name: string; email: string } | null>(null) const [createSuccess, setCreateSuccess] = useState<{ name: string; email: string } | null>(null) + const [policyFilter, setPolicyFilter] = useState<'all'|'unlimited'|'five'>('all') // NEW + const [sortByUsers, setSortByUsers] = useState<'asc'|'desc'>('desc') // NEW + const loadStats = async () => { if (!token) return setStatsLoading(true) @@ -73,13 +78,18 @@ export default function MatrixManagementPage() { const isActive = !!m?.isActive const createdAt = m?.createdAt || m?.ego_activated_at || m?.activatedAt || new Date().toISOString() const topNodeEmail = m?.topNodeEmail || m?.masterTopUserEmail || m?.email || '' + const rootUserId = Number(m?.rootUserId ?? m?.root_user_id ?? 0) + const matrixId = m?.matrixId ?? m?.id // prefer matrixId for routing + const maxDepth = (m?.maxDepth ?? m?.policyMaxDepth) // backend optional return { - id: String(m?.rootUserId ?? m?.id ?? `m-${idx}`), + id: String(matrixId ?? `m-${idx}`), name: String(m?.name ?? 'Unnamed Matrix'), status: isActive ? 'active' : 'inactive', usersCount: Number(m?.usersCount ?? 0), createdAt: String(createdAt), topNodeEmail: String(topNodeEmail), + rootUserId, + policyMaxDepth: typeof maxDepth === 'number' ? maxDepth : null // NEW } }) setMatrices(mapped) @@ -190,6 +200,19 @@ export default function MatrixManagementPage() { ) } + // derived list with filter/sort + const matricesView = useMemo(() => { + let list = [...matrices] + if (policyFilter !== 'all') { + list = list.filter(m => { + const unlimited = !m.policyMaxDepth || m.policyMaxDepth <= 0 + return policyFilter === 'unlimited' ? unlimited : (!unlimited && m.policyMaxDepth === 5) + }) + } + list.sort((a,b) => sortByUsers === 'asc' ? (a.usersCount - b.usersCount) : (b.usersCount - a.usersCount)) + return list + }, [matrices, policyFilter, sortByUsers]) + const StatCard = ({ icon: Icon, label, @@ -260,6 +283,35 @@ export default function MatrixManagementPage() {
+ {/* Filters */} +
+
+ + +
+
+ + +
+
+ ℹ️ Tooltip: Users count respects each matrix’s max depth policy. +
+
+ + {/* Optional health hint */} + {policyFilter !== 'five' && matricesView.some(m => !m.policyMaxDepth || m.policyMaxDepth <= 0) && ( +
+ Large tree matrices may require pagination by levels. Learn more +
+ )} + {/* Matrix cards */}
{statsLoading ? ( @@ -274,19 +326,23 @@ export default function MatrixManagementPage() {
)) - ) : matrices.length === 0 ? ( + ) : matricesView.length === 0 ? (
No matrices found.
) : ( - matrices.map(m => ( + matricesView.map(m => (

{m.name}

- +
+ + Max depth: {(!m.policyMaxDepth || m.policyMaxDepth <= 0) ? 'Unlimited' : m.policyMaxDepth} + +
-
+
{m.usersCount} users @@ -302,7 +358,6 @@ export default function MatrixManagementPage() { {m.topNodeEmail}
-
+ {/* Quick default A–B editor */} +
+ Default depth slice: + 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]" + /> + to + 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]" + /> +
)) diff --git a/src/app/login/components/LoginForm.tsx b/src/app/login/components/LoginForm.tsx index 2b2b23b..84bba4e 100644 --- a/src/app/login/components/LoginForm.tsx +++ b/src/app/login/components/LoginForm.tsx @@ -113,10 +113,11 @@ export default function LoginForm() {