519 lines
22 KiB
TypeScript
519 lines
22 KiB
TypeScript
'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<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 [closing, setClosing] = useState(false) // NEW: animated closing state
|
|
|
|
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])
|
|
|
|
// 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<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])
|
|
|
|
// 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 = (
|
|
<div className="fixed inset-0 z-[10000]">
|
|
{/* Backdrop: animate opacity */}
|
|
<div
|
|
className={`absolute inset-0 backdrop-blur-sm transition-opacity duration-200 ${closing ? 'opacity-0' : 'opacity-100'} bg-black/60`}
|
|
onClick={closeWithAnimation} // CHANGED: use animated close
|
|
/>
|
|
<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
|
|
transition-all duration-200 ${closing ? 'opacity-0 scale-95 translate-y-1' : 'opacity-100 scale-100 translate-y-0'}`}
|
|
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>
|
|
<button
|
|
onClick={closeWithAnimation} // CHANGED: animated close
|
|
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">{t('autofix.kd642e230')}</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={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"
|
|
/>
|
|
</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">{t('autofix.k10e2568f')}</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">{t('autofix.kc0e3b03d')}<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">{t('autofix.kb87eb38b')}</div>
|
|
)}
|
|
{!error && query.trim().length >= 3 && !hasSearched && !loading && (
|
|
<div className="py-12 text-sm text-blue-300 text-center">{t('autofix.k7c740cd5')}</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">{t('autofix.k1e5d5139')}</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"
|
|
>{t('autofix.kadd80fbc')}</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"
|
|
/>{t('autofix.k11974e0f')}</label>
|
|
|
|
{advanced && (
|
|
<div className="space-y-2">
|
|
<select
|
|
key={parentsRevision}
|
|
value={parentId ?? ''}
|
|
onChange={e => setParentId(e.target.value ? Number(e.target.value) : undefined)}
|
|
className="w-full rounded-md bg-[#173456] border border-blue-800 text-xs text-blue-100 px-2 py-2 focus:ring-2 focus:ring-blue-500 focus:border-blue-500"
|
|
title={addDisabledReason || undefined}
|
|
>
|
|
<option value="">(Auto referral / root)</option>
|
|
{potentialParents.map(p => {
|
|
const used = parentUsage.get(p.id) || 0
|
|
const isRoot = (p.level ?? 0) === 0
|
|
const full = (!isRoot && used >= 5) || (!!policyMaxDepth && policyMaxDepth > 0 && p.level >= policyMaxDepth) // CHANGED
|
|
const rem = !policyMaxDepth || policyMaxDepth <= 0 ? '∞' : Math.max(0, policyMaxDepth - p.level)
|
|
return (
|
|
<option key={p.id} value={p.id} disabled={full} title={full ? 'Parent full or at policy depth' : undefined}>
|
|
{p.name} • L{p.level} • Slots {isRoot ? `${used} (root ∞)` : `${used}/5`} • Rem levels: {rem}
|
|
</option>
|
|
)
|
|
})}
|
|
</select>
|
|
{/* CHANGED: clarify root unlimited and rogue behavior */}
|
|
<p className="text-[11px] text-blue-300">
|
|
{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.`}
|
|
</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"
|
|
/>{t('autofix.kf823daf7')}</label>
|
|
<p className="text-[11px] text-blue-300">
|
|
If the referrer is outside the matrix, the user is placed under root; enabling fallback may mark the user as rogue.
|
|
</p>
|
|
|
|
{addError && <div className="text-xs text-red-400">{addError}</div>}
|
|
{addSuccess && <div className="text-xs text-green-400">{addSuccess}</div>}
|
|
|
|
<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={closeWithAnimation} // CHANGED: animated close
|
|
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)
|
|
}
|