fix:거래처 검색 팝업 위치 개선 - createPortal + fixed 포지셔닝으로 뷰포트 밖 벗어남 방지

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
김보곤
2026-02-20 11:16:12 +09:00
parent 7ec39cd1aa
commit 744f8c54f4

View File

@@ -1679,13 +1679,13 @@ className="px-4 py-2 text-sm font-medium text-white bg-violet-600 rounded-lg hov
const partnerSearchRef = useRef(null);
const partnerSearchTimerRef = useRef(null);
const partnerSearchInputRef = useRef(null);
const partnerSearchBtnRef = useRef(null);
// 검색 팝업 열기/닫기
const openPartnerSearch = () => {
setShowPartnerSearch(true);
setPartnerSearchQuery('');
setPartnerSearchResults(tradingPartners || []);
setTimeout(() => partnerSearchInputRef.current?.focus(), 50);
};
const closePartnerSearch = () => {
setShowPartnerSearch(false);
@@ -1693,11 +1693,39 @@ className="px-4 py-2 text-sm font-medium text-white bg-violet-600 rounded-lg hov
setPartnerSearchResults([]);
};
// 검색 팝업 외부 클릭 감지
// 검색 팝업 위치 계산 + 포커스 + 외부 클릭
useEffect(() => {
if (!showPartnerSearch) return;
// 위치 계산
const positionPopup = () => {
if (!partnerSearchBtnRef.current || !partnerSearchRef.current) return;
const btnRect = partnerSearchBtnRef.current.getBoundingClientRect();
const popup = partnerSearchRef.current;
const popupW = 420, popupMaxH = 360, gap = 6;
// 가로: 버튼 오른쪽 끝 기준 정렬
let left = btnRect.right - popupW;
if (left < 8) left = 8;
if (left + popupW > window.innerWidth - 8) left = window.innerWidth - popupW - 8;
popup.style.left = left + 'px';
// 세로: 아래 공간 우선, 부족하면 위로
const spaceBelow = window.innerHeight - btnRect.bottom - gap;
const spaceAbove = btnRect.top - gap;
if (spaceBelow >= popupMaxH || spaceBelow >= spaceAbove) {
popup.style.top = (btnRect.bottom + gap) + 'px';
popup.style.bottom = 'auto';
} else {
popup.style.top = 'auto';
popup.style.bottom = (window.innerHeight - btnRect.top + gap) + 'px';
}
};
requestAnimationFrame(() => {
positionPopup();
partnerSearchInputRef.current?.focus();
});
// 외부 클릭
const handleClickOutside = (e) => {
if (partnerSearchRef.current && !partnerSearchRef.current.contains(e.target)) {
if (partnerSearchRef.current && !partnerSearchRef.current.contains(e.target)
&& partnerSearchBtnRef.current && !partnerSearchBtnRef.current.contains(e.target)) {
closePartnerSearch();
}
};
@@ -1847,7 +1875,7 @@ className="px-3 py-1.5 bg-violet-100 text-violet-700 rounded-lg text-xs font-med
</div>
{/* 등록번호 + 종사업장번호 */}
<div className="grid grid-cols-2 gap-3">
<div className="relative">
<div>
<label className="block text-xs text-stone-500 mb-1">등록번호 (사업자번호)</label>
<div className="flex gap-1.5">
<input
@@ -1858,6 +1886,7 @@ className="flex-1 px-3 py-2 border border-stone-300 rounded-lg text-sm focus:rin
placeholder="000-00-00000"
/>
<button
ref={partnerSearchBtnRef}
type="button"
onClick={openPartnerSearch}
className="px-2.5 py-2 bg-violet-100 text-violet-700 rounded-lg hover:bg-violet-200 transition-colors shrink-0"
@@ -1866,68 +1895,6 @@ className="px-2.5 py-2 bg-violet-100 text-violet-700 rounded-lg hover:bg-violet-
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
</button>
</div>
{/* 거래처 검색 팝업 */}
{showPartnerSearch && (
<div ref={partnerSearchRef} className="absolute z-50 bottom-full left-0 mb-1 w-[420px] max-h-[400px] bg-white rounded-xl shadow-2xl border border-stone-200 overflow-hidden">
{/* 검색 헤더 */}
<div className="p-3 border-b border-stone-100 bg-stone-50">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-stone-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<input
ref={partnerSearchInputRef}
type="text"
value={partnerSearchQuery}
onChange={(e) => handlePartnerSearchChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') closePartnerSearch();
if (e.key === 'Enter' && partnerSearchResults.length > 0) {
e.preventDefault();
handlePartnerSearchSelect(partnerSearchResults[0]);
}
}}
className="flex-1 text-sm bg-transparent outline-none placeholder-stone-400"
placeholder="거래처명, 사업자번호, 대표자명으로 검색..."
/>
{partnerSearchLoading && (
<svg className="w-4 h-4 animate-spin text-violet-500 shrink-0" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
)}
<button onClick={closePartnerSearch} className="p-0.5 text-stone-400 hover:text-stone-600 shrink-0">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
</div>
{/* 검색 결과 */}
<div className="overflow-y-auto max-h-[320px]">
{partnerSearchResults.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-stone-400">
{partnerSearchQuery ? '검색 결과가 없습니다' : '등록된 거래처가 없습니다'}
</div>
) : (
partnerSearchResults.slice(0, 50).map((p) => (
<button
key={p.id}
type="button"
onClick={() => handlePartnerSearchSelect(p)}
className="w-full text-left px-4 py-2.5 hover:bg-violet-50 transition-colors border-b border-stone-50 last:border-b-0"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-stone-800">{p.name}</span>
<span className="text-xs text-stone-400 font-mono">{p.biz_no || '-'}</span>
</div>
{(p.ceo || p.type || p.category) && (
<div className="flex items-center gap-2 mt-0.5">
{p.ceo && <span className="text-xs text-stone-500">{p.ceo}</span>}
{(p.type || p.category) && (
<span className="text-xs text-stone-400">{[p.type, p.category].filter(Boolean).join(' / ')}</span>
)}
</div>
)}
</button>
))
)}
</div>
</div>
)}
</div>
<div>
<label className="block text-xs text-stone-500 mb-1">종사업장번호</label>
@@ -2114,6 +2081,76 @@ className="px-6 py-2 bg-violet-600 text-white rounded-lg text-sm font-medium hov
onClose={() => setShowCardPicker(false)}
/>
)}
{/* 거래처 검색 팝업 (Portal → body에 렌더링) */}
{showPartnerSearch && ReactDOM.createPortal(
<div ref={partnerSearchRef} className="fixed z-[9999] w-[420px] bg-white rounded-xl shadow-2xl border border-stone-200 overflow-hidden">
{/* 검색 헤더 */}
<div className="p-3 border-b border-stone-100 bg-stone-50">
<div className="flex items-center gap-2">
<svg className="w-4 h-4 text-stone-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"/></svg>
<input
ref={partnerSearchInputRef}
type="text"
value={partnerSearchQuery}
onChange={(e) => handlePartnerSearchChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Escape') closePartnerSearch();
if (e.key === 'Enter' && partnerSearchResults.length > 0) {
e.preventDefault();
handlePartnerSearchSelect(partnerSearchResults[0]);
}
}}
className="flex-1 text-sm bg-transparent outline-none placeholder-stone-400"
placeholder="거래처명, 사업자번호, 대표자명으로 검색..."
/>
{partnerSearchLoading && (
<svg className="w-4 h-4 animate-spin text-violet-500 shrink-0" fill="none" viewBox="0 0 24 24"><circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"/><path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"/></svg>
)}
<button onClick={closePartnerSearch} className="p-0.5 text-stone-400 hover:text-stone-600 shrink-0">
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path strokeLinecap="round" strokeLinejoin="round" strokeWidth="2" d="M6 18L18 6M6 6l12 12"/></svg>
</button>
</div>
</div>
{/* 검색 결과 */}
<div className="overflow-y-auto max-h-[300px]">
{partnerSearchResults.length === 0 ? (
<div className="px-4 py-8 text-center text-sm text-stone-400">
{partnerSearchQuery ? '검색 결과가 없습니다' : '등록된 거래처가 없습니다'}
</div>
) : (
partnerSearchResults.slice(0, 50).map((p) => (
<button
key={p.id}
type="button"
onClick={() => handlePartnerSearchSelect(p)}
className="w-full text-left px-4 py-2.5 hover:bg-violet-50 transition-colors border-b border-stone-50 last:border-b-0"
>
<div className="flex items-center justify-between">
<span className="text-sm font-medium text-stone-800">{p.name}</span>
<span className="text-xs text-stone-400 font-mono">{p.biz_no || '-'}</span>
</div>
{(p.ceo || p.type || p.category) && (
<div className="flex items-center gap-2 mt-0.5">
{p.ceo && <span className="text-xs text-stone-500">{p.ceo}</span>}
{(p.type || p.category) && (
<span className="text-xs text-stone-400">{[p.type, p.category].filter(Boolean).join(' / ')}</span>
)}
</div>
)}
</button>
))
)}
</div>
{/* 결과 수 표시 */}
{partnerSearchResults.length > 0 && (
<div className="px-3 py-1.5 border-t border-stone-100 bg-stone-50 text-xs text-stone-400 text-right">
{partnerSearchResults.length}
</div>
)}
</div>,
document.body
)}
</div>
);
};