feat: matrix management backend link #1

This commit is contained in:
DeathKaioken 2025-11-17 22:11:53 +01:00
parent e7bfe43250
commit 0b325bf44c
10 changed files with 1514 additions and 224 deletions

View File

@ -0,0 +1,473 @@
'use client'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { createPortal } from 'react-dom'
import { MagnifyingGlassIcon, XMarkIcon, BuildingOffice2Icon, UserIcon } from '@heroicons/react/24/outline'
import { getUserCandidates } from '../hooks/search-candidate'
import { addUserToMatrix } from '../hooks/addUsertoMatrix'
import type { MatrixUser, UserType } from '../hooks/getStats'
type Props = {
open: boolean
onClose: () => void
matrixName: string
rootUserId?: number
matrixId?: string | number
topNodeEmail?: string
existingUsers: MatrixUser[]
onAdd: (u: { id: number; name: string; email: string; type: UserType }) => void
policyMaxDepth?: number | null // NEW
}
export default function SearchModal({
open,
onClose,
matrixName,
rootUserId,
matrixId,
topNodeEmail,
existingUsers,
onAdd,
policyMaxDepth // NEW
}: Props) {
const [query, setQuery] = useState('')
const [typeFilter, setTypeFilter] = useState<'all' | UserType>('all')
const [loading, setLoading] = useState(false)
const [error, setError] = useState<string>('')
const [items, setItems] = useState<Array<{ userId: number; name: string; email: string; userType: UserType }>>([])
const [total, setTotal] = useState(0)
const [limit] = useState(20)
const [selected, setSelected] = useState<{ userId: number; name: string; email: string; userType: UserType } | null>(null) // NEW
const [advanced, setAdvanced] = useState(false) // NEW
const [parentId, setParentId] = useState<number | undefined>(undefined) // NEW
const [forceFallback, setForceFallback] = useState<boolean>(true) // NEW
const [adding, setAdding] = useState(false) // NEW
const [addError, setAddError] = useState<string>('') // NEW
const [addSuccess, setAddSuccess] = useState<string>('') // NEW
const [hasSearched, setHasSearched] = useState(false) // NEW
const formRef = useRef<HTMLFormElement | null>(null)
const reqIdRef = useRef(0) // request guard to avoid applying stale results
const doSearch = useCallback(async () => {
setError('')
// Preserve list during refresh to avoid jumpiness
const shouldPreserve = hasSearched && items.length > 0
if (!shouldPreserve) {
setItems([])
setTotal(0)
}
const qTrim = query.trim()
if (qTrim.length < 3) {
console.warn('[SearchModal] Skip search: need >=3 chars')
setHasSearched(false)
return
}
setHasSearched(true)
const myReqId = ++reqIdRef.current
try {
setLoading(true)
const data = await getUserCandidates({
q: qTrim,
type: typeFilter === 'all' ? undefined : typeFilter,
rootUserId,
matrixId,
topNodeEmail,
limit,
offset: 0
})
// Ignore stale responses
if (myReqId !== reqIdRef.current) return
const existingIds = new Set(existingUsers.map(u => String(u.id)))
const filtered = (data.items || []).filter(i => !existingIds.has(String(i.userId)))
setItems(filtered)
setTotal(data.total || 0)
console.info('[SearchModal] Search success', {
q: data.q,
returned: filtered.length,
original: data.items.length,
total: data.total,
combo: (data as any)?._debug?.combo
})
if (filtered.length === 0 && data.total > 0) {
console.warn('[SearchModal] All backend results filtered out as duplicates')
}
} catch (e: any) {
if (myReqId !== reqIdRef.current) return
console.error('[SearchModal] Search error', e)
setError(e?.message || 'Search failed')
} finally {
if (myReqId === reqIdRef.current) setLoading(false)
}
}, [query, typeFilter, rootUserId, matrixId, topNodeEmail, limit, existingUsers, hasSearched, items])
useEffect(() => {
if (!open) {
setQuery('')
setTypeFilter('all')
setItems([])
setTotal(0)
setError('')
setLoading(false)
setHasSearched(false)
reqIdRef.current = 0 // reset guard
}
}, [open])
useEffect(() => {
if (!open) return
const prev = document.body.style.overflow
document.body.style.overflow = 'hidden'
return () => { document.body.style.overflow = prev }
}, [open])
// Auto-prune current results when parent existingUsers changes (keeps modal open)
useEffect(() => {
if (!open || items.length === 0) return
const existingIds = new Set(existingUsers.map(u => String(u.id)))
const cleaned = items.filter(i => !existingIds.has(String(i.userId)))
if (cleaned.length !== items.length) {
console.info('[SearchModal] Pruned results after parent update', { before: items.length, after: cleaned.length })
setItems(cleaned)
}
}, [existingUsers, items, open])
// Compute children counts per parent (uses parentUserId on existingUsers)
const parentUsage = useMemo(() => {
const map = new Map<number, number>()
existingUsers.forEach(u => {
if (u.parentUserId != null) {
map.set(u.parentUserId, (map.get(u.parentUserId) || 0) + 1)
}
})
return map
}, [existingUsers])
const potentialParents = useMemo(() => {
// All users up to depth 5 can be parents (capacity 5)
return existingUsers
.filter(u => u.level < 6)
.sort((a, b) => a.level - b.level || a.id - b.id)
}, [existingUsers])
const selectedParent = useMemo(() => {
if (!parentId) return null
return existingUsers.find(u => u.id === parentId) || null
}, [parentId, existingUsers])
const remainingLevels = useMemo(() => {
if (!selectedParent) return null
if (!policyMaxDepth || policyMaxDepth <= 0) return Infinity
return Math.max(0, policyMaxDepth - Number(selectedParent.level ?? 0))
}, [selectedParent, policyMaxDepth])
const addDisabledReason = useMemo(() => {
if (!selectedParent) return ''
if (!policyMaxDepth || policyMaxDepth <= 0) return ''
if (Number(selectedParent.level ?? 0) >= policyMaxDepth) {
return `Parent at max depth (${policyMaxDepth}).`
}
return ''
}, [selectedParent, policyMaxDepth])
const handleAdd = async () => {
if (!selected) return
setAddError('')
setAddSuccess('')
setAdding(true)
try {
const data = await addUserToMatrix({
childUserId: selected.userId,
parentUserId: advanced ? parentId : undefined,
forceParentFallback: forceFallback,
rootUserId,
matrixId,
topNodeEmail
})
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)
// Soft refresh: keep list visible; doSearch won't clear items now
setTimeout(() => { void doSearch() }, 200)
} catch (e: any) {
console.error('[SearchModal] addUserToMatrix error', e)
setAddError(e?.message || 'Add failed')
} finally {
setAdding(false)
}
}
if (!open) return null
const modal = (
<div className="fixed inset-0 z-[10000]"> {/* elevated z-index */}
<div className="absolute inset-0 bg-black/60 backdrop-blur-sm" onClick={onClose} />
<div className="absolute inset-0 flex items-center justify-center p-4 sm:p-6">
<div
className="w-full max-w-full sm:max-w-xl md:max-w-3xl lg:max-w-4xl rounded-2xl overflow-hidden bg-[#0F1F3A] shadow-2xl ring-1 ring-black/40 flex flex-col"
style={{ maxHeight: '90vh' }}
>
{/* Header */}
<div className="relative px-6 py-5 border-b border-blue-900/40 bg-gradient-to-r from-[#142b52] via-[#13365f] to-[#154270]">
<div className="flex items-center justify-between">
<h3 className="text-lg font-semibold text-white">
Add users to {matrixName}
</h3>
{/* Close button improved hover/focus */}
<button
onClick={onClose}
className="p-1.5 rounded-md text-blue-200 transition
hover:bg-white/15 hover:text-white
focus:outline-none focus:ring-2 focus:ring-white/60 focus:ring-offset-2 focus:ring-offset-[#13365f]
active:scale-95"
aria-label="Close"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<p className="mt-1 text-xs text-blue-200">
Search by name or email. Minimum 3 characters. Existing matrix members are hidden.
</p>
</div>
{/* Form */}
<form
ref={formRef}
onSubmit={e => {
e.preventDefault()
void doSearch()
}}
className="px-6 py-4 grid grid-cols-1 md:grid-cols-5 gap-3 border-b border-blue-900/40 bg-[#112645]"
>
{/* Query */}
<div className="md:col-span-2">
<div className="relative">
<MagnifyingGlassIcon className="pointer-events-none absolute left-2.5 top-1/2 -translate-y-1/2 h-4 w-4 text-blue-300" />
<input
value={query}
onChange={e => setQuery(e.target.value)}
placeholder="Search name or email…"
className="w-full rounded-md bg-[#173456] border border-blue-800 text-sm text-blue-100 placeholder-blue-300 pl-8 pr-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
/>
</div>
</div>
{/* Type */}
<div>
<select
value={typeFilter}
onChange={e => setTypeFilter(e.target.value as any)}
className="w-full rounded-md bg-[#173456] border border-blue-800 text-sm text-blue-100 px-3 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500 transition"
>
<option value="all">All Types</option>
<option value="personal">Personal</option>
<option value="company">Company</option>
</select>
</div>
{/* Buttons */}
<div className="flex gap-2">
<button
type="submit"
disabled={loading || query.trim().length < 3}
className="flex-1 rounded-md bg-blue-600 hover:bg-blue-500 disabled:opacity-50 text-white px-3 py-2 text-sm font-medium shadow-sm transition"
>
{loading ? 'Searching…' : 'Search'}
</button>
<button
type="button"
onClick={() => { setQuery(''); setItems([]); setTotal(0); setError(''); setHasSearched(false); }}
className="rounded-md border border-blue-800 bg-[#173456] px-3 py-2 text-sm text-blue-100 hover:bg-blue-800/40 transition"
>
Clear
</button>
</div>
{/* Total */}
<div className="text-sm text-blue-200 self-center">
Total: <span className="font-semibold text-white">{total}</span>
</div>
</form>
{/* Results + selection area (scrollable) */}
<div className="flex-1 overflow-y-auto px-6 py-4 space-y-4">
{/* Results section */}
<div className="relative">
{error && (
<div className="text-sm text-red-400 mb-4">{error}</div>
)}
{!error && query.trim().length < 3 && (
<div className="py-12 text-sm text-blue-300 text-center">
Enter at least 3 characters and click Search.
</div>
)}
{!error && query.trim().length >= 3 && !hasSearched && !loading && (
<div className="py-12 text-sm text-blue-300 text-center">
Ready to search. Click the Search button to fetch candidates.
</div>
)}
{/* Skeleton only for first-time load (when no items yet) */}
{!error && query.trim().length >= 3 && loading && items.length === 0 && (
<ul className="space-y-0 divide-y divide-blue-900/40 border border-blue-900/40 rounded-md bg-[#132c4e]">
{Array.from({ length: 5 }).map((_, i) => (
<li key={i} className="animate-pulse px-4 py-3">
<div className="h-3.5 w-36 bg-blue-800/40 rounded" />
<div className="mt-2 h-3 w-56 bg-blue-800/30 rounded" />
</li>
))}
</ul>
)}
{!error && hasSearched && !loading && query.trim().length >= 3 && items.length === 0 && (
<div className="py-12 text-sm text-blue-300 text-center">
No users match your filters.
</div>
)}
{!error && hasSearched && items.length > 0 && (
<ul className="divide-y divide-blue-900/40 border border-blue-900/40 rounded-lg bg-[#132c4e]">
{items.map(u => (
<li
key={u.userId}
className="px-4 py-3 flex items-center justify-between gap-3 hover:bg-blue-800/40 transition"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
{u.userType === 'company'
? <BuildingOffice2Icon className="h-4 w-4 text-indigo-400" />
: <UserIcon className="h-4 w-4 text-blue-300" />}
<span className="text-sm font-medium text-blue-100 truncate max-w-[160px]">{u.name}</span>
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full ${
u.userType === 'company'
? 'bg-indigo-700/40 text-indigo-200'
: 'bg-blue-700/40 text-blue-200'
}`}>
{u.userType === 'company' ? 'Company' : 'Personal'}
</span>
</div>
<div className="mt-0.5 text-[11px] text-blue-300 break-all">{u.email}</div>
</div>
<button
onClick={() => {
console.log('[SearchModal] Select candidate', { id: u.userId })
setSelected(u)
setAddError('')
setAddSuccess('')
}}
className="shrink-0 inline-flex items-center rounded-md bg-blue-600 hover:bg-blue-500 text-white px-3 py-1.5 text-xs font-medium shadow-sm transition"
>
{selected?.userId === u.userId ? 'Selected' : 'Select'}
</button>
</li>
))}
</ul>
)}
{/* Soft-loading overlay over existing list to avoid jumpiness */}
{loading && items.length > 0 && (
<div className="pointer-events-none absolute inset-0 flex items-center justify-center bg-[#0F1F3A]/30">
<span className="h-5 w-5 rounded-full border-2 border-blue-400 border-b-transparent animate-spin" />
</div>
)}
</div>
{/* Selected candidate details (conditional) */}
{selected && (
<div className="border border-blue-900/40 rounded-lg p-4 bg-[#132c4e] space-y-3">
<div className="flex items-center justify-between">
<div className="text-sm text-blue-100 font-medium">
Candidate: {selected.name} <span className="text-blue-300">({selected.email})</span>
</div>
<button
onClick={() => { setSelected(null); setParentId(undefined); }}
className="text-xs text-blue-300 hover:text-white transition"
>
Clear selection
</button>
</div>
<label className="flex items-center gap-2 text-xs text-blue-200">
<input
type="checkbox"
checked={advanced}
onChange={e => setAdvanced(e.target.checked)}
className="h-3 w-3 rounded border-blue-700 bg-blue-900 text-indigo-500 focus:ring-indigo-400"
/>
Advanced: choose parent manually
</label>
{advanced && (
<div className="space-y-2">
<select
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
>
<option value="">(Auto referral / root)</option>
{potentialParents.map(p => {
const used = parentUsage.get(p.id) || 0
const full = used >= 5
const rem = !policyMaxDepth || policyMaxDepth <= 0 ? '∞' : Math.max(0, policyMaxDepth - p.level)
return (
<option key={p.id} value={p.id} disabled={full}>
{p.name} L{p.level} Slots {used}/5 Rem levels: {rem}
</option>
)
})}
</select>
<p className="text-[11px] text-blue-300">
{(!policyMaxDepth || policyMaxDepth <= 0)
? 'Unlimited policy: no remaining-level cap.'
: `Remaining levels under chosen parent = Max(${policyMaxDepth}) - parent level.`}
</p>
</div>
)}
<label className="flex items-center gap-2 text-xs text-blue-200">
<input
type="checkbox"
checked={forceFallback}
onChange={e => setForceFallback(e.target.checked)}
className="h-3 w-3 rounded border-blue-700 bg-blue-900 text-indigo-500 focus:ring-indigo-400"
/>
Fallback to root if referral parent not in matrix
</label>
{addError && <div className="text-xs text-red-400">{addError}</div>}
{addSuccess && <div className="text-xs text-green-400">{addSuccess}</div>}
<div className="flex justify-end">
<button
onClick={handleAdd}
disabled={adding || (!!addDisabledReason && !!advanced)} // NEW
title={addDisabledReason || undefined} // NEW
className="inline-flex items-center rounded-md bg-indigo-600 hover:bg-indigo-500 disabled:opacity-50 text-white px-4 py-2 text-xs font-medium shadow-sm transition"
>
{adding ? 'Adding…' : 'Add to Matrix'}
</button>
</div>
</div>
)}
</div>
{/* Footer (hidden when a candidate is selected) */}
{!selected && (
<div className="px-6 py-3 border-t border-blue-900/40 flex items-center justify-end bg-[#112645]">
<button
onClick={onClose}
className="text-sm rounded-md px-4 py-2 font-medium
bg-white/10 text-blue-200 backdrop-blur
hover:bg-indigo-500/20 hover:text-white hover:shadow-sm
focus:outline-none focus:ring-2 focus:ring-indigo-300 focus:ring-offset-2 focus:ring-offset-[#112645]
active:scale-95 transition"
>
Done
</button>
</div>
)}
</div>
</div>
</div>
)
return createPortal(modal, document.body)
}

View File

@ -0,0 +1,101 @@
import { authFetch } from '../../../../utils/authFetch'
export type AddUserToMatrixParams = {
childUserId: number
parentUserId?: number
forceParentFallback?: boolean
rootUserId?: number
matrixId?: string | number
topNodeEmail?: string
}
export type AddUserToMatrixResponse = {
success: boolean
data?: {
rootUserId: number
parentUserId: number
childUserId: number
position: number
remainingFreeSlots: number
usersPreview?: Array<{
userId: number
level?: number
name?: string
email?: string
userType?: string
parentUserId?: number | null
}>
}
message?: string
}
export async function addUserToMatrix(params: AddUserToMatrixParams) {
const {
childUserId,
parentUserId,
forceParentFallback = false,
rootUserId,
matrixId,
topNodeEmail
} = params
if (!childUserId) throw new Error('childUserId required')
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
if (!base) console.warn('[addUserToMatrix] NEXT_PUBLIC_API_BASE_URL missing')
// Choose exactly one identifier
const hasRoot = typeof rootUserId === 'number' && rootUserId > 0
const hasMatrix = !!matrixId
const hasEmail = !!topNodeEmail
if (!hasRoot && !hasMatrix && !hasEmail) {
throw new Error('One of rootUserId, matrixId or topNodeEmail is required')
}
const body: any = {
childUserId,
forceParentFallback: !!forceParentFallback
}
if (parentUserId) body.parentUserId = parentUserId
if (hasRoot) body.rootUserId = rootUserId
else if (hasMatrix) body.matrixId = matrixId
else body.topNodeEmail = topNodeEmail
const url = `${base}/api/admin/matrix/add-user`
console.info('[addUserToMatrix] POST', { url, body })
const res = await authFetch(url, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
Accept: 'application/json'
},
credentials: 'include',
body: JSON.stringify(body)
})
const ct = res.headers.get('content-type') || ''
const raw = await res.text()
let json: AddUserToMatrixResponse | null = null
try {
json = ct.includes('application/json') ? JSON.parse(raw) : null
} catch {
json = null
}
console.debug('[addUserToMatrix] Response', {
status: res.status,
ok: res.ok,
hasJson: !!json,
bodyPreview: raw.slice(0, 300)
})
if (!res.ok) {
const msg = json?.message || `Request failed: ${res.status}`
throw new Error(msg)
}
if (!json?.success) {
throw new Error(json?.message || 'Backend returned non-success')
}
return json.data!
}

View File

@ -0,0 +1,212 @@
import { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { authFetch } from '../../../../utils/authFetch'
export type UserType = 'personal' | 'company'
export type MatrixUser = {
id: number
name: string
email: string
type: UserType
level: number
parentUserId?: number | null // NEW
}
type ApiUser = {
userId: number
level?: number | null
depth?: number | null
name?: string | null
displayName?: string | null
email: string
userType: string
role: string
createdAt: string
parentUserId: number | null
position: number | null
}
type ApiResponse = {
success: boolean
data?: {
rootUserId: number
maxDepth: number
limit: number
offset: number
includeRoot: boolean
users: ApiUser[]
}
error?: any
}
export type UseMatrixUsersParams = {
depth?: number
limit?: number
offset?: number
includeRoot?: boolean
matrixId?: string | number
topNodeEmail?: string
}
export function useMatrixUsers(
rootUserId: number | undefined,
params: UseMatrixUsersParams = {}
) {
const { depth = 5, limit = 100, offset = 0, includeRoot = true, matrixId, topNodeEmail } = params
const [users, setUsers] = useState<MatrixUser[]>([])
const [loading, setLoading] = useState(false)
const [error, setError] = useState<unknown>(null)
const [tick, setTick] = useState(0)
const abortRef = useRef<AbortController | null>(null)
const [serverMaxDepth, setServerMaxDepth] = useState<number | null>(null) // NEW
// Include new identifiers in diagnostics
const builtParams = useMemo(() => {
const p = { rootUserId, matrixId, topNodeEmail, depth, limit, offset, includeRoot }
console.debug('[useMatrixUsers] Params built', p)
return p
}, [rootUserId, matrixId, topNodeEmail, depth, limit, offset, includeRoot])
const refetch = useCallback(() => {
console.info('[useMatrixUsers] refetch() called')
setTick(t => t + 1)
}, [])
useEffect(() => {
console.info('[useMatrixUsers] Hook mounted')
return () => {
console.info('[useMatrixUsers] Hook unmounted, aborting any inflight request')
abortRef.current?.abort()
}
}, [])
useEffect(() => {
// Require at least one acceptable identifier
const hasRoot = typeof rootUserId === 'number' && rootUserId > 0
const hasMatrix = !!matrixId
const hasEmail = !!topNodeEmail
if (!hasRoot && !hasMatrix && !hasEmail) {
console.error('[useMatrixUsers] Missing identifier. Provide one of: rootUserId, matrixId, topNodeEmail.', { rootUserId, matrixId, topNodeEmail })
setUsers([])
setError(new Error('One of rootUserId, matrixId or topNodeEmail is required'))
setLoading(false)
return
}
abortRef.current?.abort()
const controller = new AbortController()
abortRef.current = controller
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '')
if (!base) {
console.warn('[useMatrixUsers] NEXT_PUBLIC_API_BASE_URL is not set. Falling back to same-origin (may fail in dev).')
}
// Choose exactly ONE identifier to avoid backend confusion
const qs = new URLSearchParams()
let chosenKey: 'rootUserId' | 'matrixId' | 'topNodeEmail'
let chosenValue: string | number
if (hasRoot) {
qs.set('rootUserId', String(rootUserId))
chosenKey = 'rootUserId'
chosenValue = rootUserId!
} else if (hasMatrix) {
qs.set('matrixId', String(matrixId))
chosenKey = 'matrixId'
chosenValue = matrixId as any
} else {
qs.set('topNodeEmail', String(topNodeEmail))
chosenKey = 'topNodeEmail'
chosenValue = topNodeEmail as any
}
qs.set('depth', String(depth))
qs.set('limit', String(limit))
qs.set('offset', String(offset))
qs.set('includeRoot', String(includeRoot))
const url = `${base}/api/admin/matrix/users?${qs.toString()}`
console.info('[useMatrixUsers] Fetch start (via authFetch)', {
url,
method: 'GET',
identifiers: { rootUserId, matrixId, topNodeEmail, chosen: { key: chosenKey, value: chosenValue } },
params: { depth, limit, offset, includeRoot }
})
console.log('[useMatrixUsers] REQUEST GET', url)
const t0 = performance.now()
setLoading(true)
setError(null)
authFetch(url, {
method: 'GET',
credentials: 'include',
headers: { Accept: 'application/json' },
signal: controller.signal as any
})
.then(async r => {
const t1 = performance.now()
const ct = r.headers.get('content-type') || ''
console.debug('[useMatrixUsers] Response received', { status: r.status, durationMs: Math.round(t1 - t0), contentType: ct })
if (!r.ok || !ct.includes('application/json')) {
const text = await r.text().catch(() => '')
console.error('[useMatrixUsers] Non-OK or non-JSON response', { status: r.status, bodyPreview: text.slice(0, 500) })
throw new Error(`Request failed: ${r.status}`)
}
const json: ApiResponse = await r.json()
if (!json?.success || !json?.data) {
console.warn('[useMatrixUsers] Non-success response', json)
throw new Error('Backend returned non-success for /admin/matrix/users')
}
const { data } = json
console.debug('[useMatrixUsers] Meta', {
rootUserId: data.rootUserId,
maxDepth: data.maxDepth,
limit: data.limit,
offset: data.offset,
includeRoot: data.includeRoot,
usersCount: data.users?.length ?? 0
})
setServerMaxDepth(typeof data.maxDepth === 'number' ? data.maxDepth : null) // NEW
const mapped: MatrixUser[] = (data.users || []).map(u => {
const rawLevel = (typeof u.level === 'number' ? u.level : (typeof u.depth === 'number' ? u.depth : undefined))
const level = (typeof rawLevel === 'number' && rawLevel >= 0) ? rawLevel : 0
if (rawLevel === undefined || rawLevel === null) {
console.warn('[useMatrixUsers] Coerced missing level/depth to 0', { userId: u.userId })
}
const name = (u.name?.trim()) || (u.displayName?.trim()) || u.email
return {
id: u.userId,
name,
email: u.email,
type: u.userType === 'company' ? 'company' : 'personal',
level,
parentUserId: u.parentUserId ?? null
}
})
mapped.sort((a, b) => a.level - b.level || a.id - b.id)
setUsers(mapped)
console.info('[useMatrixUsers] Users mapped', { count: mapped.length, sample: mapped.slice(0, 3) })
})
.catch(err => {
if (controller.signal.aborted) {
console.log('[useMatrixUsers] Fetch aborted')
return
}
console.error('[useMatrixUsers] Fetch error', err)
setError(err)
})
.finally(() => {
setLoading(false)
})
return () => controller.abort()
}, [rootUserId, matrixId, topNodeEmail, depth, limit, offset, includeRoot, tick])
const meta = { rootUserId, matrixId, topNodeEmail, depth, limit, offset, includeRoot, serverMaxDepth } // NEW
return { users, loading, error, meta, refetch, serverMaxDepth } // NEW
}

View File

@ -0,0 +1,224 @@
import { authFetch } from '../../../../utils/authFetch';
export type CandidateItem = {
userId: number;
email: string;
userType: 'personal' | 'company';
name: string;
};
export type UserCandidatesResponse = {
success: boolean;
data?: {
q: string | null;
type: 'all' | 'personal' | 'company';
rootUserId: number | null;
limit: number;
offset: number;
total: number;
items: CandidateItem[];
};
message?: string;
};
export type UserCandidatesData = {
q: string | null;
type: 'all' | 'personal' | 'company';
rootUserId: number | null;
limit: number;
offset: number;
total: number;
items: CandidateItem[];
_debug?: {
endpoint: string;
query: Record<string, string>;
combo: string;
};
};
export type GetUserCandidatesParams = {
q: string;
type?: 'all' | 'personal' | 'company';
rootUserId?: number;
matrixId?: string | number;
topNodeEmail?: string;
limit?: number;
offset?: number;
};
export async function getUserCandidates(params: GetUserCandidatesParams): Promise<UserCandidatesData> {
const {
q,
type = 'all',
rootUserId,
matrixId,
topNodeEmail,
limit = 20,
offset = 0
} = params;
const base = (process.env.NEXT_PUBLIC_API_BASE_URL || '').replace(/\/+$/, '');
if (!base) {
console.warn('[getUserCandidates] NEXT_PUBLIC_API_BASE_URL not set. Falling back to same-origin.');
}
const qTrimmed = q.trim();
console.info('[getUserCandidates] Building candidate request', {
base,
q: qTrimmed,
typeSent: type !== 'all' ? type : undefined,
identifiers: { rootUserId, matrixId, topNodeEmail },
pagination: { limit, offset }
});
// Build identifier combinations: all -> root-only -> matrix-only -> email-only
const combos: Array<{ label: string; apply: (qs: URLSearchParams) => void }> = [];
const hasRoot = typeof rootUserId === 'number' && rootUserId > 0;
const hasMatrix = !!matrixId;
const hasEmail = !!topNodeEmail;
if (hasRoot || hasMatrix || hasEmail) {
combos.push({
label: 'all-identifiers',
apply: (qs) => {
if (hasRoot) qs.set('rootUserId', String(rootUserId));
if (hasMatrix) qs.set('matrixId', String(matrixId));
if (hasEmail) qs.set('topNodeEmail', String(topNodeEmail));
}
});
}
if (hasRoot) combos.push({ label: 'root-only', apply: (qs) => { qs.set('rootUserId', String(rootUserId)); } });
if (hasMatrix) combos.push({ label: 'matrix-only', apply: (qs) => { qs.set('matrixId', String(matrixId)); } });
if (hasEmail) combos.push({ label: 'email-only', apply: (qs) => { qs.set('topNodeEmail', String(topNodeEmail)); } });
if (combos.length === 0) combos.push({ label: 'no-identifiers', apply: () => {} });
const endpointVariants = [
(qs: string) => `${base}/api/admin/matrix/users/candidates?${qs}`,
(qs: string) => `${base}/api/admin/matrix/user-candidates?${qs}`
];
console.debug('[getUserCandidates] Endpoint variants', endpointVariants.map(f => f('...')));
let lastError: any = null;
let lastZeroData: UserCandidatesData | null = null;
// Try each identifier combo against both endpoint variants
for (const combo of combos) {
const qs = new URLSearchParams();
qs.set('q', qTrimmed);
qs.set('limit', String(limit));
qs.set('offset', String(offset));
if (type !== 'all') qs.set('type', type);
combo.apply(qs);
const qsObj = Object.fromEntries(qs.entries());
console.debug('[getUserCandidates] Final query params', { combo: combo.label, qs: qsObj });
for (let i = 0; i < endpointVariants.length; i++) {
const url = endpointVariants[i](qs.toString());
const fetchOpts = { method: 'GET', headers: { Accept: 'application/json' } as const };
console.info('[getUserCandidates] REQUEST GET', {
url,
attempt: i + 1,
combo: combo.label,
identifiers: { rootUserId, matrixId, topNodeEmail },
params: { q: qTrimmed, type: type !== 'all' ? type : undefined, limit, offset },
fetchOpts
});
const t0 = performance.now();
const res = await authFetch(url, fetchOpts);
const t1 = performance.now();
const ct = res.headers.get('content-type') || '';
console.debug('[getUserCandidates] Response meta', {
status: res.status,
ok: res.ok,
durationMs: Math.round(t1 - t0),
contentType: ct
});
// Preview raw body (first 300 chars)
let rawPreview = '';
try {
rawPreview = await res.clone().text();
} catch {}
if (rawPreview) {
console.trace('[getUserCandidates] Raw body preview (trimmed)', rawPreview.slice(0, 300));
}
if (res.status === 404 && i < endpointVariants.length - 1) {
try {
const preview = ct.includes('application/json') ? await res.json() : await res.text();
console.warn('[getUserCandidates] 404 on endpoint variant, trying fallback', {
tried: url,
combo: combo.label,
preview: typeof preview === 'string' ? preview.slice(0, 200) : preview
});
} catch {}
continue;
}
if (!ct.includes('application/json')) {
const text = await res.text().catch(() => '');
console.error('[getUserCandidates] Non-JSON response', { status: res.status, bodyPreview: text.slice(0, 500) });
lastError = new Error(`Request failed: ${res.status}`);
break;
}
const json: UserCandidatesResponse = await res.json().catch(() => ({ success: false, message: 'Invalid JSON' } as any));
console.debug('[getUserCandidates] Parsed JSON', {
success: json?.success,
message: json?.message,
dataMeta: json?.data && {
q: json.data.q,
type: json.data.type,
total: json.data.total,
itemsCount: json.data.items?.length
}
});
if (!res.ok || !json?.success) {
console.error('[getUserCandidates] Backend reported failure', {
status: res.status,
successFlag: json?.success,
message: json?.message
});
lastError = new Error(json?.message || `Request failed: ${res.status}`);
break;
}
const dataWithDebug: UserCandidatesData = {
...json.data!,
_debug: { endpoint: url, query: qsObj, combo: combo.label }
};
if ((dataWithDebug.total || 0) > 0) {
console.info('[getUserCandidates] Success (non-empty)', {
total: dataWithDebug.total,
itemsCount: dataWithDebug.items.length,
combo: combo.label,
endpoint: url
});
return dataWithDebug;
}
// Keep last zero result but continue trying other combos/endpoints
lastZeroData = dataWithDebug;
console.info('[getUserCandidates] Success (empty)', { combo: combo.label, endpoint: url });
}
if (lastError) break; // stop on hard error
}
if (lastError) {
console.error('[getUserCandidates] Exhausted endpoint variants with error', { lastError: lastError?.message });
throw lastError;
}
// Return the last empty response (with _debug info) if everything was empty
if (lastZeroData) {
console.warn('[getUserCandidates] All combos returned empty results', { lastCombo: lastZeroData._debug });
return lastZeroData;
}
throw new Error('Request failed');
}

View File

@ -4,32 +4,13 @@ import React, { useEffect, useMemo, useState } from 'react'
import { useSearchParams, useRouter } from 'next/navigation' import { useSearchParams, useRouter } from 'next/navigation'
import PageLayout from '../../../components/PageLayout' import PageLayout from '../../../components/PageLayout'
import { ArrowLeftIcon, MagnifyingGlassIcon, PlusIcon, UserIcon, BuildingOffice2Icon } from '@heroicons/react/24/outline' import { ArrowLeftIcon, MagnifyingGlassIcon, PlusIcon, UserIcon, BuildingOffice2Icon } from '@heroicons/react/24/outline'
import { useMatrixUsers, MatrixUser, UserType } from './hooks/getStats'
type UserType = 'personal' | 'company' import useAuthStore from '../../../store/authStore'
type MatrixUser = { import { getMatrixStats } from '../hooks/getMatrixStats'
id: string | number import SearchModal from './components/searchModal'
name: string
email: string
type: UserType
level: number // 0 for top-node, then 1..N
}
const LEVEL_CAP = (level: number) => Math.pow(5, level) // L1=5, L2=25, ... const LEVEL_CAP = (level: number) => Math.pow(5, level) // L1=5, L2=25, ...
// Dummy users to pick from in the modal
const DUMMY_POOL: Array<Omit<MatrixUser, 'level'>> = [
{ id: 101, name: 'Alice Johnson', email: 'alice@example.com', type: 'personal' },
{ id: 102, name: 'Beta GmbH', email: 'office@beta-gmbh.de', type: 'company' },
{ id: 103, name: 'Carlos Diaz', email: 'carlos@sample.io', type: 'personal' },
{ id: 104, name: 'Delta Solutions AG', email: 'contact@delta.ag', type: 'company' },
{ id: 105, name: 'Emily Nguyen', email: 'emily.ng@ex.com', type: 'personal' },
{ id: 106, name: 'Foxtrot LLC', email: 'hello@foxtrot.llc', type: 'company' },
{ id: 107, name: 'Grace Blake', email: 'grace@ex.com', type: 'personal' },
{ id: 108, name: 'Hestia Corp', email: 'hq@hestia.io', type: 'company' },
{ id: 109, name: 'Ivan Petrov', email: 'ivan@ex.com', type: 'personal' },
{ id: 110, name: 'Juno Partners', email: 'team@juno.partners', type: 'company' },
]
export default function MatrixDetailPage() { export default function MatrixDetailPage() {
const sp = useSearchParams() const sp = useSearchParams()
const router = useRouter() const router = useRouter()
@ -37,59 +18,123 @@ export default function MatrixDetailPage() {
const matrixId = sp.get('id') || 'm-1' const matrixId = sp.get('id') || 'm-1'
const matrixName = sp.get('name') || 'Unnamed Matrix' const matrixName = sp.get('name') || 'Unnamed Matrix'
const topNodeEmail = sp.get('top') || 'top@example.com' 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 })
// Build initial dummy matrix users // Resolve rootUserId when missing by looking it up via stats
const [users, setUsers] = useState<MatrixUser[]>(() => { const accessToken = useAuthStore(s => s.accessToken)
// Level 0 = top node from URL const [resolvedRootUserId, setResolvedRootUserId] = useState<number | undefined>(rootUserId)
const initial: MatrixUser[] = [
{ id: 'top', name: 'Top Node', email: topNodeEmail, type: 'personal', level: 0 }, useEffect(() => {
] let cancelled = false
// Fill some demo users across levels async function resolveRoot() {
const seed: Omit<MatrixUser, 'level'>[] = DUMMY_POOL.slice(0, 12) if (rootUserId && rootUserId > 0) {
let remaining = [...seed] console.info('[MatrixDetailPage] Using rootUserId from URL', { rootUserId })
let level = 1 setResolvedRootUserId(rootUserId)
while (remaining.length > 0 && level <= 3) { return
const cap = LEVEL_CAP(level)
const take = Math.min(remaining.length, Math.max(1, Math.min(cap, 6))) // keep demo compact
initial.push(...remaining.splice(0, take).map(u => ({ ...u, level })))
level++
} }
return initial 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)
)
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
const { users: fetchedUsers, loading: usersLoading, error: usersError, meta, refetch, serverMaxDepth } = useMatrixUsers(resolvedRootUserId, {
depth: 5,
includeRoot: true,
limit: 100,
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 // Modal state
const [open, setOpen] = useState(false) const [open, setOpen] = useState(false)
const [query, setQuery] = useState('') // ADD: global search state (was removed)
const [typeFilter, setTypeFilter] = useState<'all' | UserType>('all') const [globalSearch, setGlobalSearch] = useState('')
// Available candidates = pool minus already added ids // Refresh overlay state
const availableUsers = useMemo(() => { const [refreshing, setRefreshing] = useState(false)
const picked = new Set(users.map(u => String(u.id)))
return DUMMY_POOL.filter(u => !picked.has(String(u.id)))
}, [users])
const filteredCandidates = useMemo(() => { // Collapsed state for each level
const q = query.trim().toLowerCase() const [collapsedLevels, setCollapsedLevels] = useState<{ [level: number]: boolean }>({
return availableUsers.filter(u => { 0: true, 1: true, 2: true, 3: true, 4: true, 5: true
if (typeFilter !== 'all' && u.type !== typeFilter) return false })
if (!q) return true
return u.name.toLowerCase().includes(q) || u.email.toLowerCase().includes(q) // Per-level search
const [levelSearch, setLevelSearch] = useState<{ [level: number]: string }>({
0: '', 1: '', 2: '', 3: '', 4: '', 5: ''
}) })
}, [availableUsers, query, typeFilter])
// Counts per level and next available level logic // Counts per level and next available level logic
const byLevel = useMemo(() => { const byLevel = useMemo(() => {
const map = new Map<number, MatrixUser[]>() const map = new Map<number, MatrixUser[]>()
users.forEach(u => { users.forEach(u => {
const arr = map.get(u.level) || [] if (!u.name) {
arr.push(u) console.warn('[MatrixDetailPage] User missing name, fallback email used', { id: u.id, email: u.email })
map.set(u.level, arr) }
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 return map
}, [users]) }, [users])
const nextAvailableLevel = () => { const nextAvailableLevel = () => {
// Start from level 1 upwards; find first with space; else go to next new level
let lvl = 1 let lvl = 1
while (true) { while (true) {
const current = byLevel.get(lvl)?.length || 0 const current = byLevel.get(lvl)?.length || 0
@ -100,7 +145,9 @@ export default function MatrixDetailPage() {
} }
const addToMatrix = (u: Omit<MatrixUser, 'level'>) => { const addToMatrix = (u: Omit<MatrixUser, 'level'>) => {
setUsers(prev => [...prev, { ...u, level: nextAvailableLevel() }]) const level = nextAvailableLevel()
console.info('[MatrixDetailPage] addToMatrix', { userId: u.id, nextLevel: level })
setUsers(prev => [...prev, { ...u, level }])
} }
// Simple chip for user // Simple chip for user
@ -113,54 +160,124 @@ export default function MatrixDetailPage() {
// Compact grid for a level // Compact grid for a level
const LevelSection = ({ level }: { level: number }) => { const LevelSection = ({ level }: { level: number }) => {
const cap = level === 0 ? 1 : LEVEL_CAP(level) const cap = isUnlimited ? undefined : (level === 0 ? 1 : LEVEL_CAP(level))
const list = byLevel.get(level) || [] const listAll = (byLevel.get(level) || []).filter(u =>
const pct = Math.min(100, Math.round((list.length / cap) * 100)) u.level >= depthA && u.level <= depthB && (includeRoot || u.level !== 0)
const title = )
level === 0 ? 'Level 0 (Top node)' : `Level ${level}${list.length} / ${cap}` const list = listAll.slice(0, pageFor(level) * levelPageSize)
const title = `Level ${level}${listAll.length} users`
const showLoadMore = listAll.length > list.length
return ( return (
<section className="rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden"> <section className="rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden">
<div className="px-4 sm:px-5 py-4 border-b border-gray-100 flex items-center justify-between"> <div className="px-4 sm:px-5 py-4 border-b border-gray-100 flex items-center justify-between">
<h3 className="text-sm font-semibold text-gray-900">{title}</h3> <h3 className="text-sm font-semibold text-gray-900">{title}</h3>
<span className="text-[11px] font-semibold text-indigo-700">{pct}%</span> {!isUnlimited && level > 0 && (
<div className="flex items-center gap-2">
<span className="text-[11px] font-semibold text-indigo-700">{Math.min(100, Math.round((listAll.length / (cap || 1)) * 100))}%</span>
</div> </div>
{/* Progress */}
<div className="px-4 sm:px-5 py-3">
<div className="h-2 w-full rounded-full bg-gray-100 ring-1 ring-gray-200 overflow-hidden">
<div
className="h-full bg-gradient-to-r from-indigo-500 via-violet-500 to-fuchsia-500 transition-[width] duration-500"
style={{ width: `${pct}%` }}
/>
</div>
</div>
{/* Users */}
<div className="px-4 sm:px-5 pb-4">
{list.length === 0 ? (
<div className="text-xs text-gray-500 italic">No users in this level yet.</div>
) : (
<div className="mt-2 flex flex-wrap gap-2">
{list.slice(0, 30).map(u => <UserChip key={`${level}-${u.id}`} u={u} />)}
{list.length > 30 && (
<span className="inline-flex items-center rounded-full bg-gray-100 px-2 py-1 text-[11px] text-gray-700">
+{list.length - 30} more
</span>
)} )}
</div> </div>
<div className="px-4 sm:px-5 pb-4">
{listAll.length === 0 ? (
<div className="text-xs text-gray-500 italic">{isUnlimited ? 'No users at this level yet.' : 'No users in this level yet.'}</div>
) : (
<>
<div className="mt-2 flex flex-wrap gap-2">
{list.map(u => <UserChip key={`${level}-${u.id}`} u={u} />)}
</div>
{showLoadMore && (
<div className="mt-3">
<button onClick={() => nextPage(level)} className="text-xs text-indigo-700 hover:text-indigo-900 underline">Load more users</button>
</div>
)}
</>
)} )}
</div> </div>
</section> </section>
) )
} }
// Depth range AB 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<Record<number, number>>({})
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
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 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.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])
return ( return (
<PageLayout> <PageLayout>
<div className="min-h-screen w-full px-4 sm:px-6 py-8 bg-white"> {/* Smooth refresh overlay */}
<div className="max-w-7xl mx-auto"> {refreshing && (
{/* Header / Overview */} <div className="fixed inset-0 z-[9999] flex items-center justify-center bg-white/50 backdrop-blur-sm transition-opacity">
<div className="flex items-start justify-between gap-4 mb-6"> <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-indigo-600 border-b-transparent animate-spin" />
<span className="text-sm text-gray-700">Refreshing</span>
</div>
</div>
)}
{/* Centered page container to avoid full-width stretch */}
<div className="min-h-screen w-full bg-white">
<div className="mx-auto max-w-6xl px-4 sm:px-6 py-6">
{/* Modern header card with action */}
<div className="mb-6 rounded-xl border border-gray-200 bg-white shadow-sm overflow-hidden">
<div className="px-5 py-4 flex items-start justify-between gap-4">
<div className="space-y-1"> <div className="space-y-1">
<button <button
onClick={() => router.push('/admin/matrix-management')} onClick={() => router.push('/admin/matrix-management')}
@ -173,42 +290,172 @@ export default function MatrixDetailPage() {
<p className="text-sm text-gray-600"> <p className="text-sm text-gray-600">
Top node: <span className="font-medium text-gray-900">{topNodeEmail}</span> Top node: <span className="font-medium text-gray-900">{topNodeEmail}</span>
</p> </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-2.5 py-1 text-[11px] text-gray-800">
Children/node: 5
</span>
<span className="inline-flex items-center rounded-full bg-indigo-50 border border-indigo-200 px-2.5 py-1 text-[11px] text-indigo-800">
Max depth: {(!serverMaxDepth || serverMaxDepth <= 0) ? 'Unlimited' : serverMaxDepth}
</span>
</div> </div>
</div>
<div className="flex items-center gap-2">
<button <button
onClick={() => setOpen(true)} onClick={() => { setOpen(true) }}
className="inline-flex items-center gap-2 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white px-4 py-2 shadow-sm" className="inline-flex items-center gap-2 rounded-lg bg-blue-700 hover:bg-blue-600 text-white px-4 py-2 shadow-sm"
> >
<PlusIcon className="h-5 w-5" /> <PlusIcon className="h-5 w-5" />
Add users to matrix Add users to matrix
</button> </button>
</div> </div>
</div>
{/* Levels display */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
<LevelSection level={0} />
<LevelSection level={1} />
<LevelSection level={2} />
<LevelSection level={3} />
{/* Add more levels visually if needed */}
</div> </div>
{/* Full users list by level */} {/* banner for unlimited */}
<div className="mt-8 rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden"> {isUnlimited && (
<div className="mb-4 rounded-md px-4 py-2 text-xs text-blue-900 bg-blue-50 border border-blue-200">
Large structure. Results are paginated by depth and count.
</div>
)}
{/* sticky depth controls */}
<div className="sticky top-0 z-10 bg-white/85 backdrop-blur px-4 sm:px-6 py-3 border-b border-gray-100 flex items-center gap-3 rounded-md">
<div className="flex items-center gap-2">
<label className="text-xs text-gray-600">From</label>
<input type="number" min={0} value={depthA} onChange={e => setDepthA(Math.max(0, Number(e.target.value) || 0))} className="w-16 rounded border border-gray-300 px-2 py-1 text-xs" />
<label className="text-xs text-gray-600">to</label>
<input type="number" min={depthA} value={depthB} onChange={e => setDepthB(Math.max(depthA, Number(e.target.value) || depthA))} className="w-16 rounded border border-gray-300 px-2 py-1 text-xs" />
</div>
<button
onClick={() => { setDepthA(Math.max(0, depthA - 5)); setDepthB(Math.max(5, depthB - 5)); }}
className="inline-flex items-center gap-1 rounded-md border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-300"
>
Load previous 5 levels
</button>
<button
onClick={() => { setDepthA(depthA + 5); setDepthB(depthB + 5); }}
className="inline-flex items-center gap-1 rounded-md border border-blue-200 bg-blue-50 px-2.5 py-1 text-xs font-medium text-blue-700 hover:bg-blue-100 focus:outline-none focus:ring-2 focus:ring-blue-300"
>
Load next 5 levels
</button>
{/* NEW: include root toggle */}
<label className="ml-3 inline-flex items-center gap-2 text-xs text-gray-700">
<input
type="checkbox"
checked={includeRoot}
onChange={e => setIncludeRoot(e.target.checked)}
className="h-3 w-3 rounded border-gray-300 text-indigo-600 focus:ring-indigo-500"
/>
Include root
</label>
<div className="ml-auto text-xs text-gray-600">
Showing levels {depthA}{depthB} of {isUnlimited ? 'Unlimited' : serverMaxDepth}
</div>
<button onClick={exportCsv} className="ml-3 text-xs text-indigo-700 hover:text-indigo-900 underline">Export CSV (levels {depthA}{depthB})</button>
</div>
{/* small stats */}
<div className="mt-3 mb-4 grid grid-cols-1 sm:grid-cols-3 gap-3">
<div className="rounded-lg border border-gray-200 bg-white p-3">
<div className="text-[11px] text-gray-500">Users (current slice)</div>
<div className="text-base font-semibold text-gray-900">{usersInSlice.length}</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-3">
<div className="text-[11px] text-gray-500">Total descendants</div>
<div className="text-base font-semibold text-gray-900">{isUnlimited ? 'All descendants so far' : totalDescendants}</div>
</div>
<div className="rounded-lg border border-gray-200 bg-white p-3">
<div className="text-[11px] text-gray-500">Active levels loaded</div>
<div className="text-base font-semibold text-gray-900">{depthA}{depthB}</div>
</div>
</div>
{/* dynamic levels */}
<div className="grid grid-cols-1 lg:grid-cols-2 gap-5">
{Array
.from(byLevel.keys())
.filter(l => l >= depthA && l <= depthB)
.filter(l => includeRoot || l !== 0) // NEW: hide level 0 when unchecked
.sort((a,b)=>a-b)
.map(l => (
<LevelSection key={l} level={l} />
))}
</div>
{/* jump to level */}
<div className="mt-4 mb-8">
<label className="text-xs text-gray-600 mr-2">Jump to level</label>
<select className="rounded border border-gray-300 px-2 py-1 text-xs" onChange={e => {
const lv = Number(e.target.value) || 0
setDepthA(lv); setDepthB(Math.max(lv, lv + 5))
}}>
{Array.from(byLevel.keys()).sort((a,b)=>a-b).map(l => <option key={l} value={l}>Level {l}</option>)}
</select>
</div>
{/* Collapsible All users list by level */}
<div className="rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
<div className="px-5 py-4 border-b border-gray-100"> <div className="px-5 py-4 border-b border-gray-100">
<h2 className="text-base font-semibold text-gray-900">All users in this matrix</h2> <h2 className="text-base font-semibold text-gray-900">All users in this matrix</h2>
<p className="text-xs text-gray-600">Grouped by levels (power of five structure).</p> <p className="text-xs text-gray-600">Grouped by levels (power of five structure).</p>
</div> </div>
<div className="divide-y divide-gray-100"> <div className="divide-y divide-gray-100">
{[...byLevel.keys()].sort((a, b) => a - b).map(lvl => { {[0,1,2,3,4,5].map(lvl => {
const list = byLevel.get(lvl) || [] const list = byLevel.get(lvl) || []
const filteredList = list.filter(u => {
const globalMatch = !globalSearch || (
u.name.toLowerCase().includes(globalSearch.toLowerCase()) ||
u.email.toLowerCase().includes(globalSearch.toLowerCase())
)
const levelMatch = !levelSearch[lvl] || (
u.name.toLowerCase().includes(levelSearch[lvl].toLowerCase()) ||
u.email.toLowerCase().includes(levelSearch[lvl].toLowerCase())
)
return globalMatch && levelMatch
})
return ( return (
<div key={lvl} className="px-5 py-4"> <div key={lvl} className="px-5 py-4">
<div className="mb-2 text-sm font-semibold text-gray-800"> <button
className="flex items-center justify-between w-full text-left"
onClick={() => {
console.log('[MatrixDetailPage] toggle level', { level: lvl, to: !collapsedLevels[lvl] })
setCollapsedLevels(prev => ({ ...prev, [lvl]: !prev[lvl] }))
}}
>
<span className="mb-2 text-sm font-semibold text-gray-800 flex items-center gap-2">
{lvl === 0 ? 'Level 0 (Top node)' : `Level ${lvl}`} {list.length} user(s) {lvl === 0 ? 'Level 0 (Top node)' : `Level ${lvl}`} {list.length} user(s)
</span>
<span className="ml-2">
{collapsedLevels[lvl] ? (
<span className="inline-block w-4 h-4 text-gray-400">&#9654;</span>
) : (
<span className="inline-block w-4 h-4 text-gray-400">&#9660;</span>
)}
</span>
</button>
{/* Per-level search only when expanded */}
{!collapsedLevels[lvl] && (
<>
<div className="flex justify-end mt-2 mb-3">
<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-gray-400" />
<input
type="text"
value={levelSearch[lvl]}
onChange={e => {
console.log('[MatrixDetailPage] levelSearch', { level: lvl, q: e.target.value })
setLevelSearch(prev => ({ ...prev, [lvl]: e.target.value }))
}}
placeholder="Search in level..."
className="pl-8 pr-2 py-1 rounded-md border border-gray-200 text-xs focus:ring-1 focus:ring-indigo-500 focus:border-transparent w-full"
/>
</div>
</div> </div>
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3"> <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{list.map(u => ( {filteredList.length === 0 && (
<div className="col-span-full text-xs text-gray-500 italic">No users found.</div>
)}
{filteredList.length > 0 && filteredList.map(u => (
<div key={`${lvl}-${u.id}`} className="rounded-lg border border-gray-200 p-3 bg-gray-50"> <div key={`${lvl}-${u.id}`} className="rounded-lg border border-gray-200 p-3 bg-gray-50">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div className="text-sm font-medium text-gray-900">{u.name}</div> <div className="text-sm font-medium text-gray-900">{u.name}</div>
@ -222,86 +469,28 @@ export default function MatrixDetailPage() {
</div> </div>
))} ))}
</div> </div>
</>
)}
</div> </div>
) )
})} })}
</div> </div>
</div> </div>
</div>
</div>
{/* Add Users Modal */} {/* Add Users Modal */}
{open && ( <SearchModal
<div className="fixed inset-0 z-50"> open={open}
<div className="absolute inset-0 bg-black/40" onClick={() => setOpen(false)} /> onClose={handleModalClose}
<div className="absolute inset-0 flex items-center justify-center p-4"> matrixName={matrixName}
<div className="w-full max-w-2xl rounded-xl bg-white shadow-2xl ring-1 ring-black/10 overflow-hidden"> rootUserId={resolvedRootUserId}
<div className="px-5 py-4 border-b border-gray-100 flex items-center justify-between"> matrixId={matrixId}
<h3 className="text-base font-semibold text-gray-900">Add users to {matrixName}</h3> topNodeEmail={topNodeEmail}
<button onClick={() => setOpen(false)} className="text-sm text-gray-600 hover:text-gray-800">Close</button> existingUsers={users}
</div> policyMaxDepth={serverMaxDepth ?? null}
onAdd={(u) => { addToMatrix(u) }}
<div className="px-5 py-4 grid grid-cols-1 md:grid-cols-4 gap-3 border-b border-gray-100">
<div className="md:col-span-2 relative">
<MagnifyingGlassIcon className="h-4 w-4 text-gray-500 absolute left-2.5 top-2.5" />
<input
value={query}
onChange={e => { setQuery(e.target.value) }}
placeholder="Search name or email…"
className="w-full rounded-md border border-gray-300 pl-8 pr-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900 placeholder:text-gray-700 placeholder:opacity-100"
/> />
</div> </div>
<select
value={typeFilter}
onChange={e => setTypeFilter(e.target.value as any)}
className="w-full rounded-md border border-gray-300 px-3 py-2 text-sm focus:ring-2 focus:ring-indigo-500 focus:border-transparent text-gray-900"
>
<option value="all">All Types</option>
<option value="personal">Personal</option>
<option value="company">Company</option>
</select>
<div className="text-sm text-gray-600 self-center">
Candidates: <span className="font-semibold text-gray-900">{filteredCandidates.length}</span>
</div> </div>
</div>
<div className="max-h-[50vh] overflow-auto">
{filteredCandidates.length === 0 ? (
<div className="px-5 py-8 text-sm text-gray-500">No users match your filters.</div>
) : (
<ul className="divide-y divide-gray-100">
{filteredCandidates.map(u => (
<li key={u.id} className="px-5 py-3 flex items-center justify-between gap-3">
<div className="min-w-0">
<div className="text-sm font-medium text-gray-900">{u.name}</div>
<div className="text-xs text-gray-600">{u.email}</div>
</div>
<div className="flex items-center gap-2">
<span className={`text-[10px] font-semibold px-2 py-0.5 rounded-full ${
u.type === 'company' ? 'bg-indigo-100 text-indigo-800' : 'bg-blue-100 text-blue-800'
}`}>
{u.type === 'company' ? 'Company' : 'Personal'}
</span>
<button
onClick={() => addToMatrix(u)}
className="inline-flex items-center rounded-md bg-indigo-600 hover:bg-indigo-500 text-white px-3 py-1.5 text-xs font-medium"
>
Add
</button>
</div>
</li>
))}
</ul>
)}
</div>
<div className="px-5 py-3 border-t border-gray-100 flex items-center justify-end">
<button onClick={() => setOpen(false)} className="text-sm text-gray-700 hover:text-gray-900">Done</button>
</div>
</div>
</div>
</div>
)}
</PageLayout> </PageLayout>
) )
} }

View File

@ -12,7 +12,9 @@ export async function getMatrixStats(params: {
const { token, baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || '' } = params const { token, baseUrl = process.env.NEXT_PUBLIC_API_BASE_URL || '' } = params
if (!token) return { ok: false, status: 401, message: 'Missing token' } if (!token) return { ok: false, status: 401, message: 'Missing token' }
const url = `${baseUrl}/api/matrix/stats` const base = (baseUrl || '').replace(/\/+$/, '')
const url = `${base}/api/matrix/stats`
console.info('[getMatrixStats] REQUEST GET', url)
try { try {
const res = await fetch(url, { const res = await fetch(url, {
method: 'GET', method: 'GET',
@ -21,11 +23,13 @@ export async function getMatrixStats(params: {
}) })
let body: any = null let body: any = null
try { body = await res.json() } catch {} try { body = await res.json() } catch {}
console.debug('[getMatrixStats] Response', { status: res.status, hasBody: !!body, keys: body ? Object.keys(body) : [] })
if (!res.ok) { if (!res.ok) {
return { ok: false, status: res.status, body, message: body?.message || `Fetch stats failed (${res.status})` } return { ok: false, status: res.status, body, message: body?.message || `Fetch stats failed (${res.status})` }
} }
return { ok: true, status: res.status, body } return { ok: true, status: res.status, body }
} catch { } catch (e) {
console.error('[getMatrixStats] Network error', e)
return { ok: false, status: 0, message: 'Network error' } return { ok: false, status: 0, message: 'Network error' }
} }
} }

View File

@ -22,6 +22,8 @@ type Matrix = {
usersCount: number usersCount: number
createdAt: string createdAt: string
topNodeEmail: string topNodeEmail: string
rootUserId: number // added
policyMaxDepth?: number | null // NEW
} }
export default function MatrixManagementPage() { export default function MatrixManagementPage() {
@ -59,6 +61,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<'all'|'unlimited'|'five'>('all') // NEW
const [sortByUsers, setSortByUsers] = useState<'asc'|'desc'>('desc') // NEW
const loadStats = async () => { const loadStats = async () => {
if (!token) return if (!token) return
setStatsLoading(true) setStatsLoading(true)
@ -73,13 +78,18 @@ export default function MatrixManagementPage() {
const isActive = !!m?.isActive const isActive = !!m?.isActive
const createdAt = m?.createdAt || m?.ego_activated_at || m?.activatedAt || new Date().toISOString() const createdAt = m?.createdAt || m?.ego_activated_at || m?.activatedAt || new Date().toISOString()
const topNodeEmail = m?.topNodeEmail || m?.masterTopUserEmail || m?.email || '' const topNodeEmail = m?.topNodeEmail || m?.masterTopUserEmail || m?.email || ''
const rootUserId = Number(m?.rootUserId ?? m?.root_user_id ?? 0)
const matrixId = m?.matrixId ?? m?.id // prefer matrixId for routing
const maxDepth = (m?.maxDepth ?? m?.policyMaxDepth) // backend optional
return { return {
id: String(m?.rootUserId ?? m?.id ?? `m-${idx}`), id: String(matrixId ?? `m-${idx}`),
name: String(m?.name ?? 'Unnamed Matrix'), name: String(m?.name ?? 'Unnamed Matrix'),
status: isActive ? 'active' : 'inactive', status: isActive ? 'active' : 'inactive',
usersCount: Number(m?.usersCount ?? 0), usersCount: Number(m?.usersCount ?? 0),
createdAt: String(createdAt), createdAt: String(createdAt),
topNodeEmail: String(topNodeEmail), topNodeEmail: String(topNodeEmail),
rootUserId,
policyMaxDepth: typeof maxDepth === 'number' ? maxDepth : null // NEW
} }
}) })
setMatrices(mapped) setMatrices(mapped)
@ -190,6 +200,19 @@ export default function MatrixManagementPage() {
) )
} }
// derived list with filter/sort
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.sort((a,b) => sortByUsers === 'asc' ? (a.usersCount - b.usersCount) : (b.usersCount - a.usersCount))
return list
}, [matrices, policyFilter, sortByUsers])
const StatCard = ({ const StatCard = ({
icon: Icon, icon: Icon,
label, label,
@ -260,6 +283,35 @@ export default function MatrixManagementPage() {
<StatCard icon={UsersIcon} label="Total Users Subscribed" value={stats.totalUsers} color="bg-amber-600" /> <StatCard icon={UsersIcon} label="Total Users Subscribed" value={stats.totalUsers} color="bg-amber-600" />
</div> </div>
{/* Filters */}
<div className="mb-6 flex items-center gap-3">
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">Policy</label>
<select value={policyFilter} onChange={e => setPolicyFilter(e.target.value as any)} className="rounded border border-gray-300 px-2 py-1 text-sm">
<option value="all">All</option>
<option value="unlimited">Unlimited</option>
<option value="five">5-level</option>
</select>
</div>
<div className="flex items-center gap-2">
<label className="text-sm text-gray-600">Sort by users</label>
<select value={sortByUsers} onChange={e => setSortByUsers(e.target.value as any)} className="rounded border border-gray-300 px-2 py-1 text-sm">
<option value="desc">Desc</option>
<option value="asc">Asc</option>
</select>
</div>
<div className="text-xs text-gray-500" title="Users count respects each matrixs max depth policy.">
Tooltip: Users count respects each matrixs max depth policy.
</div>
</div>
{/* Optional health hint */}
{policyFilter !== 'five' && matricesView.some(m => !m.policyMaxDepth || m.policyMaxDepth <= 0) && (
<div className="mb-4 rounded-md border border-blue-200 bg-blue-50 px-3 py-2 text-xs text-blue-900">
Large tree matrices may require pagination by levels. <a className="underline" href="https://example.com/docs/matrix-pagination" target="_blank">Learn more</a>
</div>
)}
{/* Matrix cards */} {/* Matrix cards */}
<div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3"> <div className="grid grid-cols-1 gap-6 sm:grid-cols-2 xl:grid-cols-3">
{statsLoading ? ( {statsLoading ? (
@ -274,19 +326,23 @@ export default function MatrixManagementPage() {
</div> </div>
</div> </div>
)) ))
) : matrices.length === 0 ? ( ) : matricesView.length === 0 ? (
<div className="text-sm text-gray-600">No matrices found.</div> <div className="text-sm text-gray-600">No matrices found.</div>
) : ( ) : (
matrices.map(m => ( matricesView.map(m => (
<article key={m.id} className="rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden"> <article key={m.id} className="rounded-xl bg-white border border-gray-200 shadow-sm overflow-hidden">
<div className="p-5"> <div className="p-5">
<div className="flex items-start justify-between gap-3"> <div className="flex items-start justify-between gap-3">
<h3 className="text-lg font-semibold text-gray-900">{m.name}</h3> <h3 className="text-lg font-semibold text-gray-900">{m.name}</h3>
<StatusBadge status={m.status} /> <StatusBadge status={m.status} />
</div> </div>
<div className="mt-2">
<span className="inline-flex items-center rounded-full bg-indigo-50 border border-indigo-200 px-2 py-0.5 text-[11px] text-indigo-800">
Max depth: {(!m.policyMaxDepth || m.policyMaxDepth <= 0) ? 'Unlimited' : m.policyMaxDepth}
</span>
</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">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2" title="Users count respects each matrixs max depth policy.">
<UsersIcon className="h-5 w-5 text-gray-500" /> <UsersIcon className="h-5 w-5 text-gray-500" />
<span className="font-medium">{m.usersCount}</span> <span className="font-medium">{m.usersCount}</span>
<span className="text-gray-500">users</span> <span className="text-gray-500">users</span>
@ -302,7 +358,6 @@ export default function MatrixManagementPage() {
<span className="text-gray-700 truncate">{m.topNodeEmail}</span> <span className="text-gray-700 truncate">{m.topNodeEmail}</span>
</div> </div>
</div> </div>
<div className="mt-5 flex items-center justify-between"> <div className="mt-5 flex items-center justify-between">
<button <button
onClick={() => toggleStatus(m.id)} onClick={() => toggleStatus(m.id)}
@ -317,10 +372,16 @@ export default function MatrixManagementPage() {
<button <button
className="text-sm font-medium text-indigo-600 hover:text-indigo-500" className="text-sm font-medium text-indigo-600 hover:text-indigo-500"
onClick={() => { onClick={() => {
// get default depth range from localStorage per matrix
const defA = Number(localStorage.getItem(`matrixDepthA:${m.id}`) ?? 0)
const defB = Number(localStorage.getItem(`matrixDepthB:${m.id}`) ?? 5)
const params = new URLSearchParams({ const params = new URLSearchParams({
id: String(m.id), id: String(m.id),
name: m.name, name: m.name,
top: m.topNodeEmail, top: m.topNodeEmail,
rootUserId: String(m.rootUserId),
a: String(Number.isFinite(defA) ? defA : 0),
b: String(Number.isFinite(defB) ? defB : 5)
}) })
router.push(`/admin/matrix-management/detail?${params.toString()}`) router.push(`/admin/matrix-management/detail?${params.toString()}`)
}} }}
@ -328,6 +389,25 @@ export default function MatrixManagementPage() {
View details View details
</button> </button>
</div> </div>
{/* Quick default AB editor */}
<div className="mt-3 flex items-center gap-2">
<span className="text-[11px] text-gray-500">Default depth slice:</span>
<input
type="number"
min={0}
defaultValue={Number(localStorage.getItem(`matrixDepthA:${m.id}`) ?? 0)}
onBlur={e => localStorage.setItem(`matrixDepthA:${m.id}`, String(Math.max(0, Number(e.target.value) || 0)))}
className="w-16 rounded border border-gray-300 px-2 py-1 text-[11px]"
/>
<span className="text-[11px] text-gray-500">to</span>
<input
type="number"
min={0}
defaultValue={Number(localStorage.getItem(`matrixDepthB:${m.id}`) ?? 5)}
onBlur={e => localStorage.setItem(`matrixDepthB:${m.id}`, String(Math.max(0, Number(e.target.value) || 0)))}
className="w-16 rounded border border-gray-300 px-2 py-1 text-[11px]"
/>
</div>
</div> </div>
</article> </article>
)) ))

View File

@ -113,10 +113,11 @@ export default function LoginForm() {
<div <div
className="w-full flex justify-center items-start relative" className="w-full flex justify-center items-start relative"
style={{ style={{
// Removed fixed 100vh + overflow hidden to allow scrolling // Ensure the background fills the viewport height
minHeight: 'auto', minHeight: '100vh',
// Reduce bottom padding to avoid extra white space
paddingTop: isMobile ? '0.75rem' : '4rem', paddingTop: isMobile ? '0.75rem' : '4rem',
paddingBottom: '3rem', paddingBottom: isMobile ? '1rem' : '1.5rem',
backgroundImage: 'url(/images/misc/marble_bluegoldwhite_BG.jpg)', backgroundImage: 'url(/images/misc/marble_bluegoldwhite_BG.jpg)',
backgroundSize: 'cover', backgroundSize: 'cover',
backgroundPosition: 'center' backgroundPosition: 'center'

View File

@ -43,19 +43,19 @@ export default function LoginPage() {
return ( return (
<PageLayout showFooter={false}> <PageLayout showFooter={false}>
<div className="relative w-full flex flex-col min-h-screen bg-[#FAF9F6]"> <div className="relative w-full flex flex-col min-h-screen">
<div className="relative z-10 flex-1 flex items-start justify-center"> <div className="relative z-10 flex-1 flex items-start justify-center">
<div className="w-full"> <div className="w-full">
<LoginForm /> <LoginForm />
</div> </div>
</div> </div>
{/* Footer for mobile */} {/* Removed mobile footer to avoid bottom white container */}
<div className="relative z-10 md:hidden"> {/* <div className="relative z-10 md:hidden">
<div className="text-center py-4 text-sm text-slate-700"> <div className="text-center py-4 text-sm text-slate-700">
© 2024 Profit Planet. Alle Rechte vorbehalten. © 2024 Profit Planet. Alle Rechte vorbehalten.
</div> </div>
</div> </div> */}
</div> </div>
</PageLayout> </PageLayout>
) )

View File

@ -61,7 +61,13 @@ export async function authFetch(input: RequestInfo | URL, init: CustomRequestIni
// Always send credentials so refresh cookie is included when server-side refresh is needed // Always send credentials so refresh cookie is included when server-side refresh is needed
const fetchWithAuth = async (hdrs: Record<string, string>): Promise<Response> => { const fetchWithAuth = async (hdrs: Record<string, string>): Promise<Response> => {
log("📡 authFetch: Sending request", { url, headers: Object.keys(hdrs), credentials: "include" }); log("📡 authFetch: Sending request", {
url,
headers: Object.keys(hdrs),
hasAuth: !!hdrs.Authorization,
authPrefix: hdrs.Authorization ? `${hdrs.Authorization.substring(0, 24)}...` : null,
credentials: "include",
});
return fetch(url, { ...init, headers: hdrs, credentials: "include" }); return fetch(url, { ...init, headers: hdrs, credentials: "include" });
}; };