557 lines
24 KiB
TypeScript
557 lines
24 KiB
TypeScript
'use client'
|
||
|
||
|
||
|
||
import { useTranslation } from '../../../i18n/useTranslation';
|
||
import React, { useEffect, useMemo, useState, Suspense } from 'react' // CHANGED: add Suspense
|
||
import { useSearchParams, useRouter } from 'next/navigation'
|
||
import PageLayout from '../../../components/PageLayout'
|
||
import { ArrowLeftIcon, MagnifyingGlassIcon, PlusIcon, UserIcon, BuildingOffice2Icon } from '@heroicons/react/24/outline'
|
||
import { useMatrixUsers, MatrixUser } from './hooks/getStats'
|
||
import useAuthStore from '../../../store/authStore'
|
||
import { getMatrixStats } from '../hooks/getMatrixStats'
|
||
import SearchModal from './components/searchModal'
|
||
|
||
const DEFAULT_FETCH_DEPTH = 50 // provisional large depth to approximate unlimited
|
||
const LEVEL_CAP = (level: number) => Math.pow(5, level) // L1=5, L2=25, ...
|
||
|
||
function MatrixDetailPageInner() {
|
||
const { t } = useTranslation()
|
||
const sp = useSearchParams()
|
||
const router = useRouter()
|
||
|
||
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 })
|
||
|
||
// Resolve rootUserId when missing by looking it up via stats
|
||
const accessToken = useAuthStore(s => s.accessToken)
|
||
const [resolvedRootUserId, setResolvedRootUserId] = useState<number | undefined>(rootUserId)
|
||
// NEW: track policy (DB) max depth (null => unlimited)
|
||
const [policyMaxDepth, setPolicyMaxDepth] = useState<number | null>(null)
|
||
|
||
useEffect(() => {
|
||
let cancelled = false
|
||
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)
|
||
)
|
||
// NEW: extract policy maxDepth (may be null)
|
||
const pmRaw = found?.maxDepth ?? found?.max_depth ?? null
|
||
if (!cancelled) {
|
||
setPolicyMaxDepth(pmRaw == null ? null : Number(pmRaw))
|
||
}
|
||
const ru = Number(found?.rootUserId ?? found?.root_user_id)
|
||
if (ru > 0 && !cancelled) {
|
||
console.info('[MatrixDetailPage] Resolved rootUserId from stats', { matrixId, rootUserId: ru })
|
||
setResolvedRootUserId(ru)
|
||
} else {
|
||
console.warn('[MatrixDetailPage] Could not resolve rootUserId from stats', { matrixId, found })
|
||
setResolvedRootUserId(undefined)
|
||
}
|
||
}
|
||
resolveRoot()
|
||
return () => { cancelled = true }
|
||
}, [matrixId, rootUserId, accessToken])
|
||
|
||
// Backend users (changed depth from 5 to DEFAULT_FETCH_DEPTH)
|
||
const { users: fetchedUsers, loading: usersLoading, error: usersError, meta, refetch, serverMaxDepth } = useMatrixUsers(resolvedRootUserId, {
|
||
depth: DEFAULT_FETCH_DEPTH,
|
||
includeRoot: true,
|
||
limit: 2000,
|
||
offset: 0,
|
||
matrixId,
|
||
topNodeEmail
|
||
})
|
||
|
||
// Prepare for backend fetches
|
||
const [users, setUsers] = useState<MatrixUser[]>([])
|
||
|
||
useEffect(() => {
|
||
console.info('[MatrixDetailPage] useMatrixUsers state', {
|
||
loading: usersLoading,
|
||
error: !!usersError,
|
||
fetchedCount: fetchedUsers.length,
|
||
meta
|
||
})
|
||
setUsers(fetchedUsers)
|
||
}, [fetchedUsers, usersLoading, usersError, meta])
|
||
|
||
// Modal state
|
||
const [open, setOpen] = useState(false)
|
||
// ADD: global search state (was removed)
|
||
const [globalSearch, setGlobalSearch] = useState('')
|
||
|
||
// Refresh overlay state
|
||
const [refreshing, setRefreshing] = useState(false)
|
||
|
||
// Collapsed state for each level
|
||
const [collapsedLevels, setCollapsedLevels] = useState<{ [level: number]: boolean }>({
|
||
0: true, 1: true, 2: true, 3: true, 4: true, 5: true
|
||
})
|
||
|
||
// Per-level search
|
||
const [levelSearch, setLevelSearch] = useState<{ [level: number]: string }>({
|
||
0: '', 1: '', 2: '', 3: '', 4: '', 5: ''
|
||
})
|
||
|
||
// Counts per level and next available level logic
|
||
const byLevel = useMemo(() => {
|
||
const map = new Map<number, MatrixUser[]>()
|
||
users.forEach(u => {
|
||
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 = () => {
|
||
let lvl = 1
|
||
while (true) {
|
||
const current = byLevel.get(lvl)?.length || 0
|
||
if (current < LEVEL_CAP(lvl)) return lvl
|
||
lvl += 1
|
||
if (lvl > 8) return lvl // safety ceiling in demo
|
||
}
|
||
}
|
||
|
||
const addToMatrix = (u: Omit<MatrixUser, 'level'>) => {
|
||
const level = nextAvailableLevel()
|
||
console.info('[MatrixDetailPage] addToMatrix', { userId: u.id, nextLevel: level })
|
||
setUsers(prev => [...prev, { ...u, level }])
|
||
}
|
||
|
||
// Simple chip for user
|
||
const UserChip = ({ u }: { u: MatrixUser }) => (
|
||
<div className="inline-flex items-center gap-2 rounded-full bg-gray-50 border border-gray-200 px-3 py-1 text-xs text-gray-800">
|
||
{u.type === 'company' ? <BuildingOffice2Icon className="h-4 w-4 text-indigo-600" /> : <UserIcon className="h-4 w-4 text-blue-600" />}
|
||
<span className="font-medium truncate max-w-[140px]">{u.name}</span>
|
||
</div>
|
||
)
|
||
|
||
// Global search (already present) + node collapse state
|
||
const [collapsedNodes, setCollapsedNodes] = useState<Record<number, boolean>>({})
|
||
const toggleNode = (id: number) => setCollapsedNodes(p => ({ ...p, [id]: !p[id] }))
|
||
|
||
// Build children adjacency map
|
||
const childrenMap = useMemo(() => {
|
||
const m = new Map<number, MatrixUser[]>()
|
||
users.forEach(u => {
|
||
if (u.parentUserId != null) {
|
||
const arr = m.get(u.parentUserId) || []
|
||
arr.push(u)
|
||
m.set(u.parentUserId, arr)
|
||
}
|
||
})
|
||
// sort children by optional position then id
|
||
m.forEach(arr => arr.sort((a,b) => {
|
||
const pa = (a as any).position ?? 0
|
||
const pb = (b as any).position ?? 0
|
||
return pa - pb || a.id - b.id
|
||
}))
|
||
return m
|
||
}, [users])
|
||
|
||
// Root node
|
||
const rootNode = useMemo(
|
||
() => users.find(u => u.level === 0 || u.id === resolvedRootUserId),
|
||
[users, resolvedRootUserId]
|
||
)
|
||
|
||
const rootChildren = useMemo(() => rootNode ? (childrenMap.get(rootNode.id) || []) : [], [rootNode, childrenMap])
|
||
const rootChildrenCount = useMemo(() => rootChildren.length, [rootChildren])
|
||
const displayedRootSlots = useMemo(() =>
|
||
rootChildren.filter(c => {
|
||
const pos = Number((c as any).position ?? -1)
|
||
return pos >= 1 && pos <= 5
|
||
}).length
|
||
, [rootChildren])
|
||
|
||
// Rogue count if flags exist
|
||
const rogueCount = useMemo(
|
||
() => users.filter(u => (u as any).rogueUser || (u as any).rogue_user || (u as any).rogue).length,
|
||
[users]
|
||
)
|
||
|
||
// Filter match helper
|
||
const searchLower = globalSearch.trim().toLowerCase()
|
||
const matchesSearch = (u: MatrixUser) =>
|
||
!searchLower ||
|
||
u.name.toLowerCase().includes(searchLower) ||
|
||
u.email.toLowerCase().includes(searchLower)
|
||
|
||
// Determine which nodes should be visible when searching:
|
||
// Show all ancestors of matching nodes so the path is visible.
|
||
const visibleIds = useMemo(() => {
|
||
if (!searchLower) return new Set(users.map(u => u.id))
|
||
const matchIds = new Set<number>()
|
||
const parentMap = new Map<number, number | undefined>()
|
||
users.forEach(u => parentMap.set(u.id, u.parentUserId == null ? undefined : u.parentUserId))
|
||
users.forEach(u => {
|
||
if (matchesSearch(u)) {
|
||
let cur: number | undefined = u.id
|
||
while (cur != null) {
|
||
if (!matchIds.has(cur)) matchIds.add(cur)
|
||
cur = parentMap.get(cur)
|
||
}
|
||
}
|
||
})
|
||
return matchIds
|
||
}, [users, searchLower])
|
||
|
||
// Auto-expand ancestors for search results
|
||
useEffect(() => {
|
||
if (!searchLower) return
|
||
// Expand all visible nodes containing matches
|
||
setCollapsedNodes(prev => {
|
||
const next = { ...prev }
|
||
visibleIds.forEach(id => { next[id] = false })
|
||
return next
|
||
})
|
||
}, [searchLower, visibleIds])
|
||
|
||
// Tree renderer (inside component to access scope)
|
||
const renderNode = (node: MatrixUser, depth: number) => {
|
||
const children = childrenMap.get(node.id) || []
|
||
const hasChildren = children.length > 0
|
||
const collapsed = collapsedNodes[node.id]
|
||
const highlight = matchesSearch(node) && searchLower.length > 0
|
||
if (!visibleIds.has(node.id)) return null
|
||
const isRoot = node.level === 0
|
||
const pos = node.position ?? null
|
||
return (
|
||
<li key={node.id} className="relative">
|
||
<div
|
||
className={`flex items-center gap-2 rounded-md border px-2 py-1 text-xs ${
|
||
highlight ? 'border-blue-500 bg-blue-50' : 'border-gray-200 bg-white'
|
||
}`}
|
||
>
|
||
{hasChildren && (
|
||
<button
|
||
onClick={() => toggleNode(node.id)}
|
||
className="h-4 w-4 rounded border border-gray-300 text-[10px] flex items-center justify-center bg-gray-50 hover:bg-gray-100"
|
||
aria-label={collapsed ? 'Expand' : 'Collapse'}
|
||
>
|
||
{collapsed ? '+' : '–'}
|
||
</button>
|
||
)}
|
||
{!hasChildren && <span className="h-4 w-4" />}
|
||
{node.type === 'company'
|
||
? <BuildingOffice2Icon className="h-4 w-4 text-indigo-600" />
|
||
: <UserIcon className="h-4 w-4 text-blue-600" />}
|
||
<span className="font-medium truncate max-w-[160px]">{node.name}</span>
|
||
<span className="text-[10px] px-1 rounded bg-gray-100 text-gray-600">L{node.level}</span>
|
||
{(node as any).rogueUser || (node as any).rogue_user || (node as any).rogue ? (
|
||
<span className="text-[10px] px-2 py-0.5 rounded-full bg-yellow-100 text-yellow-800">Rogue</span>
|
||
) : null}
|
||
{pos != null && (
|
||
<span className="text-[10px] px-1 rounded bg-gray-100 text-gray-600">
|
||
pos {pos}
|
||
</span>
|
||
)}
|
||
{isRoot && (
|
||
<span className="ml-auto text-[10px] text-gray-500">
|
||
Unlimited; positions numbered sequentially
|
||
</span>
|
||
)}
|
||
{!isRoot && hasChildren && (
|
||
<span className="ml-auto text-[10px] text-gray-500">
|
||
{children.length}/5 (slots 1–5)
|
||
</span>
|
||
)}
|
||
</div>
|
||
{hasChildren && !collapsed && (
|
||
<ul className="ml-6 mt-1 flex flex-col gap-1">
|
||
{children.map(c => renderNode(c, depth + 1))}
|
||
</ul>
|
||
)}
|
||
</li>
|
||
)
|
||
}
|
||
|
||
// CSV export (now all users fetched)
|
||
const exportCsv = () => {
|
||
const rows = [['id','name','email','type','level','parentUserId','rogue']]
|
||
users.forEach(u => rows.push([
|
||
u.id,
|
||
u.name,
|
||
u.email,
|
||
u.type,
|
||
u.level,
|
||
u.parentUserId ?? '',
|
||
((u as any).rogueUser || (u as any).rogue_user || (u as any).rogue) ? 'true' : 'false'
|
||
] as any))
|
||
const csv = rows.map(r => r.map(v => `"${String(v).replace(/"/g,'""')}"`).join(',')).join('\n')
|
||
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8' })
|
||
const a = document.createElement('a')
|
||
a.href = URL.createObjectURL(blob)
|
||
a.download = `matrix-${matrixId}-unlimited.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])
|
||
|
||
// REMOVE old isUnlimited derivation using serverMaxDepth; REPLACE with policy-based
|
||
// const isUnlimited = !serverMaxDepth || serverMaxDepth <= 0;
|
||
const isUnlimited = policyMaxDepth == null || policyMaxDepth <= 0 // NEW
|
||
|
||
const policyDepth = (policyMaxDepth && policyMaxDepth > 0) ? policyMaxDepth : null
|
||
const perLevelCounts = useMemo(() => {
|
||
const m = new Map<number, number>()
|
||
users.forEach(u => {
|
||
if (u.level != null && u.level >= 0) {
|
||
m.set(u.level, (m.get(u.level) || 0) + 1)
|
||
}
|
||
})
|
||
return m
|
||
}, [users])
|
||
const totalNonRoot = useMemo(() => users.filter(u => (u.level ?? 0) > 0).length, [users])
|
||
const fillMetrics = useMemo(() => {
|
||
if (!policyDepth) return { label: 'N/A (unlimited policy)', highestFull: 'N/A' }
|
||
let capacitySum = 0
|
||
let highestFullLevel: number | null = null
|
||
for (let k = 1; k <= policyDepth; k++) {
|
||
const cap = Math.pow(5, k)
|
||
capacitySum += cap
|
||
const lvlCount = perLevelCounts.get(k) || 0
|
||
if (lvlCount >= cap) highestFullLevel = k
|
||
}
|
||
if (capacitySum === 0) return { label: 'N/A', highestFull: 'N/A' }
|
||
const pct = Math.round((totalNonRoot / capacitySum) * 100 * 100) / 100
|
||
return { label: `${pct}%`, highestFull: highestFullLevel == null ? 'None' : `L${highestFullLevel}` }
|
||
}, [policyDepth, perLevelCounts, totalNonRoot])
|
||
|
||
return (
|
||
<PageLayout>
|
||
{/* Smooth refresh overlay */}
|
||
{refreshing && (
|
||
<div className="fixed inset-0 z-[9999] flex items-center justify-center bg-white/50 backdrop-blur-sm transition-opacity">
|
||
<div className="flex items-center gap-3 rounded-lg bg-white shadow-md border border-gray-200 px-4 py-3">
|
||
<span className="h-5 w-5 rounded-full border-2 border-blue-900 border-b-transparent animate-spin" />
|
||
<span className="text-sm text-gray-700">{t('autofix.k14a4b43e')}</span>
|
||
</div>
|
||
</div>
|
||
)}
|
||
|
||
<div className="bg-gradient-to-tr from-blue-50 via-white to-blue-100 min-h-screen w-full">
|
||
<div className="mx-auto max-w-6xl px-2 sm:px-6 py-8">
|
||
{/* Header card */}
|
||
<header className="mb-8 rounded-2xl border border-gray-100 bg-white shadow-lg px-8 py-8 flex flex-col gap-4">
|
||
<div className="flex items-center justify-between">
|
||
<div className="space-y-2">
|
||
<button
|
||
onClick={() => router.push('/admin/matrix-management')}
|
||
className="inline-flex items-center gap-2 text-sm text-blue-900 hover:text-blue-700"
|
||
>
|
||
<ArrowLeftIcon className="h-4 w-4" />{t('autofix.k65b67dc3')}</button>
|
||
<h1 className="text-3xl font-extrabold text-blue-900">{matrixName}</h1>
|
||
<p className="text-base text-blue-700">{t('autofix.k31d46514')}<span className="font-semibold text-blue-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-3 py-1 text-xs text-gray-800">
|
||
Root: unlimited immediate children (sequential positions)
|
||
</span>
|
||
<span className="inline-flex items-center rounded-full bg-gray-100 border border-gray-200 px-3 py-1 text-xs text-gray-800">
|
||
Non-root: 5 children (positions 1–5)
|
||
</span>
|
||
<span className="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-3 py-1 text-xs text-blue-900">
|
||
Policy depth (DB): {isUnlimited ? 'Unlimited' : policyMaxDepth}
|
||
</span>
|
||
<span className="inline-flex items-center rounded-full bg-purple-50 border border-purple-200 px-3 py-1 text-xs text-purple-900">
|
||
Fetch depth (client slice): {DEFAULT_FETCH_DEPTH}
|
||
</span>
|
||
{serverMaxDepth != null && (
|
||
<span className="inline-flex items-center rounded-full bg-amber-50 border border-amber-200 px-3 py-1 text-xs text-amber-800">
|
||
Server-reported max depth: {serverMaxDepth}
|
||
</span>
|
||
)}
|
||
<span className="inline-flex items-center rounded-full bg-gray-100 border border-gray-200 px-3 py-1 text-xs text-gray-800">
|
||
Root children: {rootChildrenCount} (unlimited)
|
||
</span>
|
||
<span className="inline-flex items-center rounded-full bg-gray-100 border border-gray-200 px-3 py-1 text-xs text-gray-800">
|
||
Displayed slots under root (positions 1–5): {displayedRootSlots}/5
|
||
</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-900 hover:bg-blue-800 text-blue-50 px-5 py-3 text-base font-semibold shadow transition"
|
||
>
|
||
<PlusIcon className="h-5 w-5" />{t('autofix.kc7c429a6')}</button>
|
||
</div>
|
||
</div>
|
||
</header>
|
||
|
||
{/* 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">
|
||
Unlimited matrix: depth grows without a configured cap. Display limited by fetch slice ({DEFAULT_FETCH_DEPTH} levels requested).
|
||
</div>
|
||
)}
|
||
|
||
{/* Sticky controls (CHANGED depth display) */}
|
||
<div className="sticky top-0 z-10 bg-white/90 backdrop-blur px-6 py-4 border-b border-blue-100 flex flex-wrap items-center gap-4 rounded-xl mb-6 shadow">
|
||
<div className="relative w-64">
|
||
<MagnifyingGlassIcon className="pointer-events-none absolute left-2 top-1/2 -translate-y-1/2 h-4 w-4 text-blue-300" />
|
||
<input
|
||
value={globalSearch}
|
||
onChange={e => setGlobalSearch(e.target.value)}
|
||
placeholder={t('autofix.kd304af2e')}
|
||
className="pl-8 pr-2 py-2 rounded-lg border border-gray-200 text-xs focus:ring-1 focus:ring-blue-900 focus:border-transparent w-full"
|
||
/>
|
||
</div>
|
||
<button
|
||
onClick={() => exportCsv()}
|
||
className="text-xs text-blue-900 hover:text-blue-700 underline"
|
||
>
|
||
Export CSV (all fetched)
|
||
</button>
|
||
<div className="ml-auto text-[11px] text-gray-600">
|
||
Policy depth: {isUnlimited ? 'Unlimited' : policyMaxDepth}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Small stats (CHANGED wording) */}
|
||
<div className="mb-8 grid grid-cols-1 sm:grid-cols-4 gap-6">
|
||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||
<div className="text-xs text-gray-500 mb-1">{t('autofix.k65e33378')}</div>
|
||
<div className="text-xl font-semibold text-blue-900">{users.length}</div>
|
||
</div>
|
||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||
<div className="text-xs text-gray-500 mb-1">{t('autofix.kb343460d')}</div>
|
||
<div className="text-xl font-semibold text-blue-900">{rogueCount}</div>
|
||
</div>
|
||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||
<div className="text-xs text-gray-500 mb-1">Structure</div>
|
||
<div className="text-xl font-semibold text-blue-900">{t('autofix.kf3557acd')}</div>
|
||
</div>
|
||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||
<div className="text-xs text-gray-500 mb-1">{t('autofix.k776b751c')}</div>
|
||
<div className="text-xl font-semibold text-blue-900">{isUnlimited ? 'Unlimited' : policyMaxDepth}</div>
|
||
</div>
|
||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||
<div className="text-xs text-gray-500 mb-1">{t('autofix.k9683262f')}</div>
|
||
<div className="text-xl font-semibold text-blue-900">{fillMetrics.label}</div>
|
||
</div>
|
||
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
|
||
<div className="text-xs text-gray-500 mb-1">{t('autofix.k7f9568ec')}</div>
|
||
<div className="text-xl font-semibold text-blue-900">{fillMetrics.highestFull}</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Unlimited hierarchical tree (replaces dynamic levels + grouped level list) */}
|
||
<div className="rounded-2xl bg-white border border-gray-100 shadow-lg overflow-hidden mb-8">
|
||
<div className="px-8 py-6 border-b border-gray-100">
|
||
<h2 className="text-xl font-semibold text-blue-900">Matrix Tree (Unlimited Depth)</h2>
|
||
<p className="text-xs text-blue-700">{t('autofix.kab4f5159')}</p>
|
||
</div>
|
||
<div className="px-8 py-6">
|
||
{!rootNode && (
|
||
<div className="text-xs text-gray-500 italic">{t('autofix.k4e61bc77')}</div>
|
||
)}
|
||
{rootNode && (
|
||
<ul className="flex flex-col gap-1">
|
||
{renderNode(rootNode, 0)}
|
||
</ul>
|
||
)}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Vacancies placeholder */}
|
||
<div className="rounded-2xl bg-white border border-dashed border-blue-200 shadow-sm p-6 mb-8">
|
||
<h3 className="text-lg font-semibold text-blue-900 mb-2">Vacancies</h3>
|
||
<p className="text-sm text-blue-700">{t('autofix.k9b3266b5')}</p>
|
||
</div>
|
||
|
||
{/* Add Users Modal */}
|
||
<SearchModal
|
||
open={open}
|
||
onClose={handleModalClose}
|
||
matrixName={matrixName}
|
||
rootUserId={resolvedRootUserId}
|
||
matrixId={matrixId}
|
||
topNodeEmail={topNodeEmail}
|
||
existingUsers={users}
|
||
policyMaxDepth={policyMaxDepth}
|
||
onAdd={(u) => { addToMatrix(u) }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</PageLayout>
|
||
)
|
||
}
|
||
|
||
// CHANGED: default export wraps inner component in Suspense
|
||
export default function MatrixDetailPage() {
|
||
return (
|
||
<Suspense
|
||
fallback={
|
||
<PageLayout>
|
||
<div className="min-h-screen flex items-center justify-center">
|
||
<div className="text-center">
|
||
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-[#8D6B1D] mx-auto mb-3" />
|
||
<p className="text-[#4A4A4A]">Loading...</p>
|
||
</div>
|
||
</div>
|
||
</PageLayout>
|
||
}
|
||
>
|
||
<MatrixDetailPageInner />
|
||
</Suspense>
|
||
)
|
||
} |