fix:거래처 검색 팝업 위치 개선 - createPortal + fixed 포지셔닝으로 뷰포트 밖 벗어남 방지
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user