'use client' import { useTranslation } from '../../../../i18n/useTranslation'; 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 { t } = useTranslation(); const [query, setQuery] = useState('') const [typeFilter, setTypeFilter] = useState<'all' | UserType>('all') const [loading, setLoading] = useState(false) const [error, setError] = useState('') const [items, setItems] = useState>([]) 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(undefined) // NEW const [forceFallback, setForceFallback] = useState(true) // NEW const [adding, setAdding] = useState(false) // NEW 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 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]) // 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() 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]) // 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 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]) // 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('') setAddSuccess('') setAdding(true) try { // If advanced is not checked, or if advanced is checked but parentId is not set (root selected), use rootUserId as parentUserId const effectiveParentId = (!advanced || !parentId) ? rootUserId : parentId; const data = await addUserToMatrix({ childUserId: selected.userId, parentUserId: effectiveParentId, 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 }) // 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) } catch (e: any) { console.error('[SearchModal] addUserToMatrix error', e) setAddError(e?.message || 'Add failed') } finally { setAdding(false) } } if (!open) return null const modal = (
{/* Backdrop: animate opacity */}
{/* Header */}

Add users to “{matrixName}”

{t('autofix.kd642e230')}

{/* Form */}
{ 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 */}
setQuery(e.target.value)} placeholder={t('autofix.kb35549bb')} 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" />
{/* Type */}
{/* Buttons */}
{/* Total */}
{t('autofix.kc0e3b03d')}{total}
{/* Results + selection area (scrollable) */}
{/* Results section */}
{error && (
{error}
)} {!error && query.trim().length < 3 && (
{t('autofix.kb87eb38b')}
)} {!error && query.trim().length >= 3 && !hasSearched && !loading && (
{t('autofix.k7c740cd5')}
)} {/* Skeleton only for first-time load (when no items yet) */} {!error && query.trim().length >= 3 && loading && items.length === 0 && (
    {Array.from({ length: 5 }).map((_, i) => (
  • ))}
)} {!error && hasSearched && !loading && query.trim().length >= 3 && items.length === 0 && (
{t('autofix.k1e5d5139')}
)} {!error && hasSearched && items.length > 0 && (
    {items.map(u => (
  • {u.userType === 'company' ? : } {u.name} {u.userType === 'company' ? 'Company' : 'Personal'}
    {u.email}
  • ))}
)} {/* Soft-loading overlay over existing list to avoid jumpiness */} {loading && items.length > 0 && (
)}
{/* Selected candidate details (conditional) */} {selected && (
Candidate: {selected.name} ({selected.email})
{advanced && (
{/* CHANGED: clarify root unlimited and rogue behavior */}

{isRootSelected ? 'Root has unlimited capacity; placing under root does not mark the user as rogue.' : (!policyMaxDepth || policyMaxDepth <= 0) ? 'Unlimited policy: no remaining-level cap for subtree.' : `Remaining levels under chosen parent = Max(${policyMaxDepth}) - parent level.`}

)}

If the referrer is outside the matrix, the user is placed under root; enabling fallback may mark the user as rogue.

{addError &&
{addError}
} {addSuccess &&
{addSuccess}
}
)}
{/* Footer (hidden when a candidate is selected) */} {!selected && (
)}
) return createPortal(modal, document.body) }