profit-planet-frontend/src/app/admin/matrix-management/detail/page.tsx
2026-05-03 22:20:17 +02:00

557 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

'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 15)
</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 15)
</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 15): {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>
)
}