profit-planet-frontend/src/app/admin/matrix-management/detail/components/searchModal.tsx
2026-05-03 22:20:17 +02:00

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)
}