refactor: matrix stuff
This commit is contained in:
parent
25b8e10ca0
commit
48c50ee5f1
@ -443,20 +443,20 @@ export default function SearchModal({
|
||||
{advanced && (
|
||||
<div className="space-y-2">
|
||||
<select
|
||||
key={parentsRevision} // NEW: force remount to refresh options
|
||||
key={parentsRevision}
|
||||
value={parentId ?? ''}
|
||||
onChange={e => setParentId(e.target.value ? Number(e.target.value) : undefined)}
|
||||
className="w-full rounded-md bg-[#173456] border border-blue-800 text-xs text-blue-100 px-2 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
||||
title={addDisabledReason || undefined} // NEW
|
||||
title={addDisabledReason || undefined}
|
||||
>
|
||||
<option value="">(Auto referral / root)</option>
|
||||
{potentialParents.map(p => {
|
||||
const used = parentUsage.get(p.id) || 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)
|
||||
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}
|
||||
</option>
|
||||
)
|
||||
@ -482,6 +482,9 @@ export default function SearchModal({
|
||||
/>
|
||||
Fallback to root if referral parent not in matrix
|
||||
</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>}
|
||||
{addSuccess && <div className="text-xs text-green-400">{addSuccess}</div>}
|
||||
|
||||
@ -195,11 +195,14 @@ export default function MatrixDetailPage() {
|
||||
[users, resolvedRootUserId]
|
||||
)
|
||||
|
||||
// NEW: immediate children count for root (unlimited capacity display)
|
||||
const rootChildrenCount = useMemo(() => {
|
||||
if (!rootNode) return 0
|
||||
return (childrenMap.get(rootNode.id) || []).length
|
||||
}, [rootNode, childrenMap])
|
||||
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(
|
||||
@ -285,12 +288,12 @@ export default function MatrixDetailPage() {
|
||||
)}
|
||||
{isRoot && (
|
||||
<span className="ml-auto text-[10px] text-gray-500">
|
||||
{children.length} children (Unlimited)
|
||||
Unlimited; positions numbered sequentially
|
||||
</span>
|
||||
)}
|
||||
{!isRoot && hasChildren && (
|
||||
<span className="ml-auto text-[10px] text-gray-500">
|
||||
{children.length}/5
|
||||
{children.length}/5 (slots 1–5)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
@ -342,6 +345,32 @@ export default function MatrixDetailPage() {
|
||||
// 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 */}
|
||||
@ -372,15 +401,28 @@ export default function MatrixDetailPage() {
|
||||
Top node: <span className="font-semibold text-blue-900">{topNodeEmail}</span>
|
||||
</p>
|
||||
<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">
|
||||
Children/node: non‑root 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 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">
|
||||
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 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 1–5): {displayedRootSlots}/5
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@ -426,7 +468,7 @@ export default function MatrixDetailPage() {
|
||||
</div>
|
||||
|
||||
{/* 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="text-xs text-gray-500 mb-1">Total users fetched</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-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">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>
|
||||
|
||||
{/* Unlimited hierarchical tree (replaces dynamic levels + grouped level list) */}
|
||||
@ -463,6 +513,14 @@ export default function MatrixDetailPage() {
|
||||
</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 */}
|
||||
<SearchModal
|
||||
open={open}
|
||||
@ -472,7 +530,7 @@ export default function MatrixDetailPage() {
|
||||
matrixId={matrixId}
|
||||
topNodeEmail={topNodeEmail}
|
||||
existingUsers={users}
|
||||
policyMaxDepth={policyMaxDepth} // CHANGED: pass real policy max depth
|
||||
policyMaxDepth={policyMaxDepth}
|
||||
onAdd={(u) => { addToMatrix(u) }}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@ -62,8 +62,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<'unlimited'|'five'>('unlimited') // NEW
|
||||
const [sortByUsers, setSortByUsers] = useState<'asc'|'desc'>('desc') // NEW
|
||||
const [policyFilter, setPolicyFilter] = useState<'all'|'unlimited'|'five'>('all') // CHANGED
|
||||
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 loadStats = async () => {
|
||||
@ -220,11 +221,20 @@ export default function MatrixManagementPage() {
|
||||
let list = [...matrices]
|
||||
list = list.filter(m => {
|
||||
const unlimited = !m.policyMaxDepth || m.policyMaxDepth <= 0
|
||||
if (policyFilter === 'all') return true
|
||||
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
|
||||
}, [matrices, policyFilter, sortByUsers])
|
||||
}, [matrices, policyFilter, sortByUsers, sortByPolicy])
|
||||
|
||||
const StatCard = ({
|
||||
icon: Icon,
|
||||
@ -284,6 +294,28 @@ export default function MatrixManagementPage() {
|
||||
</div>
|
||||
</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 */}
|
||||
{statsError && (
|
||||
<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>
|
||||
<StatusBadge status={m.status} />
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<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">
|
||||
Max depth: {(!m.policyMaxDepth || m.policyMaxDepth <= 0) ? 'Unlimited' : m.policyMaxDepth}
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||
<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'}`}>
|
||||
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 1–5)
|
||||
</span>
|
||||
</div>
|
||||
<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' ? 'Deactivate' : 'Activate')}
|
||||
</button>
|
||||
<span className="text-[11px] text-gray-500">
|
||||
State change will affect add/remove operations.
|
||||
</span>
|
||||
<button
|
||||
className="text-sm font-medium text-blue-900 hover:text-blue-700"
|
||||
onClick={() => {
|
||||
|
||||
@ -203,9 +203,14 @@ export function usePersonalMatrixOverview(matrixId?: string | number) {
|
||||
.then(async r => {
|
||||
const ct = r.headers.get('content-type') || ''
|
||||
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(() => '')
|
||||
console.warn('[usePersonalMatrixOverview] Non-JSON or error body:', txt.slice(0, 200))
|
||||
throw new Error(`Request failed: ${r.status} ${txt.slice(0, 120)}`)
|
||||
}
|
||||
const json = await r.json()
|
||||
|
||||
Loading…
Reference in New Issue
Block a user