diff --git a/src/app/admin/matrix-management/detail/components/searchModal.tsx b/src/app/admin/matrix-management/detail/components/searchModal.tsx index b12e0b0..11c7dba 100644 --- a/src/app/admin/matrix-management/detail/components/searchModal.tsx +++ b/src/app/admin/matrix-management/detail/components/searchModal.tsx @@ -45,6 +45,7 @@ export default function SearchModal({ const [addError, setAddError] = useState('') // NEW const [addSuccess, setAddSuccess] = useState('') // NEW const [hasSearched, setHasSearched] = useState(false) // NEW + const [closing, setClosing] = useState(false) // NEW: animated closing state const formRef = useRef(null) const reqIdRef = useRef(0) // request guard to avoid applying stale results @@ -134,6 +135,9 @@ export default function SearchModal({ } }, [existingUsers, items, open]) + // Track a revision to force remount of parent dropdown when existingUsers changes + const [parentsRevision, setParentsRevision] = useState(0) // NEW + // Compute children counts per parent (uses parentUserId on existingUsers) const parentUsage = useMemo(() => { const map = new Map() @@ -157,6 +161,19 @@ export default function SearchModal({ return existingUsers.find(u => u.id === parentId) || null }, [parentId, existingUsers]) + // NEW: when existingUsers changes, refresh dropdown and clear invalid/now-full parent selection + useEffect(() => { + setParentsRevision(r => r + 1) + if (!selectedParent) return + const used = parentUsage.get(selectedParent.id) || 0 + const isRoot = (selectedParent.level ?? 0) === 0 + const isFull = !isRoot && used >= 5 + const stillExists = !!existingUsers.find(u => u.id === selectedParent.id) + if (!stillExists || isFull) { + setParentId(undefined) + } + }, [existingUsers, parentUsage, selectedParent]) + const remainingLevels = useMemo(() => { if (!selectedParent) return null if (!policyMaxDepth || policyMaxDepth <= 0) return Infinity @@ -172,6 +189,28 @@ export default function SearchModal({ return '' }, [selectedParent, policyMaxDepth]) + // Helper: is root selected + const isRootSelected = useMemo(() => { + if (!selectedParent) return false + return (selectedParent.level ?? 0) === 0 + }, [selectedParent]) + + const closeWithAnimation = useCallback(() => { + // guard: if already closing, ignore + if (closing) return + setClosing(true) + // allow CSS transitions to play + setTimeout(() => { + setClosing(false) + onClose() + }, 200) // keep brief for responsiveness + }, [closing, onClose]) + + useEffect(() => { + // reset closing flag when reopened + if (open) setClosing(false) + }, [open]) + const handleAdd = async () => { if (!selected) return setAddError('') @@ -189,10 +228,13 @@ export default function SearchModal({ 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) + // NEW: animated close instead of abrupt onClose + closeWithAnimation() + return + // setSelected(null) + // setParentId(undefined) // Soft refresh: keep list visible; doSearch won't clear items now - setTimeout(() => { void doSearch() }, 200) + // setTimeout(() => { void doSearch() }, 200) } catch (e: any) { console.error('[SearchModal] addUserToMatrix error', e) setAddError(e?.message || 'Add failed') @@ -204,11 +246,16 @@ export default function SearchModal({ if (!open) return null const modal = ( -
{/* elevated z-index */} -
+
+ {/* Backdrop: animate opacity */} +
{/* Header */} @@ -217,9 +264,8 @@ export default function SearchModal({

Add users to “{matrixName}”

- {/* Close button improved hover/focus */}
)} @@ -453,7 +504,7 @@ export default function SearchModal({ {!selected && (
+ )} + {!hasChildren && } + {node.type === 'company' + ? + : } + {node.name} + L{node.level} + {(node as any).rogueUser || (node as any).rogue_user || (node as any).rogue ? ( + Rogue + ) : null} + {pos != null && ( + + pos {pos} + + )} + {isRoot && ( + + {children.length} children (Unlimited) + + )} + {!isRoot && hasChildren && ( + + {children.length}/5 + )}
-
- {listAll.length === 0 ? ( -
{isUnlimited ? 'No users at this level yet.' : 'No users in this level yet.'}
- ) : ( - <> -
- {list.map(u => )} -
- {showLoadMore && ( -
- -
- )} - - )} -
- + {hasChildren && !collapsed && ( +
    + {children.map(c => renderNode(c, depth + 1))} +
+ )} + ) } - // 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 + // CSV export (now all users fetched) 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 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}-levels-${depthA}-${depthB}.csv` + a.download = `matrix-${matrixId}-unlimited.csv` a.click() URL.revokeObjectURL(a.href) } @@ -260,6 +338,10 @@ export default function MatrixDetailPage() { } }, [usersLoading, refreshing]) + // REMOVE old isUnlimited derivation using serverMaxDepth; REPLACE with policy-based + // const isUnlimited = !serverMaxDepth || serverMaxDepth <= 0; + const isUnlimited = policyMaxDepth == null || policyMaxDepth <= 0 // NEW + return ( {/* Smooth refresh overlay */} @@ -290,11 +372,15 @@ export default function MatrixDetailPage() { Top node: {topNodeEmail}

+ {/* CHANGED: capacity clarification */} - Children/node: 5 + Children/node: non‑root 5, root unlimited - Max depth: {(!serverMaxDepth || serverMaxDepth <= 0) ? 'Unlimited' : serverMaxDepth} + Max depth: {isUnlimited ? 'Unlimited' : policyMaxDepth} + + + Root children: {rootChildrenCount} (Unlimited)
@@ -313,161 +399,67 @@ export default function MatrixDetailPage() { {/* Banner for unlimited */} {isUnlimited && (
- Large structure. Results are paginated by depth and count. + Unlimited matrix: depth grows without a configured cap. Display limited by fetch slice ({DEFAULT_FETCH_DEPTH} levels requested).
)} - {/* Sticky depth controls */} + {/* Sticky controls (CHANGED depth display) */}
-
- - setDepthA(Math.max(0, Number(e.target.value) || 0))} className="w-16 rounded-lg border border-gray-300 px-2 py-1 text-xs" /> - - setDepthB(Math.max(depthA, Number(e.target.value) || depthA))} className="w-16 rounded-lg border border-gray-300 px-2 py-1 text-xs" /> -
- - -
) -} +} \ No newline at end of file diff --git a/src/app/admin/matrix-management/hooks/changeMatrixState.ts b/src/app/admin/matrix-management/hooks/changeMatrixState.ts new file mode 100644 index 0000000..b17652b --- /dev/null +++ b/src/app/admin/matrix-management/hooks/changeMatrixState.ts @@ -0,0 +1,45 @@ +import { authFetch } from '../../../utils/authFetch' + +export type MatrixStateData = { + matrixInstanceId: string | number + wasActive: boolean + isActive: boolean + status: 'deactivated' | 'already_inactive' | 'activated' | 'already_active' +} + +type MatrixStateResponse = { + success: boolean + data?: MatrixStateData + message?: string +} + +const baseUrl = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '') + +async function patch(endpoint: string) { + const url = `${baseUrl}${endpoint}` + const res = await authFetch(url, { + method: 'PATCH', + headers: { Accept: 'application/json' }, + credentials: 'include' + }) + const ct = res.headers.get('content-type') || '' + const raw = await res.text() + const json: MatrixStateResponse | null = ct.includes('application/json') ? JSON.parse(raw) : null + + if (!res.ok || !json?.success) { + const msg = json?.message || `Request failed: ${res.status}` + throw new Error(msg) + } + return json.data! +} + +export async function deactivateMatrix(id: string | number) { + if (!id && id !== 0) throw new Error('matrix id required') + return patch(`/api/admin/matrix/${id}/deactivate`) +} + +export async function activateMatrix(id: string | number) { + if (!id && id !== 0) throw new Error('matrix id required') + // Assuming symmetrical endpoint; backend may expose this path. + return patch(`/api/admin/matrix/${id}/activate`) +} diff --git a/src/app/admin/matrix-management/page.tsx b/src/app/admin/matrix-management/page.tsx index 796990c..a61820a 100644 --- a/src/app/admin/matrix-management/page.tsx +++ b/src/app/admin/matrix-management/page.tsx @@ -14,6 +14,7 @@ import { useRouter } from 'next/navigation' import useAuthStore from '../../store/authStore' import { createMatrix } from './hooks/createMatrix' import { getMatrixStats } from './hooks/getMatrixStats' +import { deactivateMatrix, activateMatrix } from './hooks/changeMatrixState' // NEW type Matrix = { id: string @@ -61,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<'all'|'unlimited'|'five'>('all') // NEW + const [policyFilter, setPolicyFilter] = useState<'unlimited'|'five'>('unlimited') // NEW const [sortByUsers, setSortByUsers] = useState<'asc'|'desc'>('desc') // NEW + const [mutatingId, setMutatingId] = useState(null) // NEW const loadStats = async () => { if (!token) return @@ -194,21 +196,32 @@ export default function MatrixManagementPage() { } } - const toggleStatus = (id: string) => { - setMatrices(prev => - prev.map(m => (m.id === id ? { ...m, status: m.status === 'active' ? 'inactive' : 'active' } : m)) - ) + const toggleStatus = async (id: string) => { + try { + const target = matrices.find(m => m.id === id) + if (!target) return + setStatsError('') + setMutatingId(id) + if (target.status === 'active') { + await deactivateMatrix(id) + } else { + await activateMatrix(id) + } + await loadStats() + } catch (e: any) { + setStatsError(e?.message || 'Failed to change matrix state.') + } finally { + setMutatingId(null) + } } - // derived list with filter/sort + // derived list with filter/sort (always apply selected filter) 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 = 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]) @@ -285,35 +298,6 @@ export default function MatrixManagementPage() {
- {/* Filters */} -
-
- - -
-
- - -
-
- ℹ️ 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 ? ( @@ -362,12 +346,15 @@ export default function MatrixManagementPage() {
-
- Default depth slice: - localStorage.setItem(`matrixDepthA:${m.id}`, String(Math.max(0, Number(e.target.value) || 0)))} - className="w-16 rounded-lg border border-gray-300 px-2 py-1 text-xs" - /> - to - localStorage.setItem(`matrixDepthB:${m.id}`, String(Math.max(0, Number(e.target.value) || 0)))} - className="w-16 rounded-lg border border-gray-300 px-2 py-1 text-xs" - /> -
)) diff --git a/src/app/admin/pool-management/components/createNewPoolModal.tsx b/src/app/admin/pool-management/components/createNewPoolModal.tsx new file mode 100644 index 0000000..f713233 --- /dev/null +++ b/src/app/admin/pool-management/components/createNewPoolModal.tsx @@ -0,0 +1,125 @@ +'use client' +import React from 'react' + +interface Props { + isOpen: boolean + onClose: () => void + onCreate: (data: { name: string; description: string }) => void | Promise + creating: boolean + error?: string + success?: string + clearMessages: () => void +} + +export default function CreateNewPoolModal({ + isOpen, + onClose, + onCreate, + creating, + error, + success, + clearMessages +}: Props) { + const [name, setName] = React.useState('') + const [description, setDescription] = React.useState('') + + React.useEffect(() => { + if (!isOpen) { + setName('') + setDescription('') + } + }, [isOpen]) + + if (!isOpen) return null + + return ( +
+ {/* Overlay */} +
+ {/* Modal */} +
+
+

Create New Pool

+ +
+ + {success && ( +
+ {success} +
+ )} + {error && ( +
+ {error} +
+ )} + +
{ + e.preventDefault() + clearMessages() + onCreate({ name, description }) + }} + className="space-y-4" + > +
+ + setName(e.target.value)} + disabled={creating} + /> +
+
+ +