refactor: matrix stuff

This commit is contained in:
DeathKaioken 2025-12-06 12:34:04 +01:00
parent 25b8e10ca0
commit 48c50ee5f1
4 changed files with 130 additions and 26 deletions

View File

@ -443,20 +443,20 @@ export default function SearchModal({
{advanced && ( {advanced && (
<div className="space-y-2"> <div className="space-y-2">
<select <select
key={parentsRevision} // NEW: force remount to refresh options key={parentsRevision}
value={parentId ?? ''} value={parentId ?? ''}
onChange={e => setParentId(e.target.value ? Number(e.target.value) : undefined)} 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" 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 title={addDisabledReason || undefined}
> >
<option value="">(Auto referral / root)</option> <option value="">(Auto referral / root)</option>
{potentialParents.map(p => { {potentialParents.map(p => {
const used = parentUsage.get(p.id) || 0 const used = parentUsage.get(p.id) || 0
const isRoot = (p.level ?? 0) === 0 const isRoot = (p.level ?? 0) === 0
const full = !isRoot && used >= 5 const full = (!isRoot && used >= 5) || (!!policyMaxDepth && policyMaxDepth > 0 && p.level >= policyMaxDepth) // CHANGED
const rem = !policyMaxDepth || policyMaxDepth <= 0 ? '∞' : Math.max(0, policyMaxDepth - p.level) const rem = !policyMaxDepth || policyMaxDepth <= 0 ? '∞' : Math.max(0, policyMaxDepth - p.level)
return ( return (
<option key={p.id} value={p.id} disabled={full}> <option key={p.id} value={p.id} disabled={full} title={full ? 'Parent full or at policy depth' : undefined}>
{p.name} L{p.level} Slots {isRoot ? `${used} (root ∞)` : `${used}/5`} Rem levels: {rem} {p.name} L{p.level} Slots {isRoot ? `${used} (root ∞)` : `${used}/5`} Rem levels: {rem}
</option> </option>
) )
@ -482,6 +482,9 @@ export default function SearchModal({
/> />
Fallback to root if referral parent not in matrix Fallback to root if referral parent not in matrix
</label> </label>
<p className="text-[11px] text-blue-300">
If the referrer is outside the matrix, the user is placed under root; enabling fallback may mark the user as rogue.
</p>
{addError && <div className="text-xs text-red-400">{addError}</div>} {addError && <div className="text-xs text-red-400">{addError}</div>}
{addSuccess && <div className="text-xs text-green-400">{addSuccess}</div>} {addSuccess && <div className="text-xs text-green-400">{addSuccess}</div>}

View File

@ -195,11 +195,14 @@ export default function MatrixDetailPage() {
[users, resolvedRootUserId] [users, resolvedRootUserId]
) )
// NEW: immediate children count for root (unlimited capacity display) const rootChildren = useMemo(() => rootNode ? (childrenMap.get(rootNode.id) || []) : [], [rootNode, childrenMap])
const rootChildrenCount = useMemo(() => { const rootChildrenCount = useMemo(() => rootChildren.length, [rootChildren])
if (!rootNode) return 0 const displayedRootSlots = useMemo(() =>
return (childrenMap.get(rootNode.id) || []).length rootChildren.filter(c => {
}, [rootNode, childrenMap]) const pos = Number((c as any).position ?? -1)
return pos >= 1 && pos <= 5
}).length
, [rootChildren])
// Rogue count if flags exist // Rogue count if flags exist
const rogueCount = useMemo( const rogueCount = useMemo(
@ -285,12 +288,12 @@ export default function MatrixDetailPage() {
)} )}
{isRoot && ( {isRoot && (
<span className="ml-auto text-[10px] text-gray-500"> <span className="ml-auto text-[10px] text-gray-500">
{children.length} children (Unlimited) Unlimited; positions numbered sequentially
</span> </span>
)} )}
{!isRoot && hasChildren && ( {!isRoot && hasChildren && (
<span className="ml-auto text-[10px] text-gray-500"> <span className="ml-auto text-[10px] text-gray-500">
{children.length}/5 {children.length}/5 (slots 15)
</span> </span>
)} )}
</div> </div>
@ -342,6 +345,32 @@ export default function MatrixDetailPage() {
// const isUnlimited = !serverMaxDepth || serverMaxDepth <= 0; // const isUnlimited = !serverMaxDepth || serverMaxDepth <= 0;
const isUnlimited = policyMaxDepth == null || policyMaxDepth <= 0 // NEW 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 ( return (
<PageLayout> <PageLayout>
{/* Smooth refresh overlay */} {/* Smooth refresh overlay */}
@ -372,15 +401,28 @@ export default function MatrixDetailPage() {
Top node: <span className="font-semibold text-blue-900">{topNodeEmail}</span> Top node: <span className="font-semibold text-blue-900">{topNodeEmail}</span>
</p> </p>
<div className="mt-2 flex flex-wrap items-center gap-2"> <div className="mt-2 flex flex-wrap items-center gap-2">
{/* CHANGED: capacity clarification */}
<span className="inline-flex items-center rounded-full bg-gray-100 border border-gray-200 px-3 py-1 text-xs text-gray-800"> <span className="inline-flex items-center rounded-full bg-gray-100 border border-gray-200 px-3 py-1 text-xs text-gray-800">
Children/node: nonroot 5, root unlimited 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>
<span className="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-3 py-1 text-xs text-blue-900"> <span className="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-3 py-1 text-xs text-blue-900">
Max depth: {isUnlimited ? 'Unlimited' : policyMaxDepth} 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>
<span className="inline-flex items-center rounded-full bg-gray-100 border border-gray-200 px-3 py-1 text-xs text-gray-800"> <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) Displayed slots under root (positions 15): {displayedRootSlots}/5
</span> </span>
</div> </div>
</div> </div>
@ -426,7 +468,7 @@ export default function MatrixDetailPage() {
</div> </div>
{/* Small stats (CHANGED wording) */} {/* Small stats (CHANGED wording) */}
<div className="mb-8 grid grid-cols-1 sm:grid-cols-3 gap-6"> <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="rounded-xl border border-gray-100 bg-white p-5 shadow">
<div className="text-xs text-gray-500 mb-1">Total users fetched</div> <div className="text-xs text-gray-500 mb-1">Total users fetched</div>
<div className="text-xl font-semibold text-blue-900">{users.length}</div> <div className="text-xl font-semibold text-blue-900">{users.length}</div>
@ -443,6 +485,14 @@ export default function MatrixDetailPage() {
<div className="text-xs text-gray-500 mb-1">Policy Max Depth</div> <div className="text-xs text-gray-500 mb-1">Policy Max Depth</div>
<div className="text-xl font-semibold text-blue-900">{isUnlimited ? 'Unlimited' : policyMaxDepth}</div> <div className="text-xl font-semibold text-blue-900">{isUnlimited ? 'Unlimited' : policyMaxDepth}</div>
</div> </div>
<div className="rounded-xl border border-gray-100 bg-white p-5 shadow">
<div className="text-xs text-gray-500 mb-1">Fill %</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">Highest full level</div>
<div className="text-xl font-semibold text-blue-900">{fillMetrics.highestFull}</div>
</div>
</div> </div>
{/* Unlimited hierarchical tree (replaces dynamic levels + grouped level list) */} {/* Unlimited hierarchical tree (replaces dynamic levels + grouped level list) */}
@ -463,6 +513,14 @@ export default function MatrixDetailPage() {
</div> </div>
</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">
Pending backend wiring to MatrixController.listVacancies. This section will surface empty slots and allow reassignment.
</p>
</div>
{/* Add Users Modal */} {/* Add Users Modal */}
<SearchModal <SearchModal
open={open} open={open}
@ -472,7 +530,7 @@ export default function MatrixDetailPage() {
matrixId={matrixId} matrixId={matrixId}
topNodeEmail={topNodeEmail} topNodeEmail={topNodeEmail}
existingUsers={users} existingUsers={users}
policyMaxDepth={policyMaxDepth} // CHANGED: pass real policy max depth policyMaxDepth={policyMaxDepth}
onAdd={(u) => { addToMatrix(u) }} onAdd={(u) => { addToMatrix(u) }}
/> />
</div> </div>

View File

@ -62,8 +62,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<'unlimited'|'five'>('unlimited') // NEW const [policyFilter, setPolicyFilter] = useState<'all'|'unlimited'|'five'>('all') // CHANGED
const [sortByUsers, setSortByUsers] = useState<'asc'|'desc'>('desc') // NEW const [sortByUsers, setSortByUsers] = useState<'asc'|'desc'>('desc')
const [sortByPolicy, setSortByPolicy] = useState<'none'|'asc'|'desc'>('none') // NEW
const [mutatingId, setMutatingId] = useState<string | null>(null) // NEW const [mutatingId, setMutatingId] = useState<string | null>(null) // NEW
const loadStats = async () => { const loadStats = async () => {
@ -220,11 +221,20 @@ export default function MatrixManagementPage() {
let list = [...matrices] let list = [...matrices]
list = list.filter(m => { list = list.filter(m => {
const unlimited = !m.policyMaxDepth || m.policyMaxDepth <= 0 const unlimited = !m.policyMaxDepth || m.policyMaxDepth <= 0
if (policyFilter === 'all') return true
return policyFilter === 'unlimited' ? unlimited : (!unlimited && m.policyMaxDepth === 5) return policyFilter === 'unlimited' ? unlimited : (!unlimited && m.policyMaxDepth === 5)
}) })
list.sort((a,b) => sortByUsers === 'asc' ? (a.usersCount - b.usersCount) : (b.usersCount - a.usersCount)) list.sort((a,b) => {
if (sortByPolicy !== 'none') {
const pa = (!a.policyMaxDepth || a.policyMaxDepth <= 0) ? Infinity : a.policyMaxDepth
const pb = (!b.policyMaxDepth || b.policyMaxDepth <= 0) ? Infinity : b.policyMaxDepth
const diff = sortByPolicy === 'asc' ? pa - pb : pb - pa
if (diff !== 0) return diff
}
return sortByUsers === 'asc' ? (a.usersCount - b.usersCount) : (b.usersCount - a.usersCount)
})
return list return list
}, [matrices, policyFilter, sortByUsers]) }, [matrices, policyFilter, sortByUsers, sortByPolicy])
const StatCard = ({ const StatCard = ({
icon: Icon, icon: Icon,
@ -284,6 +294,28 @@ export default function MatrixManagementPage() {
</div> </div>
</header> </header>
{/* Filters */}
<div className="flex flex-wrap items-center gap-3 mb-6">
<div className="flex items-center gap-2 text-xs text-blue-900">
<span className="font-semibold">Policy filter:</span>
<button onClick={() => setPolicyFilter('all')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='all'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>All</button>
<button onClick={() => setPolicyFilter('unlimited')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='unlimited'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>Unlimited</button>
<button onClick={() => setPolicyFilter('five')} className={`px-3 py-1 rounded-full border text-xs ${policyFilter==='five'?'bg-blue-900 text-white':'border-blue-200 text-blue-800'}`}>Depth 5</button>
</div>
<div className="flex items-center gap-2 text-xs text-blue-900">
<span className="font-semibold">Sort:</span>
<select value={sortByPolicy} onChange={e => setSortByPolicy(e.target.value as any)} className="border rounded px-2 py-1">
<option value="none">None</option>
<option value="asc">Policy </option>
<option value="desc">Policy </option>
</select>
<select value={sortByUsers} onChange={e => setSortByUsers(e.target.value as any)} className="border rounded px-2 py-1">
<option value="desc">Users </option>
<option value="asc">Users </option>
</select>
</div>
</div>
{/* Error banner for stats */} {/* Error banner for stats */}
{statsError && ( {statsError && (
<div className="mb-6 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700"> <div className="mb-6 rounded-md border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700">
@ -321,9 +353,12 @@ export default function MatrixManagementPage() {
<h3 className="text-xl font-semibold text-blue-900">{m.name}</h3> <h3 className="text-xl font-semibold text-blue-900">{m.name}</h3>
<StatusBadge status={m.status} /> <StatusBadge status={m.status} />
</div> </div>
<div className="mt-2"> <div className="mt-2 flex flex-wrap gap-2 text-xs">
<span className="inline-flex items-center rounded-full bg-blue-50 border border-blue-200 px-2 py-0.5 text-xs text-blue-900"> <span className={`inline-flex items-center rounded-full px-2 py-0.5 border ${m.status==='inactive'?'border-gray-200 bg-gray-50 text-gray-500':'border-blue-200 bg-blue-50 text-blue-900'}`}>
Max depth: {(!m.policyMaxDepth || m.policyMaxDepth <= 0) ? 'Unlimited' : m.policyMaxDepth} Policy: {(!m.policyMaxDepth || m.policyMaxDepth <= 0) ? 'Unlimited' : m.policyMaxDepth}
</span>
<span className={`inline-flex items-center rounded-full px-2 py-0.5 border ${m.status==='inactive'?'border-gray-200 bg-gray-50 text-gray-500':'border-gray-200 bg-gray-100 text-gray-800'}`}>
Root: unlimited immediate children (sequential), non-root: 5 children (positions 15)
</span> </span>
</div> </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">
@ -356,6 +391,9 @@ export default function MatrixManagementPage() {
? (m.status === 'active' ? 'Deactivating…' : 'Activating…') ? (m.status === 'active' ? 'Deactivating…' : 'Activating…')
: (m.status === 'active' ? 'Deactivate' : 'Activate')} : (m.status === 'active' ? 'Deactivate' : 'Activate')}
</button> </button>
<span className="text-[11px] text-gray-500">
State change will affect add/remove operations.
</span>
<button <button
className="text-sm font-medium text-blue-900 hover:text-blue-700" className="text-sm font-medium text-blue-900 hover:text-blue-700"
onClick={() => { onClick={() => {

View File

@ -203,9 +203,14 @@ export function usePersonalMatrixOverview(matrixId?: string | number) {
.then(async r => { .then(async r => {
const ct = r.headers.get('content-type') || '' const ct = r.headers.get('content-type') || ''
console.log('[usePersonalMatrixOverview] Status:', r.status, 'CT:', ct) console.log('[usePersonalMatrixOverview] Status:', r.status, 'CT:', ct)
if (!r.ok || !ct.includes('application/json')) { const isJson = ct.includes('application/json')
if (!r.ok) {
const body = isJson ? await r.json().catch(() => ({})) : await r.text().catch(() => '')
const msg = isJson ? (body?.message || JSON.stringify(body) || 'Request failed') : (String(body || '').slice(0, 200) || 'Request failed')
throw new Error(`Request failed: ${r.status} ${msg}`)
}
if (!isJson) {
const txt = await r.text().catch(() => '') const txt = await r.text().catch(() => '')
console.warn('[usePersonalMatrixOverview] Non-JSON or error body:', txt.slice(0, 200))
throw new Error(`Request failed: ${r.status} ${txt.slice(0, 120)}`) throw new Error(`Request failed: ${r.status} ${txt.slice(0, 120)}`)
} }
const json = await r.json() const json = await r.json()