diff --git a/claudedocs/guides/[FIX-2026-03-17] popover-outside-click-dialog-cascade.md b/claudedocs/guides/[FIX-2026-03-17] popover-outside-click-dialog-cascade.md new file mode 100644 index 00000000..2d9a27b9 --- /dev/null +++ b/claudedocs/guides/[FIX-2026-03-17] popover-outside-click-dialog-cascade.md @@ -0,0 +1,81 @@ +# Popover 외부 클릭 시 Dialog cascade 문제 해결 + +**작성일**: 2026-03-17 +**문제 유형**: UI 컴포넌트 이벤트 처리 +**적용 범위**: Popover 기반 UI 컴포넌트 5종 + +--- + +## 문제 현상 + +### 원래 버그 (2026-02-26 이전) +DatePicker, SearchableSelect 등이 **Dialog(모달) 안에서** 사용될 때, 날짜/옵션 선택 시 **모달 자체가 닫히는 문제** 발생. + +**원인**: Popover가 Portal로 ``에 렌더링되어, 클릭 이벤트가 Dialog의 `onInteractOutside`까지 cascade → Dialog 닫힘 + +### 1차 수정의 부작용 (2026-02-26 ~ 2026-03-17) +`e.preventDefault()`로 cascade를 차단했으나, Popover 자체도 외부 클릭으로 닫히지 않게 됨. + +**증상**: 달력/검색 셀렉트를 열면 해당 컴포넌트를 직접 클릭하거나 ESC를 눌러야만 닫힘. 화면 아무 곳을 클릭해도 열린 채로 유지. + +--- + +## 해결책 + +`e.preventDefault()`로 Dialog cascade는 차단하되, `setOpen(false)`로 Popover를 수동으로 닫기. + +```tsx + { + // Dialog cascade 방지 (Portal 렌더링으로 인한 모달 닫힘 방지) + e.preventDefault(); + // Popover는 수동으로 닫기 + setOpen(false); + }} + onInteractOutside={(e) => { + e.preventDefault(); + setOpen(false); + }} +> +``` + +--- + +## 동작 비교 + +| 시나리오 | 수정 전 (원래 버그) | 1차 수정 (부작용) | 최종 수정 | +|---------|-------------------|-----------------|----------| +| 일반 페이지 외부 클릭 | Popover 닫힘 | Popover 안 닫힘 | Popover 닫힘 ✅ | +| 모달 내 외부 클릭 | Popover + 모달 닫힘 | 아무것도 안 닫힘 | Popover만 닫힘 ✅ | +| 모달 내 날짜/옵션 선택 | 모달이 같이 닫힘 | 정상 | 정상 ✅ | + +--- + +## 적용 파일 (5개) + +| 컴포넌트 | 파일 | +|---------|------| +| DatePicker | `src/components/ui/date-picker.tsx` | +| DateRangePicker | `src/components/ui/date-range-picker.tsx` | +| TimePicker | `src/components/ui/time-picker.tsx` | +| SearchableSelect | `src/components/ui/searchable-select.tsx` | +| MultiSelectCombobox | `src/components/ui/multi-select-combobox.tsx` | + +--- + +## 히스토리 + +| 날짜 | 커밋 | 내용 | +|------|------|------| +| 2026-02-06 | `c2ed7154` | DatePicker 최초 생성 (preventDefault 없음) | +| 2026-01-29 | `576da0c9` | SearchableSelect 최초 생성 (preventDefault 없음) | +| 2026-02-26 | `b1686aaf` | 5개 컴포넌트에 `e.preventDefault()` 일괄 추가 (모달 닫힘 방지) | +| 2026-03-17 | - | `e.preventDefault()` + `setOpen(false)` 패턴으로 수정 | + +--- + +## 주의사항 + +- 새로운 Popover 기반 컴포넌트 생성 시 이 패턴 적용 필수 +- `e.preventDefault()`만 단독 사용 금지 → 반드시 `setOpen(false)` 함께 호출 +- 이 패턴은 Radix UI의 DismissableLayer cascade 동작에 의존하므로 Radix 메이저 버전 업그레이드 시 재검증 필요 diff --git a/src/app/[locale]/(protected)/sales/stocks/[id]/page.tsx b/src/app/[locale]/(protected)/sales/stocks/[id]/page.tsx new file mode 100644 index 00000000..f0071ca5 --- /dev/null +++ b/src/app/[locale]/(protected)/sales/stocks/[id]/page.tsx @@ -0,0 +1,75 @@ +'use client'; + +/** + * 재고생산 상세/수정 페이지 + * + * - 기본: 상세 보기 (StockProductionDetail) + * - ?mode=edit: 수정 (StockProductionForm) + */ + +import { useState, useEffect } from 'react'; +import { useParams, useSearchParams } from 'next/navigation'; +import { Loader2 } from 'lucide-react'; +import { toast } from 'sonner'; +import { StockProductionDetail } from '@/components/stocks/StockProductionDetail'; +import { StockProductionForm } from '@/components/stocks/StockProductionForm'; +import { getStockOrderById, type StockOrder } from '@/components/stocks/actions'; + +function EditStockContent({ id }: { id: string }) { + const [order, setOrder] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + async function load() { + try { + const result = await getStockOrderById(id); + if (result.__authError) { + toast.error('인증이 만료되었습니다.'); + return; + } + if (result.success && result.data) { + setOrder(result.data); + } else { + toast.error(result.error || '데이터를 불러오는데 실패했습니다.'); + } + } finally { + setLoading(false); + } + } + load(); + }, [id]); + + if (loading) { + return ( +
+
+ +

데이터를 불러오는 중...

+
+
+ ); + } + + if (!order) { + return ( +
+

데이터를 찾을 수 없습니다.

+
+ ); + } + + return ; +} + +export default function StockDetailPage() { + const params = useParams(); + const searchParams = useSearchParams(); + const id = params.id as string; + const mode = searchParams.get('mode'); + + if (mode === 'edit') { + return ; + } + + return ; +} diff --git a/src/app/[locale]/(protected)/sales/stocks/page.tsx b/src/app/[locale]/(protected)/sales/stocks/page.tsx new file mode 100644 index 00000000..a4b1cb73 --- /dev/null +++ b/src/app/[locale]/(protected)/sales/stocks/page.tsx @@ -0,0 +1,27 @@ +'use client'; + +/** + * 재고생산관리 페이지 + * + * - 기본: 목록 (StockProductionList) + * - ?mode=new: 등록 (StockProductionForm) + */ + +import { useSearchParams } from 'next/navigation'; +import { StockProductionList } from '@/components/stocks/StockProductionList'; +import { StockProductionForm } from '@/components/stocks/StockProductionForm'; + +function CreateStockContent() { + return ; +} + +export default function StocksPage() { + const searchParams = useSearchParams(); + const mode = searchParams.get('mode'); + + if (mode === 'new') { + return ; + } + + return ; +} diff --git a/src/components/accounting/CardTransactionInquiry/index.tsx b/src/components/accounting/CardTransactionInquiry/index.tsx index 53a93980..83bce790 100644 --- a/src/components/accounting/CardTransactionInquiry/index.tsx +++ b/src/components/accounting/CardTransactionInquiry/index.tsx @@ -40,7 +40,7 @@ import { } from '@/components/templates/UniversalListPage'; import type { CardTransaction, InlineEditData, SortOption } from './types'; import { - SORT_OPTIONS, DEDUCTION_OPTIONS, ACCOUNT_SUBJECT_OPTIONS, + SORT_OPTIONS, DEDUCTION_OPTIONS, } from './types'; import { AccountSubjectSelect } from '@/components/accounting/common'; import { @@ -74,10 +74,7 @@ const excelColumns: ExcelColumn[] = [ { header: '공급가액', key: 'supplyAmount', width: 12 }, { header: '세액', key: 'taxAmount', width: 10 }, { header: '계정과목', key: 'accountSubject', width: 12, - transform: (v) => { - const found = ACCOUNT_SUBJECT_OPTIONS.find(o => o.value === v); - return found?.label || String(v || ''); - }}, + transform: (v) => String(v || '') }, ]; // ===== 테이블 컬럼 정의 (체크박스/No. 제외 15개) ===== diff --git a/src/components/accounting/CardTransactionInquiry/types.ts b/src/components/accounting/CardTransactionInquiry/types.ts index 515483fb..5bb8c96e 100644 --- a/src/components/accounting/CardTransactionInquiry/types.ts +++ b/src/components/accounting/CardTransactionInquiry/types.ts @@ -118,26 +118,6 @@ export const USAGE_TYPE_OPTIONS = [ { value: 'miscellaneous', label: '잡비' }, ]; -// ===== 계정과목 옵션 ===== -export const ACCOUNT_SUBJECT_OPTIONS = [ - { value: '', label: '선택' }, - { value: 'purchasePayment', label: '매입대금' }, - { value: 'advance', label: '선급금' }, - { value: 'suspense', label: '가지급금' }, - { value: 'rent', label: '임대료' }, - { value: 'interestExpense', label: '이자비용' }, - { value: 'depositPayment', label: '보증금 지급' }, - { value: 'loanRepayment', label: '차입금 상환' }, - { value: 'dividendPayment', label: '배당금 지급' }, - { value: 'vatPayment', label: '부가세 납부' }, - { value: 'salary', label: '급여' }, - { value: 'insurance', label: '4대보험' }, - { value: 'tax', label: '세금' }, - { value: 'utilities', label: '공과금' }, - { value: 'expenses', label: '경비' }, - { value: 'other', label: '기타' }, -]; - // ===== 월 프리셋 옵션 ===== export const MONTH_PRESETS = [ { label: '이번달', value: 0 }, diff --git a/src/components/accounting/DepositManagement/index.tsx b/src/components/accounting/DepositManagement/index.tsx index a10b2f64..99d5457d 100644 --- a/src/components/accounting/DepositManagement/index.tsx +++ b/src/components/accounting/DepositManagement/index.tsx @@ -42,13 +42,6 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { TableRow, TableCell } from '@/components/ui/table'; -import { - Select, - SelectContent, - SelectItem, - SelectTrigger, - SelectValue, -} from '@/components/ui/select'; import { UniversalListPage, type UniversalListConfig, @@ -57,6 +50,7 @@ import { type StatCard, } from '@/components/templates/UniversalListPage'; import { MobileCard } from '@/components/organisms/MobileCard'; +import { AccountSubjectSelect } from '@/components/accounting/common'; import type { DepositRecord, SortOption, @@ -65,7 +59,6 @@ import { SORT_OPTIONS, DEPOSIT_TYPE_LABELS, DEPOSIT_TYPE_FILTER_OPTIONS, - ACCOUNT_SUBJECT_OPTIONS, } from './types'; import { deleteDeposit, updateDepositTypes, getDeposits } from './actions'; import { formatNumber } from '@/lib/utils/amount'; @@ -117,7 +110,7 @@ export function DepositManagement({ initialData, initialPagination: _initialPagi const [sortOption, setSortOption] = useState('latest'); // 계정과목명 저장 다이얼로그 - const [selectedAccountSubject, setSelectedAccountSubject] = useState('unset'); + const [selectedAccountSubject, setSelectedAccountSubject] = useState(''); const [showSaveDialog, setShowSaveDialog] = useState(false); const [selectedItemsForSave, setSelectedItemsForSave] = useState>(new Set()); @@ -312,22 +305,17 @@ export function DepositManagement({ initialData, initialPagination: _initialPagi }, filterTitle: '입금 필터', - // 헤더 액션: 계정과목명 Select + 저장 + 새로고침 + // 헤더 액션: 계정과목명 검색 Select + 저장 + 새로고침 headerActions: ({ selectedItems }) => (
계정과목명 - +