From e76fac0ab1e47fc108130a311aa6334f762453e9 Mon Sep 17 00:00:00 2001 From: byeongcheolryu Date: Wed, 14 Jan 2026 15:27:59 +0900 Subject: [PATCH 1/2] =?UTF-8?q?feat(WEB):=20UniversalListPage=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B0=8F=20=ED=8C=8C=EC=9D=BC?= =?UTF-8?q?=EB=9F=BF=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UniversalListPage 템플릿 컴포넌트 생성 - 카드관리(HR) 파일럿 마이그레이션 (기본 케이스) - 게시판목록 파일럿 마이그레이션 (동적 탭 fetchTabs) - 발주관리 파일럿 마이그레이션 (ScheduleCalendar beforeTableContent) - 클라이언트 사이드 필터링 지원 (customFilterFn, customSortFn) Co-Authored-By: Claude Opus 4.5 --- ...-14] universal-list-component-checklist.md | 163 +++++ ...UniversalListPage-pilot-session-context.md | 131 ++++ .../[locale]/(protected)/board-test/page.tsx | 14 + .../order/order-management-test/page.tsx | 16 + .../hr/card-management-test/page.tsx | 16 + .../board/BoardList/BoardListUnified.tsx | 371 ++++++++++ .../OrderManagementUnified.tsx | 640 ++++++++++++++++++ .../CardManagement/CardManagementUnified.tsx | 266 ++++++++ .../templates/UniversalListPage/index.tsx | 525 ++++++++++++++ .../templates/UniversalListPage/types.ts | 257 +++++++ src/components/templates/index.ts | 19 +- src/layouts/AuthenticatedLayout.tsx | 123 +--- 12 files changed, 2450 insertions(+), 91 deletions(-) create mode 100644 claudedocs/[IMPL-2026-01-14] universal-list-component-checklist.md create mode 100644 claudedocs/[NEXT-2026-01-14] UniversalListPage-pilot-session-context.md create mode 100644 src/app/[locale]/(protected)/board-test/page.tsx create mode 100644 src/app/[locale]/(protected)/construction/order/order-management-test/page.tsx create mode 100644 src/app/[locale]/(protected)/hr/card-management-test/page.tsx create mode 100644 src/components/board/BoardList/BoardListUnified.tsx create mode 100644 src/components/business/construction/order-management/OrderManagementUnified.tsx create mode 100644 src/components/hr/CardManagement/CardManagementUnified.tsx create mode 100644 src/components/templates/UniversalListPage/index.tsx create mode 100644 src/components/templates/UniversalListPage/types.ts diff --git a/claudedocs/[IMPL-2026-01-14] universal-list-component-checklist.md b/claudedocs/[IMPL-2026-01-14] universal-list-component-checklist.md new file mode 100644 index 00000000..2a6de393 --- /dev/null +++ b/claudedocs/[IMPL-2026-01-14] universal-list-component-checklist.md @@ -0,0 +1,163 @@ +# UniversalListPage 컴포넌트 통합 작업 + +> **목표**: 59개 리스트 페이지를 1개의 공통 컴포넌트로 통합 +> **시작일**: 2026-01-14 +> **원칙**: 기존 기능 100% 유지, 테이블 영역만 공통화 + +--- + +## Phase 1: 준비 작업 + +- [x] Git 브랜치 생성 (`feature/universal-list-component`) ✅ +- [x] 기존 IntegratedListTemplateV2 분석 완료 확인 ✅ +- [x] 공통 패턴 / 특이 케이스 최종 정리 ✅ + +--- + +## Phase 2: 핵심 컴포넌트 구현 + +### 2.1 타입 정의 +- [x] `UniversalListConfig` 인터페이스 정의 ✅ +- [x] `TableColumn`, `FilterConfig` 등 공통 타입 정의 ✅ +- [x] 특이 케이스용 옵션 타입 정의 (모달, 동적 탭 등) ✅ + +### 2.2 UniversalListPage 컴포넌트 +- [x] 기본 구조 구현 (상태 관리, 핸들러) ✅ +- [x] IntegratedListTemplateV2 연동 ✅ +- [x] renderTableRow / renderMobileCard 콜백 처리 ✅ +- [x] 삭제 AlertDialog 통합 ✅ +- [x] 검색/필터/페이지네이션 통합 ✅ + +### 2.3 특이 케이스 지원 +- [x] `detailMode: 'page' | 'modal'` 옵션 ✅ +- [x] 동적 탭 지원 (`fetchTabs` 함수 옵션) ✅ +- [x] 커스텀 액션 버튼 지원 (`customActions`) ✅ +- [x] 문서 미리보기 모달 지원 (`DetailModalComponent`) ✅ + +--- + +## Phase 3: 파일럿 마이그레이션 + +- [ ] 기본 케이스 1개 선정 및 마이그레이션 +- [ ] 특이 케이스 1개 선정 및 마이그레이션 +- [ ] 기능 테스트 (데스크톱 + 모바일) +- [ ] 스크린샷 비교 검증 + +--- + +## Phase 4: 도메인별 마이그레이션 + +### 4.1 건설 도메인 (18개) +- [ ] 현장설명회관리 (SiteBriefingListClient) +- [ ] 견적관리 (EstimateListClient) +- [ ] 입찰관리 (BiddingListClient) +- [ ] 계약관리 (ContractListClient) +- [ ] 인수인계보고서 (HandoverReportListClient) +- [ ] 현장관리 (SiteManagementListClient) +- [ ] 구조검토관리 (StructureReviewListClient) +- [ ] 이슈관리 (IssueManagementListClient) +- [ ] 작업인력현황 (WorkerStatusListClient) +- [ ] 품목관리 (ItemManagementClient) +- [ ] 단가관리 (PricingListClient) +- [ ] 노임관리 (LaborManagementClient) +- [ ] 발주관리 (OrderManagementListClient) +- [ ] 기성청구관리 (ProgressBillingManagementListClient) +- [ ] 공과관리 (UtilityManagementListClient) +- [ ] 시공관리 (ConstructionManagementListClient) +- [ ] 거래처관리 (PartnerListClient) +- [ ] 프로젝트관리 (ProjectListClient) + +### 4.2 HR 도메인 (5개) +- [ ] 급여관리 (SalaryManagement) +- [ ] 사원관리 (EmployeeManagement) +- [ ] 휴가관리 (VacationManagement) +- [ ] 근태관리 (AttendanceManagement) +- [ ] 카드관리 (CardManagement) + +### 4.3 회계 도메인 (11개) +- [ ] 거래처관리 (VendorManagement) +- [ ] 매입관리 (PurchaseManagement) +- [ ] 매출관리 (SalesManagement) +- [ ] 입금관리 (DepositManagement) +- [ ] 출금관리 (WithdrawalManagement) +- [ ] 어음관리 (BillManagement) +- [ ] 거래처원장 (VendorLedger) +- [ ] 지출예상내역서 (ExpectedExpenseManagement) +- [ ] 입출금계좌조회 (BankTransactionInquiry) +- [ ] 카드내역조회 (CardTransactionInquiry) +- [ ] 악성채권추심 (BadDebtCollection) + +### 4.4 생산/자재/품질/출고 도메인 (6개) +- [ ] 작업지시관리 (WorkOrderList) +- [ ] 작업실적조회 (WorkResultList) +- [ ] 재고현황 (StockStatusList) +- [ ] 입고관리 (ReceivingList) +- [ ] 검사관리 (InspectionList) +- [ ] 출하관리 (ShipmentList) + +### 4.5 전자결재 도메인 (3개) ⚠️ 특이 케이스 +- [ ] 기안함 (DraftBox) - 문서 미리보기 모달 + 상신 +- [ ] 결재함 (ApprovalBox) - 문서 미리보기 모달 + 승인/반려 +- [ ] 참조함 (ReferenceBox) - 문서 미리보기 모달 + 읽음/안읽음 + +### 4.6 설정 도메인 (4개) +- [ ] 계좌관리 (AccountManagement) +- [ ] 팝업관리 (PopupList) +- [ ] 결제내역 (PaymentHistoryManagement) +- [ ] 권한관리 (PermissionManagement) + +### 4.7 영업 도메인 (3개) 🆕 +- [ ] 수주관리 (order-management-sales/page.tsx) +- [ ] 생산발주 (order-management-sales/production-orders/page.tsx) +- [ ] 거래처관리-영업 (client-management-sales-admin/page.tsx) + +### 4.8 기타 도메인 (9개) +- [ ] 품목기준관리 (ItemListClient) +- [ ] 견적관리 (QuoteManagementClient) +- [ ] 단가관리-일반 (PricingListClient) +- [ ] 공정관리 (ProcessListClient) +- [ ] 게시판목록 (BoardList) ⚠️ 동적 탭 +- [ ] 게시판관리 (BoardManagement) +- [ ] 공지사항 (NoticeList) +- [ ] 이벤트 (EventList) +- [ ] 1:1문의 (InquiryList) + +--- + +## Phase 5: 최종 검증 + +- [ ] 전체 59개 페이지 기능 테스트 +- [ ] 스크린샷 비교 검증 (변경 전 vs 후) +- [ ] 모바일 뷰 테스트 +- [ ] 성능 테스트 (빌드 사이즈, 로딩 속도) + +--- + +## 특이 케이스 정리 + +| 페이지 | 특이점 | 처리 방안 | +|--------|--------|----------| +| DraftBox | 문서 미리보기 모달 + 상신 | `detailMode: 'modal'` + `customActions` | +| ApprovalBox | 문서 미리보기 모달 + 승인/반려 | `detailMode: 'modal'` + `customActions` | +| ReferenceBox | 문서 미리보기 모달 + 읽음 처리 | `detailMode: 'modal'` + `customActions` | +| BoardList | 동적 탭 (API 기반) | `tabs: () => Promise` | + +--- + +## 변경 이력 + +| 날짜 | 작업 내용 | +|------|----------| +| 2026-01-14 | 체크리스트 문서 생성, 작업 시작 | +| 2026-01-14 | 영업 도메인 3개 추가 (총 56개 → 59개) | + +--- + +## 백업 스크린샷 위치 + +| 폴더 | 개수 | 설명 | +|------|------|------| +| `~/Desktop/test-urls_리스트 게시판 스샷/` | 34개 | 일반 도메인 | +| `~/Desktop/construction-test-urls_리스트 게시판 스샷/` | 18개 | 건설 도메인 | +| `~/Desktop/추가_리스트_스샷/` | 7개 | 누락 페이지 | +| **합계** | **59개** | | diff --git a/claudedocs/[NEXT-2026-01-14] UniversalListPage-pilot-session-context.md b/claudedocs/[NEXT-2026-01-14] UniversalListPage-pilot-session-context.md new file mode 100644 index 00000000..f248ce38 --- /dev/null +++ b/claudedocs/[NEXT-2026-01-14] UniversalListPage-pilot-session-context.md @@ -0,0 +1,131 @@ +# UniversalListPage 파일럿 마이그레이션 세션 컨텍스트 + +## 세션 요약 (2026-01-14) + +### 완료된 작업 +- [x] UniversalListPage 컴포넌트 생성 (Phase 1, 2) +- [x] 카드관리 (HR) 파일럿 마이그레이션 - 기본 케이스 +- [x] 게시판목록 (BoardList) 파일럿 마이그레이션 - 동적 탭 (fetchTabs) +- [x] 발주관리 (OrderManagement) 파일럿 마이그레이션 - ScheduleCalendar (beforeTableContent) +- [x] 3개 파일럿 페이지 기능 검증 완료 + +### 프로젝트 목표 +59개 리스트 페이지를 하나의 `UniversalListPage` 컴포넌트로 통합 +- **핵심 원칙**: 기존 기능 100% 유지, 테이블 영역만 공통화 +- **접근 방식**: Config 기반 커스터마이징 + +--- + +## 파일럿 검증 결과 + +| 파일럿 | 특이 케이스 | 테스트 URL | 상태 | +|--------|------------|-----------|------| +| **카드관리** | 기본 케이스 | `/ko/hr/card-management-test` | ✅ 완료 | +| **게시판목록** | 동적 탭 (fetchTabs) | `/ko/board-test` | ✅ 완료 | +| **발주관리** | ScheduleCalendar | `/ko/construction/order/order-management-test` | ✅ 완료 | + +### 검증된 기능 +1. ✅ 테이블 렌더링 (20개 행, 페이지네이션) +2. ✅ 행 선택 (체크박스, 카운터, 삭제 버튼) +3. ✅ 수정/삭제 버튼 (선택 시 표시, 페이지 이동) +4. ✅ 클라이언트 사이드 필터링 (customFilterFn, customSortFn) +5. ✅ 커스텀 콘텐츠 슬롯 (beforeTableContent, tableHeaderActions) +6. ✅ 동적 탭 (fetchTabs API 호출) +7. ✅ 필터 설정 (filterConfig 기반 다중 필터) + +--- + +## 생성/수정된 파일 + +### UniversalListPage 핵심 파일 +``` +src/components/templates/UniversalListPage/ +├── index.tsx # 메인 컴포넌트 +└── types.ts # 타입 정의 +``` + +### 파일럿 Unified 컴포넌트 +``` +src/components/business/hr/CardManagement/CardManagementUnified.tsx +src/components/business/construction/order-management/OrderManagementUnified.tsx +src/components/board/BoardList/BoardListUnified.tsx +``` + +### 테스트 페이지 +``` +src/app/[locale]/(protected)/hr/card-management-test/page.tsx +src/app/[locale]/(protected)/board-test/page.tsx +src/app/[locale]/(protected)/construction/order/order-management-test/page.tsx +``` + +--- + +## 핵심 수정 사항 + +### types.ts에 추가된 Config 옵션 +```typescript +// 클라이언트 사이드 필터링 확장 +customFilterFn?: (items: T[], filterValues: Record) => T[]; +customSortFn?: (items: T[], filterValues: Record) => T[]; + +// 테이블 헤더 액션 +tableHeaderActions?: ReactNode; +``` + +### index.tsx 주요 수정 +1. **탭 기본값 수정**: `activeTab` 기본값을 `'default'`로 변경 (IntegratedListTemplateV2와 일치) +2. **빈 탭 배열 처리**: `tabs={computedTabs.length > 0 ? computedTabs : undefined}` +3. **customFilterFn/customSortFn 적용**: filteredData useMemo에서 사용 + +--- + +## 다음 세션 TODO + +### Phase 4: 본격 마이그레이션 +- [ ] 나머지 56개 페이지 우선순위 정리 +- [ ] 복잡도별 그룹핑 (기본 / 필터 복잡 / 커스텀 슬롯) +- [ ] 그룹별 순차 마이그레이션 + +### 고려사항 +- 기존 페이지와 테스트 페이지 비교 검증 후 교체 +- 마이그레이션 완료 후 기존 컴포넌트 정리 + +--- + +## 참고 사항 + +### UniversalListConfig 주요 속성 +```typescript +interface UniversalListConfig { + title: string; + basePath: string; + idField: keyof T | ((item: T) => string); + actions: ListActions; + columns: TableColumn[]; + + // 렌더링 + renderTableRow: (item, index, globalIndex, handlers) => ReactNode; + renderMobileCard: (item, index, globalIndex, handlers) => ReactNode; + + // 필터링 + filterConfig?: FilterFieldConfig[]; + clientSideFiltering?: boolean; + customFilterFn?: (items, filterValues) => T[]; + + // 탭 + tabs?: TabOption[]; + fetchTabs?: () => Promise; + + // 슬롯 + beforeTableContent?: ReactNode; + tableHeaderActions?: ReactNode; + headerActions?: (params) => ReactNode; +} +``` + +### 기존 vs 테스트 URL 비교 +| 기능 | 기존 | 테스트 (UniversalListPage) | +|------|------|---------------------------| +| 카드관리 | `/ko/hr/card-management` | `/ko/hr/card-management-test` | +| 게시판 | `/ko/board` | `/ko/board-test` | +| 발주관리 | `/ko/construction/order/order-management` | `/ko/construction/order/order-management-test` | \ No newline at end of file diff --git a/src/app/[locale]/(protected)/board-test/page.tsx b/src/app/[locale]/(protected)/board-test/page.tsx new file mode 100644 index 00000000..2800976f --- /dev/null +++ b/src/app/[locale]/(protected)/board-test/page.tsx @@ -0,0 +1,14 @@ +'use client'; + +/** + * 게시판 목록 UniversalListPage 테스트 페이지 + * + * URL: /ko/board-test + * 비교: /ko/board (기존) + */ + +import { BoardListUnified } from '@/components/board/BoardList/BoardListUnified'; + +export default function BoardListTestPage() { + return ; +} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/construction/order/order-management-test/page.tsx b/src/app/[locale]/(protected)/construction/order/order-management-test/page.tsx new file mode 100644 index 00000000..917c2797 --- /dev/null +++ b/src/app/[locale]/(protected)/construction/order/order-management-test/page.tsx @@ -0,0 +1,16 @@ +'use client'; + +/** + * 발주관리 UniversalListPage 테스트 페이지 + * + * 특이 케이스: ScheduleCalendar 컴포넌트 (beforeTableContent) + * URL: /ko/construction/order/order-management-test + * + * 비교 테스트: /ko/construction/order/order-management (기존) + */ + +import { OrderManagementUnified } from '@/components/business/construction/order-management/OrderManagementUnified'; + +export default function OrderManagementTestPage() { + return ; +} diff --git a/src/app/[locale]/(protected)/hr/card-management-test/page.tsx b/src/app/[locale]/(protected)/hr/card-management-test/page.tsx new file mode 100644 index 00000000..eb00ed1d --- /dev/null +++ b/src/app/[locale]/(protected)/hr/card-management-test/page.tsx @@ -0,0 +1,16 @@ +'use client'; + +/** + * 카드관리 UniversalListPage 테스트 페이지 + * + * 기존 CardManagement와 동일한 기능을 UniversalListPage config 방식으로 구현한 테스트 버전 + * URL: /ko/hr/card-management-test + * + * 비교 테스트 완료 후 삭제 예정 + */ + +import { CardManagementUnified } from '@/components/hr/CardManagement/CardManagementUnified'; + +export default function CardManagementTestPage() { + return ; +} diff --git a/src/components/board/BoardList/BoardListUnified.tsx b/src/components/board/BoardList/BoardListUnified.tsx new file mode 100644 index 00000000..fc2fd928 --- /dev/null +++ b/src/components/board/BoardList/BoardListUnified.tsx @@ -0,0 +1,371 @@ +'use client'; + +/** + * 게시판 리스트 - UniversalListPage 버전 + * + * 특이 케이스: + * - 동적 탭 (API에서 게시판 목록 로드) + * - 탭별 다른 API 호출 (일반 게시판 vs 나의 게시글) + * - 본인 글만 수정/삭제 가능 + */ + +import { useState, useMemo, useEffect, useCallback } from 'react'; +import { useRouter } from 'next/navigation'; +import { format } from 'date-fns'; +import { FileText, Plus, Pencil, Trash2 } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { TableRow, TableCell } from '@/components/ui/table'; +import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; +import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; +import { + UniversalListPage, + type UniversalListConfig, + type SelectionHandlers, + type RowClickHandlers, + type TabOption, +} from '@/components/templates/UniversalListPage'; +import type { Post } from '../types'; +import { getBoards } from '../BoardManagement/actions'; +import { getPosts, getMyPosts, deletePost } from '../actions'; +import type { Board } from '../BoardManagement/types'; + +export function BoardListUnified() { + const router = useRouter(); + + // 동적 탭을 위한 게시판 목록 상태 + const [boards, setBoards] = useState([]); + const [activeTab, setActiveTab] = useState(''); + const [currentUserId, setCurrentUserId] = useState(''); + + // 날짜 범위 필터 상태 + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + + // 현재 사용자 ID 가져오기 + useEffect(() => { + const userId = localStorage.getItem('user_id') || ''; + setCurrentUserId(userId); + }, []); + + // 게시판 목록 로드 및 동적 탭 생성 + const fetchTabs = useCallback(async (): Promise => { + const result = await getBoards(); + if (result.success && result.data) { + setBoards(result.data); + // 첫 번째 게시판을 기본 탭으로 설정 + if (result.data.length > 0 && !activeTab) { + setActiveTab(result.data[0].boardCode); + } + + const boardTabs = result.data.map((board) => ({ + value: board.boardCode, + label: board.boardName, + count: 0, + })); + + return [ + ...boardTabs, + { + value: 'my', + label: '나의 게시글', + count: 0, + }, + ]; + } + return []; + }, [activeTab]); + + // UniversalListPage Config 정의 + const config: UniversalListConfig = useMemo(() => ({ + // ===== 페이지 기본 정보 ===== + title: '게시판', + description: '게시판의 게시글을 등록하고 관리합니다.', + icon: FileText, + basePath: '/board', + + // ===== ID 추출 ===== + idField: 'id', + + // ===== API 액션 ===== + actions: { + getList: async (params) => { + const tab = params?.tab || activeTab; + if (!tab) { + return { success: true, data: [], totalCount: 0 }; + } + + let result; + if (tab === 'my') { + // 나의 게시글 조회 + result = await getMyPosts({ + search: params?.search, + per_page: params?.pageSize || 20, + page: params?.page || 1, + }); + } else { + // 특정 게시판 게시글 조회 + result = await getPosts(tab, { + search: params?.search, + per_page: params?.pageSize || 20, + page: params?.page || 1, + }); + } + + if (result.success && result.posts) { + return { + success: true, + data: result.posts, + totalCount: result.data?.total || result.posts.length, + totalPages: result.data?.last_page || 1, + }; + } + + return { + success: false, + error: result.error, + data: [], + totalCount: 0, + }; + }, + deleteItem: async (id: string) => { + // 게시글 삭제는 boardCode가 필요하므로 별도 처리 + // UniversalListPage에서는 사용하지 않고 커스텀 삭제 처리 + return { success: false, error: 'Use custom delete handler' }; + }, + }, + + // ===== 테이블 컬럼 ===== + columns: [ + { key: 'no', label: 'No.', className: 'w-[60px] text-center' }, + { key: 'title', label: '제목', className: 'min-w-[300px]' }, + { key: 'author', label: '작성자', className: 'w-[120px]' }, + { key: 'createdAt', label: '등록일', className: 'w-[120px]' }, + { key: 'viewCount', label: '조회수', className: 'w-[80px] text-center' }, + { key: 'actions', label: '작업', className: 'w-[100px] text-center' }, + ], + + // ===== 동적 탭 ===== + fetchTabs, + defaultTab: activeTab || undefined, + + // ===== 검색 설정 ===== + searchPlaceholder: '제목, 작성자 검색...', + + // ===== 상세 보기 모드 ===== + detailMode: 'none', // 커스텀 라우팅 사용 (boardCode 포함) + + // ===== 헤더 액션 ===== + headerActions: ({ onCreate }) => ( + <> + + + + ), + + // ===== 삭제 확인 메시지 ===== + deleteConfirmMessage: { + title: '게시글 삭제', + description: '정말 삭제하시겠습니까?', + }, + + // ===== 테이블 행 렌더링 ===== + renderTableRow: ( + item: Post, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => { + const { isSelected, onToggle } = handlers; + const isMyPost = item.authorId === currentUserId; + + const handleRowClick = () => { + router.push(`/ko/board/${item.boardCode}/${item.id}`); + }; + + const handleEdit = () => { + router.push(`/ko/board/${item.boardCode}/${item.id}/edit`); + }; + + const handleDelete = async () => { + if (confirm('정말 삭제하시겠습니까?')) { + const result = await deletePost(item.boardCode, item.id); + if (result.success) { + window.location.reload(); // 삭제 후 새로고침 + } + } + }; + + return ( + + e.stopPropagation()}> + + + {/* No. */} + + {item.isPinned ? ( + + 공지 + + ) : ( + globalIndex + )} + + {/* 제목 */} + +
+ {item.isPinned && ( + [공지] + )} + {item.isSecret && ( + [비밀] + )} + {item.title} +
+
+ {/* 작성자 */} + {item.authorName} + {/* 등록일 */} + {format(new Date(item.createdAt), 'yyyy-MM-dd')} + {/* 조회수 */} + {item.viewCount} + {/* 작업 */} + e.stopPropagation()}> + {isSelected && isMyPost && ( +
+ + +
+ )} +
+
+ ); + }, + + // ===== 모바일 카드 렌더링 ===== + renderMobileCard: ( + item: Post, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => { + const { isSelected, onToggle } = handlers; + const isMyPost = item.authorId === currentUserId; + + const handleRowClick = () => { + router.push(`/ko/board/${item.boardCode}/${item.id}`); + }; + + const handleEdit = () => { + router.push(`/ko/board/${item.boardCode}/${item.id}/edit`); + }; + + const handleDelete = async () => { + if (confirm('정말 삭제하시겠습니까?')) { + const result = await deletePost(item.boardCode, item.id); + if (result.success) { + window.location.reload(); + } + } + }; + + return ( + + {item.isPinned && ( + [공지] + )} + {item.isSecret && ( + [비밀] + )} + {item.title} + + } + isSelected={isSelected} + onToggleSelection={onToggle} + infoGrid={ +
+ + + + +
+ } + actions={ + isSelected && isMyPost ? ( +
+ + +
+ ) : undefined + } + onClick={handleRowClick} + /> + ); + }, + + // ===== 추가 옵션 ===== + showCheckbox: true, + showRowNumber: true, + itemsPerPage: 20, + }), [activeTab, boards, currentUserId, fetchTabs, router, startDate, endDate]); + + return ( + + config={config} + /> + ); +} + +export default BoardListUnified; diff --git a/src/components/business/construction/order-management/OrderManagementUnified.tsx b/src/components/business/construction/order-management/OrderManagementUnified.tsx new file mode 100644 index 00000000..fc043768 --- /dev/null +++ b/src/components/business/construction/order-management/OrderManagementUnified.tsx @@ -0,0 +1,640 @@ +'use client'; + +/** + * 발주관리 리스트 - UniversalListPage 버전 + * + * 특이 케이스: + * - ScheduleCalendar 컴포넌트 (beforeTableContent) + * - 9개의 다중선택 필터 + * - 달력 날짜 클릭 시 테이블 필터링 + * - 클라이언트 사이드 필터링/페이지네이션 + */ + +import { useState, useMemo, useCallback, useEffect } from 'react'; +import { useRouter } from 'next/navigation'; +import { Package, Pencil, Trash2, Plus } from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { TableCell, TableRow } from '@/components/ui/table'; +import { Checkbox } from '@/components/ui/checkbox'; +import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox'; +import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; +import { Badge } from '@/components/ui/badge'; +import { + UniversalListPage, + type UniversalListConfig, + type SelectionHandlers, + type RowClickHandlers, + type FilterFieldConfig, +} from '@/components/templates/UniversalListPage'; +import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; +import { ScheduleCalendar, ScheduleEvent, DayBadge } from '@/components/common/ScheduleCalendar'; +import { format, parseISO, isSameDay, startOfDay } from 'date-fns'; +import type { Order } from './types'; +import { + ORDER_STATUS_OPTIONS, + ORDER_SORT_OPTIONS, + ORDER_STATUS_STYLES, + ORDER_STATUS_LABELS, + ORDER_TYPE_OPTIONS, + ORDER_TYPE_LABELS, + MOCK_PARTNERS, + MOCK_SITES, + MOCK_CONSTRUCTION_PM, + MOCK_ORDER_MANAGERS, + MOCK_ORDER_COMPANIES, + MOCK_WORK_TEAM_LEADERS, + getScheduleColorByManager, +} from './types'; +import { + getOrderList, + deleteOrder, + deleteOrders, +} from './actions'; + +interface OrderManagementUnifiedProps { + initialData?: Order[]; +} + +export function OrderManagementUnified({ initialData }: OrderManagementUnifiedProps) { + const router = useRouter(); + + // 달력 관련 상태 (beforeTableContent에서 사용하므로 config 외부에서 관리) + const [selectedCalendarDate, setSelectedCalendarDate] = useState(null); + const [calendarDate, setCalendarDate] = useState(new Date()); + const [siteFilters, setSiteFilters] = useState([]); + const [workTeamFilters, setWorkTeamFilters] = useState([]); + + // 날짜 범위 필터 상태 + const [startDate, setStartDate] = useState(''); + const [endDate, setEndDate] = useState(''); + + // 전체 데이터 (달력 이벤트용) + const [allOrders, setAllOrders] = useState(initialData || []); + const [isLoading, setIsLoading] = useState(false); + + // 필터 옵션들 + const siteOptions: MultiSelectOption[] = useMemo(() => MOCK_SITES, []); + const workTeamOptions: MultiSelectOption[] = useMemo(() => MOCK_WORK_TEAM_LEADERS, []); + const partnerOptions: MultiSelectOption[] = useMemo(() => MOCK_PARTNERS, []); + const constructionPMOptions: MultiSelectOption[] = useMemo(() => MOCK_CONSTRUCTION_PM, []); + const orderManagerOptions: MultiSelectOption[] = useMemo(() => MOCK_ORDER_MANAGERS, []); + const orderCompanyOptions: MultiSelectOption[] = useMemo(() => MOCK_ORDER_COMPANIES, []); + const orderTypeOptions: MultiSelectOption[] = useMemo(() => ORDER_TYPE_OPTIONS, []); + + // 데이터 로드 + const loadData = useCallback(async () => { + setIsLoading(true); + try { + const result = await getOrderList({ + size: 1000, + startDate: startDate || undefined, + endDate: endDate || undefined, + }); + + if (result.success && result.data) { + setAllOrders(result.data.items); + } + } catch { + console.error('데이터 로드 실패'); + } finally { + setIsLoading(false); + } + }, [startDate, endDate]); + + // 초기 데이터가 없으면 로드 + useEffect(() => { + if (!initialData || initialData.length === 0) { + loadData(); + } + }, [initialData, loadData]); + + // 달력용 이벤트 데이터 변환 (필터 적용) + const calendarEvents: ScheduleEvent[] = useMemo(() => { + return allOrders + .filter((order) => { + // 현장 필터 + if (siteFilters.length > 0) { + const matchingSite = MOCK_SITES.find((s) => order.siteName.includes(s.label.split(' ')[0])); + if (!matchingSite || !siteFilters.includes(matchingSite.value)) { + return false; + } + } + + // 작업반장 필터 + if (workTeamFilters.length > 0) { + const matchingLeader = MOCK_WORK_TEAM_LEADERS.find((l) => order.orderManager.includes(l.label.replace('반장', ''))); + if (!matchingLeader || !workTeamFilters.includes(matchingLeader.value)) { + return false; + } + } + + return true; + }) + .map((order) => ({ + id: order.id, + title: `${order.orderManager} - ${order.siteName} / ${order.orderNumber}`, + startDate: order.periodStart, + endDate: order.periodEnd, + color: getScheduleColorByManager(order.orderManager), + status: order.status, + data: order, + })); + }, [allOrders, siteFilters, workTeamFilters]); + + // 달력용 뱃지 데이터 - 사용하지 않음 + const calendarBadges: DayBadge[] = []; + + // 달력 이벤트 핸들러 + const handleCalendarDateClick = useCallback((date: Date) => { + if (selectedCalendarDate && isSameDay(selectedCalendarDate, date)) { + setSelectedCalendarDate(null); + } else { + setSelectedCalendarDate(date); + } + }, [selectedCalendarDate]); + + const handleCalendarEventClick = useCallback((event: ScheduleEvent) => { + if (event.data) { + router.push(`/ko/construction/order/order-management/${event.id}`); + } + }, [router]); + + const handleCalendarMonthChange = useCallback((date: Date) => { + setCalendarDate(date); + }, []); + + // 날짜 포맷 + const formatDate = (dateStr: string | null) => { + if (!dateStr) return '-'; + return dateStr.split('T')[0]; + }; + + // 달력 필터 슬롯 + const calendarFilterSlot = ( +
+ + +
+ ); + + // UniversalListPage Config 정의 + const config: UniversalListConfig = useMemo(() => ({ + // ===== 페이지 기본 정보 ===== + title: '발주관리', + description: '발주 스케줄 및 목록을 관리합니다', + icon: Package, + basePath: '/construction/order/order-management', + + // ===== ID 추출 ===== + idField: 'id', + + // ===== API 액션 ===== + actions: { + getList: async () => { + const result = await getOrderList({ + size: 1000, + startDate: startDate || undefined, + endDate: endDate || undefined, + }); + return { + success: result.success, + data: result.data?.items || [], + totalCount: result.data?.items?.length || 0, + error: result.error, + }; + }, + deleteItem: async (id: string) => { + return await deleteOrder(id); + }, + deleteBulk: async (ids: string[]) => { + return await deleteOrders(ids); + }, + }, + + // ===== 테이블 컬럼 ===== + columns: [ + { key: 'no', label: '번호', className: 'w-[50px] text-center' }, + { key: 'contractNumber', label: '계약번호', className: 'w-[100px]' }, + { key: 'partnerName', label: '거래처', className: 'w-[80px]' }, + { key: 'siteName', label: '현장명', className: 'min-w-[100px]' }, + { key: 'name', label: '명칭', className: 'w-[80px]' }, + { key: 'constructionPM', label: '공사PM', className: 'w-[70px]' }, + { key: 'orderManager', label: '발주담당자', className: 'w-[80px]' }, + { key: 'orderNumber', label: '발주번호', className: 'w-[100px]' }, + { key: 'orderCompany', label: '발주처명', className: 'w-[80px]' }, + { key: 'workTeamLeader', label: '작업반장', className: 'w-[70px]' }, + { key: 'constructionStartDate', label: '시공투입일', className: 'w-[90px]' }, + { key: 'orderType', label: '구분', className: 'w-[80px] text-center' }, + { key: 'item', label: '품목', className: 'w-[80px]' }, + { key: 'quantity', label: '수량', className: 'w-[60px] text-right' }, + { key: 'orderDate', label: '발주일', className: 'w-[90px]' }, + { key: 'plannedDeliveryDate', label: '계획인수일', className: 'w-[90px]' }, + { key: 'actualDeliveryDate', label: '실제인수일', className: 'w-[90px]' }, + { key: 'status', label: '상태', className: 'w-[80px] text-center' }, + { key: 'actions', label: '작업', className: 'w-[80px] text-center' }, + ], + + // ===== 클라이언트 사이드 필터링 ===== + clientSideFiltering: true, + + // 검색 필터 함수 + searchFilter: (item: Order, searchValue: string) => { + const search = searchValue.toLowerCase(); + return ( + item.orderNumber.toLowerCase().includes(search) || + item.partnerName.toLowerCase().includes(search) || + item.siteName.toLowerCase().includes(search) || + item.orderManager.toLowerCase().includes(search) + ); + }, + + // ===== 필터 설정 ===== + filterConfig: [ + { key: 'partners', label: '거래처', type: 'multi', options: partnerOptions }, + { key: 'sites', label: '현장명', type: 'multi', options: siteOptions }, + { key: 'constructionPMs', label: '공사PM', type: 'multi', options: constructionPMOptions }, + { key: 'orderManagers', label: '발주담당자', type: 'multi', options: orderManagerOptions }, + { key: 'orderCompanies', label: '발주처', type: 'multi', options: orderCompanyOptions }, + { key: 'workTeamLeaders', label: '작업반장', type: 'multi', options: workTeamOptions }, + { key: 'orderTypes', label: '구분', type: 'multi', options: orderTypeOptions }, + { key: 'status', label: '상태', type: 'single', options: ORDER_STATUS_OPTIONS.filter(opt => opt.value !== 'all') }, + { key: 'sortBy', label: '정렬', type: 'single', options: ORDER_SORT_OPTIONS, allOptionLabel: '최신순' }, + ] as FilterFieldConfig[], + + // 커스텀 필터 적용 함수 + customFilterFn: (items: Order[], filterValues: Record) => { + return items.filter((order) => { + // 거래처 필터 + const partners = filterValues.partners as string[] || []; + if (partners.length > 0) { + const matchingPartner = MOCK_PARTNERS.find((p) => p.label === order.partnerName); + if (!matchingPartner || !partners.includes(matchingPartner.value)) { + return false; + } + } + + // 현장명 필터 + const sites = filterValues.sites as string[] || []; + if (sites.length > 0) { + const matchingSite = MOCK_SITES.find((s) => s.label === order.siteName); + if (!matchingSite || !sites.includes(matchingSite.value)) { + return false; + } + } + + // 공사PM 필터 + const constructionPMs = filterValues.constructionPMs as string[] || []; + if (constructionPMs.length > 0) { + const matchingPM = MOCK_CONSTRUCTION_PM.find((p) => p.label === order.constructionPM); + if (!matchingPM || !constructionPMs.includes(matchingPM.value)) { + return false; + } + } + + // 발주담당자 필터 + const orderManagers = filterValues.orderManagers as string[] || []; + if (orderManagers.length > 0) { + const matchingManager = MOCK_ORDER_MANAGERS.find((m) => m.label === order.orderManager); + if (!matchingManager || !orderManagers.includes(matchingManager.value)) { + return false; + } + } + + // 발주처 필터 + const orderCompanies = filterValues.orderCompanies as string[] || []; + if (orderCompanies.length > 0) { + const matchingCompany = MOCK_ORDER_COMPANIES.find((c) => c.label === order.orderCompany); + if (!matchingCompany || !orderCompanies.includes(matchingCompany.value)) { + return false; + } + } + + // 작업반장 필터 + const workTeamLeaders = filterValues.workTeamLeaders as string[] || []; + if (workTeamLeaders.length > 0) { + const matchingLeader = MOCK_WORK_TEAM_LEADERS.find((l) => l.label === order.workTeamLeader); + if (!matchingLeader || !workTeamLeaders.includes(matchingLeader.value)) { + return false; + } + } + + // 구분 필터 + const orderTypes = filterValues.orderTypes as string[] || []; + if (orderTypes.length > 0 && !orderTypes.includes(order.orderType)) { + return false; + } + + // 상태 필터 + const status = filterValues.status as string || 'all'; + if (status !== 'all' && order.status !== status) { + return false; + } + + // 달력 날짜 필터 (selectedCalendarDate) + if (selectedCalendarDate) { + const orderStart = startOfDay(parseISO(order.periodStart)); + const orderEnd = startOfDay(parseISO(order.periodEnd)); + const selected = startOfDay(selectedCalendarDate); + + if (selected < orderStart || selected > orderEnd) { + return false; + } + } + + return true; + }); + }, + + // 커스텀 정렬 함수 + customSortFn: (items: Order[], filterValues: Record) => { + const sortBy = filterValues.sortBy as string || 'latest'; + const sorted = [...items]; + + switch (sortBy) { + case 'latest': + sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + break; + case 'oldest': + sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + break; + case 'partnerNameAsc': + sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko')); + break; + case 'partnerNameDesc': + sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko')); + break; + case 'siteNameAsc': + sorted.sort((a, b) => a.siteName.localeCompare(b.siteName, 'ko')); + break; + case 'siteNameDesc': + sorted.sort((a, b) => b.siteName.localeCompare(a.siteName, 'ko')); + break; + case 'deliveryDateAsc': + sorted.sort((a, b) => a.plannedDeliveryDate.localeCompare(b.plannedDeliveryDate)); + break; + case 'deliveryDateDesc': + sorted.sort((a, b) => b.plannedDeliveryDate.localeCompare(a.plannedDeliveryDate)); + break; + } + + return sorted; + }, + + // ===== 검색 설정 ===== + searchPlaceholder: '발주번호, 거래처, 현장명, 발주담당 검색', + + // ===== 상세 보기 모드 ===== + detailMode: 'page', + + // ===== 헤더 액션 ===== + headerActions: ({ onCreate }) => ( + + + 발주 등록 + + } + /> + ), + + // ===== 테이블 헤더 추가 액션 ===== + tableHeaderActions: ( +
+ {selectedCalendarDate && ( + <> + + ({format(selectedCalendarDate, 'M/d')} 필터 적용중) + + + + )} +
+ ), + + // ===== 삭제 확인 메시지 ===== + deleteConfirmMessage: { + title: '발주 삭제', + description: '선택한 발주를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.', + }, + + // ===== 테이블 행 렌더링 ===== + renderTableRow: ( + item: Order, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => { + const { isSelected, onToggle, onRowClick, onEdit, onDelete } = handlers; + + return ( + onRowClick(item)} + > + e.stopPropagation()}> + + + {globalIndex} + {item.contractNumber} + {item.partnerName} + {item.siteName} + {item.name} + {item.constructionPM} + {item.orderManager} + {item.orderNumber} + {item.orderCompany} + {item.workTeamLeader} + {formatDate(item.constructionStartDate)} + + + {ORDER_TYPE_LABELS[item.orderType]} + + + {item.item} + {item.quantity} + {formatDate(item.orderDate)} + {formatDate(item.plannedDeliveryDate)} + {formatDate(item.actualDeliveryDate)} + + + {ORDER_STATUS_LABELS[item.status]} + + + e.stopPropagation()}> + {isSelected && ( +
+ + +
+ )} +
+
+ ); + }, + + // ===== 모바일 카드 렌더링 ===== + renderMobileCard: ( + item: Order, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => { + const { isSelected, onToggle, onRowClick, onEdit, onDelete } = handlers; + + return ( + + + #{globalIndex} + + + {item.orderNumber} + + + } + statusBadge={ + + {ORDER_STATUS_LABELS[item.status]} + + } + isSelected={isSelected} + onToggleSelection={onToggle} + onCardClick={() => onRowClick(item)} + infoGrid={ +
+ + + + +
+ } + actions={ + isSelected ? ( +
+ + +
+ ) : undefined + } + /> + ); + }, + + // ===== 테이블 전 콘텐츠 (달력) ===== + beforeTableContent: ( +
+ +
+ ), + + // ===== 추가 옵션 ===== + showCheckbox: true, + showRowNumber: true, + itemsPerPage: 20, + }), [ + startDate, + endDate, + selectedCalendarDate, + calendarDate, + calendarEvents, + calendarBadges, + calendarFilterSlot, + isLoading, + handleCalendarDateClick, + handleCalendarEventClick, + handleCalendarMonthChange, + partnerOptions, + siteOptions, + constructionPMOptions, + orderManagerOptions, + orderCompanyOptions, + workTeamOptions, + orderTypeOptions, + router, + ]); + + return ( + + config={config} + initialData={initialData} + /> + ); +} + +export default OrderManagementUnified; diff --git a/src/components/hr/CardManagement/CardManagementUnified.tsx b/src/components/hr/CardManagement/CardManagementUnified.tsx new file mode 100644 index 00000000..e492f2b4 --- /dev/null +++ b/src/components/hr/CardManagement/CardManagementUnified.tsx @@ -0,0 +1,266 @@ +'use client'; + +import { useMemo } from 'react'; +import { CreditCard, Edit, Trash2, Plus } from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { Button } from '@/components/ui/button'; +import { Checkbox } from '@/components/ui/checkbox'; +import { TableRow, TableCell } from '@/components/ui/table'; +import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; +import { + UniversalListPage, + type UniversalListConfig, + type SelectionHandlers, + type RowClickHandlers, +} from '@/components/templates/UniversalListPage'; +import type { Card } from './types'; +import { + CARD_STATUS_LABELS, + CARD_STATUS_COLORS, + getCardCompanyLabel, +} from './types'; +import { getCards, deleteCard, deleteCards } from './actions'; + +// 카드번호는 이미 마스킹되어 있음 (****-****-****-1234) +const maskCardNumber = (cardNumber: string): string => { + return cardNumber; +}; + +interface CardManagementUnifiedProps { + initialData?: Card[]; +} + +export function CardManagementUnified({ initialData }: CardManagementUnifiedProps) { + // UniversalListPage Config 정의 + const config: UniversalListConfig = useMemo(() => ({ + // ===== 페이지 기본 정보 ===== + title: '카드관리', + description: '카드 목록을 관리합니다', + icon: CreditCard, + basePath: '/hr/card-management', + + // ===== ID 추출 ===== + idField: 'id', + + // ===== API 액션 ===== + actions: { + getList: async () => { + const result = await getCards({ per_page: 100 }); + return { + success: result.success, + data: result.data, + totalCount: result.data?.length || 0, + error: result.error, + }; + }, + deleteItem: async (id: string) => { + return await deleteCard(id); + }, + deleteBulk: async (ids: string[]) => { + return await deleteCards(ids); + }, + }, + + // ===== 테이블 컬럼 ===== + columns: [ + { key: 'rowNumber', label: '번호', className: 'w-[60px] text-center' }, + { key: 'cardCompany', label: '카드사', className: 'min-w-[100px]' }, + { key: 'cardNumber', label: '카드번호', className: 'min-w-[160px]' }, + { key: 'cardName', label: '카드명', className: 'min-w-[120px]' }, + { key: 'status', label: '상태', className: 'min-w-[80px]' }, + { key: 'department', label: '부서', className: 'min-w-[100px]' }, + { key: 'userName', label: '사용자', className: 'min-w-[100px]' }, + { key: 'position', label: '직책', className: 'min-w-[100px]' }, + { key: 'actions', label: '작업', className: 'w-[100px] text-right' }, + ], + + // ===== 클라이언트 사이드 필터링 ===== + clientSideFiltering: true, + + // 탭 필터 함수 + tabFilter: (item: Card, activeTab: string) => { + if (activeTab === 'all') return true; + return item.status === activeTab; + }, + + // 검색 필터 함수 + searchFilter: (item: Card, searchValue: string) => { + const search = searchValue.toLowerCase(); + return ( + item.cardName.toLowerCase().includes(search) || + item.cardNumber.includes(search) || + getCardCompanyLabel(item.cardCompany).toLowerCase().includes(search) || + (item.user?.employeeName.toLowerCase().includes(search) || false) + ); + }, + + // ===== 탭 설정 (데이터 기반으로 count 업데이트) ===== + tabs: [ + { value: 'all', label: '전체', count: 0, color: 'gray' }, + { value: 'active', label: '사용', count: 0, color: 'green' }, + { value: 'suspended', label: '정지', count: 0, color: 'red' }, + ], + + // ===== 검색 설정 ===== + searchPlaceholder: '카드명, 카드번호, 카드사, 사용자 검색...', + + // ===== 상세 보기 모드 ===== + detailMode: 'page', + + // ===== 헤더 액션 ===== + headerActions: ({ onCreate }) => ( + + ), + + // ===== 삭제 확인 메시지 ===== + deleteConfirmMessage: { + title: '카드 삭제', + description: '삭제된 카드 정보는 복구할 수 없습니다.', + }, + + // ===== 테이블 행 렌더링 ===== + renderTableRow: ( + item: Card, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => { + const { isSelected, onToggle, onRowClick, onEdit, onDelete } = handlers; + + return ( + onRowClick(item)} + > + e.stopPropagation()}> + + + + {globalIndex} + + {getCardCompanyLabel(item.cardCompany)} + {maskCardNumber(item.cardNumber)} + {item.cardName} + + + {CARD_STATUS_LABELS[item.status]} + + + {item.user?.departmentName || '-'} + {item.user?.employeeName || '-'} + {item.user?.positionName || '-'} + e.stopPropagation()}> + {isSelected && ( +
+ + +
+ )} +
+
+ ); + }, + + // ===== 모바일 카드 렌더링 ===== + renderMobileCard: ( + item: Card, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => { + const { isSelected, onToggle, onRowClick, onEdit, onDelete } = handlers; + + return ( + + + #{globalIndex} + + + {getCardCompanyLabel(item.cardCompany)} + + + } + statusBadge={ + + {CARD_STATUS_LABELS[item.status]} + + } + isSelected={isSelected} + onToggleSelection={onToggle} + onCardClick={() => onRowClick(item)} + infoGrid={ +
+ + + + + +
+ } + actions={ + isSelected ? ( +
+ + +
+ ) : undefined + } + /> + ); + }, + + // ===== 추가 옵션 ===== + showCheckbox: true, + showRowNumber: true, + itemsPerPage: 20, + }), []); + + return ( + + config={config} + initialData={initialData} + /> + ); +} diff --git a/src/components/templates/UniversalListPage/index.tsx b/src/components/templates/UniversalListPage/index.tsx new file mode 100644 index 00000000..b05e9868 --- /dev/null +++ b/src/components/templates/UniversalListPage/index.tsx @@ -0,0 +1,525 @@ +'use client'; + +/** + * UniversalListPage - 통합 리스트 페이지 컴포넌트 + * + * 59개 리스트 페이지를 하나의 config 기반 컴포넌트로 통합 + * 기존 기능 100% 유지, 테이블 영역만 공통화 + * + * 지원 모드: + * - 서버 사이드 필터링/페이지네이션 (기본) + * - 클라이언트 사이드 필터링/페이지네이션 (clientSideFiltering: true) + */ + +import { useState, useEffect, useCallback, useMemo } from 'react'; +import { useRouter, useParams } from 'next/navigation'; +import { toast } from 'sonner'; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from '@/components/ui/alert-dialog'; +import { + IntegratedListTemplateV2, + type PaginationConfig, +} from '@/components/templates/IntegratedListTemplateV2'; +import type { + UniversalListPageProps, + TabOption, + FilterValues, +} from './types'; + +export function UniversalListPage({ + config, + initialData, + initialTotalCount, +}: UniversalListPageProps) { + const router = useRouter(); + const params = useParams(); + const locale = (params.locale as string) || 'ko'; + + // ===== 상태 관리 ===== + // 원본 데이터 (클라이언트 사이드 필터링용) + const [rawData, setRawData] = useState(initialData || []); + // UI 상태 + const [currentPage, setCurrentPage] = useState(1); + const [isLoading, setIsLoading] = useState(!initialData); + const [searchValue, setSearchValue] = useState(''); + const [selectedItems, setSelectedItems] = useState>(new Set()); + // 탭이 없으면 IntegratedListTemplateV2의 기본값 'default'와 일치해야 함 + const [activeTab, setActiveTab] = useState( + config.defaultTab || config.tabs?.[0]?.value || 'default' + ); + const [filters, setFilters] = useState>( + config.initialFilters || {} + ); + const [tabs, setTabs] = useState(config.tabs || []); + + // 모달 상태 (detailMode === 'modal'일 때 사용) + const [isModalOpen, setIsModalOpen] = useState(false); + const [selectedItem, setSelectedItem] = useState(null); + + // 삭제 다이얼로그 상태 + const [deleteDialogOpen, setDeleteDialogOpen] = useState(false); + const [itemToDelete, setItemToDelete] = useState(null); + const [isBulkDelete, setIsBulkDelete] = useState(false); + + const itemsPerPage = config.itemsPerPage || 20; + + // ===== ID 추출 헬퍼 ===== + const getItemId = useCallback( + (item: T): string => { + if (typeof config.idField === 'function') { + return config.idField(item); + } + return String(item[config.idField]); + }, + [config.idField] + ); + + // ===== 클라이언트 사이드 필터링 ===== + const filteredData = useMemo(() => { + if (!config.clientSideFiltering) { + return rawData; + } + + let filtered = [...rawData]; + + // 커스텀 필터 함수 (filterConfig 기반 복잡한 필터링) + if (config.customFilterFn) { + filtered = config.customFilterFn(filtered, filters); + } + + // 탭 필터 + if (activeTab !== 'all' && config.tabFilter) { + filtered = filtered.filter((item) => config.tabFilter!(item, activeTab)); + } + + // 검색 필터 + if (searchValue && config.searchFilter) { + filtered = filtered.filter((item) => + config.searchFilter!(item, searchValue) + ); + } + + // 커스텀 정렬 함수 + if (config.customSortFn) { + filtered = config.customSortFn(filtered, filters); + } + + return filtered; + }, [rawData, activeTab, searchValue, filters, config.clientSideFiltering, config.tabFilter, config.searchFilter, config.customFilterFn, config.customSortFn]); + + // 클라이언트 사이드 페이지네이션 + const paginatedData = useMemo(() => { + if (!config.clientSideFiltering) { + return rawData; + } + const startIndex = (currentPage - 1) * itemsPerPage; + return filteredData.slice(startIndex, startIndex + itemsPerPage); + }, [config.clientSideFiltering, filteredData, currentPage, itemsPerPage, rawData]); + + // 총 개수 및 페이지 수 + const totalCount = config.clientSideFiltering ? filteredData.length : rawData.length; + const totalPages = Math.ceil(totalCount / itemsPerPage); + + // 표시할 데이터 + const displayData = config.clientSideFiltering ? paginatedData : rawData; + + // ===== 탭 카운트 계산 (클라이언트 사이드) ===== + const computedTabs = useMemo(() => { + if (!config.clientSideFiltering || !config.tabs || !config.tabFilter) { + return tabs; + } + + return config.tabs.map((tab) => { + if (tab.value === 'all') { + return { ...tab, count: rawData.length }; + } + const count = rawData.filter((item) => config.tabFilter!(item, tab.value)).length; + return { ...tab, count }; + }); + }, [config.clientSideFiltering, config.tabs, config.tabFilter, rawData, tabs]); + + // ===== 데이터 로딩 ===== + const fetchData = useCallback(async () => { + setIsLoading(true); + try { + const result = await config.actions.getList( + config.clientSideFiltering + ? { pageSize: 9999 } // 클라이언트 사이드: 전체 데이터 로드 + : { + page: currentPage, + pageSize: itemsPerPage, + search: searchValue, + filters, + tab: activeTab, + } + ); + + if (result.success && result.data) { + setRawData(result.data); + setIsLoading(false); + } else { + toast.error(result.error || '데이터를 불러오는데 실패했습니다.'); + setIsLoading(false); + } + } catch (error) { + console.error('[UniversalListPage] Fetch error:', error); + toast.error('데이터를 불러오는데 실패했습니다.'); + setIsLoading(false); + } + }, [config.actions, config.clientSideFiltering, currentPage, itemsPerPage, searchValue, filters, activeTab]); + + // 초기 로딩 + useEffect(() => { + if (!initialData) { + fetchData(); + } + }, []); + + // 서버 사이드 필터링: 의존성 변경 시 데이터 새로고침 + useEffect(() => { + if (!config.clientSideFiltering && !isLoading) { + fetchData(); + } + }, [currentPage, searchValue, filters, activeTab]); + + // 동적 탭 로딩 + useEffect(() => { + if (config.fetchTabs) { + config.fetchTabs().then((fetchedTabs) => { + setTabs(fetchedTabs); + if (!activeTab || activeTab === 'all') { + setActiveTab(fetchedTabs[0]?.value || 'all'); + } + }); + } + }, [config.fetchTabs]); + + // ===== 선택 핸들러 ===== + const toggleSelection = useCallback( + (id: string) => { + setSelectedItems((prev) => { + const newSet = new Set(prev); + if (newSet.has(id)) { + newSet.delete(id); + } else { + newSet.add(id); + } + return newSet; + }); + }, + [] + ); + + const toggleSelectAll = useCallback(() => { + const currentData = displayData; + if (selectedItems.size === currentData.length && currentData.length > 0) { + setSelectedItems(new Set()); + } else { + const allIds = new Set(currentData.map((item) => getItemId(item))); + setSelectedItems(allIds); + } + }, [displayData, selectedItems.size, getItemId]); + + // ===== 행 클릭 핸들러 ===== + const handleRowClick = useCallback( + (item: T) => { + const id = getItemId(item); + const detailMode = config.detailMode || 'page'; + + if (detailMode === 'modal') { + setSelectedItem(item); + setIsModalOpen(true); + } else if (detailMode === 'page') { + router.push(`/${locale}${config.basePath}/${id}`); + } + }, + [config.basePath, config.detailMode, getItemId, locale, router] + ); + + const handleEdit = useCallback( + (item: T) => { + const id = getItemId(item); + router.push(`/${locale}${config.basePath}/${id}/edit`); + }, + [config.basePath, getItemId, locale, router] + ); + + const handleCreate = useCallback(() => { + router.push(`/${locale}${config.basePath}/new`); + }, [config.basePath, locale, router]); + + // ===== 삭제 핸들러 ===== + const handleDeleteClick = useCallback((item: T) => { + setItemToDelete(item); + setIsBulkDelete(false); + setDeleteDialogOpen(true); + }, []); + + const handleBulkDeleteClick = useCallback(() => { + if (selectedItems.size === 0) { + toast.warning('삭제할 항목을 선택해주세요.'); + return; + } + setIsBulkDelete(true); + setDeleteDialogOpen(true); + }, [selectedItems.size]); + + const handleDeleteConfirm = useCallback(async () => { + try { + if (isBulkDelete) { + if (config.actions.deleteBulk) { + const result = await config.actions.deleteBulk(Array.from(selectedItems)); + if (result.success) { + toast.success(`${selectedItems.size}건이 삭제되었습니다.`); + // 클라이언트 사이드: 로컬 데이터에서 제거 + if (config.clientSideFiltering) { + setRawData((prev) => + prev.filter((item) => !selectedItems.has(getItemId(item))) + ); + } else { + fetchData(); + } + setSelectedItems(new Set()); + } else { + toast.error(result.error || '삭제에 실패했습니다.'); + } + } else if (config.actions.deleteItem) { + const ids = Array.from(selectedItems); + let successCount = 0; + for (const id of ids) { + const result = await config.actions.deleteItem(id); + if (result.success) successCount++; + } + toast.success(`${successCount}건이 삭제되었습니다.`); + if (config.clientSideFiltering) { + setRawData((prev) => + prev.filter((item) => !selectedItems.has(getItemId(item))) + ); + } else { + fetchData(); + } + setSelectedItems(new Set()); + } + } else if (itemToDelete) { + if (config.actions.deleteItem) { + const id = getItemId(itemToDelete); + const result = await config.actions.deleteItem(id); + if (result.success) { + toast.success('삭제되었습니다.'); + if (config.clientSideFiltering) { + setRawData((prev) => prev.filter((item) => getItemId(item) !== id)); + } else { + fetchData(); + } + } else { + toast.error(result.error || '삭제에 실패했습니다.'); + } + } + } + } catch (error) { + console.error('[UniversalListPage] Delete error:', error); + toast.error('삭제 중 오류가 발생했습니다.'); + } finally { + setDeleteDialogOpen(false); + setItemToDelete(null); + } + }, [config.actions, config.clientSideFiltering, fetchData, getItemId, isBulkDelete, itemToDelete, selectedItems]); + + // ===== 검색 핸들러 ===== + const handleSearchChange = useCallback((value: string) => { + setSearchValue(value); + setCurrentPage(1); + setSelectedItems(new Set()); + }, []); + + // ===== 필터 핸들러 ===== + const handleFilterChange = useCallback((key: string, value: string | string[]) => { + setFilters((prev) => ({ ...prev, [key]: value })); + setCurrentPage(1); + setSelectedItems(new Set()); + }, []); + + const handleFilterReset = useCallback(() => { + setFilters(config.initialFilters || {}); + setCurrentPage(1); + setSelectedItems(new Set()); + }, [config.initialFilters]); + + // ===== 탭 핸들러 ===== + const handleTabChange = useCallback((value: string) => { + setActiveTab(value); + setCurrentPage(1); + setSelectedItems(new Set()); + }, []); + + // ===== 페이지네이션 핸들러 ===== + const handlePageChange = useCallback((page: number) => { + setCurrentPage(page); + setSelectedItems(new Set()); + }, []); + + // ===== 통계 카드 계산 ===== + const computedStats = useMemo(() => { + if (config.computeStats) { + return config.computeStats( + config.clientSideFiltering ? rawData : displayData, + config.clientSideFiltering ? rawData.length : totalCount + ); + } + return config.stats; + }, [config.computeStats, config.stats, config.clientSideFiltering, rawData, displayData, totalCount]); + + // ===== 필터 값 변환 ===== + const filterValuesObj: FilterValues = useMemo(() => { + return filters as FilterValues; + }, [filters]); + + // ===== 페이지네이션 config ===== + const paginationConfig: PaginationConfig = useMemo( + () => ({ + currentPage, + totalPages, + totalItems: totalCount, + itemsPerPage, + onPageChange: handlePageChange, + }), + [currentPage, totalPages, totalCount, itemsPerPage, handlePageChange] + ); + + // ===== 렌더링 함수 래퍼 ===== + const renderTableRow = useCallback( + (item: T, index: number, globalIndex: number) => { + const id = getItemId(item); + const isSelected = selectedItems.has(id); + return config.renderTableRow(item, index, globalIndex, { + isSelected, + onToggle: () => toggleSelection(id), + onRowClick: () => handleRowClick(item), + onEdit: () => handleEdit(item), + onDelete: () => handleDeleteClick(item), + }); + }, + [config, getItemId, handleDeleteClick, handleEdit, handleRowClick, selectedItems, toggleSelection] + ); + + const renderMobileCard = useCallback( + (item: T, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => { + return config.renderMobileCard(item, index, globalIndex, { + isSelected, + onToggle, + onRowClick: () => handleRowClick(item), + onEdit: () => handleEdit(item), + onDelete: () => handleDeleteClick(item), + }); + }, + [config, handleDeleteClick, handleEdit, handleRowClick] + ); + + // ===== 삭제 확인 메시지 ===== + const deleteConfirmTitle = config.deleteConfirmMessage?.title || '삭제 확인'; + const deleteConfirmDescription = + config.deleteConfirmMessage?.description || + (isBulkDelete + ? `선택한 ${selectedItems.size}건을 삭제하시겠습니까?` + : '이 항목을 삭제하시겠습니까?'); + + return ( + <> + + // 페이지 헤더 + title={config.title} + description={config.description} + icon={config.icon} + headerActions={config.headerActions?.({ onCreate: handleCreate })} + // 탭 콘텐츠 + tabsContent={config.tabsContent} + // 통계 카드 + stats={computedStats} + // 경고 배너 + alertBanner={config.alertBanner} + // 검색 및 필터 + searchValue={searchValue} + onSearchChange={handleSearchChange} + searchPlaceholder={config.searchPlaceholder} + extraFilters={config.extraFilters} + hideSearch={config.hideSearch} + // 탭 (빈 배열일 때는 undefined로 전달해서 IntegratedListTemplateV2의 기본 탭 사용) + tabs={computedTabs.length > 0 ? computedTabs : undefined} + activeTab={activeTab} + onTabChange={handleTabChange} + // 필터 시스템 + filterConfig={config.filterConfig} + filterValues={filterValuesObj} + onFilterChange={handleFilterChange} + onFilterReset={handleFilterReset} + filterTitle={config.filterTitle} + // 테이블 앞 콘텐츠 + beforeTableContent={config.beforeTableContent} + // 테이블 헤더 액션 + tableHeaderActions={config.tableHeaderActions} + // 테이블 컬럼 + tableColumns={config.columns} + // 테이블 푸터 + tableFooter={config.tableFooter} + // 데이터 + data={displayData} + totalCount={totalCount} + allData={config.clientSideFiltering ? filteredData : undefined} + // 체크박스 선택 + selectedItems={selectedItems} + onToggleSelection={toggleSelection} + onToggleSelectAll={toggleSelectAll} + getItemId={getItemId} + onBulkDelete={config.actions.deleteItem ? handleBulkDeleteClick : undefined} + // 표시 옵션 + showCheckbox={config.showCheckbox} + showRowNumber={config.showRowNumber} + // 렌더링 함수 + renderTableRow={renderTableRow} + renderMobileCard={renderMobileCard} + // 페이지네이션 + pagination={paginationConfig} + // 로딩 상태 + isLoading={isLoading} + /> + + {/* 삭제 확인 다이얼로그 */} + + + + {deleteConfirmTitle} + {deleteConfirmDescription} + + + 취소 + 삭제 + + + + + {/* 상세 모달 (detailMode === 'modal'일 때) */} + {config.detailMode === 'modal' && config.DetailModalComponent && ( + { + setIsModalOpen(false); + setSelectedItem(null); + }} + item={selectedItem} + onRefresh={fetchData} + /> + )} + + ); +} + +// 타입 re-export +export * from './types'; \ No newline at end of file diff --git a/src/components/templates/UniversalListPage/types.ts b/src/components/templates/UniversalListPage/types.ts new file mode 100644 index 00000000..de1d8f26 --- /dev/null +++ b/src/components/templates/UniversalListPage/types.ts @@ -0,0 +1,257 @@ +/** + * UniversalListPage 타입 정의 + * + * 59개 리스트 페이지를 통합하기 위한 config 기반 타입 시스템 + * 기존 기능 100% 유지, 테이블 영역만 공통화 + */ + +import { ReactNode, RefObject } from 'react'; +import { LucideIcon } from 'lucide-react'; +import type { FilterFieldConfig, FilterValues } from '@/components/molecules/MobileFilter'; + +// ===== 기본 타입 (IntegratedListTemplateV2에서 re-export) ===== +export type { FilterFieldConfig, FilterValues }; + +export interface TabOption { + value: string; + label: string; + count: number; + color?: string; +} + +export interface TableColumn { + key: string; + label: string; + className?: string; + hideOnMobile?: boolean; + hideOnTablet?: boolean; +} + +export interface StatCard { + label: string; + value: string | number; + icon: LucideIcon; + iconColor: string; + onClick?: () => void; + isActive?: boolean; +} + +// ===== API 응답 타입 ===== +export interface ListResult { + success: boolean; + data?: T[]; + totalCount?: number; + totalPages?: number; + error?: string; +} + +export interface DeleteResult { + success: boolean; + error?: string; +} + +// ===== 액션 설정 ===== +export interface ListActions { + /** 목록 조회 API */ + getList: (params?: ListParams) => Promise>; + /** 단일 삭제 API (선택) */ + deleteItem?: (id: string) => Promise; + /** 일괄 삭제 API (선택) */ + deleteBulk?: (ids: string[]) => Promise; +} + +export interface ListParams { + page?: number; + pageSize?: number; + search?: string; + filters?: Record; + tab?: string; + sortBy?: string; + sortOrder?: 'asc' | 'desc'; +} + +// ===== 상세 보기 모드 ===== +export type DetailMode = 'page' | 'modal' | 'none'; + +// ===== 커스텀 액션 버튼 ===== +export interface CustomAction { + key: string; + label: string; + icon?: LucideIcon; + variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link'; + /** 단일 아이템에 대한 액션 */ + onClick?: (item: T) => void | Promise; + /** 선택된 아이템들에 대한 일괄 액션 */ + onBulkClick?: (items: T[]) => void | Promise; + /** 액션 표시 조건 */ + showWhen?: 'always' | 'selected' | 'single-selected' | 'multi-selected'; + /** 확인 다이얼로그 표시 여부 */ + confirmDialog?: { + title: string; + description: string; + confirmText?: string; + cancelText?: string; + }; +} + +// ===== 선택 핸들러 (renderTableRow, renderMobileCard에서 사용) ===== +export interface SelectionHandlers { + isSelected: boolean; + onToggle: () => void; +} + +// ===== 행 클릭 핸들러 ===== +export interface RowClickHandlers { + onRowClick: (item: T) => void; + onEdit?: (item: T) => void; + onDelete?: (item: T) => void; +} + +// ===== 메인 Config 타입 ===== +export interface UniversalListConfig { + // ===== 페이지 기본 정보 ===== + /** 페이지 제목 */ + title: string; + /** 페이지 설명 (선택) */ + description?: string; + /** 페이지 아이콘 (선택) */ + icon?: LucideIcon; + /** 기본 경로 (예: '/hr/employee-management') */ + basePath: string; + + // ===== ID 추출 ===== + /** 아이템에서 ID 추출 (string 키 또는 함수) */ + idField: keyof T | ((item: T) => string); + + // ===== API 액션 ===== + actions: ListActions; + + // ===== 테이블 컬럼 ===== + columns: TableColumn[]; + + // ===== 필터 설정 ===== + /** 필터 필드 설정 */ + filterConfig?: FilterFieldConfig[]; + /** 필터 초기값 */ + initialFilters?: Record; + /** 필터 바텀시트 제목 (모바일) */ + filterTitle?: string; + + // ===== 탭 설정 ===== + /** 고정 탭 목록 */ + tabs?: TabOption[]; + /** 동적 탭 (API에서 가져오기) */ + fetchTabs?: () => Promise; + /** 기본 활성 탭 */ + defaultTab?: string; + + // ===== 통계 카드 ===== + /** 고정 통계 카드 */ + stats?: StatCard[]; + /** 동적 통계 (데이터 기반 계산) */ + computeStats?: (data: T[], totalCount: number) => StatCard[]; + + // ===== 렌더링 함수 ===== + /** 테이블 행 렌더링 */ + renderTableRow: ( + item: T, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => ReactNode; + /** 모바일 카드 렌더링 */ + renderMobileCard: ( + item: T, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => ReactNode; + + // ===== 상세 보기 설정 ===== + /** 상세 보기 모드 (기본: 'page') */ + detailMode?: DetailMode; + /** 모달 모드일 때 사용할 모달 컴포넌트 */ + DetailModalComponent?: React.ComponentType<{ + isOpen: boolean; + onClose: () => void; + item: T | null; + onRefresh?: () => void; + }>; + + // ===== 커스텀 액션 ===== + /** 헤더 액션 (등록 버튼 등) */ + headerActions?: (params: { onCreate?: () => void }) => ReactNode; + /** 커스텀 액션 버튼 (상신, 승인 등) */ + customActions?: CustomAction[]; + + // ===== 추가 옵션 ===== + /** 검색 플레이스홀더 */ + searchPlaceholder?: string; + /** 검색창 숨김 */ + hideSearch?: boolean; + /** 체크박스 표시 여부 (기본: true) */ + showCheckbox?: boolean; + /** 번호 컬럼 표시 여부 (기본: true) */ + showRowNumber?: boolean; + /** 페이지당 항목 수 (기본: 20) */ + itemsPerPage?: number; + /** 삭제 확인 메시지 */ + deleteConfirmMessage?: { + title?: string; + description?: string; + }; + + // ===== 클라이언트 사이드 필터링 ===== + /** + * 클라이언트 사이드 필터링 모드 (기본: false) + * true인 경우 getList가 전체 데이터를 반환하고, 컴포넌트 내부에서 필터링/페이지네이션 처리 + */ + clientSideFiltering?: boolean; + /** 클라이언트 사이드 검색 필터 함수 */ + searchFilter?: (item: T, searchValue: string) => boolean; + /** 클라이언트 사이드 탭 필터 함수 */ + tabFilter?: (item: T, activeTab: string) => boolean; + /** 커스텀 필터 함수 (filterConfig 기반 복잡한 필터링) */ + customFilterFn?: (items: T[], filterValues: Record) => T[]; + /** 커스텀 정렬 함수 */ + customSortFn?: (items: T[], filterValues: Record) => T[]; + + // ===== 테이블 헤더 액션 ===== + /** 테이블 헤더 우측 추가 액션 (총건, 필터 해제 버튼 등) */ + tableHeaderActions?: ReactNode; + + // ===== 추가 슬롯 ===== + /** 테이블 앞 커스텀 콘텐츠 */ + beforeTableContent?: ReactNode; + /** 테이블 하단 푸터 */ + tableFooter?: ReactNode; + /** 경고 배너 */ + alertBanner?: ReactNode; + /** 헤더 액션 영역 아래, 검색 위 커스텀 탭 */ + tabsContent?: ReactNode; + /** 추가 필터 (Select, DatePicker 등) */ + extraFilters?: ReactNode; +} + +// ===== 컴포넌트 Props ===== +export interface UniversalListPageProps { + config: UniversalListConfig; + /** 초기 데이터 (SSR용, 선택) */ + initialData?: T[]; + /** 초기 총 개수 */ + initialTotalCount?: number; +} + +// ===== 내부 상태 타입 ===== +export interface ListState { + data: T[]; + totalCount: number; + totalPages: number; + currentPage: number; + isLoading: boolean; + searchValue: string; + selectedItems: Set; + activeTab: string; + filters: Record; + tabs: TabOption[]; +} diff --git a/src/components/templates/index.ts b/src/components/templates/index.ts index 04684e35..56a212b4 100644 --- a/src/components/templates/index.ts +++ b/src/components/templates/index.ts @@ -8,4 +8,21 @@ export type { VersionHistoryItem, DevMetadata, IntegratedListTemplateV2Props, -} from "./IntegratedListTemplateV2"; \ No newline at end of file +} from "./IntegratedListTemplateV2"; + +// UniversalListPage - 통합 리스트 페이지 컴포넌트 +export { UniversalListPage } from "./UniversalListPage"; +export type { + UniversalListConfig, + UniversalListPageProps, + ListActions, + ListParams, + ListResult, + DeleteResult, + CustomAction, + DetailMode, + SelectionHandlers, + RowClickHandlers, + FilterFieldConfig, + FilterValues, +} from "./UniversalListPage"; \ No newline at end of file diff --git a/src/layouts/AuthenticatedLayout.tsx b/src/layouts/AuthenticatedLayout.tsx index 16067b11..eceda07a 100644 --- a/src/layouts/AuthenticatedLayout.tsx +++ b/src/layouts/AuthenticatedLayout.tsx @@ -93,6 +93,9 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro // 회사 선택 상태 (목업) const [selectedCompany, setSelectedCompany] = useState("all"); + // 알림 벨 애니메이션 상태 (클릭으로 토글) + const [bellAnimating, setBellAnimating] = useState(true); + // 🔧 클라이언트 마운트 상태 (새로고침 시 스피너 표시용) const [isMounted, setIsMounted] = useState(false); @@ -438,52 +441,20 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro - {/* 알림 버튼 */} - - - - - - {/* 알림 리스트 */} -
- {MOCK_NOTIFICATIONS.map((notification) => ( -
- {/* 이미지 플레이스홀더 */} -
- IMG -
- {/* 내용 */} -
-
- {notification.category} - {notification.isNew && ( - - N - - )} -
-

{notification.title}

-
- {/* 날짜 */} - {notification.date} -
- ))} -
-
-
+ {/* 알림 버튼 - 클릭하면 애니메이션 토글 */} + {/* 유저 프로필 드롭다운 */} @@ -647,50 +618,22 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro 품질인정심사 - {/* 알림 버튼 */} - - - - - - {/* 알림 리스트 */} -
- {MOCK_NOTIFICATIONS.map((notification) => ( -
- {/* 이미지 플레이스홀더 */} -
- IMG -
- {/* 내용 */} -
-
- {notification.category} - {notification.isNew && ( - - N - - )} -
-

{notification.title}

-
- {/* 날짜 */} - {notification.date} -
- ))} -
-
-
+ {/* 알림 버튼 - 클릭하면 애니메이션 토글 */} + {/* 유저 프로필 드롭다운 */} From ad493bcea65e64447076fcbfec2a5ab984d747b9 Mon Sep 17 00:00:00 2001 From: byeongcheolryu Date: Fri, 16 Jan 2026 15:19:09 +0900 Subject: [PATCH 2/2] =?UTF-8?q?feat(WEB):=20UniversalListPage=20=EC=A0=84?= =?UTF-8?q?=EC=B2=B4=20=EB=A7=88=EC=9D=B4=EA=B7=B8=EB=A0=88=EC=9D=B4?= =?UTF-8?q?=EC=85=98=20=EB=B0=8F=20=EC=BD=94=EB=93=9C=20=EC=A0=95=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - UniversalListPage/IntegratedListTemplateV2 컴포넌트 기능 개선 - 회계, HR, 건설, 고객센터, 결재, 설정 등 전체 리스트 컴포넌트 마이그레이션 - 테스트 페이지 및 미사용 API 라우트 정리 (board-test, order-management-test 등) - 미들웨어 토큰 갱신 로직 개선 - AuthenticatedLayout 구조 개선 - claudedocs 문서 업데이트 Co-Authored-By: Claude Opus 4.5 --- ...-14] universal-list-component-checklist.md | 679 ++++++++++- ...UniversalListPage-pilot-session-context.md | 190 ++- .../[PLAN-2026-01-16] layout-restructure.md | 96 ++ ...6-01-15] universal-list-page-inspection.md | 200 ++++ .../[REF] UniversalListPage-QA-patterns.md | 313 +++++ claudedocs/[REF] mobile-zoom-fix-guide.md | 165 +++ .../[REF] mobile-zoom-prevention-guide.md | 101 ++ ...[IMPL-2025-12-30] token-refresh-caching.md | 10 + ...IMPL-2026-01-15] middleware-pre-refresh.md | 424 +++++++ .../[locale]/(protected)/board-test/page.tsx | 14 - .../(protected)/boards/[boardCode]/page.tsx | 189 +-- .../order/order-management-test/page.tsx | 16 - .../hr/card-management-test/page.tsx | 16 - .../client-management-sales-admin/page.tsx | 239 ++-- .../sales/order-management-sales/page.tsx | 301 +++-- .../production-orders/page.tsx | 164 ++- src/app/[locale]/layout.tsx | 12 +- src/app/api/files/[id]/download/route.ts | 65 -- src/app/api/menus/route.ts | 92 -- .../pages/[pageId]/route.ts | 60 - .../[tenantId]/item-master-config/route.ts | 74 -- src/app/globals.css | 12 + .../accounting/BadDebtCollection/index.tsx | 798 ++++++------- .../BankTransactionInquiry/index.tsx | 752 ++++++------ .../BillManagement/BillManagementClient.tsx | 342 ++++-- .../accounting/BillManagement/index.tsx | 751 ++++++------ .../CardTransactionInquiry/index.tsx | 735 ++++++------ .../accounting/DepositManagement/index.tsx | 890 +++++++------- .../ExpectedExpenseManagement/index.tsx | 351 +++--- .../accounting/PurchaseManagement/index.tsx | 916 +++++++-------- .../accounting/SalesManagement/index.tsx | 959 +++++++--------- .../accounting/VendorLedger/index.tsx | 403 ++++--- .../VendorManagementClient.tsx | 307 +++-- .../accounting/VendorManagement/index.tsx | 847 +++++++------- .../accounting/WithdrawalManagement/index.tsx | 896 ++++++++------- src/components/approval/ApprovalBox/index.tsx | 885 +++++++------- .../approval/DocumentCreate/index.tsx | 2 +- src/components/approval/DraftBox/index.tsx | 851 +++++++------- .../approval/ReferenceBox/index.tsx | 479 ++++---- src/components/board/BoardList/index.tsx | 535 +++++---- .../board/BoardManagement/index.tsx | 415 ++----- .../bidding/BiddingListClient.tsx | 774 +++++-------- .../contract/ContractListClient.tsx | 796 +++++-------- .../estimates/EstimateListClient.tsx | 735 +++++------- .../HandoverReportListClient.tsx | 656 +++++------ .../IssueManagementListClient.tsx | 828 ++++++------- .../item-management/ItemManagementClient.tsx | 228 ++-- .../LaborManagementClient.tsx | 733 +++++------- .../ConstructionManagementListClient.tsx | 882 ++++++-------- .../OrderManagementListClient.tsx | 1022 +++++++---------- .../partners/PartnerListClient.tsx | 690 ++++------- .../pricing-management/PricingListClient.tsx | 886 ++++++-------- .../ProgressBillingManagementListClient.tsx | 605 +++++----- .../site-briefings/SiteBriefingListClient.tsx | 829 +++++-------- .../SiteManagementListClient.tsx | 712 ++++-------- .../StructureReviewListClient.tsx | 760 +++++------- .../UtilityManagementListClient.tsx | 788 +++++-------- .../worker-status/WorkerStatusListClient.tsx | 687 +++++------ .../EventManagement/EventList.tsx | 490 ++++---- .../InquiryManagement/InquiryList.tsx | 541 ++++----- .../NoticeManagement/NoticeList.tsx | 437 +++---- .../hr/AttendanceManagement/index.tsx | 485 ++++---- src/components/hr/CardManagement/index.tsx | 366 +++--- .../hr/EmployeeManagement/index.tsx | 670 ++++++----- src/components/hr/SalaryManagement/index.tsx | 403 +++---- .../hr/VacationManagement/index.tsx | 422 +++---- src/components/items/ItemListClient.tsx | 430 ++++--- .../ReceivingManagement/ReceivingList.tsx | 577 +++++----- .../material/StockStatus/StockStatusList.tsx | 640 +++++------ .../molecules/DateRangeSelector.tsx | 4 +- .../ShipmentManagement/ShipmentList.tsx | 631 +++++----- src/components/pricing/PricingListClient.tsx | 136 ++- .../process-management/ProcessListClient.tsx | 631 +++++----- .../production/WorkOrders/WorkOrderList.tsx | 537 ++++----- .../production/WorkResults/WorkResultList.tsx | 543 +++++---- .../InspectionManagement/InspectionList.tsx | 498 ++++---- .../quotes/QuoteManagementClient.tsx | 934 +++++++-------- .../settings/AccountManagement/index.tsx | 455 ++++---- .../PaymentHistoryClient.tsx | 358 +++--- .../PaymentHistoryManagement/index.tsx | 95 +- .../settings/PermissionManagement/index.tsx | 149 ++- .../settings/PopupManagement/PopupList.tsx | 504 ++++---- .../templates/IntegratedListTemplateV2.tsx | 94 +- .../templates/UniversalListPage/index.tsx | 209 +++- .../templates/UniversalListPage/types.ts | 149 ++- src/layouts/AuthenticatedLayout.tsx | 185 ++- src/lib/api/php-proxy.ts | 97 -- src/lib/utils/menuRefresh.ts | 4 +- src/middleware.ts | 336 +++++- tsconfig.tsbuildinfo | 2 +- 90 files changed, 19864 insertions(+), 20305 deletions(-) create mode 100644 claudedocs/[PLAN-2026-01-16] layout-restructure.md create mode 100644 claudedocs/[QA-2026-01-15] universal-list-page-inspection.md create mode 100644 claudedocs/[REF] UniversalListPage-QA-patterns.md create mode 100644 claudedocs/[REF] mobile-zoom-fix-guide.md create mode 100644 claudedocs/[REF] mobile-zoom-prevention-guide.md create mode 100644 claudedocs/auth/[IMPL-2026-01-15] middleware-pre-refresh.md delete mode 100644 src/app/[locale]/(protected)/board-test/page.tsx delete mode 100644 src/app/[locale]/(protected)/construction/order/order-management-test/page.tsx delete mode 100644 src/app/[locale]/(protected)/hr/card-management-test/page.tsx delete mode 100644 src/app/api/files/[id]/download/route.ts delete mode 100644 src/app/api/menus/route.ts delete mode 100644 src/app/api/tenants/[tenantId]/item-master-config/pages/[pageId]/route.ts delete mode 100644 src/app/api/tenants/[tenantId]/item-master-config/route.ts delete mode 100644 src/lib/api/php-proxy.ts diff --git a/claudedocs/[IMPL-2026-01-14] universal-list-component-checklist.md b/claudedocs/[IMPL-2026-01-14] universal-list-component-checklist.md index 2a6de393..705970d4 100644 --- a/claudedocs/[IMPL-2026-01-14] universal-list-component-checklist.md +++ b/claudedocs/[IMPL-2026-01-14] universal-list-component-checklist.md @@ -1,8 +1,94 @@ # UniversalListPage 컴포넌트 통합 작업 -> **목표**: 59개 리스트 페이지를 1개의 공통 컴포넌트로 통합 +> **목표**: 55개 리스트 페이지를 1개의 공통 컴포넌트로 통합 > **시작일**: 2026-01-14 > **원칙**: 기존 기능 100% 유지, 테이블 영역만 공통화 +> **상태**: ✅ 전체 완료 (55/55 페이지, 100%) + +--- + +## 📊 페이지 수 산정 (2026-01-16 확정) + +### 최종 페이지 수: 55개 + +| 항목 | 개수 | 설명 | +|------|------|------| +| UniversalListPage 사용 파일 | 62개 | 전체 import 기준 | +| 템플릿 export 파일 | -1개 | `templates/index.ts` (export only) | +| 중복 파일 쌍 | -6개 | wrapper + client 패턴 | +| **실제 페이지 수** | **55개** | | + +### 중복 파일 쌍 목록 (6쌍) + +동일한 페이지인데 wrapper(index.tsx)와 client 컴포넌트가 분리된 경우: + +| # | 페이지 | 파일 1 (wrapper) | 파일 2 (client) | +|---|--------|------------------|-----------------| +| 1 | 거래처관리(회계) | `VendorManagement/index.tsx` | `VendorManagementClient.tsx` | +| 2 | 어음관리 | `BillManagement/index.tsx` | `BillManagementClient.tsx` | +| 3 | 결제내역 | `PaymentHistoryManagement/index.tsx` | `PaymentHistoryClient.tsx` | +| 4 | 카드관리 | `CardManagement/index.tsx` | `CardManagementUnified.tsx` | +| 5 | 게시판목록 | `BoardList/index.tsx` | `BoardListUnified.tsx` | +| 6 | 발주관리 | `OrderManagementListClient.tsx` | `OrderManagementUnified.tsx` | + +### 마이그레이션 제외 페이지 + +| 파일 | 제외 사유 | +|------|----------| +| `construction/projects/ProjectListClient.tsx` | PageLayout 직접 사용 (IntegratedListTemplateV2 미사용) | +| `settings/PermissionManagement/index.tsx` | IntegratedListTemplateV2 미사용 | +| `customer-center/FAQManagement/FAQList.tsx` | IntegratedListTemplateV2 미사용 | +| `pricing/PricingListClient.tsx` (일반) | IntegratedListTemplateV2 미사용 | +| 영업 도메인 3개 | 별도 구조 사용 (추후 검토) | + +> **Note**: 수량이 변동되는 원인은 중복 파일(wrapper/client 패턴)과 제외 대상 파일 때문입니다. + +--- + +## 🎯 핵심 목적 (절대 잊지 말 것!) + +**이 공통화 작업의 근본적인 목적은 모바일에서 필터를 바텀시트로 보여주기 위함이다.** + +### filterConfig 사용 규칙 +- `filterConfig`를 사용하면 **자동으로** PC/모바일 분기 처리됨 + - **PC (1280px+)**: 인라인 필터 (테이블 헤더 영역) + - **모바일 (~1279px)**: 바텀시트 필터 (MobileFilter 컴포넌트) +- **새로운 모바일 필터 기능을 만들지 말 것!** 이미 공통화되어 있음 +- 정렬, 상태 필터 등 모든 필터는 `filterConfig`로 정의 + +### 예시 +```typescript +// ✅ 올바른 방식 - filterConfig 사용 +filterConfig: [ + { + key: 'sort', + label: '정렬', + type: 'single', + options: [{ value: 'latest', label: '최신순' }, ...], + }, +], + +// ❌ 잘못된 방식 - 별도 모바일 필터 구현 +mobileTableHeaderActions: ... // 이런 거 만들지 말 것! +``` + +--- + +## 🚨 작업 정책 (필독!) + +### 본 페이지 직접 작업 원칙 +- **테스트 페이지 생성 금지**: `-test` 접미사 페이지 만들지 말 것 +- **feature 브랜치 활용**: 이미 `feature/universal-list-component` 브랜치에서 작업 중 +- **본 페이지에 바로 적용**: 마이그레이션은 원본 파일에 직접 수행 +- **롤백 가능**: 문제 발생 시 `git checkout` 또는 브랜치 전환으로 복구 + +### ❌ 삭제된 테스트 페이지 (2026-01-14) +| 삭제된 테스트 페이지 | 본 페이지 | +|---------------------|----------| +| `/board-test/` | `/board/` | +| `/construction/order/order-management-test/` | `/construction/order/order-management/` | +| `/hr/card-management-test/` | `/hr/card-management/` | +| `/customer-center/notices-test/` | `/customer-center/notices/` | --- @@ -36,12 +122,271 @@ --- +## Phase 2.5: 공통 옵션화 리팩토링 ✅ 완료 + +> **목적**: headerActions의 달력/버튼을 config 옵션으로 통합하여 위치/스타일 공통 관리 + +### 2.5.1 DateRangeSelector 옵션화 +- [x] `UniversalListConfig`에 `dateRangeSelector` 옵션 추가 ✅ +- [x] IntegratedListTemplateV2에서 달력 렌더링 위치 통합 ✅ +- [x] 기존 페이지 headerActions → config 옵션으로 마이그레이션 ✅ + +```typescript +// config 옵션 정의 +dateRangeSelector?: { + enabled: boolean; + showPresets?: boolean; // 당월, 전월, 오늘 등 프리셋 버튼 + startDate?: string; + endDate?: string; + onStartDateChange?: (date: string) => void; + onEndDateChange?: (date: string) => void; +}; +``` + +### 2.5.2 등록 버튼 옵션화 +- [x] `UniversalListConfig`에 `createButton` 옵션 추가 ✅ +- [x] 버튼 위치 오른쪽 끝 고정 (공통) ✅ +- [x] 기존 페이지 headerActions → config 옵션으로 마이그레이션 ✅ + +```typescript +// config 옵션 정의 +createButton?: { + label: string; // '등록', '공정 등록' 등 + onClick: () => void; + icon?: LucideIcon; // 기본값: Plus +}; +``` + +### 2.5.3 레이아웃 규칙 +``` +[달력 (왼쪽)] -------------- [등록 버튼 (오른쪽 끝)] +``` + +### 마이그레이션 완료 파일 (Level 1) +| 파일 | 달력 | 등록버튼 | 상태 | +|-----|------|---------|------| +| InquiryList.tsx | ✅ | ✅ | ✅ 완료 | +| NoticeList.tsx | ✅ | ❌ | ✅ 완료 | +| EventList.tsx | ✅ | ❌ | ✅ 완료 | +| PopupList.tsx | ❌ | ✅ | ✅ 완료 | + +> **Note**: 나머지 페이지들은 Level 2+ 마이그레이션 시 적용 예정 + +--- + ## Phase 3: 파일럿 마이그레이션 -- [ ] 기본 케이스 1개 선정 및 마이그레이션 -- [ ] 특이 케이스 1개 선정 및 마이그레이션 -- [ ] 기능 테스트 (데스크톱 + 모바일) -- [ ] 스크린샷 비교 검증 +> ⚠️ **2026-01-14 수정**: 이전 세션에서 완료 표시했으나 실제 코드 미적용 확인됨. 파일럿은 건너뛰고 Level 1부터 순차 진행. + +- [ ] ~~기본 케이스 - 카드관리(HR)~~ → Level 3으로 이동 (복잡한 상태) +- [ ] ~~특이 케이스 - 게시판목록~~ → Level 4로 이동 (동적 탭) +- [ ] ~~특이 케이스 - 발주관리~~ → Level 2로 이동 (ScheduleCalendar) + +### Level 1 마이그레이션 진행 상황 (15/15 완료 ✅) + +| # | 파일 | 상태 | 완료일 | +|---|-----|------|--------| +| 1 | `production/WorkOrders/WorkOrderList.tsx` | ✅ 완료 | 2026-01-14 | +| 2 | `production/WorkResults/WorkResultList.tsx` | ✅ 완료 | 2026-01-14 | +| 3 | `outbound/ShipmentManagement/ShipmentList.tsx` | ✅ 완료 | 2026-01-14 | +| 4 | `material/StockStatus/StockStatusList.tsx` | ✅ 완료 | 2026-01-14 | +| 5 | `material/ReceivingManagement/ReceivingList.tsx` | ✅ 완료 | 2026-01-14 | +| 6 | `quality/InspectionManagement/InspectionList.tsx` | ✅ 완료 | 2026-01-14 | +| 7 | `items/ItemListClient.tsx` | ✅ 완료 | 2026-01-14 | +| 8 | `settings/PaymentHistoryManagement/PaymentHistoryClient.tsx` | ✅ 완료 | 2026-01-14 | +| 9 | `settings/PopupManagement/PopupList.tsx` | ✅ 완료 | 2026-01-14 | +| 10 | `customer-center/EventManagement/EventList.tsx` | ✅ 완료 | 2026-01-14 | +| 11 | `customer-center/InquiryManagement/InquiryList.tsx` | ✅ 완료 | 2026-01-14 | +| 12 | `customer-center/NoticeManagement/NoticeList.tsx` | ✅ 완료 | 2026-01-14 | +| 13 | `quotes/QuoteManagementClient.tsx` | ✅ 완료 | 2026-01-14 | +| 14 | `process-management/ProcessListClient.tsx` | ✅ 완료 | 2026-01-14 | +| 15 | `settings/AccountManagement/index.tsx` | ✅ 완료 | 2026-01-14 | + +### Level 2 마이그레이션 진행 상황 (건설 17개 + 회계 13개 = 총 30개) + +> **Note**: ProjectListClient는 PageLayout 직접 사용 (IntegratedListTemplateV2 미사용)으로 마이그레이션 대상에서 제외 + +#### 건설 도메인 (17개 대상, 17개 완료 ✅) +| # | 파일 | 특이사항 | 상태 | 완료일 | +|---|-----|---------|------|--------| +| 1 | `construction/estimates/EstimateListClient.tsx` | 거래처(다중), 견적자(다중), 상태, 정렬 | ✅ 완료 | 2026-01-14 | +| 2 | `construction/bidding/BiddingListClient.tsx` | 거래처(다중), 입찰자(다중), 상태, 정렬 | ✅ 완료 | 2026-01-14 | +| 3 | `construction/site-briefings/SiteBriefingListClient.tsx` | 거래처(다중), 타입, 상태, 정렬 | ✅ 완료 | 2026-01-14 | +| 4 | `construction/contract/ContractListClient.tsx` | 거래처(다중), 계약담당자(다중), 공사PM(다중) | ✅ 완료 | 2026-01-14 | +| 5 | `construction/partners/PartnerListClient.tsx` | 탭(전체/신규), 악성채권, 정렬 | ✅ 완료 | 2026-01-14 | +| 6 | `construction/handover-report/HandoverReportListClient.tsx` | 거래처, 계약담당자, 공사PM | ✅ 완료 | 2026-01-15 | +| 7 | `construction/worker-status/WorkerStatusListClient.tsx` | 거래처, 현장, 구분, 부서, 이름 | ✅ 완료 | 2026-01-15 | +| 8 | `construction/utility-management/UtilityManagementListClient.tsx` | 7개 필터, AlertDialog | ✅ 완료 | 2026-01-15 | +| 9 | `construction/progress-billing/ProgressBillingManagementListClient.tsx` | showQuickButtons | ✅ 완료 | 2026-01-15 | +| 10 | `construction/structure-review/StructureReviewListClient.tsx` | AlertDialog, createButton | ✅ 완료 | 2026-01-15 | +| 11 | `construction/site-management/SiteManagementListClient.tsx` | AlertDialog | ✅ 완료 | 2026-01-15 | +| 12 | `construction/pricing-management/PricingListClient.tsx` | **renderCustomTableHeader (동적 컬럼)** | ✅ 완료 | 2026-01-15 | +| 13 | `construction/issue-management/IssueManagementListClient.tsx` | bulkActions (회수 기능) | ✅ 완료 | 2026-01-15 | +| 14 | `construction/order-management/OrderManagementListClient.tsx` | ScheduleCalendar (beforeTableContent) | ✅ 완료 | 2026-01-15 | +| 15 | `construction/management/ConstructionManagementListClient.tsx` | ScheduleCalendar (beforeTableContent) | ✅ 완료 | 2026-01-15 | +| 16 | `construction/labor-management/LaborManagementClient.tsx` | 노무 관리 필터 | ✅ 완료 | 2026-01-15 | +| 17 | `construction/item-management/ItemManagementClient.tsx` | 품목 분류 필터 | ✅ 완료 | 2026-01-15 | + +#### 회계 도메인 (13개 대상, 13개 완료 ✅) +| # | 파일 | 특이사항 | 상태 | 완료일 | +|---|-----|---------|------|--------| +| 1 | `accounting/VendorManagement/index.tsx` | 5개 single 필터, Stats 카드 | ✅ 완료 | 2026-01-15 | +| 2 | `accounting/SalesManagement/index.tsx` | Switch, beforeTableContent, tableHeaderActions, tableFooter | ✅ 완료 | 2026-01-15 | +| 3 | `accounting/PurchaseManagement/index.tsx` | Switch, beforeTableContent, tableHeaderActions, tableFooter | ✅ 완료 | 2026-01-15 | +| 4 | `accounting/DepositManagement/index.tsx` | beforeTableContent (새로고침), tableHeaderActions | ✅ 완료 | 2026-01-15 | +| 5 | `accounting/WithdrawalManagement/index.tsx` | 계정과목명 저장, beforeTableContent, tableHeaderActions | ✅ 완료 | 2026-01-15 | +| 6 | `accounting/BillManagement/index.tsx` | 어음관리 필터, RadioGroup | ✅ 완료 | 2026-01-15 | +| 7 | `accounting/BadDebtCollection/index.tsx` | 부실채권 관리, Switch 토글, 3개 필터 | ✅ 완료 | 2026-01-15 | +| 8 | `accounting/BankTransactionInquiry/index.tsx` | 서버사이드 페이지네이션, tableFooter, 3개 필터 | ✅ 완료 | 2026-01-15 | +| 9 | `accounting/CardTransactionInquiry/index.tsx` | 상세 모달, 계정과목명 일괄 저장, 2개 필터 | ✅ 완료 | 2026-01-15 | +| 10 | `accounting/VendorLedger/index.tsx` | 서버사이드 페이지네이션, 엑셀 다운로드, tableFooter | ✅ 완료 | 2026-01-15 | +| 11 | `accounting/ExpectedExpenseManagement/index.tsx` | **매우 복잡** (월별 그룹핑, 폼 다이얼로그, externalPagination/externalSelection) | ✅ 완료 | 2026-01-15 | +| 12 | `accounting/BillManagement/BillManagementClient.tsx` | dateRangeSelector, beforeTableContent (상태+저장+라디오), externalPagination/Selection | ✅ 완료 | 2026-01-15 | +| 13 | `accounting/VendorManagement/VendorManagementClient.tsx` | computeStats, 5개 필터, 클라이언트 필터링, externalPagination/Selection | ✅ 완료 | 2026-01-15 | + +--- + +## 📋 마이그레이션 페이지별 테스트 체크리스트 + +### 데스크톱 기능 테스트 +- [ ] 테이블 렌더링 (데이터 표시, 컬럼 정렬) +- [ ] 행 선택 (체크박스 동작, 선택 카운터) +- [ ] 수정/삭제 버튼 (선택 시 표시, 페이지 이동) +- [ ] 필터 동작 (검색, 필터 적용, 초기화) +- [ ] 페이지네이션 (페이지 이동, 개수 변경) +- [ ] 탭 동작 (탭 전환, 데이터 필터링) + +### 📱 모바일 반응형 테스트 +> **최소 지원 너비**: 280px (모바일 최소 사이즈 기준) + +- [ ] **레이아웃 깨짐 확인**: 280px까지 축소 시 요소 겹침/튀어나감 없음 +- [ ] **줄바꿈 정상**: 긴 텍스트 줄바꿈 처리 확인 +- [ ] **버튼/뱃지**: 영역 밖으로 튀어나가지 않음 +- [ ] **모바일 필터**: 하단에서 슬라이드업 정상 동작 +- [ ] **필터 적용/초기화**: 모바일 필터 버튼 정상 작동 +- [ ] **모바일 카드 뷰**: renderMobileCard 정상 표시 +- [ ] **터치 동작**: 체크박스, 버튼 터치 반응 정상 + +### 스크린샷 비교 +- [ ] 데스크톱: 기존 페이지 vs 마이그레이션 페이지 비교 +- [ ] 모바일: 기존 페이지 vs 마이그레이션 페이지 비교 + +--- + +## 📊 복잡도별 분류 (마이그레이션 우선순위) + +> **통합 후 이점**: 새 기능 추가/버그 수정 시 55개 파일 → **1개 파일**만 수정 + +### Level 1 (기본) - 15개 ⭐ 1순위 +단순 테이블 + 기본 탭 필터만 있는 경우 + +| 파일 | 설명 | +|-----|------| +| `production/WorkOrders/WorkOrderList.tsx` | 탭 기반 상태 필터링 | +| `production/WorkResults/WorkResultList.tsx` | 기본 리스트 | +| `outbound/ShipmentManagement/ShipmentList.tsx` | 상태별 통계, 탭 필터 | +| `material/StockStatus/StockStatusList.tsx` | 재고 현황 | +| `material/ReceivingManagement/ReceivingList.tsx` | 기본 수입 목록 | +| `quality/InspectionManagement/InspectionList.tsx` | 검사 상태별 탭 | +| `items/ItemListClient.tsx` | 품목 유형별 탭 | +| `settings/PaymentHistoryManagement/PaymentHistoryClient.tsx` | 결제 이력 | +| `settings/PopupManagement/PopupList.tsx` | 팝업 관리 | +| `customer-center/EventManagement/EventList.tsx` | 이벤트 관리 | +| `customer-center/InquiryManagement/InquiryList.tsx` | 문의 관리 | +| `customer-center/NoticeManagement/NoticeList.tsx` | 공지사항 | +| `quotes/QuoteManagementClient.tsx` | 견적 관리 | +| `process-management/ProcessListClient.tsx` | 프로세스 관리 | +| `settings/AccountManagement/index.tsx` | 계정 관리 | + +### Level 2 (필터 복잡) - 30개 ⭐ 2순위 +FilterFieldConfig 기반 다중 필터, 정렬 옵션 (주류 패턴) + +#### 건설 도메인 (17개) +| 파일 | 특이사항 | +|-----|---------| +| `construction/site-briefings/SiteBriefingListClient.tsx` | 거래처(다중), 타입, 상태, 정렬 | +| `construction/estimates/EstimateListClient.tsx` | 거래처(다중), 견적자(다중), 상태, 정렬 | +| `construction/bidding/BiddingListClient.tsx` | 입찰 정보 필터 | +| `construction/contract/ContractListClient.tsx` | 계약 정보 필터 | +| `construction/partners/PartnerListClient.tsx` | 협력업체 필터 | +| `construction/handover-report/HandoverReportListClient.tsx` | 준공 보고 필터 | +| `construction/worker-status/WorkerStatusListClient.tsx` | 근로자 상태 필터 | +| `construction/utility-management/UtilityManagementListClient.tsx` | 유틸리티 관리 필터 | +| `construction/progress-billing/ProgressBillingManagementListClient.tsx` | 기성 청구 필터 | +| `construction/structure-review/StructureReviewListClient.tsx` | 구조 검토 필터 | +| `construction/site-management/SiteManagementListClient.tsx` | 현장 정보 필터 | +| `construction/pricing-management/PricingListClient.tsx` | **동적 컬럼 (renderCustomTableHeader)** | +| `construction/issue-management/IssueManagementListClient.tsx` | 거래처, 현장, 구분, 중요도, 상태 | +| `construction/order-management/OrderManagementListClient.tsx` | ScheduleCalendar (beforeTableContent) | +| `construction/management/ConstructionManagementListClient.tsx` | ScheduleCalendar (beforeTableContent) | +| `construction/labor-management/LaborManagementClient.tsx` | 노무 관리 필터 | +| `construction/item-management/ItemManagementClient.tsx` | 품목 분류 필터 | + +#### 회계 도메인 (13개) +| 파일 | 특이사항 | +|-----|---------| +| `accounting/VendorManagement/VendorManagementClient.tsx` | 거래처 분류(다중), 신용등급, 거래등급 | +| `accounting/VendorManagement/index.tsx` | VendorManagementClient wrapper | +| `accounting/PurchaseManagement/index.tsx` | 구매 관리 필터 | +| `accounting/SalesManagement/index.tsx` | 판매 관리 필터 | +| `accounting/DepositManagement/index.tsx` | 입금 관리 필터 | +| `accounting/WithdrawalManagement/index.tsx` | 출금 관리 필터 | +| `accounting/BadDebtCollection/index.tsx` | 부실채권 관리 필터 | +| `accounting/ExpectedExpenseManagement/index.tsx` | 예상 지출 관리 필터 | +| `accounting/BillManagement/index.tsx` | 청구서 관리 wrapper | +| `accounting/BillManagement/BillManagementClient.tsx` | 청구서 관리 필터 | +| `accounting/BankTransactionInquiry/index.tsx` | 입출금계좌조회 | +| `accounting/CardTransactionInquiry/index.tsx` | 카드내역조회 | +| `accounting/VendorLedger/index.tsx` | 거래처원장 | + +### Level 3~5 마이그레이션 (2026-01-15) ✅ 완료 + +> **결론 변경**: Level 3~5 컴포넌트(10개)도 **UniversalListPage로 마이그레이션 진행** +> **이유**: 장기적 유지보수 및 모바일 대응 일원화를 위해 모든 리스트 페이지 통합 + +#### Phase 3-1: UniversalListPage 기능 확장 ✅ 완료 + +| 기능 | 설명 | 상태 | +|------|------|------| +| `renderDialogs` | 커스텀 다이얼로그 슬롯 | [x] 완료 | +| `dynamicHeaderActions` | 선택 상태 기반 동적 헤더 액션 | [x] 완료 (tableHeaderActions에 selectedItems 전달) | +| `fetchTabs` | API 기반 동적 탭 생성 | [x] 완료 (이미 구현되어 있었음) | +| `columnsPerTab` | 탭별 다른 컬럼 구조 지원 | [x] 완료 | +| `extraFilters` | 추가 필터 슬롯 | [x] 완료 (이미 구현되어 있었음) | + +#### Phase 3-2: 게시판 도메인 마이그레이션 (2개) ✅ 완료 + +| # | 파일 | 특이사항 | 상태 | +|---|------|---------|------| +| 1 | `board/BoardManagement/index.tsx` | AlertDialog + 선택 기반 수정/삭제 | [x] 완료 | +| 2 | `board/BoardList/index.tsx` | API 동적 탭 (fetchTabs) + 서버사이드 페이지네이션 | [x] 완료 | + +#### Phase 3-3: 전자결재 도메인 마이그레이션 (3개) ✅ 완료 + +| # | 파일 | 특이사항 | 상태 | +|---|------|---------|------| +| 1 | `approval/DraftBox/index.tsx` | DocumentDetailModal + 상신/삭제 + 동적 헤더 | [x] 완료 | +| 2 | `approval/ApprovalBox/index.tsx` | DocumentDetailModal + 승인/반려 다이얼로그 | [x] 완료 | +| 3 | `approval/ReferenceBox/index.tsx` | DocumentDetailModal + 열람/미열람 처리 | [x] 완료 | + +#### Phase 3-4: HR 도메인 마이그레이션 (5개) ✅ 완료 + +| # | 파일 | 특이사항 | 상태 | +|---|------|---------|------| +| 1 | `hr/CardManagement/index.tsx` | 3개 탭 + AlertDialog (가장 단순) | [x] 완료 | +| 2 | `hr/SalaryManagement/index.tsx` | SalaryDetailDialog + 선택 기반 동적 버튼 | [x] 완료 | +| 3 | `hr/AttendanceManagement/index.tsx` | 9개 탭 + 2개 다이얼로그 + extraFilters | [x] 완료 | +| 4 | `hr/EmployeeManagement/index.tsx` | 4개 탭 + 복수 다이얼로그 + DateRangeSelector | [x] 완료 | +| 5 | `hr/VacationManagement/index.tsx` | 3개 탭(탭별 상이한 컬럼) + 4개 다이얼로그 | [x] 완료 | + +### 최종 분류 통계 ✅ 완료 + +| 레벨 | 개수 | 상태 | 비고 | +|-----|-----|------|-----| +| Level 1 (기본) | 15개 | ✅ 완료 | UniversalListPage 마이그레이션 | +| Level 2 (필터) | 30개 | ✅ 완료 | UniversalListPage 마이그레이션 | +| Level 3~5 (복잡) | 10개 | ✅ 완료 | UniversalListPage 마이그레이션 | +| **합계** | **55개** | ✅ **완료** | **전체 통합 완료!** | --- @@ -124,12 +469,160 @@ --- -## Phase 5: 최종 검증 +## Phase 5: 최종 검증 (QA 체크리스트) -- [ ] 전체 59개 페이지 기능 테스트 -- [ ] 스크린샷 비교 검증 (변경 전 vs 후) -- [ ] 모바일 뷰 테스트 -- [ ] 성능 테스트 (빌드 사이즈, 로딩 속도) +### QA 검수 기준 +- ✅ **PC**: 테이블 렌더링, 필터, 페이지네이션, 행 선택, 수정/삭제 +- ✅ **모바일**: 카드 뷰, 바텀시트 필터, 터치 동작 +- ✅ **공통**: API 연동, 데이터 표시, 에러 처리 + +--- + +### Level 1 - 기본 페이지 (15개) ✅ QA 완료 + +| # | 페이지 | 경로 | PC | 모바일 | 비고 | +|---|--------|------|:--:|:------:|------| +| 1 | 작업지시관리 | `/production/work-orders` | [x] | [x] | | +| 2 | 작업실적조회 | `/production/work-results` | [x] | [x] | | +| 3 | 출하관리 | `/outbound/shipment` | [x] | [x] | | +| 4 | 재고현황 | `/material/stock-status` | [x] | [x] | | +| 5 | 입고관리 | `/material/receiving` | [x] | [x] | | +| 6 | 검사관리 | `/quality/inspection` | [x] | [x] | | +| 7 | 품목기준관리 | `/items` | [x] | [x] | | +| 8 | 결제내역 | `/settings/payment-history` | [x] | [x] | | +| 9 | 팝업관리 | `/settings/popup` | [x] | [x] | | +| 10 | 이벤트관리 | `/customer-center/events` | [x] | [x] | 모바일 탭 이슈 해결 완료 | +| 11 | 1:1문의 | `/customer-center/inquiries` | [x] | [x] | 필터 동작 검증 완료 | +| 12 | 공지사항 | `/customer-center/notices` | [x] | [x] | | +| 13 | 견적관리 | `/quotes` | [x] | [x] | | +| 14 | 공정관리 | `/process-management` | [x] | [x] | | +| 15 | 계좌관리 | `/settings/accounts` | [x] | [x] | | + +--- + +### Level 2 - 건설 도메인 (17개) ✅ QA 완료 + +| # | 페이지 | 경로 | PC | 모바일 | 비고 | +|---|--------|------|:--:|:------:|------| +| 1 | 견적관리 | `/construction/estimates` | [x] | [x] | | +| 2 | 입찰관리 | `/construction/bidding` | [x] | [x] | | +| 3 | 현장설명회 | `/construction/site-briefings` | [x] | [x] | | +| 4 | 계약관리 | `/construction/contracts` | [x] | [x] | | +| 5 | 협력업체 | `/construction/partners` | [x] | [x] | | +| 6 | 인수인계보고서 | `/construction/handover-report` | [x] | [x] | | +| 7 | 작업인력현황 | `/construction/worker-status` | [x] | [x] | | +| 8 | 공과관리 | `/construction/utility` | [x] | [x] | | +| 9 | 기성청구관리 | `/construction/progress-billing` | [x] | [x] | | +| 10 | 구조검토관리 | `/construction/structure-review` | [x] | [x] | | +| 11 | 현장관리 | `/construction/sites` | [x] | [x] | | +| 12 | 단가관리 | `/construction/pricing` | [x] | [x] | 동적 컬럼 | +| 13 | 이슈관리 | `/construction/issues` | [x] | [x] | | +| 14 | 발주관리 | `/construction/order/order-management` | [x] | [x] | ScheduleCalendar | +| 15 | 시공관리 | `/construction/management` | [x] | [x] | ScheduleCalendar | +| 16 | 노임관리 | `/construction/labor` | [x] | [x] | | +| 17 | 품목관리 | `/construction/items` | [x] | [x] | | + +--- + +### Level 2 - 회계 도메인 (13개) ✅ QA 완료 + +| # | 페이지 | 경로 | PC | 모바일 | 비고 | +|---|--------|------|:--:|:------:|------| +| 1 | 거래처관리 | `/accounting/vendors` | [x] | [x] | | +| 2 | 매출관리 | `/accounting/sales` | [x] | [x] | filterConfig 추가 | +| 3 | 매입관리 | `/accounting/purchases` | [x] | [x] | filterConfig 추가 | +| 4 | 입금관리 | `/accounting/deposits` | [x] | [x] | | +| 5 | 출금관리 | `/accounting/withdrawals` | [x] | [x] | | +| 6 | 어음관리 | `/accounting/bills` | [x] | [x] | | +| 7 | 악성채권추심 | `/accounting/bad-debt` | [x] | [x] | filterConfig 추가 | +| 8 | 입출금계좌조회 | `/accounting/bank-transactions` | [x] | [x] | filterConfig 추가 | +| 9 | 카드내역조회 | `/accounting/card-transactions` | [x] | [x] | | +| 10 | 거래처원장 | `/accounting/vendor-ledger` | [x] | [x] | | +| 11 | 지출예상내역서 | `/accounting/expected-expenses` | [x] | [x] | | +| 12 | 어음관리Client | `/accounting/bills` | [x] | [x] | | +| 13 | 거래처관리Client | `/accounting/vendors` | [x] | [x] | | + +--- + +### Level 3~5 - 복잡 페이지 (10개) ✅ QA 완료 + +| # | 페이지 | 경로 | PC | 모바일 | 비고 | +|---|--------|------|:--:|:------:|------| +| 1 | 게시판관리 | `/board/management` | [x] | [x] | | +| 2 | 게시판목록 | `/board` | [x] | [x] | 동적 탭 | +| 3 | 기안함 | `/approval/draft` | [x] | [x] | 문서 모달, 모바일 필터 추가 | +| 4 | 결재함 | `/approval/approval` | [x] | [x] | 승인/반려, 모바일 필터 추가 | +| 5 | 참조함 | `/approval/reference` | [x] | [x] | 모바일 필터 추가 | +| 6 | 카드관리 | `/hr/card-management` | [x] | [x] | 탭 필터만 (PC필터 없음) | +| 7 | 급여관리 | `/hr/salary-management` | [x] | [x] | 정렬 필터 | +| 8 | 근태관리 | `/hr/attendance-management` | [x] | [x] | 9개 탭, 필터+정렬 | +| 9 | 사원관리 | `/hr/employee-management` | [x] | [x] | 필터+정렬 | +| 10 | 휴가관리 | `/hr/vacation-management` | [x] | [x] | 필터+정렬 | + +--- + +### QA 진행 현황 + +| 레벨 | 전체 | PC 완료 | 모바일 완료 | 진행률 | +|-----|-----|---------|------------|--------| +| Level 1 | 15 | 15 | 15 | **100%** ✅ | +| Level 2 건설 | 17 | 17 | 17 | **100%** ✅ | +| Level 2 회계 | 13 | 13 | 13 | **100%** ✅ | +| Level 3~5 | 10 | 10 | 10 | **100%** ✅ | +| **합계** | **55** | **55** | **55** | **100%** ✅ | + +### 🚨 알려진 이슈 + +| 이슈 | 영향 범위 | 상태 | 비고 | +|------|----------|------|------| +| 모바일 탭 미표시 | 탭 사용 페이지 전체 | ✅ 해결 완료 | IntegratedListTemplateV2 수정 (hidden → block) | + +--- + +## Phase 6: 다음 개선 사항 (Next Steps) + +### 6.1 모바일 인피니티 스크롤 (Infinite Scroll) + +> **목적**: 모바일 카드 뷰에서 페이지네이션 대신 무한 스크롤로 UX 개선 + +#### 구현 계획 + +**config 옵션 추가**: +```typescript +// UniversalListConfig에 추가 +infiniteScroll?: { + enabled: boolean; + mobileOnly?: boolean; // 모바일에서만 적용 (기본: true) + pageSize?: number; // 한 번에 로드할 개수 (기본: 20) + threshold?: number; // 트리거 위치 (기본: 0.8 = 80% 스크롤) +}; +``` + +**동작 방식**: +``` +모바일 + infiniteScroll.enabled? + ├─ Yes → 페이지네이션 숨김 + IntersectionObserver로 무한스크롤 + └─ No → 기존 페이지네이션 유지 +``` + +**기술 스택**: +- `IntersectionObserver` (네이티브) 또는 `react-intersection-observer` +- 스크롤 80% 도달 시 다음 pageSize개 로드 +- 로딩 스피너 표시 → 데이터 append → 스피너 제거 + +**적용 효과**: +- config 한 줄 추가로 55개 페이지 자동 적용 +- PC는 기존 페이지네이션 유지 +- 모바일만 무한스크롤 적용 + +#### 구현 체크리스트 +- [ ] `IntersectionObserver` 훅 구현 (`useInfiniteScroll`) +- [ ] `UniversalListConfig`에 `infiniteScroll` 옵션 추가 +- [ ] `IntegratedListTemplateV2`에 무한스크롤 로직 추가 +- [ ] 모바일 감지 시 페이지네이션 → 무한스크롤 전환 +- [ ] 로딩 스피너 컴포넌트 추가 +- [ ] 파일럿 페이지 테스트 +- [ ] 전체 페이지 적용 --- @@ -149,7 +642,48 @@ | 날짜 | 작업 내용 | |------|----------| | 2026-01-14 | 체크리스트 문서 생성, 작업 시작 | -| 2026-01-14 | 영업 도메인 3개 추가 (총 56개 → 59개) | +| 2026-01-14 | 영업 도메인 3개 발견 (마이그레이션 대상 검토 필요) | +| 2026-01-14 | ~~파일럿 3개 완료~~ → **실제 미적용 확인됨** | +| 2026-01-14 | 복잡도별 분류 완료 (Level 1~5, 55개 파일) | +| 2026-01-14 | 모바일 반응형 테스트 체크리스트 추가 | +| 2026-01-14 | "본 페이지 직접 작업" 정책 추가, 테스트 페이지 4개 삭제 | +| 2026-01-14 | Level 1: NoticeList, PopupList, EventList, InquiryList 마이그레이션 완료 (4/15) | +| 2026-01-14 | **체크리스트 정합성 수정** - 파일럿 3개 미완료 확인, Level 1 진행 상황 테이블 추가 | +| 2026-01-14 | Level 1 마이그레이션 진행: WorkOrderList, WorkResultList, ShipmentList, StockStatusList, ReceivingList, InspectionList 완료 (6개) | +| 2026-01-14 | Level 1 마이그레이션 진행: ItemListClient, PaymentHistoryClient, AccountManagement 완료 (3개 추가, 총 13/15) | +| 2026-01-14 | **Level 1 완료!** QuoteManagementClient, ProcessListClient 마이그레이션 완료 (15/15) | +| 2026-01-14 | Level 1 검수: 탭 기본값(ShipmentList, StockStatusList), optional chaining(UniversalListPage), 필터 중복(InquiryList), "총 N건" 위치 수정 | +| 2026-01-14 | **Phase 2.5 추가**: 달력/버튼 공통 옵션화 리팩토링 계획 문서화 | +| 2026-01-14 | **Phase 2.5 완료**: dateRangeSelector/createButton config 옵션 구현, Level 1 페이지 4개 마이그레이션 (InquiryList, NoticeList, EventList, PopupList) | +| 2026-01-14 | **Level 2 시작**: 건설 도메인 5개 완료 (EstimateListClient, BiddingListClient, SiteBriefingListClient, ContractListClient, PartnerListClient) | +| 2026-01-14 | ProjectListClient 제외 (PageLayout 직접 사용, IntegratedListTemplateV2 미사용) | +| 2026-01-15 | 건설 도메인 6개 추가 완료 (HandoverReport, WorkerStatus, Utility, ProgressBilling, StructureReview, SiteManagement) | +| 2026-01-15 | **UniversalListPage에 renderCustomTableHeader 지원 추가** (동적 컬럼용) | +| 2026-01-15 | 건설 도메인 6개 추가 완료 (PricingList, IssueManagement, OrderManagement, ConstructionManagement, LaborManagement, ItemManagement) | +| 2026-01-15 | **건설 도메인 17개 모두 완료!** ✅ | +| 2026-01-15 | 회계 도메인 5개 완료 (VendorManagement, SalesManagement, PurchaseManagement, DepositManagement, WithdrawalManagement) | +| 2026-01-15 | 회계 도메인 6개 추가 완료 (BillManagement, BadDebtCollection, BankTransactionInquiry, CardTransactionInquiry, VendorLedger, ExpectedExpenseManagement) | +| 2026-01-15 | **UniversalListPage에 externalPagination, externalSelection 지원 추가** (복잡한 외부 상태 관리용) | +| 2026-01-15 | 회계 도메인 11/13 완료 (남은 2개: BillManagementClient, VendorManagementClient 확인 필요) | +| 2026-01-15 | **회계 도메인 13/13 완료!** ✅ BillManagementClient, VendorManagementClient 마이그레이션 완료 | +| 2026-01-15 | **Level 3~5 분석 완료** - 전자결재(3), HR(5), 게시판(2) 총 10개 파일 분석 | +| 2026-01-15 | **마이그레이션 최종 결론 변경**: Level 3~5도 UniversalListPage로 마이그레이션 진행 결정 | +| 2026-01-15 | **Phase 3-1 완료**: UniversalListPage 기능 확장 (renderDialogs, headerActions with selectedItems) | +| 2026-01-15 | **Phase 3-2 완료**: 게시판 도메인 마이그레이션 2개 (BoardManagement, BoardList) | +| 2026-01-15 | **Phase 3-3 완료**: 전자결재 도메인 마이그레이션 3개 (DraftBox, ApprovalBox, ReferenceBox) | +| 2026-01-15 | **Phase 3-4 완료**: HR 도메인 마이그레이션 5개 (CardManagement, SalaryManagement, AttendanceManagement, EmployeeManagement, VacationManagement) | +| 2026-01-15 | **🎉 프로젝트 완료!** Level 1~5 (55개) 전체 마이그레이션 완료 | +| 2026-01-15 | **Level 1 QA 완료!** 15개 페이지 PC/모바일 검수, 이벤트관리 모바일 탭 이슈 발견 (공통 수정 예정) | +| 2026-01-15 | **Level 2 건설 QA 완료!** 17개 페이지 PC/모바일 검수 완료 | +| 2026-01-15 | **Level 2 회계 모바일 필터 추가!** 매출관리, 매입관리, 악성채권추심, 은행거래조회 4개 페이지 | +| 2026-01-15 | **결재 도메인 모바일 필터 추가!** 기안함, 결재함, 참조함 3개 페이지에 filterConfig + onFilterChange 추가 | +| 2026-01-15 | **Level 3~5 QA 완료!** HR 도메인 5개 페이지 (카드/급여/근태/사원/휴가) 전체 검수 완료 | +| 2026-01-15 | **🎉 전체 QA 완료!** 55개 페이지 PC/모바일 검수 100% 완료 | +| 2026-01-16 | **📊 페이지 수 최종 확정!** 62개 파일 중 중복(6쌍) 및 제외 대상 정리 → 55개 페이지 확정 | +| 2026-01-16 | **Phase 5 기능 검수 시작** - 수동 QA 진행, 오류 발견 및 수정 | +| 2026-01-16 | **휴가관리 탭 카운트 수정** - config.tabs 변경 감지 useEffect 추가 (UniversalListPage) | +| 2026-01-16 | **휴가관리 기능 검증** - 휴가신청/승인/거절 버튼 정상 동작 확인 | +| 2026-01-16 | **휴가관리 승인/거절 건수 표시 수정** - handleApproveClick/handleRejectClick에서 selected를 내부 state로 복사 | --- @@ -160,4 +694,125 @@ | `~/Desktop/test-urls_리스트 게시판 스샷/` | 34개 | 일반 도메인 | | `~/Desktop/construction-test-urls_리스트 게시판 스샷/` | 18개 | 건설 도메인 | | `~/Desktop/추가_리스트_스샷/` | 7개 | 누락 페이지 | -| **합계** | **59개** | | +| **합계** | **59개** | 스크린샷 기준 (제외 대상 포함) | + +> **Note**: 스크린샷은 59개지만, 실제 마이그레이션 대상 페이지는 55개입니다. (제외 대상 4개, 중복 제거) + +--- + +## 🔍 Phase 5: 전체 기능 검수 (2026-01-16) + +> **목적**: UniversalListPage 적용 후 모든 핵심 기능 정상 동작 확인 +> **검수 항목**: 검색 / 탭 / 필터 / 체크박스 / 상세이동 / 등록버튼 + +### 검수 항목 설명 +| 항목 | 설명 | +|------|------| +| 🔍 검색 | 검색창 입력 후 필터링 동작 | +| 📑 탭 | 탭 버튼 클릭 시 데이터 전환 | +| 🎛️ 필터 | 필터 선택/적용/초기화 동작 | +| ☑️ 체크박스 | 테이블 행 체크박스 선택 동작 | +| 👁️ 상세 | 테이블 로우 클릭 → 상세페이지/모달 이동 | +| ➕ 등록 | 등록 버튼 클릭 → 등록페이지 이동 | + +### HR 도메인 (5개) +| # | 페이지 | URL | 🔍 검색 | 📑 탭 | 🎛️ 필터 | ☑️ 체크 | 👁️ 상세 | ➕ 등록 | 상태 | +|---|--------|-----|---------|-------|---------|---------|---------|---------|------| +| 1 | 사원관리 | `/hr/employee-management` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 2 | 카드관리 | `/hr/card-management` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 3 | 근태관리 | `/hr/attendance-management` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 4 | 급여관리 | `/hr/salary-management` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 5 | 휴가관리 | `/hr/vacation-management` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | + +### 전자결재 도메인 (3개) +| # | 페이지 | URL | 🔍 검색 | 📑 탭 | 🎛️ 필터 | ☑️ 체크 | 👁️ 상세 | ➕ 등록 | 상태 | +|---|--------|-----|---------|-------|---------|---------|---------|---------|------| +| 1 | 기안함 | `/approval/draft-box` | [ ] | [ ] | [ ] | [ ] | [ ] | N/A | 🔄 | +| 2 | 결재함 | `/approval/approval-box` | [ ] | [ ] | [ ] | [ ] | [ ] | N/A | 🔄 | +| 3 | 참조함 | `/approval/reference-box` | [ ] | [ ] | [ ] | [ ] | [ ] | N/A | 🔄 | + +### 게시판 도메인 (2개) +| # | 페이지 | URL | 🔍 검색 | 📑 탭 | 🎛️ 필터 | ☑️ 체크 | 👁️ 상세 | ➕ 등록 | 상태 | +|---|--------|-----|---------|-------|---------|---------|---------|---------|------| +| 1 | 게시판관리 | `/settings/board-management` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 2 | 게시판목록 | `/boards/[boardCode]` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | + +### 건설 도메인 (17개) +| # | 페이지 | URL | 🔍 검색 | 📑 탭 | 🎛️ 필터 | ☑️ 체크 | 👁️ 상세 | ➕ 등록 | 상태 | +|---|--------|-----|---------|-------|---------|---------|---------|---------|------| +| 1 | 견적관리 | `/construction/estimates` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 2 | 입찰관리 | `/construction/bidding` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 3 | 현장설명회 | `/construction/site-briefings` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 4 | 계약관리 | `/construction/contracts` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 5 | 협력업체 | `/construction/partners` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 6 | 준공보고 | `/construction/handover-reports` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 7 | 근로자현황 | `/construction/worker-status` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 8 | 유틸리티관리 | `/construction/utility-management` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 9 | 기성관리 | `/construction/progress-billing` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 10 | 구조검토 | `/construction/structure-review` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 11 | 현장관리 | `/construction/sites` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 12 | 단가관리 | `/construction/pricing` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 13 | 이슈관리 | `/construction/issues` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 14 | 발주관리 | `/construction/order-management` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 15 | 공사관리 | `/construction/management` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 16 | 노무관리 | `/construction/labor-management` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 17 | 품목관리 | `/construction/item-management` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | + +### 회계 도메인 (13개) +| # | 페이지 | URL | 🔍 검색 | 📑 탭 | 🎛️ 필터 | ☑️ 체크 | 👁️ 상세 | ➕ 등록 | 상태 | +|---|--------|-----|---------|-------|---------|---------|---------|---------|------| +| 1 | 거래처관리 | `/accounting/vendor-management` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 2 | 매출관리 | `/accounting/sales-management` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 3 | 매입관리 | `/accounting/purchase-management` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 4 | 입금관리 | `/accounting/deposit-management` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 5 | 출금관리 | `/accounting/withdrawal-management` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 6 | 어음관리 | `/accounting/bill-management` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 7 | 부실채권 | `/accounting/bad-debt-collection` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 8 | 입출금조회 | `/accounting/bank-transactions` | [ ] | [ ] | [ ] | [ ] | [ ] | N/A | 🔄 | +| 9 | 카드조회 | `/accounting/card-transactions` | [ ] | [ ] | [ ] | [ ] | [ ] | N/A | 🔄 | +| 10 | 거래처원장 | `/accounting/vendor-ledger` | [ ] | [ ] | [ ] | [ ] | [ ] | N/A | 🔄 | +| 11 | 예상지출 | `/accounting/expected-expenses` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | + +### 기타 도메인 (15개) +| # | 페이지 | URL | 🔍 검색 | 📑 탭 | 🎛️ 필터 | ☑️ 체크 | 👁️ 상세 | ➕ 등록 | 상태 | +|---|--------|-----|---------|-------|---------|---------|---------|---------|------| +| 1 | 작업지시 | `/production/work-orders` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 2 | 작업실적 | `/production/work-results` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 3 | 출하관리 | `/outbound/shipment-management` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 4 | 재고현황 | `/material/stock-status` | [ ] | [ ] | [ ] | [ ] | [ ] | N/A | 🔄 | +| 5 | 입고관리 | `/material/receiving` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 6 | 검사관리 | `/quality/inspection` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 7 | 품목관리 | `/items` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 8 | 결제이력 | `/settings/payment-history` | [ ] | [ ] | [ ] | [ ] | [ ] | N/A | 🔄 | +| 9 | 팝업관리 | `/settings/popup-management` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 10 | 이벤트관리 | `/customer-center/events` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 11 | 문의관리 | `/customer-center/inquiries` | [ ] | [ ] | [ ] | [ ] | [ ] | N/A | 🔄 | +| 12 | 공지관리 | `/customer-center/notices` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 13 | 견적관리 | `/quotes` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 14 | 공정관리 | `/process-management` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | +| 15 | 계정관리 | `/settings/account-management` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | + +### 영업 도메인 (추가) +| # | 페이지 | URL | 🔍 검색 | 📑 탭 | 🎛️ 필터 | ☑️ 체크 | 👁️ 상세 | ➕ 등록 | 상태 | +|---|--------|-----|---------|-------|---------|---------|---------|---------|------| +| 1 | 거래처관리 | `/sales/client-management-sales-admin` | [ ] | [ ] | [ ] | [ ] | [ ] | [ ] | 🔄 | + +--- + +### 🐛 발견된 오류 목록 + +| # | 페이지 | 오류 내용 | 원인 | 해결 상태 | +|---|--------|----------|------|----------| +| 1 | 급여관리 | 달력 아이콘 짤림 (375px) | Input width 너무 좁음 | ✅ 수정 | +| 2 | 급여관리 | DateRangeSelector 미사용 | 직접 Input 사용 | ✅ 수정 | +| 3 | 거래처관리(영업) | `headerActions.call is not a function` | headerActions 함수 아님 | ✅ 수정 | +| 4 | 거래처관리(영업) | NaN globalIndex | externalPagination 형태 불일치 | ✅ 수정 | +| 5 | 휴가관리 | `externalSelection.onToggleSelection is not a function` | externalSelection 형태 불일치 | ✅ 수정 | +| 6 | 휴가관리 | 탭 변경 시 `Invalid time value` | 날짜 필드 null 체크 없음 + 오타 | ✅ 수정 | +| 7 | 근태관리 | 프리셋 버튼 미표시 | showPresets: false | ✅ 수정 | +| 8 | 휴가관리 | 탭 카운트 모두 동일하게 표시 | tabFilter 제거 후 count 동기화 안됨 | ✅ 수정 | +| 9 | UniversalListPage | config.tabs 변경 시 내부 상태 미동기화 | useEffect 누락 | ✅ 수정 | +| 10 | 사원관리 | 프리셋 버튼 미표시 | showPresets: false | ✅ 수정 | +| 11 | 휴가관리 | 승인/거절 팝업 미표시 | selectedItems 상태 불일치 | ✅ 수정 | +| 12 | 휴가관리 | 승인/거절 팝업에 선택 건수 0으로 표시 | headerActions의 selected가 내부 state로 복사 안됨 | ✅ 수정 | +| 13 | 단가관리(판매) | `headerActions.call is not a function` | headerActions가 함수 아닌 ReactNode | ✅ 수정 | diff --git a/claudedocs/[NEXT-2026-01-14] UniversalListPage-pilot-session-context.md b/claudedocs/[NEXT-2026-01-14] UniversalListPage-pilot-session-context.md index f248ce38..70ea8fd9 100644 --- a/claudedocs/[NEXT-2026-01-14] UniversalListPage-pilot-session-context.md +++ b/claudedocs/[NEXT-2026-01-14] UniversalListPage-pilot-session-context.md @@ -1,131 +1,91 @@ -# UniversalListPage 파일럿 마이그레이션 세션 컨텍스트 +# UniversalListPage 마이그레이션 세션 컨텍스트 -## 세션 요약 (2026-01-14) +## 🎉 프로젝트 완료 (2026-01-15) -### 완료된 작업 -- [x] UniversalListPage 컴포넌트 생성 (Phase 1, 2) -- [x] 카드관리 (HR) 파일럿 마이그레이션 - 기본 케이스 -- [x] 게시판목록 (BoardList) 파일럿 마이그레이션 - 동적 탭 (fetchTabs) -- [x] 발주관리 (OrderManagement) 파일럿 마이그레이션 - ScheduleCalendar (beforeTableContent) -- [x] 3개 파일럿 페이지 기능 검증 완료 +### 최종 결과 +| 레벨 | 개수 | 상태 | 처리 방식 | +|-----|-----|------|----------| +| Level 1 (기본) | 15개 | ✅ 완료 | UniversalListPage 마이그레이션 | +| Level 2 (필터) | 30개 | ✅ 완료 | UniversalListPage 마이그레이션 | +| Level 3~5 (복잡) | 10개 | ✅ 분석 완료 | 마이그레이션 제외 (현상 유지) | +| **합계** | **55개** | ✅ | 45개 마이그레이션 + 10개 현상 유지 | -### 프로젝트 목표 -59개 리스트 페이지를 하나의 `UniversalListPage` 컴포넌트로 통합 -- **핵심 원칙**: 기존 기능 100% 유지, 테이블 영역만 공통화 -- **접근 방식**: Config 기반 커스터마이징 +### Level 3~5 마이그레이션 제외 사유 + +#### 전자결재 도메인 (3개) - 제외 +| 파일 | 제외 사유 | +|------|----------| +| DraftBox | DocumentDetailModal (커스텀 인터페이스), 선택 기반 동적 헤더, 상신/삭제 액션 | +| ApprovalBox | DocumentDetailModal, 승인/반려/재상신 다이얼로그, 4개 탭 | +| ReferenceBox | DocumentDetailModal, 열람/미열람 처리 다이얼로그, 3개 탭 | + +#### HR 도메인 (5개) - 제외 +| 파일 | 제외 사유 | +|------|----------| +| SalaryManagement | SalaryDetailDialog, 급여 상태 변경, 선택 기반 동적 버튼 | +| AttendanceManagement | 9개 탭, 2개 다이얼로그, extraFilters, 클라이언트 필터링 | +| VacationManagement | 3개 탭(탭별 상이한 컬럼), 2개 다이얼로그 + 2개 AlertDialog | +| EmployeeManagement | 4개 탭, FieldSettingsDialog + UserInviteDialog + DateRangeSelector | +| CardManagement | 3개 탭, AlertDialog, 클라이언트 필터링 | + +#### 게시판 도메인 (2개) - 제외 +| 파일 | 제외 사유 | +|------|----------| +| BoardManagement | AlertDialog, 선택 기반 수정/삭제, 클라이언트 페이지네이션 | +| BoardList | API 기반 동적 탭 (getBoards), "나의 게시글" 특수 탭, 서버사이드 페이지네이션 | + +### 핵심 결론 +> **UniversalListPage는 Level 1~2 (단순~중간 복잡도) 컴포넌트에 적합** +> **Level 3~5 (복잡) 컴포넌트는 IntegratedListTemplateV2 직접 사용이 더 효율적** --- -## 파일럿 검증 결과 +## 🎯 핵심 목적 (절대 잊지 말 것!) +**이 공통화 작업의 근본적인 목적은 모바일에서 필터를 바텀시트로 보여주기 위함이다.** -| 파일럿 | 특이 케이스 | 테스트 URL | 상태 | -|--------|------------|-----------|------| -| **카드관리** | 기본 케이스 | `/ko/hr/card-management-test` | ✅ 완료 | -| **게시판목록** | 동적 탭 (fetchTabs) | `/ko/board-test` | ✅ 완료 | -| **발주관리** | ScheduleCalendar | `/ko/construction/order/order-management-test` | ✅ 완료 | - -### 검증된 기능 -1. ✅ 테이블 렌더링 (20개 행, 페이지네이션) -2. ✅ 행 선택 (체크박스, 카운터, 삭제 버튼) -3. ✅ 수정/삭제 버튼 (선택 시 표시, 페이지 이동) -4. ✅ 클라이언트 사이드 필터링 (customFilterFn, customSortFn) -5. ✅ 커스텀 콘텐츠 슬롯 (beforeTableContent, tableHeaderActions) -6. ✅ 동적 탭 (fetchTabs API 호출) -7. ✅ 필터 설정 (filterConfig 기반 다중 필터) +- `filterConfig` 사용 → 자동으로 PC/모바일 분기 + - PC (1280px+): 인라인 필터 + - 모바일 (~1279px): 바텀시트 필터 (MobileFilter) +- **새로운 모바일 필터 기능 만들지 말 것!** --- -## 생성/수정된 파일 +## 완료된 마이그레이션 목록 -### UniversalListPage 핵심 파일 -``` -src/components/templates/UniversalListPage/ -├── index.tsx # 메인 컴포넌트 -└── types.ts # 타입 정의 -``` +### Level 1 (15/15) ✅ +| # | 파일 | 완료일 | +|---|------|--------| +| 1 | WorkOrderList | 2026-01-14 | +| 2 | WorkResultList | 2026-01-14 | +| 3 | ShipmentList | 2026-01-14 | +| 4 | StockStatusList | 2026-01-14 | +| 5 | ReceivingList | 2026-01-14 | +| 6 | InspectionList | 2026-01-14 | +| 7 | ItemListClient | 2026-01-14 | +| 8 | PaymentHistoryClient | 2026-01-14 | +| 9 | PopupList | 2026-01-14 | +| 10 | EventList | 2026-01-14 | +| 11 | InquiryList | 2026-01-14 | +| 12 | NoticeList | 2026-01-14 | +| 13 | QuoteManagementClient | 2026-01-14 | +| 14 | ProcessListClient | 2026-01-14 | +| 15 | AccountManagement | 2026-01-14 | -### 파일럿 Unified 컴포넌트 -``` -src/components/business/hr/CardManagement/CardManagementUnified.tsx -src/components/business/construction/order-management/OrderManagementUnified.tsx -src/components/board/BoardList/BoardListUnified.tsx -``` +### Level 2 - 건설 도메인 (17/17) ✅ +| # | 파일 | 완료일 | +|---|------|--------| +| 1-5 | Estimate, Bidding, SiteBriefing, Contract, Partner | 2026-01-14 | +| 6-11 | HandoverReport, WorkerStatus, Utility, ProgressBilling, StructureReview, SiteManagement | 2026-01-15 | +| 12-17 | Pricing, IssueManagement, OrderManagement, ConstructionManagement, LaborManagement, ItemManagement | 2026-01-15 | -### 테스트 페이지 -``` -src/app/[locale]/(protected)/hr/card-management-test/page.tsx -src/app/[locale]/(protected)/board-test/page.tsx -src/app/[locale]/(protected)/construction/order/order-management-test/page.tsx -``` +### Level 2 - 회계 도메인 (13/13) ✅ +| # | 파일 | 완료일 | +|---|------|--------| +| 1-5 | VendorManagement, SalesManagement, PurchaseManagement, DepositManagement, WithdrawalManagement | 2026-01-15 | +| 6-10 | BillManagement, BadDebtCollection, BankTransactionInquiry, CardTransactionInquiry, VendorLedger | 2026-01-15 | +| 11-13 | ExpectedExpenseManagement, BillManagementClient, VendorManagementClient | 2026-01-15 | --- -## 핵심 수정 사항 - -### types.ts에 추가된 Config 옵션 -```typescript -// 클라이언트 사이드 필터링 확장 -customFilterFn?: (items: T[], filterValues: Record) => T[]; -customSortFn?: (items: T[], filterValues: Record) => T[]; - -// 테이블 헤더 액션 -tableHeaderActions?: ReactNode; -``` - -### index.tsx 주요 수정 -1. **탭 기본값 수정**: `activeTab` 기본값을 `'default'`로 변경 (IntegratedListTemplateV2와 일치) -2. **빈 탭 배열 처리**: `tabs={computedTabs.length > 0 ? computedTabs : undefined}` -3. **customFilterFn/customSortFn 적용**: filteredData useMemo에서 사용 - ---- - -## 다음 세션 TODO - -### Phase 4: 본격 마이그레이션 -- [ ] 나머지 56개 페이지 우선순위 정리 -- [ ] 복잡도별 그룹핑 (기본 / 필터 복잡 / 커스텀 슬롯) -- [ ] 그룹별 순차 마이그레이션 - -### 고려사항 -- 기존 페이지와 테스트 페이지 비교 검증 후 교체 -- 마이그레이션 완료 후 기존 컴포넌트 정리 - ---- - -## 참고 사항 - -### UniversalListConfig 주요 속성 -```typescript -interface UniversalListConfig { - title: string; - basePath: string; - idField: keyof T | ((item: T) => string); - actions: ListActions; - columns: TableColumn[]; - - // 렌더링 - renderTableRow: (item, index, globalIndex, handlers) => ReactNode; - renderMobileCard: (item, index, globalIndex, handlers) => ReactNode; - - // 필터링 - filterConfig?: FilterFieldConfig[]; - clientSideFiltering?: boolean; - customFilterFn?: (items, filterValues) => T[]; - - // 탭 - tabs?: TabOption[]; - fetchTabs?: () => Promise; - - // 슬롯 - beforeTableContent?: ReactNode; - tableHeaderActions?: ReactNode; - headerActions?: (params) => ReactNode; -} -``` - -### 기존 vs 테스트 URL 비교 -| 기능 | 기존 | 테스트 (UniversalListPage) | -|------|------|---------------------------| -| 카드관리 | `/ko/hr/card-management` | `/ko/hr/card-management-test` | -| 게시판 | `/ko/board` | `/ko/board-test` | -| 발주관리 | `/ko/construction/order/order-management` | `/ko/construction/order/order-management-test` | \ No newline at end of file +## 참고 문서 +- `claudedocs/[IMPL-2026-01-14] universal-list-component-checklist.md` - 메인 체크리스트 diff --git a/claudedocs/[PLAN-2026-01-16] layout-restructure.md b/claudedocs/[PLAN-2026-01-16] layout-restructure.md new file mode 100644 index 00000000..5e84fceb --- /dev/null +++ b/claudedocs/[PLAN-2026-01-16] layout-restructure.md @@ -0,0 +1,96 @@ +# 레이아웃 구조 변경 계획 + +> **상태**: 📋 대기 (기능 검수 완료 후 진행) +> **작성일**: 2026-01-16 +> **적용 대상**: IntegratedListTemplateV2.tsx (55개 페이지 일괄 적용) + +--- + +## 현재 구조 + +``` +1. 타이틀 +2. 달력 / 버튼들 (등록 버튼 여기) +3. 통계 카드 +4. 검색창 (Card로 감싸짐) +5. 테이블 Card + └─ 탭 버튼들 / 필터 / 삭제 버튼 + └─ 테이블 +``` + +--- + +## 변경 후 구조 + +``` +1. 타이틀 +2. 달력 / 달력버튼 / 검색창 (한 줄) +3. 카드섹션 (한 줄, 줄넘김 없음) +4. [탭 버튼들] ─────────────── [등록] [CSV] 버튼들 ← Card 밖 +5. 테이블 Card + ├─ 총 N건 / 선택건 / 필터 + └─ 테이블 +``` + +--- + +## 시각화 + +``` +┌─ 페이지 ─────────────────────────────────────────────────┐ +│ 휴가관리 │ +│ 직원들의 휴가 현황을 관리합니다 │ +├──────────────────────────────────────────────────────────┤ +│ [📅 2025-12-01] ~ [📅 2025-12-31] [당월][전월] [🔍검색] │ +├──────────────────────────────────────────────────────────┤ +│ [승인대기 1명] [연차 4명] [경조사 0명] [사용률 4.3%] │ ← 카드 (줄넘김X) +├──────────────────────────────────────────────────────────┤ +│ [사용현황 4] [부여현황 2] [신청현황 3] [등록] [CSV] │ ← Card 밖 +├──────────────────────────────────────────────────────────┤ +│ ┌─ 테이블 Card ────────────────────────────────────────┐ │ +│ │ 총 55건 | 3개 선택됨 [필터1] [필터2] │ │ +│ ├──────────────────────────────────────────────────────┤ │ +│ │ □ | 번호 | 부서 | 이름 | ... │ │ +│ │ □ | 1 | 개발 | 홍길동 | ... │ │ +│ └──────────────────────────────────────────────────────┘ │ +└──────────────────────────────────────────────────────────┘ +``` + +--- + +## 주요 변경점 + +| 항목 | 현재 | 변경 후 | +|------|------|---------| +| 검색창 | Card로 감싸짐, 별도 영역 | 달력 옆 한 줄에 배치 | +| 카드섹션 | flex-wrap (줄넘김) | flex-nowrap + overflow-x-auto | +| 탭 버튼 | 테이블 Card 내부 | 테이블 Card 위 (밖) | +| 등록/액션 버튼 | 헤더 영역 | 탭 버튼 오른쪽 | +| 총 N건/선택건 | 탭과 같은 줄 | 테이블 Card 내부 첫 줄 | +| 필터 | 탭과 같은 줄 | 테이블 Card 내부 첫 줄 | + +--- + +## 수정 대상 파일 + +1. **IntegratedListTemplateV2.tsx** - 전체 레이아웃 구조 변경 +2. **UniversalListPage/index.tsx** - prop 전달 방식 조정 (필요시) + +--- + +## 체크리스트 + +- [ ] 검색창 위치 이동 (달력 옆) +- [ ] 카드섹션 줄넘김 방지 (flex-nowrap) +- [ ] 탭 버튼 테이블 Card 밖으로 이동 +- [ ] 등록/액션 버튼 탭 옆으로 이동 +- [ ] 총 N건/선택건/필터 테이블 Card 내부로 이동 +- [ ] PC/모바일 반응형 확인 +- [ ] 55개 페이지 일괄 테스트 + +--- + +## 진행 조건 + +✅ **기능 검수 완료 후 진행** +- 현재 화면과 비교 검수가 필요하므로 레이아웃 변경은 기능 검수 이후에 진행 diff --git a/claudedocs/[QA-2026-01-15] universal-list-page-inspection.md b/claudedocs/[QA-2026-01-15] universal-list-page-inspection.md new file mode 100644 index 00000000..038d92e7 --- /dev/null +++ b/claudedocs/[QA-2026-01-15] universal-list-page-inspection.md @@ -0,0 +1,200 @@ +# UniversalListPage 마이그레이션 검수 체크리스트 + +> **검수일**: 2026-01-15 +> **검수 방법**: Chrome DevTools MCP를 사용한 페이지별 UI 검증 +> **총 대상**: 63개 페이지 + +--- + +## 검수 목표 + +> **기존 페이지 기능이 UniversalListPage 통합 후에도 정상 작동하는가** + +--- + +## 검수 기준 + +### 자동 검수 항목 (Claude) +| 항목 | 설명 | +|------|------| +| 데이터 표출 | 테이블/카드에 데이터가 정상적으로 표시되는가 | +| 검색 기능 | 검색어 입력 시 필터링이 작동하는가 | +| 탭 전환 | 탭 클릭 시 데이터가 올바르게 필터링되는가 | +| 커스텀 버튼 | 페이지별 고유 버튼(등록/수정/삭제 등)이 표시되는가 | +| 필터 동작 | 날짜/상태/유형 등 필터가 작동하는가 | +| 콘솔 에러 | JavaScript 에러가 없는가 | +| 모바일 뷰 | 모바일 사이즈에서 카드 형태로 표시되는가 | + +### 수동 검수 항목 (사용자) +| 항목 | 설명 | +|------|------| +| 모바일 바텀시트 | 모바일에서 필터 버튼 클릭 시 바텀시트가 정상 작동하는가 | + +--- + +## 검수 상태 범례 + +- ✅ 통과 +- ❌ 실패 +- ⚠️ 부분 통과 (이슈 있음) +- 🔍 검수 중 +- ⏳ 미확인 + +--- + +## Level 1 검수 (15개) + +| # | 페이지 | URL | 데이터 | 검색 | 필터 | 에러 | 모바일 | 상태 | +|---|--------|-----|--------|------|------|------|--------|------| +| 1 | 작업지시관리 | `/ko/production/work-orders` | | | | | | ⏳ | +| 2 | 작업실적조회 | `/ko/production/work-results` | | | | | | ⏳ | +| 3 | 출하관리 | `/ko/outbound/shipment-management` | | | | | | ⏳ | +| 4 | 재고현황 | `/ko/material/stock-status` | | | | | | ⏳ | +| 5 | 입고관리 | `/ko/material/receiving` | | | | | | ⏳ | +| 6 | 검사관리 | `/ko/quality/inspection-management` | | | | | | ⏳ | +| 7 | 품목관리 | `/ko/items` | | | | | | ⏳ | +| 8 | 결제내역 | `/ko/payment-history` | | | | | | ⏳ | +| 9 | 팝업관리 | `/ko/settings/popup-management` | | | | | | ⏳ | +| 10 | 이벤트관리 | `/ko/customer-center/events` | | | | | | ⏳ | +| 11 | 문의관리 | `/ko/customer-center/inquiries` | | | | | | ⏳ | +| 12 | 공지사항 | `/ko/customer-center/notices` | | | | | | ⏳ | +| 13 | 견적관리 | `/ko/quotes` | | | | | | ⏳ | +| 14 | 공정관리 | `/ko/process-management` | | | | | | ⏳ | +| 15 | 계좌관리 | `/ko/settings/account-management` | | | | | | ⏳ | + +--- + +## Level 2 건설 도메인 검수 (17개) + +| # | 페이지 | URL | 데이터 | 검색 | 필터 | 에러 | 모바일 | 상태 | +|---|--------|-----|--------|------|------|------|--------|------| +| 1 | 견적관리 | `/ko/construction/estimates` | | | | | | ⏳ | +| 2 | 입찰관리 | `/ko/construction/bidding` | | | | | | ⏳ | +| 3 | 현장설명회 | `/ko/construction/site-briefings` | | | | | | ⏳ | +| 4 | 계약관리 | `/ko/construction/contract` | | | | | | ⏳ | +| 5 | 협력업체 | `/ko/construction/partners` | | | | | | ⏳ | +| 6 | 인수인계보고서 | `/ko/construction/handover-report` | | | | | | ⏳ | +| 7 | 작업인력현황 | `/ko/construction/worker-status` | | | | | | ⏳ | +| 8 | 공과관리 | `/ko/construction/utility-management` | | | | | | ⏳ | +| 9 | 기성청구관리 | `/ko/construction/progress-billing` | | | | | | ⏳ | +| 10 | 구조검토 | `/ko/construction/structure-review` | | | | | | ⏳ | +| 11 | 현장관리 | `/ko/construction/site-management` | | | | | | ⏳ | +| 12 | 단가관리 | `/ko/construction/pricing` | | | | | | ⏳ | +| 13 | 이슈관리 | `/ko/construction/issue-management` | | | | | | ⏳ | +| 14 | 발주관리 | `/ko/construction/order/order-management` | | | | | | ⏳ | +| 15 | 시공관리 | `/ko/construction/management` | | | | | | ⏳ | +| 16 | 노임관리 | `/ko/construction/labor-management` | | | | | | ⏳ | +| 17 | 품목관리(건설) | `/ko/construction/order/base-info/items` | | | | | | ⏳ | + +--- + +## Level 2 회계 도메인 검수 (11개) + +| # | 페이지 | URL | 데이터 | 검색 | 필터 | 에러 | 모바일 | 상태 | +|---|--------|-----|--------|------|------|------|--------|------| +| 1 | 거래처관리 | `/ko/accounting/vendor-management` | | | | | | ⏳ | +| 2 | 매출관리 | `/ko/accounting/sales-management` | | | | | | ⏳ | +| 3 | 매입관리 | `/ko/accounting/purchase-management` | | | | | | ⏳ | +| 4 | 입금관리 | `/ko/accounting/deposit-management` | | | | | | ⏳ | +| 5 | 출금관리 | `/ko/accounting/withdrawal-management` | | | | | | ⏳ | +| 6 | 어음관리 | `/ko/accounting/bill-management` | | | | | | ⏳ | +| 7 | 악성채권추심 | `/ko/accounting/bad-debt-collection` | | | | | | ⏳ | +| 8 | 입출금계좌조회 | `/ko/accounting/bank-transaction-inquiry` | | | | | | ⏳ | +| 9 | 카드내역조회 | `/ko/accounting/card-transaction-inquiry` | | | | | | ⏳ | +| 10 | 거래처원장 | `/ko/accounting/vendor-ledger` | | | | | | ⏳ | +| 11 | 지출예상내역서 | `/ko/accounting/expected-expense-management` | | | | | | ⏳ | + +--- + +## Level 2 영업 도메인 검수 (4개) + +| # | 페이지 | URL | 데이터 | 검색 | 필터 | 에러 | 모바일 | 상태 | +|---|--------|-----|--------|------|------|------|--------|------| +| 1 | 수주관리 | `/ko/sales/order-management-sales` | | | | | | ⏳ | +| 2 | 생산발주 | `/ko/sales/order-management-sales/production-orders` | | | | | | ⏳ | +| 3 | 거래처관리(영업) | `/ko/sales/client-management-sales-admin` | | | | | | ⏳ | +| 4 | 단가관리(영업) | `/ko/sales/pricing-management` | | | | | | ⏳ | + +--- + +## Level 3~5 복잡 페이지 검수 (10개) + +### 게시판 도메인 (3개) +| # | 페이지 | URL | 데이터 | 검색 | 필터 | 에러 | 모바일 | 상태 | +|---|--------|-----|--------|------|------|------|--------|------| +| 1 | 게시판관리 | `/ko/board/management` | | | | | | ⏳ | +| 2 | 게시판목록 | `/ko/board/list` | | | | | | ⏳ | +| 3 | 동적게시판 | `/ko/boards/[boardCode]` | | | | | | ⏳ | + +### 전자결재 도메인 (3개) +| # | 페이지 | URL | 데이터 | 검색 | 필터 | 에러 | 모바일 | 상태 | +|---|--------|-----|--------|------|------|------|--------|------| +| 1 | 기안함 | `/ko/approval/draft-box` | | | | | | ⏳ | +| 2 | 결재함 | `/ko/approval/approval-box` | | | | | | ⏳ | +| 3 | 참조함 | `/ko/approval/reference-box` | | | | | | ⏳ | + +### HR 도메인 (5개) +| # | 페이지 | URL | 데이터 | 검색 | 필터 | 에러 | 모바일 | 상태 | +|---|--------|-----|--------|------|------|------|--------|------| +| 1 | 카드관리 | `/ko/hr/card-management` | | | | | | ⏳ | +| 2 | 급여관리 | `/ko/hr/salary-management` | | | | | | ⏳ | +| 3 | 근태관리 | `/ko/hr/attendance-management` | | | | | | ⏳ | +| 4 | 사원관리 | `/ko/hr/employee-management` | | | | | | ⏳ | +| 5 | 휴가관리 | `/ko/hr/vacation-management` | | | | | | ⏳ | + +--- + +## 추가 발견 페이지 검수 (2개) + +| # | 페이지 | URL | 데이터 | 검색 | 필터 | 에러 | 모바일 | 상태 | +|---|--------|-----|--------|------|------|------|--------|------| +| 1 | 권한관리 | `/ko/settings/permissions` | | | | | | ⏳ | +| 2 | 결제내역(별도) | `/ko/settings/payment-history` | | | | | | ⏳ | + +--- + +## 검수 결과 요약 + +| 레벨 | 총 개수 | 통과 | 실패 | 미확인 | +|------|--------|------|------|--------| +| Level 1 | 15 | 0 | 0 | 15 | +| Level 2 건설 | 17 | 0 | 0 | 17 | +| Level 2 회계 | 11 | 0 | 0 | 11 | +| Level 2 영업 | 4 | 0 | 0 | 4 | +| Level 3~5 | 11 | 0 | 0 | 11 | +| 추가 발견 | 2 | 0 | 0 | 2 | +| **합계** | **60** | **0** | **0** | **60** | + +> **참고**: HR/전자결재/게시판 일부는 UniversalListPage가 아닌 별도 구조 사용 가능 + +--- + +## 발견된 이슈 + +### Critical (즉시 수정 필요) +_없음_ + +### Major (수정 권장) +_없음_ + +### Minor (개선 권장) +_없음_ + +--- + +## 수동 검수 필요 항목 + +| 항목 | 상태 | 비고 | +|------|------|------| +| 모바일 바텀시트 필터 동작 | ⏳ | 사용자 수동 확인 필요 | + +--- + +## 변경 이력 + +| 일시 | 작업 내용 | +|------|----------| +| 2026-01-15 | 검수 체크리스트 문서 생성 | +| 2026-01-15 | 검수 기준 업데이트 (데이터/검색/필터/모바일 세분화) | +| 2026-01-15 | 추가 발견 페이지 5개 포함 (총 63개 → 60개 검수 대상) | +| 2026-01-15 | URL 오류 수정 (결제내역, 품목관리-건설) | diff --git a/claudedocs/[REF] UniversalListPage-QA-patterns.md b/claudedocs/[REF] UniversalListPage-QA-patterns.md new file mode 100644 index 00000000..fea0aef9 --- /dev/null +++ b/claudedocs/[REF] UniversalListPage-QA-patterns.md @@ -0,0 +1,313 @@ +# UniversalListPage 검수 패턴 가이드 + +> **목적**: 55개 페이지 검수 시 발생하는 공통 에러 패턴과 해결책 정리 +> **작성일**: 2026-01-16 +> **기준**: 지금까지 검수 중 발견된 13개 이상의 에러 분석 + +--- + +## 검수 항목 체크리스트 + +| 항목 | 아이콘 | 설명 | +|------|--------|------| +| 검색 | 🔍 | 검색창 입력 시 필터링 동작 | +| 탭 | 📑 | 탭 버튼 클릭 시 데이터 전환 | +| 필터 | 🎛️ | 필터 선택/적용/초기화 동작 | +| 체크박스 | ☑️ | 테이블 행 체크박스 선택 동작 | +| 상세 | 👁️ | 테이블 로우 클릭 → 상세페이지/모달 이동 | +| 등록 | ➕ | 등록 버튼 클릭 → 등록페이지 이동 | + +--- + +## 🚨 공통 에러 패턴 및 해결책 + +### 1. `headerActions.call is not a function` + +**증상**: 페이지 로드 시 에러 발생, 콘솔에 에러 메시지 표시 + +**원인**: `headerActions`가 ReactNode로 정의되어 있음 (함수가 아님) + +**잘못된 코드**: +```typescript +// ❌ ReactNode로 정의 +const headerActions = ( + +); +``` + +**올바른 코드**: +```typescript +// ✅ 함수로 정의 +const headerActions = () => ( + +); +``` + +--- + +### 2. 탭 클릭해도 데이터가 변경되지 않음 + +**증상**: 탭 버튼 클릭은 되지만 테이블 데이터가 그대로 유지됨 + +**원인 A (클라이언트 사이드 필터링)**: +- `filteredData`(이미 필터링된 데이터)를 `initialData`에 전달 +- UniversalListPage 내부 상태가 외부 데이터 변경을 감지 못함 + +**해결책 A**: +```typescript +// ✅ 전체 데이터 전달 + tabFilter 함수 추가 +const config = { + // ... + clientSideFiltering: true, + tabFilter: (item, activeTab) => { + if (activeTab === 'all') return true; + return item.type === activeTab; + }, + searchFilter: (item, search) => { + return item.name.toLowerCase().includes(search.toLowerCase()); + }, +}; + + +``` + +**원인 B (서버 사이드 필터링)**: +- `onTabChange` prop이 누락됨 + +**해결책 B**: +```typescript +// ✅ onTabChange prop 추가 + +``` + +--- + +### 3. 승인/거절 팝업에 선택 건수가 0으로 표시 + +**증상**: 체크박스 선택 후 버튼 클릭하면 팝업에 "0건" 표시 + +**원인**: `headerActions`에서 받는 `selected`와 컴포넌트 내부 `selectedItems` 상태가 동기화되지 않음 + +**잘못된 코드**: +```typescript +// ❌ selected를 내부 상태로 복사하지 않음 +const handleApproveClick = useCallback(() => { + setApproveDialogOpen(true); +}, []); + +// headerActions에서 + +``` + +**올바른 코드**: +```typescript +// ✅ selected를 받아서 내부 상태로 복사 +const handleApproveClick = useCallback((selected: Set) => { + setSelectedItems(selected); // 복사! + setApproveDialogOpen(true); +}, []); + +// headerActions에서 +headerActions: ({ selected }) => ( + +) +``` + +--- + +### 4. `externalSelection.onToggleSelection is not a function` + +**증상**: 체크박스 클릭 시 에러 발생 + +**원인**: `externalSelection` 프로퍼티 이름이 타입과 불일치 + +**잘못된 코드**: +```typescript +// ❌ 잘못된 프로퍼티 이름 +externalSelection={{ + selectedItems, + setSelectedItems, // ❌ + toggleSelection, // ❌ + toggleSelectAll, // ❌ +}} +``` + +**올바른 코드**: +```typescript +// ✅ 올바른 프로퍼티 이름 +externalSelection={{ + selectedItems, + onToggleSelection: toggleSelection, // ✅ + onToggleSelectAll: toggleSelectAll, // ✅ + getItemId: (item) => item.id, +}} +``` + +--- + +### 5. `externalPagination` NaN 또는 globalIndex 오류 + +**증상**: 번호 컬럼에 NaN 표시, 페이지네이션 동작 안함 + +**원인**: `externalPagination` 프로퍼티 형태 불일치 + +**올바른 형태**: +```typescript +externalPagination={{ + currentPage: pagination.currentPage, + totalPages: pagination.totalPages, + totalItems: pagination.totalItems, + itemsPerPage: pagination.perPage, // ✅ itemsPerPage (perPage 아님) + onPageChange: handlePageChange, +}} +``` + +--- + +### 6. 프리셋 버튼 (당월/전월/오늘) 미표시 + +**증상**: DateRangeSelector는 표시되지만 프리셋 버튼 없음 + +**원인**: `showPresets: false` 설정 + +**해결책**: +```typescript +dateRangeSelector: { + enabled: true, + showPresets: true, // ✅ true로 설정 + startDate, + endDate, + onStartDateChange, + onEndDateChange, +}, +``` + +--- + +### 7. 탭 카운트가 모두 동일하게 표시 + +**증상**: 모든 탭에 같은 숫자가 표시됨 + +**원인**: `config.tabs` 변경 시 UniversalListPage 내부 상태가 업데이트되지 않음 + +**해결책** (이미 UniversalListPage에 적용됨): +```typescript +// UniversalListPage/index.tsx에서 +useEffect(() => { + if (config.tabs) { + setTabs(config.tabs); + } +}, [config.tabs]); +``` + +--- + +## 📋 검수 순서 권장 + +### Step 1: 페이지 로드 확인 +- [ ] 에러 없이 페이지 로드되는가? +- [ ] 콘솔에 에러 메시지 없는가? + +### Step 2: 기본 UI 확인 +- [ ] 테이블/카드 목록 정상 표시되는가? +- [ ] 통계 카드 (있는 경우) 정상 표시되는가? +- [ ] 탭 버튼 (있는 경우) 정상 표시되는가? + +### Step 3: 탭 기능 (있는 경우) +- [ ] 탭 클릭 시 데이터가 변경되는가? +- [ ] 탭별 건수가 정확하게 표시되는가? +- [ ] 탭 변경 후 검색/필터가 유지되는가? + +### Step 4: 검색 기능 +- [ ] 검색창에 입력 시 필터링되는가? +- [ ] 검색어 삭제 시 전체 목록 표시되는가? + +### Step 5: 필터 기능 (있는 경우) +- [ ] PC에서 필터 선택 시 데이터 필터링되는가? +- [ ] 모바일에서 필터 바텀시트 열리는가? +- [ ] 필터 적용/초기화 정상 동작하는가? + +### Step 6: 체크박스 선택 +- [ ] 개별 체크박스 선택/해제 되는가? +- [ ] 전체 선택 체크박스 동작하는가? +- [ ] 선택 건수가 정확히 표시되는가? + +### Step 7: 상세 이동 +- [ ] 행 클릭 또는 상세 버튼 클릭 시 이동하는가? +- [ ] URL 파라미터 올바르게 전달되는가? + +### Step 8: 등록 버튼 (있는 경우) +- [ ] 등록 버튼 표시되는가? +- [ ] 클릭 시 등록 페이지로 이동하는가? + +### Step 9: 커스텀 액션 (승인/거절/삭제 등) +- [ ] 버튼이 올바른 위치에 표시되는가? +- [ ] 선택된 항목 수가 정확히 팝업에 표시되는가? +- [ ] 액션 실행 후 데이터가 갱신되는가? + +--- + +## 🔧 데이터 흐름 패턴 + +### 패턴 A: 클라이언트 사이드 필터링 +``` +initialData={전체데이터} + ↓ +config.tabFilter() → 탭 필터링 + ↓ +config.searchFilter() → 검색 필터링 + ↓ +내부 페이지네이션 → displayData +``` + +**적합한 경우**: +- 데이터량 적음 (500개 이하) +- 전체 데이터를 한번에 로드 가능 + +### 패턴 B: 서버 사이드 필터링 +``` +initialData={API로 받은 데이터} + ↓ +onTabChange → 외부 상태 변경 → API 재호출 +onSearchChange → 외부 상태 변경 → API 재호출 + ↓ +externalPagination으로 페이지 제어 +``` + +**적합한 경우**: +- 데이터량 많음 (1000개 이상) +- 페이지네이션된 API 사용 + +--- + +## 발견된 에러 통계 + +| 에러 유형 | 발생 횟수 | 패턴 | +|----------|----------|------| +| headerActions 함수 아님 | 2회 | 거래처관리(영업), 단가관리(판매) | +| 탭 데이터 미갱신 | 2회 | 단가관리(판매), 품목관리 | +| 선택 건수 0 표시 | 1회 | 휴가관리 | +| externalSelection 형태 불일치 | 1회 | 휴가관리 | +| showPresets 누락 | 2회 | 근태관리, 사원관리 | +| 탭 카운트 동기화 | 1회 | 휴가관리 | + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-01-16 | 문서 초안 작성 (13개 에러 패턴 분석) | diff --git a/claudedocs/[REF] mobile-zoom-fix-guide.md b/claudedocs/[REF] mobile-zoom-fix-guide.md new file mode 100644 index 00000000..785052ad --- /dev/null +++ b/claudedocs/[REF] mobile-zoom-fix-guide.md @@ -0,0 +1,165 @@ +# 모바일 핀치 줌(Pinch Zoom) 이슈 해결 가이드 + +> **작성일**: 2026-01-15 +> **상태**: 해결 완료 +> **적용 범위**: iOS Safari, Android Chrome + +--- + +## 1. 문제 현상 + +### 1-1. 초기 증상 +- 모바일에서 핀치 줌(손가락 확대)이 **특정 화면에서만** 동작 +- 확대 시 **아래에서 회색/어두운 영역**이 올라와 화면을 가림 +- Android / iOS 모두 동일한 현상 + +### 1-2. 영향 범위 +| 화면 | 줌 가능 | 회색 영역 | +|------|---------|----------| +| 로그인 페이지 | ✅ 정상 | ❌ 없음 | +| 인증된 내부 페이지 | ❌ 불가 → ✅ 수정 후 가능 | ✅ 발생 → ❌ 수정 후 해결 | + +--- + +## 2. 원인 분석 + +### 2-1. 핀치 줌 차단 원인 +**파일**: `src/layouts/AuthenticatedLayout.tsx` + +```tsx +// 문제 코드 - touch-pan-y가 핀치 줌 차단 +
+``` + +| touch-action 값 | 세로 스크롤 | 핀치 줌 | +|----------------|------------|--------| +| `pan-y` | ✅ | ❌ 차단 | +| `pan-y pinch-zoom` | ✅ | ✅ | +| `manipulation` | ✅ | ❌ 더블탭만 | + +### 2-2. 회색 영역 발생 원인 + +**원인 1**: `body`에 추가된 safe-area 패딩 +```css +/* globals.css - 문제 코드 */ +body { + padding-bottom: env(safe-area-inset-bottom, 0px); +} +``` +- 확대 시 body가 확장되면서 패딩 영역이 화면에 노출 + +**원인 2**: 모바일 레이아웃 wrapper에 배경색 미지정 +```tsx +// 문제 코드 - 배경색 없음 +
+``` +- 배경색이 없어서 확대 시 뒤에 있는 요소(어두운 배경)가 투과되어 보임 + +**원인 3**: `overflow-hidden`으로 인한 콘텐츠 클리핑 +- 고정 높이 + overflow-hidden = 확대 시 콘텐츠가 잘림 + +--- + +## 3. 해결 방법 + +### 3-1. 핀치 줌 활성화 +**파일**: `src/layouts/AuthenticatedLayout.tsx` (Line 615) + +```tsx +// 변경 전 +
+ +// 변경 후 +
+``` + +### 3-2. body 패딩 제거 +**파일**: `src/app/globals.css` + +```css +/* 변경 전 */ +body { + padding-bottom: env(safe-area-inset-bottom, 0px); +} + +/* 변경 후 - 해당 코드 제거 */ +/* safe-area 변수는 유지, body 패딩만 제거 */ +:root { + --safe-area-inset-top: env(safe-area-inset-top, 0px); + --safe-area-inset-right: env(safe-area-inset-right, 0px); + --safe-area-inset-bottom: env(safe-area-inset-bottom, 0px); + --safe-area-inset-left: env(safe-area-inset-left, 0px); +} +``` + +### 3-3. 모바일 레이아웃 배경색 및 높이 수정 +**파일**: `src/layouts/AuthenticatedLayout.tsx` (Line 370) + +```tsx +// 변경 전 +
+ +// 변경 후 +
+``` + +| 변경 항목 | 효과 | +|----------|------| +| `bg-background` | 배경색 명시적 지정 → 어두운 영역 가림 | +| `min-h-screen` | 최소 높이 보장 → 확대 시에도 배경 커버 | +| `overflow-hidden` 제거 | 확대 시 콘텐츠 클리핑 방지 | + +--- + +## 4. Viewport 설정 (참고) + +**파일**: `src/app/[locale]/layout.tsx` + +```tsx +// 현재 설정 - 줌 허용 + iOS safe-area 지원 +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + minimumScale: 1, // 최소 100% + maximumScale: 5, // 최대 500%까지 확대 가능 + userScalable: true, // 손가락 확대 허용 + viewportFit: 'cover', // 아이폰 노치/다이나믹 아일랜드/하단 홈바 영역 커버 +}; +``` + +--- + +## 5. 최종 변경 파일 목록 + +| 파일 | 변경 내용 | +|------|----------| +| `src/layouts/AuthenticatedLayout.tsx` | touch-action 수정, 배경색/높이 추가 | +| `src/app/globals.css` | body padding-bottom 제거 | +| `src/app/[locale]/layout.tsx` | viewport 설정 (이전에 적용됨) | + +--- + +## 6. 테스트 체크리스트 + +- [x] iOS Safari 핀치 줌 동작 +- [x] Android Chrome 핀치 줌 동작 +- [x] 확대 시 회색 영역 미노출 +- [x] 로그인 페이지 정상 동작 +- [x] 내부 페이지(AuthenticatedLayout) 정상 동작 +- [x] 세로 스크롤 정상 동작 + +--- + +## 7. 관련 문서 + +- `[REF] mobile-zoom-prevention-guide.md` - 줌 방지가 필요할 때 적용 가이드 + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-01-15 | 문서 작성, 이슈 해결 완료 | diff --git a/claudedocs/[REF] mobile-zoom-prevention-guide.md b/claudedocs/[REF] mobile-zoom-prevention-guide.md new file mode 100644 index 00000000..3ff28051 --- /dev/null +++ b/claudedocs/[REF] mobile-zoom-prevention-guide.md @@ -0,0 +1,101 @@ +# 모바일 확대 방지 설정 가이드 + +> **목적**: 모바일 웹에서 손가락 핀치/더블탭 확대 방지 +> **상태**: 미적용 (사용자 접근성 우선) +> **적용 시점**: 필요 시 아래 설정 적용 + +--- + +## 1. Viewport 설정 (Next.js 15) + +**파일**: `src/app/[locale]/layout.tsx` + +### 1-1. import 추가 + +```tsx +import type { Metadata, Viewport } from "next"; +``` + +### 1-2. viewport export 추가 (metadata 아래) + +```tsx +// 📱 Viewport 설정 - 모바일 확대 방지 + 100% 스케일 고정 +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + minimumScale: 1, + maximumScale: 1, + userScalable: false, // 손가락 확대 방지 (Android + iOS) + viewportFit: 'cover', // 아이폰 노치/다이나믹 아일랜드 대응 +}; +``` + +--- + +## 2. iOS Safari 자동 확대 방지 (CSS) + +**파일**: `src/app/globals.css` + +iOS Safari는 `font-size`가 16px 미만인 input에 포커스하면 자동으로 확대함. +viewport 설정만으로는 방지 안 됨. + +### 2-1. @variant 아래에 추가 + +```css +/* 📱 iOS Safari 자동 확대 방지 + - iOS는 font-size 16px 미만 input 포커스 시 자동 확대 + - 16px 이상으로 설정하면 확대 방지됨 +*/ +input, +select, +textarea { + font-size: 16px !important; +} + +/* 터치 동작 최적화 - 더블탭 확대 방지 */ +html { + touch-action: manipulation; +} +``` + +--- + +## 3. 설정별 효과 + +| 설정 | 효과 | 적용 위치 | +|------|------|-----------| +| `userScalable: false` | 핀치 확대 방지 | layout.tsx | +| `maximumScale: 1` | 최대 100% 고정 | layout.tsx | +| `minimumScale: 1` | 최소 100% 고정 | layout.tsx | +| `viewportFit: 'cover'` | 노치 영역 커버 | layout.tsx | +| `font-size: 16px` | iOS input 확대 방지 | globals.css | +| `touch-action: manipulation` | 더블탭 확대 방지 | globals.css | + +--- + +## 4. 적용 여부 결정 기준 + +### 적용 권장 상황 +- 키오스크/POS 앱처럼 고정 레이아웃 필수 +- 특정 인터랙션에서 확대가 UX를 방해하는 경우 + +### 미적용 권장 상황 (현재) +- 사용자 연령대가 높아 확대 기능 필요 +- 접근성(A11y) 가이드라인 준수 필요 +- 텍스트가 작은 영역이 있는 경우 + +--- + +## 5. 참고사항 + +- **iOS Safari**: viewport 설정만으로는 input 포커스 확대 방지 안 됨, CSS 필수 +- **Android Chrome**: viewport 설정만으로 대부분 방지됨 +- **Next.js 15**: `viewport`는 별도 export로 분리 (metadata와 별개) + +--- + +## 변경 이력 + +| 날짜 | 내용 | +|------|------| +| 2026-01-15 | 문서 작성, 설정 롤백 (접근성 우선) | \ No newline at end of file diff --git a/claudedocs/auth/[IMPL-2025-12-30] token-refresh-caching.md b/claudedocs/auth/[IMPL-2025-12-30] token-refresh-caching.md index b540a9f5..f4bbaa69 100644 --- a/claudedocs/auth/[IMPL-2025-12-30] token-refresh-caching.md +++ b/claudedocs/auth/[IMPL-2025-12-30] token-refresh-caching.md @@ -339,6 +339,16 @@ Safari에서 `SameSite=Strict` + `Secure` 조합이 localhost에서 쿠키 저 ## 10. 업데이트 이력 +### 10.0 [2026-01-15] 미들웨어 사전 갱신 기능 추가 + +**관련 문서:** `[IMPL-2026-01-15] middleware-pre-refresh.md` + +Request Coalescing 패턴만으로는 auth/check + serverFetch 동시 호출 시 Race Condition이 완전히 해결되지 않아, **미들웨어에서 페이지 렌더링 전 토큰을 미리 갱신**하는 기능 추가. + +두 기능은 상호 보완적: +- **미들웨어 사전 갱신**: 페이지 로드 전 토큰 준비 (1차 방어) +- **Request Coalescing**: API 호출 시 401 발생 시 중복 갱신 방지 (2차 방어) + ### 10.1 [2026-01-08] 누락된 API 라우트 통합 **문제 발견:** diff --git a/claudedocs/auth/[IMPL-2026-01-15] middleware-pre-refresh.md b/claudedocs/auth/[IMPL-2026-01-15] middleware-pre-refresh.md new file mode 100644 index 00000000..8d56ae6a --- /dev/null +++ b/claudedocs/auth/[IMPL-2026-01-15] middleware-pre-refresh.md @@ -0,0 +1,424 @@ +# 미들웨어 토큰 사전 갱신 (Pre-Refresh) 구현 문서 + +> 작성일: 2026-01-15 +> 상태: 완료 + +## 1. 문제 상황 + +### 1.1 기존 Request Coalescing 패턴의 한계 + +`refresh-token.ts`의 5초 캐싱 패턴으로 동시 API 호출 시 중복 갱신은 방지했지만, **auth/check + serverFetch 동시 호출** 문제가 완전히 해결되지 않았음. + +### 1.2 Race Condition 시나리오 + +``` +페이지 로드 시 (access_token 만료, refresh_token만 있는 상태) + +시간 → +──────────────────────────────────────────────────────────────────── +[페이지 렌더링 시작] + ↓ +[useEffect] → auth/check 호출 ─────┐ +[Server Component] → serverFetch ──┼─→ 둘 다 refresh_token 필요 + ↓ + 첫 번째가 갱신하면 두 번째는? + (캐시 공유해도 타이밍 문제 발생 가능) +──────────────────────────────────────────────────────────────────── +``` + +### 1.3 증상 +- 페이지 로드 시 간헐적으로 401 에러 +- 토큰 만료 직후 첫 페이지 접속 시 로그인 페이지로 튕김 +- 콘솔에 `Token refresh failed` 로그 + +--- + +## 2. 해결 방법: 미들웨어 사전 갱신 (Pre-Refresh) + +### 2.1 핵심 아이디어 + +**페이지 렌더링 전에 미들웨어에서 토큰을 미리 갱신**하여, 페이지 로드 시 모든 API 호출이 이미 갱신된 access_token을 사용하도록 함. + +``` +시간 → +──────────────────────────────────────────────────────────────────── +[브라우저 요청] → [미들웨어 7.5단계] + ↓ + access_token 없고 refresh_token만 있음? + ↓ YES + 백엔드 /api/v1/refresh 호출 (1회) + ↓ + Set-Cookie: access_token, refresh_token + ↓ +[페이지 렌더링] → auth/check, serverFetch 모두 새 access_token 사용 + ↓ + ✅ Race Condition 없음 +──────────────────────────────────────────────────────────────────── +``` + +### 2.2 기존 패턴과의 관계 + +| 기능 | 목적 | 실행 시점 | 파일 | +|------|------|----------|------| +| **Request Coalescing** | 동시 API 호출 시 refresh 중복 방지 | API 호출 시 401 응답 후 | `refresh-token.ts` | +| **미들웨어 사전 갱신** | 페이지 로드 전 토큰 준비 | 미들웨어 실행 시 | `middleware.ts` | + +두 기능은 **상호 보완적**: +- 미들웨어가 사전 갱신하면 대부분의 경우 API 호출 시 401이 발생하지 않음 +- 만약 미들웨어 이후 토큰이 만료되면 Request Coalescing이 백업으로 동작 + +--- + +## 3. 구현 코드 + +### 3.1 파일 위치 +``` +src/middleware.ts +``` + +### 3.2 추가된 코드 구조 + +```typescript +// 1. 캐시 객체 (모듈 레벨) +let middlewareRefreshCache: { + promise: Promise | null; + timestamp: number; + result: RefreshResult | null; +} = { promise: null, timestamp: 0, result: null }; + +const MIDDLEWARE_REFRESH_CACHE_TTL = 5000; // 5초 + +// 2. checkAuthentication() 확장 +function checkAuthentication(request: NextRequest): { + isAuthenticated: boolean; + authMode: 'sanctum' | 'bearer' | 'api-key' | null; + needsRefresh: boolean; // 🆕 access_token 없고 refresh_token만 있음 + refreshToken: string | null; // 🆕 갱신에 사용할 토큰 +} + +// 3. refreshTokenInMiddleware() 함수 +async function refreshTokenInMiddleware(refreshToken: string): Promise + +// 4. middleware() 함수 내 7.5단계 +export async function middleware(request: NextRequest) { + // ... 기존 1~7단계 ... + + // 7.5단계: 토큰 사전 갱신 + if (needsRefresh && refreshToken) { + const refreshResult = await refreshTokenInMiddleware(refreshToken); + // Set-Cookie로 새 토큰 설정 + } + + // ... 기존 8~10단계 ... +} +``` + +### 3.3 checkAuthentication() 반환값 변경 + +**변경 전:** +```typescript +return { + isAuthenticated: boolean; + authMode: 'sanctum' | 'bearer' | 'api-key' | null; +} +``` + +**변경 후:** +```typescript +return { + isAuthenticated: boolean; + authMode: 'sanctum' | 'bearer' | 'api-key' | null; + needsRefresh: boolean; // access_token 없고 refresh_token만 있으면 true + refreshToken: string | null; // 갱신에 사용할 refresh_token 값 +} +``` + +### 3.4 7.5단계 사전 갱신 로직 + +```typescript +// 7️⃣.5️⃣ 🔄 토큰 사전 갱신 (Race Condition 방지) +if (needsRefresh && refreshToken) { + console.log(`🔄 [Middleware] Pre-refreshing token before page render: ${pathname}`); + + const refreshResult = await refreshTokenInMiddleware(refreshToken); + + if (refreshResult.success && refreshResult.accessToken) { + const isProduction = process.env.NODE_ENV === 'production'; + const intlResponse = intlMiddleware(request); + + // Set-Cookie 헤더로 새 토큰 전송 + const accessTokenCookie = [ + `access_token=${refreshResult.accessToken}`, + 'HttpOnly', + ...(isProduction ? ['Secure'] : []), + 'SameSite=Lax', + 'Path=/', + `Max-Age=${refreshResult.expiresIn || 7200}`, + ].join('; '); + + const refreshTokenCookie = [ + `refresh_token=${refreshResult.refreshToken}`, + 'HttpOnly', + ...(isProduction ? ['Secure'] : []), + 'SameSite=Lax', + 'Path=/', + 'Max-Age=604800', // 7 days (하드코딩) + ].join('; '); + + intlResponse.headers.append('Set-Cookie', accessTokenCookie); + intlResponse.headers.append('Set-Cookie', refreshTokenCookie); + // ... 기타 쿠키 ... + + return intlResponse; + } else { + // 갱신 실패 시 로그인 페이지로 + return NextResponse.redirect(new URL('/login', request.url)); + } +} +``` + +--- + +## 4. 동작 흐름도 + +### 4.1 정상 흐름 (access_token 유효) + +``` +브라우저 → 미들웨어 → checkAuthentication() + ↓ + needsRefresh = false (access_token 있음) + ↓ + 7.5단계 스킵 → 페이지 렌더링 +``` + +### 4.2 사전 갱신 흐름 (access_token 만료, refresh_token 유효) + +``` +브라우저 → 미들웨어 → checkAuthentication() + ↓ + needsRefresh = true (access_token 없음, refresh_token 있음) + ↓ + 7.5단계: refreshTokenInMiddleware() 호출 + ↓ + 백엔드 /api/v1/refresh → 새 토큰 발급 + ↓ + Set-Cookie: access_token, refresh_token + ↓ + 페이지 렌더링 (새 토큰으로) +``` + +### 4.3 갱신 실패 흐름 (refresh_token도 만료) + +``` +브라우저 → 미들웨어 → checkAuthentication() + ↓ + needsRefresh = true + ↓ + 7.5단계: refreshTokenInMiddleware() 호출 + ↓ + 백엔드 → 401 (refresh_token 만료) + ↓ + redirect('/login') +``` + +--- + +## 5. 설정 값 + +| 항목 | 값 | 설명 | +|------|-----|------| +| MIDDLEWARE_REFRESH_CACHE_TTL | 5초 | 미들웨어 캐시 유지 시간 | +| access_token Max-Age | 7200초 (2시간) | 백엔드 expires_in 값 또는 기본값 | +| refresh_token Max-Age | 604800초 (7일) | 하드코딩 (백엔드에서 미제공) | + +--- + +## 6. 로그 메시지 + +### 6.1 사전 갱신 시작 +``` +🔄 [Middleware] Pre-refreshing token before page render: /dashboard +``` + +### 6.2 캐시 히트 +``` +🔵 [Middleware] Using cached refresh result (age: 1234ms) +``` + +### 6.3 진행 중인 갱신 대기 +``` +🔵 [Middleware] Waiting for ongoing refresh... +``` + +### 6.4 갱신 성공 +``` +✅ [Middleware] Pre-refresh successful +✅ [Middleware] Pre-refresh complete, new tokens set in cookies +``` + +### 6.5 갱신 실패 +``` +🔴 [Middleware] Pre-refresh failed: 401 +🔴 [Middleware] Pre-refresh failed, redirecting to login +``` + +--- + +## 7. Edge Runtime 고려사항 + +### 7.1 모듈 레벨 캐시의 한계 + +Edge Runtime에서는 모듈 레벨 변수가 **요청 간 공유되지 않을 수 있음**. +따라서 `middlewareRefreshCache`는 **같은 요청 내 중복 갱신 방지**에만 효과적. + +### 7.2 5초 캐시의 역할 + +- 같은 요청 처리 중 여러 번 호출되는 경우 방지 +- Edge 인스턴스 간 캐시 공유는 불가능 +- 충분히 짧아서 토큰 갱신 지연 문제 없음 + +--- + +## 8. 관련 파일 + +| 파일 | 역할 | +|------|------| +| `src/middleware.ts` | 미들웨어 사전 갱신 로직 | +| `src/lib/api/refresh-token.ts` | Request Coalescing 패턴 (백업) | +| `src/app/api/auth/check/route.ts` | 인증 확인 API | +| `src/app/api/auth/refresh/route.ts` | 토큰 갱신 프록시 | + +--- + +## 9. 관련 문서 + +- `[IMPL-2025-12-30] token-refresh-caching.md` - Request Coalescing 패턴 문서 +- `[IMPL-2025-11-07] middleware-issue-resolution.md` - 미들웨어 기본 구조 + +--- + +## 10. 업데이트 이력 + +### 10.1 [2026-01-15] 초기 구현 + +**배경:** +- auth/check와 serverFetch 동시 호출 시 Race Condition 발생 +- 기존 Request Coalescing만으로는 완전히 해결되지 않음 + +**구현 내용:** +1. `middlewareRefreshCache` 캐시 객체 추가 +2. `refreshTokenInMiddleware()` 함수 구현 +3. `checkAuthentication()`에 `needsRefresh`, `refreshToken` 반환 추가 +4. 7.5단계 사전 갱신 로직 추가 + +**결과:** +- 페이지 렌더링 전 토큰 갱신 완료 +- 이후 API 호출들은 새 access_token 사용 +- Race Condition 완전 해결 + +### 10.2 [2026-01-15] 파편화된 API route 통합 + +**배경:** +- `/api/menus` 등 별도 route에서 refresh 로직 없이 바로 401 반환 +- 1~2시간 방치 후 로그인 페이지로 튕기는 문제 발생 + +**수행 내용:** +1. 클라이언트 호출 경로 변경: + - `/api/menus` → `/api/proxy/menus` (menuRefresh.ts) + - `/api/files/${id}/download` → `/api/proxy/files/${id}/download` (DocumentCreate, DraftBox) +2. 파편화된 API route 삭제: + - `src/app/api/menus/` - 삭제 + - `src/app/api/files/` - 삭제 + - `src/app/api/tenants/` - 삭제 (미사용) + - `src/lib/api/php-proxy.ts` - 삭제 (중복 유틸) + +**결과:** +- 모든 API 호출이 `/api/proxy`를 통해 refresh 로직 적용 +- 토큰 만료 시 자동 갱신 후 재시도 + +### 10.3 [2026-01-15] 인증 흐름 전면 재설계 + +**배경:** +- pre-refresh 실패 시 무한 리다이렉트 루프 발생 +- 5️⃣ 게스트 전용 라우트에서 `needsRefresh` 상태를 고려하지 않음 +- `refresh_token`만 있는 상태를 "로그인됨"으로 섣부르게 판정 + +**문제의 무한 루프 시나리오:** +``` +/login 접근 (refresh_token만 있음) + ↓ +5️⃣ isAuthenticated=true (refresh_token 있으니까) → /dashboard로 리다이렉트 + ↓ +7.5️⃣ pre-refresh 시도 → 401 실패 → /login으로 리다이렉트 + ↓ +무한 반복! +``` + +**핵심 원인:** +- `refresh_token`만 있는 상태 = "로그인됨"이 아니라 "로그인 가능성 있음" +- 실제로 refresh 성공해야 "진짜 로그인" +- 5️⃣에서 이걸 확인 안 하고 바로 /dashboard로 보냄 + +**수정 내용 (5️⃣ 게스트 전용 라우트):** +```typescript +if (isGuestOnlyRoute(pathnameWithoutLocale)) { + // needsRefresh인 경우: 먼저 refresh 시도해서 "진짜 로그인"인지 확인 + if (needsRefresh && refreshToken) { + const refreshResult = await refreshTokenInMiddleware(refreshToken); + + if (refreshResult.success) { + // ✅ 진짜 로그인됨 → /dashboard로 (쿠키 설정) + return redirectToDashboard(with new cookies); + } else { + // ❌ 로그인 안 됨 → 쿠키 삭제 후 로그인 페이지 표시 (리다이렉트 없이!) + return showLoginPage(with cleared cookies); + } + } + + // access_token 있음 = 확실히 로그인됨 → /dashboard로 + if (isAuthenticated) { + return redirectToDashboard(); + } + + // 쿠키 없음 = 비로그인 → 로그인 페이지 표시 + return showLoginPage(); +} +``` + +**수정 후 흐름:** +``` +/login 접근 (refresh_token만 있음) + ↓ +5️⃣ needsRefresh=true → refresh 먼저 시도 + ↓ +├─ 성공 → "진짜 로그인" → /dashboard (왕복 1회) +└─ 실패 → "로그인 안 됨" → 쿠키 삭제 → 로그인 페이지 (왕복 0회!) +``` + +**결과:** +- 무한 리다이렉트 루프 완전 해결 +- 불필요한 /dashboard → /login 왕복 제거 +- refresh 실패 시 바로 로그인 페이지 표시 + +--- + +## 11. TODO (Phase 2) + +### 쿠키 설정 공통 모듈화 + +현재 쿠키 설정 코드가 6곳에 중복: +- `/api/proxy/[...path]/route.ts` +- `/api/auth/login/route.ts` +- `/api/auth/check/route.ts` +- `/api/auth/refresh/route.ts` +- `middleware.ts` +- `fetch-wrapper.ts` + +**계획:** +```typescript +// src/lib/api/cookie-utils.ts (신규) +export function createTokenCookies(tokens: TokenSet): string[] +export function clearTokenCookies(): string[] +``` + +**효과:** 유지보수성 향상 (쿠키 설정 변경 시 1곳만 수정) diff --git a/src/app/[locale]/(protected)/board-test/page.tsx b/src/app/[locale]/(protected)/board-test/page.tsx deleted file mode 100644 index 2800976f..00000000 --- a/src/app/[locale]/(protected)/board-test/page.tsx +++ /dev/null @@ -1,14 +0,0 @@ -'use client'; - -/** - * 게시판 목록 UniversalListPage 테스트 페이지 - * - * URL: /ko/board-test - * 비교: /ko/board (기존) - */ - -import { BoardListUnified } from '@/components/board/BoardList/BoardListUnified'; - -export default function BoardListTestPage() { - return ; -} \ No newline at end of file diff --git a/src/app/[locale]/(protected)/boards/[boardCode]/page.tsx b/src/app/[locale]/(protected)/boards/[boardCode]/page.tsx index 1b9b470a..277fb1a9 100644 --- a/src/app/[locale]/(protected)/boards/[boardCode]/page.tsx +++ b/src/app/[locale]/(protected)/boards/[boardCode]/page.tsx @@ -22,10 +22,10 @@ import { SelectValue, } from '@/components/ui/select'; import { - IntegratedListTemplateV2, + UniversalListPage, + type UniversalListConfig, type TableColumn, - type PaginationConfig, -} from '@/components/templates/IntegratedListTemplateV2'; +} from '@/components/templates/UniversalListPage'; import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { getDynamicBoardPosts } from '@/components/board/DynamicBoard/actions'; import { getBoardByCode } from '@/components/board/BoardManagement/actions'; @@ -231,8 +231,13 @@ export default function DynamicBoardListPage() { // 테이블 행 렌더링 const renderTableRow = useCallback( - (item: BoardPost, index: number, globalIndex: number) => { - const isSelected = selectedItems.has(item.id); + ( + item: BoardPost, + index: number, + globalIndex: number, + handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void } + ) => { + const { isSelected, onToggle } = handlers; return ( e.stopPropagation()}> handleToggleSelection(item.id)} + onCheckedChange={onToggle} /> {globalIndex} @@ -264,7 +269,7 @@ export default function DynamicBoardListPage() { ); }, - [selectedItems, handleRowClick, handleToggleSelection] + [handleRowClick] ); // 모바일 카드 렌더링 @@ -273,9 +278,9 @@ export default function DynamicBoardListPage() { item: BoardPost, index: number, globalIndex: number, - isSelected: boolean, - onToggle: () => void + handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void } ) => { + const { isSelected, onToggle } = handlers; return ( @@ -327,72 +323,99 @@ export default function DynamicBoardListPage() { ); } + // UniversalListPage 설정 + const boardConfig: UniversalListConfig = { + title: boardName, + description: boardDescription || `${boardName} 게시판입니다.`, + icon: MessageSquare, + basePath: `/boards/${boardCode}`, + idField: 'id', + + actions: { + getList: async () => ({ + success: true, + data: filteredData, + totalCount: filteredData.length, + }), + }, + + columns: tableColumns, + headerActions: ( + <> + + + + ), + tableHeaderActions: ( +
+ + 총 {filteredData.length}건 + + + +
+ ), + + searchPlaceholder: '제목, 작성자로 검색...', + itemsPerPage: ITEMS_PER_PAGE, + clientSideFiltering: true, + + renderTableRow, + renderMobileCard, + }; + return ( - - - - - } - searchValue={searchValue} - onSearchChange={setSearchValue} - searchPlaceholder="제목, 작성자로 검색..." - tableHeaderActions={ -
- - 총 {filteredData.length}건 - - - -
- } - tableColumns={tableColumns} - data={paginatedData} - allData={filteredData} - selectedItems={selectedItems} - onToggleSelection={handleToggleSelection} - onToggleSelectAll={handleToggleSelectAll} - getItemId={(item) => item.id} - renderTableRow={renderTableRow} - renderMobileCard={renderMobileCard} - pagination={pagination} + + config={boardConfig} + initialData={filteredData} + initialTotalCount={filteredData.length} + externalSelection={{ + selectedItems, + setSelectedItems, + toggleSelection: handleToggleSelection, + toggleSelectAll: handleToggleSelectAll, + }} + externalSearch={{ + searchTerm: searchValue, + setSearchTerm: setSearchValue, + }} + externalPagination={{ + currentPage, + setCurrentPage, + }} /> ); } diff --git a/src/app/[locale]/(protected)/construction/order/order-management-test/page.tsx b/src/app/[locale]/(protected)/construction/order/order-management-test/page.tsx deleted file mode 100644 index 917c2797..00000000 --- a/src/app/[locale]/(protected)/construction/order/order-management-test/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -'use client'; - -/** - * 발주관리 UniversalListPage 테스트 페이지 - * - * 특이 케이스: ScheduleCalendar 컴포넌트 (beforeTableContent) - * URL: /ko/construction/order/order-management-test - * - * 비교 테스트: /ko/construction/order/order-management (기존) - */ - -import { OrderManagementUnified } from '@/components/business/construction/order-management/OrderManagementUnified'; - -export default function OrderManagementTestPage() { - return ; -} diff --git a/src/app/[locale]/(protected)/hr/card-management-test/page.tsx b/src/app/[locale]/(protected)/hr/card-management-test/page.tsx deleted file mode 100644 index eb00ed1d..00000000 --- a/src/app/[locale]/(protected)/hr/card-management-test/page.tsx +++ /dev/null @@ -1,16 +0,0 @@ -'use client'; - -/** - * 카드관리 UniversalListPage 테스트 페이지 - * - * 기존 CardManagement와 동일한 기능을 UniversalListPage config 방식으로 구현한 테스트 버전 - * URL: /ko/hr/card-management-test - * - * 비교 테스트 완료 후 삭제 예정 - */ - -import { CardManagementUnified } from '@/components/hr/CardManagement/CardManagementUnified'; - -export default function CardManagementTestPage() { - return ; -} diff --git a/src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx b/src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx index ece2b188..21664b52 100644 --- a/src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx +++ b/src/app/[locale]/(protected)/sales/client-management-sales-admin/page.tsx @@ -33,10 +33,11 @@ import { import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { - IntegratedListTemplateV2, - TabOption, - TableColumn, -} from "@/components/templates/IntegratedListTemplateV2"; + UniversalListPage, + type UniversalListConfig, + type TabOption, + type TableColumn, +} from "@/components/templates/UniversalListPage"; import { toast } from "sonner"; import { TableRow, @@ -448,10 +449,10 @@ export default function CustomerAccountManagementPage() { const renderTableRow = ( customer: Client, index: number, - globalIndex: number + globalIndex: number, + handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void } ) => { - const itemId = customer.id; - const isSelected = selectedItems.has(itemId); + const { isSelected, onToggle } = handlers; return ( e.stopPropagation()} className="text-center"> toggleSelection(itemId)} + onCheckedChange={onToggle} /> {globalIndex} @@ -512,9 +513,9 @@ export default function CustomerAccountManagementPage() { customer: Client, index: number, globalIndex: number, - isSelected: boolean, - onToggle: () => void + handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void } ) => { + const { isSelected, onToggle } = handlers; return ( = { + title: "거래처 목록", + description: "거래처 정보 및 계정을 관리합니다", + icon: Building2, + basePath: "/sales/client-management-sales-admin", + + idField: "id", + + actions: { + getList: async () => ({ + success: true, + data: clients, + totalCount: pagination?.total || clients.length, + }), + }, + + columns: tableColumns, + + tabs: tabs, + defaultTab: filterType, + + stats: stats, + + searchPlaceholder: "거래처명, 코드, 대표자, 전화번호, 사업자번호 검색...", + + itemsPerPage, + + clientSideFiltering: true, + + searchFilter: (item, searchValue) => { + const term = searchValue.toLowerCase(); + return ( + item.name.toLowerCase().includes(term) || + item.code.toLowerCase().includes(term) || + (item.representative?.toLowerCase().includes(term) ?? false) || + (item.phone?.includes(term) ?? false) || + (item.businessNo?.includes(term) ?? false) + ); + }, + + tabFilter: (item, tabValue) => { + if (tabValue === "all") return true; + if (tabValue === "active") return item.status === "활성"; + if (tabValue === "inactive") return item.status === "비활성"; + if (tabValue === "purchase") return item.clientType === "매입" || item.clientType === "매입매출"; + if (tabValue === "sales") return item.clientType === "매출" || item.clientType === "매입매출"; + return true; + }, + + headerActions: () => ( +
+ + +
+ ), + + tableTitle: `${tabs.find((t) => t.value === filterType)?.label || "전체"} (${filteredClients.length}개)`, + + renderTableRow, + renderMobileCard, + + renderDialogs: () => ( + <> + {/* 삭제 확인 다이얼로그 */} + + + + 거래처 삭제 확인 + + {deleteTargetId + ? `거래처: ${clients.find((c) => c.id === deleteTargetId)?.name || deleteTargetId}` + : ""} +
+ 이 거래처를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다. +
+
+ + 취소 + + 삭제 + + +
+
+ + {/* 일괄 삭제 확인 다이얼로그 */} + + + + 일괄 삭제 확인 + + 선택한 {selectedItems.size}개의 거래처를 삭제하시겠습니까? +
+ 삭제된 데이터는 복구할 수 없습니다. +
+
+ + 취소 + + 삭제 + + +
+
+ + ), + }; + return ( - <> - - - -
- } - stats={stats} - searchValue={searchTerm} - onSearchChange={setSearchTerm} - searchPlaceholder="거래처명, 코드, 대표자, 전화번호, 사업자번호 검색..." - tabs={tabs} - activeTab={filterType} - onTabChange={(value) => { - setFilterType(value); - setCurrentPage(1); - }} - tableColumns={tableColumns} - tableTitle={`${tabs.find((t) => t.value === filterType)?.label || "전체"} (${filteredClients.length}개)`} - data={paginatedClients} - totalCount={filteredClients.length} - allData={mobileClients} - mobileDisplayCount={mobileDisplayCount} - infinityScrollSentinelRef={sentinelRef} - selectedItems={selectedItems} - onToggleSelection={toggleSelection} - onToggleSelectAll={toggleSelectAll} - onBulkDelete={handleBulkDelete} - getItemId={(customer) => customer.id} - renderTableRow={renderTableRow} - renderMobileCard={renderMobileCard} - pagination={{ - currentPage, - totalPages, - totalItems: pagination?.total || filteredClients.length, - itemsPerPage, - onPageChange: setCurrentPage, - }} - /> - - {/* 삭제 확인 다이얼로그 */} - - - - 거래처 삭제 확인 - - {deleteTargetId - ? `거래처: ${clients.find((c) => c.id === deleteTargetId)?.name || deleteTargetId}` - : ""} -
- 이 거래처를 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다. -
-
- - 취소 - - 삭제 - - -
-
- - {/* 일괄 삭제 확인 다이얼로그 */} - - - - 일괄 삭제 확인 - - 선택한 {selectedItems.size}개의 거래처를 삭제하시겠습니까? -
- 삭제된 데이터는 복구할 수 없습니다. -
-
- - 취소 - - 삭제 - - -
-
- + + config={clientManagementConfig} + initialData={clients} + initialTotalCount={pagination?.total || clients.length} + /> ); } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/sales/order-management-sales/page.tsx b/src/app/[locale]/(protected)/sales/order-management-sales/page.tsx index 7de2188a..dc68bc76 100644 --- a/src/app/[locale]/(protected)/sales/order-management-sales/page.tsx +++ b/src/app/[locale]/(protected)/sales/order-management-sales/page.tsx @@ -28,10 +28,11 @@ import { Button } from "@/components/ui/button"; import { Badge } from "@/components/ui/badge"; import { BadgeSm } from "@/components/atoms/BadgeSm"; import { - IntegratedListTemplateV2, - TabOption, - TableColumn, -} from "@/components/templates/IntegratedListTemplateV2"; + UniversalListPage, + type UniversalListConfig, + type TabOption, + type TableColumn, +} from "@/components/templates/UniversalListPage"; import { toast } from "sonner"; import { TableRow, @@ -462,10 +463,11 @@ export default function OrderManagementSalesPage() { const renderTableRow = ( order: Order, index: number, - globalIndex: number + globalIndex: number, + handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void } ) => { + const { isSelected, onToggle } = handlers; const itemId = order.id; - const isSelected = selectedItems.has(itemId); return ( e.stopPropagation()} className="text-center"> toggleSelection(itemId)} + onCheckedChange={onToggle} /> {globalIndex} @@ -534,9 +536,9 @@ export default function OrderManagementSalesPage() { order: Order, index: number, globalIndex: number, - isSelected: boolean, - onToggle: () => void + handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void } ) => { + const { isSelected, onToggle } = handlers; return ( = { + title: "수주 목록", + description: "수주 관리 및 생산지시 연동", + icon: FileText, + basePath: "/sales/order-management-sales", + + idField: "id", + + actions: { + getList: async () => ({ + success: true, + data: filteredOrders, + totalCount: filteredOrders.length, + }), + deleteBulk: async (ids) => { + const result = await deleteOrders(ids); + if (result.success) { + setOrders(orders.filter((o) => !ids.includes(o.id))); + setSelectedItems(new Set()); + toast.success(`${ids.length}개의 수주가 삭제되었습니다.`); + const statsResult = await getOrderStats(); + if (statsResult.success && statsResult.data) { + setApiStats(statsResult.data); + } + } + return result; + }, + }, + + columns: tableColumns, + + tabs: tabs, + defaultTab: filterType, + + computeStats: () => stats, + + searchPlaceholder: "로트번호, 견적번호, 발주처, 현장명 검색...", + + itemsPerPage: itemsPerPage, + + clientSideFiltering: true, + + searchFilter: (order, searchValue) => { + const searchLower = searchValue.toLowerCase(); + return ( + order.lotNumber.toLowerCase().includes(searchLower) || + order.quoteNumber.toLowerCase().includes(searchLower) || + order.client.toLowerCase().includes(searchLower) || + order.siteName.toLowerCase().includes(searchLower) + ); + }, + + tabFilter: (order, activeTab) => { + if (activeTab === "all") return true; + if (activeTab === "registered") return order.status === "order_registered"; + if (activeTab === "confirmed") return order.status === "order_confirmed"; + if (activeTab === "production_ordered") return order.status === "production_ordered"; + if (activeTab === "receivable") return order.hasReceivable === true; + return true; + }, + + headerActions: () => ( +
+ + +
+ ), + + renderTableRow: renderTableRow, + + renderMobileCard: renderMobileCard, + + renderDialogs: () => ( + <> + {/* 수주 취소 확인 다이얼로그 */} + + + + 수주 취소 확인 + + {cancelTargetId + ? `수주번호: ${orders.find((o) => o.id === cancelTargetId)?.lotNumber || cancelTargetId}` + : ""} +
+ 이 수주를 취소하시겠습니까? 취소된 수주는 복구할 수 없습니다. +
+
+ + 닫기 + + 취소 확정 + + +
+
+ + {/* 수주 삭제 확인 다이얼로그 */} + + + + + ⚠️ + 삭제 확인 + + +
+

+ 선택한 {deleteTargetIds.length}개의 수주를 삭제하시겠습니까? +

+
+
+ ⚠️ +
+ 주의 +
+ 삭제된 수주는 복구할 수 없습니다. 관련된 작업지시, 출하정보도 함께 삭제될 수 있습니다. +
+
+
+
+
+
+ + 취소 + + {isDeleting ? ( + + ) : ( + + )} + {isDeleting ? "삭제 중..." : "삭제"} + + +
+
+ + ), + }; + // 로딩 상태 표시 if (isLoading) { return ( @@ -635,122 +787,19 @@ export default function OrderManagementSalesPage() { } return ( - <> - - - -
- } - stats={stats} - searchValue={searchTerm} - onSearchChange={setSearchTerm} - searchPlaceholder="로트번호, 견적번호, 발주처, 현장명 검색..." - tabs={tabs} - activeTab={filterType} - onTabChange={(value) => { - setFilterType(value); - setCurrentPage(1); - }} - tableColumns={tableColumns} - tableTitle={`${tabs.find((t) => t.value === filterType)?.label || "전체"} (${filteredOrders.length}개)`} - data={paginatedOrders} - totalCount={filteredOrders.length} - allData={mobileOrders} - mobileDisplayCount={mobileDisplayCount} - infinityScrollSentinelRef={sentinelRef} - selectedItems={selectedItems} - onToggleSelection={toggleSelection} - onToggleSelectAll={toggleSelectAll} - getItemId={(order) => order.id} - onBulkDelete={handleBulkDelete} - renderTableRow={renderTableRow} - renderMobileCard={renderMobileCard} - pagination={{ - currentPage, - totalPages, - totalItems: filteredOrders.length, - itemsPerPage, - onPageChange: setCurrentPage, - }} - /> - - {/* 수주 취소 확인 다이얼로그 */} - - - - 수주 취소 확인 - - {cancelTargetId - ? `수주번호: ${orders.find((o) => o.id === cancelTargetId)?.lotNumber || cancelTargetId}` - : ""} -
- 이 수주를 취소하시겠습니까? 취소된 수주는 복구할 수 없습니다. -
-
- - 닫기 - - 취소 확정 - - -
-
- - {/* 수주 삭제 확인 다이얼로그 - 스크린샷 디자인 적용 */} - - - - - ⚠️ - 삭제 확인 - - -
-

- 선택한 {deleteTargetIds.length}개의 수주를 삭제하시겠습니까? -

- {/* 주의 박스 */} -
-
- ⚠️ -
- 주의 -
- 삭제된 수주는 복구할 수 없습니다. 관련된 작업지시, 출하정보도 함께 삭제될 수 있습니다. -
-
-
-
-
-
- - 취소 - - {isDeleting ? ( - - ) : ( - - )} - {isDeleting ? "삭제 중..." : "삭제"} - - -
-
- + + config={orderConfig} + initialData={filteredOrders} + initialTotalCount={filteredOrders.length} + externalSelection={{ + selectedItems, + onSelectionChange: setSelectedItems, + }} + onTabChange={(value) => { + setFilterType(value); + setCurrentPage(1); + }} + onSearchChange={setSearchTerm} + /> ); } \ No newline at end of file diff --git a/src/app/[locale]/(protected)/sales/order-management-sales/production-orders/page.tsx b/src/app/[locale]/(protected)/sales/order-management-sales/production-orders/page.tsx index 72d3e921..6e27ef6a 100644 --- a/src/app/[locale]/(protected)/sales/order-management-sales/production-orders/page.tsx +++ b/src/app/[locale]/(protected)/sales/order-management-sales/production-orders/page.tsx @@ -41,10 +41,11 @@ import { Trash2, } from "lucide-react"; import { - IntegratedListTemplateV2, - TabOption, - TableColumn, -} from "@/components/templates/IntegratedListTemplateV2"; + UniversalListPage, + type UniversalListConfig, + type TabOption, + type TableColumn, +} from "@/components/templates/UniversalListPage"; import { BadgeSm } from "@/components/atoms/BadgeSm"; import { ListMobileCard, InfoField } from "@/components/organisms/ListMobileCard"; @@ -389,9 +390,10 @@ export default function ProductionOrdersListPage() { const renderTableRow = ( item: ProductionOrder, index: number, - globalIndex: number + globalIndex: number, + handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void } ) => { - const isSelected = selectedItems.has(item.id); + const { isSelected, onToggle } = handlers; return ( e.stopPropagation()} className="text-center"> toggleSelection(item.id)} + onCheckedChange={onToggle} /> @@ -453,9 +455,9 @@ export default function ProductionOrdersListPage() { item: ProductionOrder, index: number, globalIndex: number, - isSelected: boolean, - onToggle: () => void + handlers: { isSelected: boolean; onToggle: () => void; onRowClick?: () => void } ) => { + const { isSelected, onToggle } = handlers; return ( - - - 수주 목록 - - } - // 진행 단계 표시 - tabsContent={ - - - - - - } - // 검색 - searchValue={searchTerm} - onSearchChange={setSearchTerm} - searchPlaceholder="생산지시번호, 수주번호, 현장명 검색..." - // 탭 - tabs={tabs} - activeTab={activeTab} - onTabChange={(value) => { - setActiveTab(value); - setCurrentPage(1); - }} - // 테이블 - tableColumns={TABLE_COLUMNS} - data={paginatedData} - allData={filteredData} - selectedItems={selectedItems} - onToggleSelection={toggleSelection} - onToggleSelectAll={toggleSelectAll} - getItemId={(item) => item.id} - onBulkDelete={handleBulkDelete} - renderTableRow={renderTableRow} - renderMobileCard={renderMobileCard} - pagination={{ - currentPage, - totalPages, - totalItems: filteredData.length, - itemsPerPage, - onPageChange: setCurrentPage, - }} - /> + // ===== UniversalListPage 설정 ===== + const productionOrderConfig: UniversalListConfig = { + title: "생산지시 목록", + icon: Factory, + basePath: "/sales/order-management-sales/production-orders", - {/* 삭제 확인 다이얼로그 */} + idField: "id", + + actions: { + getList: async () => ({ + success: true, + data: orders, + totalCount: orders.length, + }), + }, + + columns: TABLE_COLUMNS, + + tabs: tabs, + defaultTab: activeTab, + + searchPlaceholder: "생산지시번호, 수주번호, 현장명 검색...", + + itemsPerPage, + + clientSideFiltering: true, + + searchFilter: (item, searchValue) => { + const term = searchValue.toLowerCase(); + return ( + item.productionOrderNumber.toLowerCase().includes(term) || + item.orderNumber.toLowerCase().includes(term) || + item.siteName.toLowerCase().includes(term) || + item.client.toLowerCase().includes(term) + ); + }, + + tabFilter: (item, tabValue) => { + if (tabValue === "all") return true; + const statusMap: Record = { + waiting: "waiting", + in_progress: "in_progress", + completed: "completed", + }; + return item.status === statusMap[tabValue]; + }, + + headerActions: ( + + ), + + tabsContent: ( + + + + + + ), + + renderTableRow, + renderMobileCard, + + renderDialogs: () => ( @@ -610,6 +627,33 @@ export default function ProductionOrdersListPage() { - + ), + }; + + return ( + + config={productionOrderConfig} + initialData={orders} + initialTotalCount={orders.length} + externalSelection={{ + selectedItems, + setSelectedItems, + }} + externalTab={{ + activeTab, + setActiveTab: (value) => { + setActiveTab(value); + setCurrentPage(1); + }, + }} + externalSearch={{ + searchValue: searchTerm, + setSearchValue: setSearchTerm, + }} + externalPagination={{ + currentPage, + setCurrentPage, + }} + /> ); } \ No newline at end of file diff --git a/src/app/[locale]/layout.tsx b/src/app/[locale]/layout.tsx index 68164b77..dafb18b3 100644 --- a/src/app/[locale]/layout.tsx +++ b/src/app/[locale]/layout.tsx @@ -1,4 +1,4 @@ -import type { Metadata } from "next"; +import type { Metadata, Viewport } from "next"; import localFont from 'next/font/local'; import { NextIntlClientProvider } from 'next-intl'; import { getMessages } from 'next-intl/server'; @@ -52,6 +52,16 @@ export const metadata: Metadata = { }, }; +// 📱 Viewport 설정 - iOS safe-area 지원 + 확대 가능 +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + minimumScale: 1, // 최소 100% + maximumScale: 5, // 최대 500%까지 확대 가능 + userScalable: true, // 손가락 확대 허용 + viewportFit: 'cover', // 아이폰 노치/다이나믹 아일랜드/하단 홈바 영역 커버 +}; + export function generateStaticParams() { return locales.map((locale) => ({ locale })); } diff --git a/src/app/api/files/[id]/download/route.ts b/src/app/api/files/[id]/download/route.ts deleted file mode 100644 index 256fe638..00000000 --- a/src/app/api/files/[id]/download/route.ts +++ /dev/null @@ -1,65 +0,0 @@ -/** - * 파일 다운로드 프록시 API - * - * 백엔드 파일 다운로드 API는 인증이 필요하므로, - * Next.js API 라우트를 통해 인증된 요청을 프록시합니다. - * - * GET /api/files/[id]/download - */ - -import { NextRequest, NextResponse } from 'next/server'; - -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ id: string }> } -) { - try { - const { id } = await params; - const token = request.cookies.get('access_token')?.value; - - if (!token) { - return NextResponse.json( - { success: false, message: '인증이 필요합니다.' }, - { status: 401 } - ); - } - - const backendUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/files/${id}/download`; - - const response = await fetch(backendUrl, { - headers: { - 'Authorization': `Bearer ${token}`, - 'X-API-KEY': process.env.API_KEY || '', - }, - }); - - if (!response.ok) { - return NextResponse.json( - { success: false, message: '파일을 찾을 수 없습니다.' }, - { status: response.status } - ); - } - - // 파일 데이터와 헤더 전달 - const blob = await response.blob(); - const contentType = response.headers.get('content-type') || 'application/octet-stream'; - const contentDisposition = response.headers.get('content-disposition'); - - const headers: HeadersInit = { - 'Content-Type': contentType, - 'Cache-Control': 'public, max-age=31536000', // 1년 캐시 - }; - - if (contentDisposition) { - headers['Content-Disposition'] = contentDisposition; - } - - return new NextResponse(blob, { headers }); - } catch (error) { - console.error('[FileDownload] Error:', error); - return NextResponse.json( - { success: false, message: '파일 다운로드 중 오류가 발생했습니다.' }, - { status: 500 } - ); - } -} diff --git a/src/app/api/menus/route.ts b/src/app/api/menus/route.ts deleted file mode 100644 index 7ebbcbf7..00000000 --- a/src/app/api/menus/route.ts +++ /dev/null @@ -1,92 +0,0 @@ -import { NextResponse } from 'next/server'; -import type { NextRequest } from 'next/server'; - -/** - * 🔵 Next.js 내부 API - 메뉴 조회 프록시 (PHP 백엔드로 전달) - * - * ⚡ 설계 목적: - * - 동적 메뉴 갱신: 재로그인 없이 메뉴 목록 갱신 - * - 보안: HttpOnly 쿠키에서 토큰을 읽어 백엔드로 전달 - * - * 🔄 동작 흐름: - * 1. 클라이언트 → Next.js /api/menus - * 2. Next.js: HttpOnly 쿠키에서 access_token 읽기 - * 3. Next.js → PHP /api/v1/menus (메뉴 조회 요청) - * 4. Next.js → 클라이언트 (메뉴 목록 응답) - * - * 📌 백엔드 API 요청 사항: - * - 엔드포인트: GET /api/v1/menus - * - 인증: Bearer 토큰 필요 - * - 응답: { menus: [...] } (로그인 응답의 menus와 동일 구조) - * - * @see claudedocs/architecture/[PLAN-2025-12-29] dynamic-menu-refresh.md - */ -export async function GET(request: NextRequest) { - try { - // HttpOnly 쿠키에서 access_token 읽기 - const accessToken = request.cookies.get('access_token')?.value; - - if (!accessToken) { - return NextResponse.json( - { error: 'Unauthorized', message: '인증 토큰이 없습니다' }, - { status: 401 } - ); - } - - // PHP 백엔드 메뉴 API 호출 - const backendUrl = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/menus`; - - const response = await fetch(backendUrl, { - method: 'GET', - headers: { - 'Content-Type': 'application/json', - 'Accept': 'application/json', - 'Authorization': `Bearer ${accessToken}`, - 'X-API-KEY': process.env.API_KEY || '', - }, - }); - - if (!response.ok) { - // 백엔드 에러 응답 전달 - const errorData = await response.json().catch(() => ({})); - console.error('[Menu API] Backend error:', response.status, errorData); - - return NextResponse.json( - { - error: 'Backend Error', - message: errorData.message || '메뉴 조회에 실패했습니다', - status: response.status - }, - { status: response.status } - ); - } - - const data = await response.json(); - - // 백엔드 응답 구조: { data: [...] } (ApiResponse::handle 표준) - // 또는 로그인 응답과 동일한 { menus: [...] } 형태일 수 있음 - const menus = data.data || data.menus || (Array.isArray(data) ? data : null); - - // 메뉴 데이터 검증 - if (!menus || !Array.isArray(menus)) { - console.error('[Menu API] Invalid response format:', data); - return NextResponse.json( - { error: 'Invalid Response', message: '메뉴 데이터 형식이 올바르지 않습니다' }, - { status: 500 } - ); - } - - // 응답 구조 통일: { menus: [...] } - return NextResponse.json({ menus }, { status: 200 }); - - } catch (error) { - console.error('[Menu API] Proxy error:', error); - return NextResponse.json( - { - error: 'Internal Server Error', - message: error instanceof Error ? error.message : '서버 오류가 발생했습니다' - }, - { status: 500 } - ); - } -} \ No newline at end of file diff --git a/src/app/api/tenants/[tenantId]/item-master-config/pages/[pageId]/route.ts b/src/app/api/tenants/[tenantId]/item-master-config/pages/[pageId]/route.ts deleted file mode 100644 index 0c88fb6a..00000000 --- a/src/app/api/tenants/[tenantId]/item-master-config/pages/[pageId]/route.ts +++ /dev/null @@ -1,60 +0,0 @@ -import { NextRequest } from 'next/server'; -import { proxyToPhpBackend } from '@/lib/api/php-proxy'; - -/** - * 특정 페이지 조회 API - * - * 엔드포인트: GET /api/tenants/{tenantId}/item-master-config/pages/{pageId} - */ -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ tenantId: string; pageId: string }> } -) { - const { tenantId, pageId } = await params; - - return proxyToPhpBackend( - request, - `/api/v1/tenants/${tenantId}/item-master-config/pages/${pageId}`, - { method: 'GET' } - ); -} - -/** - * 특정 페이지 업데이트 API - * - * 엔드포인트: PUT /api/tenants/{tenantId}/item-master-config/pages/{pageId} - */ -export async function PUT( - request: NextRequest, - { params }: { params: Promise<{ tenantId: string; pageId: string }> } -) { - const { tenantId, pageId } = await params; - const body = await request.json(); - - return proxyToPhpBackend( - request, - `/api/v1/tenants/${tenantId}/item-master-config/pages/${pageId}`, - { - method: 'PUT', - body: JSON.stringify(body), - } - ); -} - -/** - * 특정 페이지 삭제 API - * - * 엔드포인트: DELETE /api/tenants/{tenantId}/item-master-config/pages/{pageId} - */ -export async function DELETE( - request: NextRequest, - { params }: { params: Promise<{ tenantId: string; pageId: string }> } -) { - const { tenantId, pageId } = await params; - - return proxyToPhpBackend( - request, - `/api/v1/tenants/${tenantId}/item-master-config/pages/${pageId}`, - { method: 'DELETE' } - ); -} \ No newline at end of file diff --git a/src/app/api/tenants/[tenantId]/item-master-config/route.ts b/src/app/api/tenants/[tenantId]/item-master-config/route.ts deleted file mode 100644 index 5a769161..00000000 --- a/src/app/api/tenants/[tenantId]/item-master-config/route.ts +++ /dev/null @@ -1,74 +0,0 @@ -import { NextRequest } from 'next/server'; -import { proxyToPhpBackend, appendQueryParams } from '@/lib/api/php-proxy'; - -/** - * 품목기준관리 전체 설정 조회 API - * - * 엔드포인트: GET /api/tenants/{tenantId}/item-master-config - * - * 역할: - * - PHP 백엔드로 단순 프록시 - * - tenant.id 검증은 PHP에서 수행 - * - PHP가 403 반환하면 그대로 전달 - */ -export async function GET( - request: NextRequest, - { params }: { params: Promise<{ tenantId: string }> } -) { - const { tenantId } = await params; - const { searchParams } = new URL(request.url); - - // PHP 엔드포인트 생성 (query params 포함) - const phpEndpoint = appendQueryParams( - `/api/v1/tenants/${tenantId}/item-master-config`, - searchParams - ); - - return proxyToPhpBackend(request, phpEndpoint, { - method: 'GET', - }); -} - -/** - * 품목기준관리 전체 설정 저장 API - * - * 엔드포인트: POST /api/tenants/{tenantId}/item-master-config - */ -export async function POST( - request: NextRequest, - { params }: { params: Promise<{ tenantId: string }> } -) { - const { tenantId } = await params; - const body = await request.json(); - - return proxyToPhpBackend( - request, - `/api/v1/tenants/${tenantId}/item-master-config`, - { - method: 'POST', - body: JSON.stringify(body), - } - ); -} - -/** - * 품목기준관리 전체 설정 업데이트 API - * - * 엔드포인트: PUT /api/tenants/{tenantId}/item-master-config - */ -export async function PUT( - request: NextRequest, - { params }: { params: Promise<{ tenantId: string }> } -) { - const { tenantId } = await params; - const body = await request.json(); - - return proxyToPhpBackend( - request, - `/api/v1/tenants/${tenantId}/item-master-config`, - { - method: 'PUT', - body: JSON.stringify(body), - } - ); -} \ No newline at end of file diff --git a/src/app/globals.css b/src/app/globals.css index cfd3a9bc..b74d3dd4 100644 --- a/src/app/globals.css +++ b/src/app/globals.css @@ -5,6 +5,18 @@ @variant dark (&:is(.dark *)); @variant senior (&:is(.senior *)); +/* 📱 iOS Safe Area CSS 변수 + - 아이폰 노치/다이나믹 아일랜드/홈바 영역 + - viewportFit: cover와 함께 사용 + - 레이아웃 컴포넌트에서 필요 시 사용 +*/ +:root { + --safe-area-inset-top: env(safe-area-inset-top, 0px); + --safe-area-inset-right: env(safe-area-inset-right, 0px); + --safe-area-inset-bottom: env(safe-area-inset-bottom, 0px); + --safe-area-inset-left: env(safe-area-inset-left, 0px); +} + :root { --font-size: 16px; /* Clean minimalist background */ diff --git a/src/components/accounting/BadDebtCollection/index.tsx b/src/components/accounting/BadDebtCollection/index.tsx index ea4fcb24..a3cb1959 100644 --- a/src/components/accounting/BadDebtCollection/index.tsx +++ b/src/components/accounting/BadDebtCollection/index.tsx @@ -1,27 +1,23 @@ 'use client'; +/** + * 악성채권 추심관리 - UniversalListPage 마이그레이션 + * + * 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환 + * - 클라이언트 사이드 필터링 (검색, 거래처, 상태, 정렬) + * - Stats 카드 (API 통계 또는 로컬 계산) + * - tableHeaderActions: 3개 Select 필터 + * - Switch 토글 (설정) + * - 삭제 다이얼로그 (deleteConfirmMessage) + */ + import { useState, useMemo, useCallback, useTransition } from 'react'; import { useRouter } from 'next/navigation'; -import { - AlertTriangle, - Pencil, - Trash2, - Eye, -} from 'lucide-react'; +import { AlertTriangle, Pencil, Trash2, Eye } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; import { TableRow, TableCell } from '@/components/ui/table'; import { Select, @@ -30,16 +26,15 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { MobileCard } from '@/components/molecules/MobileCard'; import { - IntegratedListTemplateV2, - type TableColumn, + UniversalListPage, + type UniversalListConfig, + type SelectionHandlers, + type RowClickHandlers, type StatCard, -} from '@/components/templates/IntegratedListTemplateV2'; -import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; -import type { - BadDebtRecord, - SortOption, -} from './types'; +} from '@/components/templates/UniversalListPage'; +import type { BadDebtRecord, SortOption } from './types'; import { COLLECTION_STATUS_LABELS, STATUS_FILTER_OPTIONS, @@ -48,6 +43,19 @@ import { } from './types'; import { deleteBadDebt, toggleBadDebt } from './actions'; +// ===== 테이블 컬럼 정의 ===== +const tableColumns = [ + { key: 'no', label: 'No.', className: 'text-center w-[60px]' }, + { key: 'vendorName', label: '거래처' }, + { key: 'debtAmount', label: '채권금액', className: 'text-right w-[140px]' }, + { key: 'occurrenceDate', label: '발생일', className: 'text-center w-[110px]' }, + { key: 'overdueDays', label: '연체일수', className: 'text-center w-[100px]' }, + { key: 'managerName', label: '담당자', className: 'w-[100px]' }, + { key: 'status', label: '상태', className: 'text-center w-[100px]' }, + { key: 'setting', label: '설정', className: 'text-center w-[80px]' }, + { key: 'actions', label: '작업', className: 'text-center w-[120px]' }, +]; + // ===== Props 타입 정의 ===== interface BadDebtCollectionProps { initialData: BadDebtRecord[]; @@ -63,407 +71,421 @@ interface BadDebtCollectionProps { // 거래처 목록 추출 (필터용) const getVendorOptions = (data: BadDebtRecord[]) => { const vendorMap = new Map(); - data.forEach(item => { + data.forEach((item) => { vendorMap.set(item.vendorId, item.vendorName); }); - return [ - { value: 'all', label: '전체' }, - ...Array.from(vendorMap.entries()).map(([id, name]) => ({ - value: id, - label: name, - })), - ]; + return Array.from(vendorMap.entries()).map(([id, name]) => ({ + value: id, + label: name, + })); }; export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollectionProps) { const router = useRouter(); const [isPending, startTransition] = useTransition(); - // ===== 상태 관리 ===== - const [searchQuery, setSearchQuery] = useState(''); - const [sortOption, setSortOption] = useState('latest'); + // ===== 외부 상태 (UniversalListPage 외부에서 관리) ===== + const [data, setData] = useState(initialData); const [vendorFilter, setVendorFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all'); - const [selectedItems, setSelectedItems] = useState>(new Set()); - const [currentPage, setCurrentPage] = useState(1); - const itemsPerPage = 20; - - // 삭제 다이얼로그 - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [deleteTargetId, setDeleteTargetId] = useState(null); - - // 데이터 (서버에서 받은 초기 데이터 사용) - const [data, setData] = useState(initialData); + const [sortOption, setSortOption] = useState('latest'); // 거래처 옵션 const vendorOptions = useMemo(() => getVendorOptions(data), [data]); - // ===== 체크박스 핸들러 ===== - const toggleSelection = useCallback((id: string) => { - setSelectedItems(prev => { - const newSet = new Set(prev); - if (newSet.has(id)) newSet.delete(id); - else newSet.add(id); - return newSet; - }); - }, []); + // ===== 핸들러 ===== + const handleRowClick = useCallback( + (item: BadDebtRecord) => { + router.push(`/ko/accounting/bad-debt-collection/${item.id}`); + }, + [router] + ); - // ===== 필터링된 데이터 ===== - const filteredData = useMemo(() => { - let result = data.filter(item => - item.vendorName.includes(searchQuery) || - item.vendorCode.includes(searchQuery) || - item.businessNumber.includes(searchQuery) - ); - - // 거래처 필터 - if (vendorFilter !== 'all') { - result = result.filter(item => item.vendorId === vendorFilter); - } - - // 상태 필터 - if (statusFilter !== 'all') { - result = result.filter(item => item.status === statusFilter); - } - - // 정렬 - switch (sortOption) { - case 'latest': - result.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); - break; - case 'oldest': - result.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); - break; - } - - return result; - }, [data, searchQuery, vendorFilter, statusFilter, sortOption]); - - const paginatedData = useMemo(() => { - const startIndex = (currentPage - 1) * itemsPerPage; - return filteredData.slice(startIndex, startIndex + itemsPerPage); - }, [filteredData, currentPage, itemsPerPage]); - - const totalPages = Math.ceil(filteredData.length / itemsPerPage); - - // ===== 전체 선택 핸들러 ===== - const toggleSelectAll = useCallback(() => { - if (selectedItems.size === filteredData.length && filteredData.length > 0) { - setSelectedItems(new Set()); - } else { - setSelectedItems(new Set(filteredData.map(item => item.id))); - } - }, [selectedItems.size, filteredData]); - - // ===== 액션 핸들러 ===== - const handleRowClick = useCallback((item: BadDebtRecord) => { - router.push(`/ko/accounting/bad-debt-collection/${item.id}`); - }, [router]); - - const handleEdit = useCallback((item: BadDebtRecord) => { - router.push(`/ko/accounting/bad-debt-collection/${item.id}/edit`); - }, [router]); - - const handleDeleteClick = useCallback((id: string) => { - setDeleteTargetId(id); - setShowDeleteDialog(true); - }, []); - - const handleConfirmDelete = useCallback(() => { - if (deleteTargetId) { - startTransition(async () => { - const result = await deleteBadDebt(deleteTargetId); - if (result.success) { - setData(prev => prev.filter(item => item.id !== deleteTargetId)); - setSelectedItems(prev => { - const newSet = new Set(prev); - newSet.delete(deleteTargetId); - return newSet; - }); - } else { - console.error('[BadDebtCollection] Delete failed:', result.error); - } - }); - } - setShowDeleteDialog(false); - setDeleteTargetId(null); - }, [deleteTargetId]); + const handleEdit = useCallback( + (item: BadDebtRecord) => { + router.push(`/ko/accounting/bad-debt-collection/${item.id}/edit`); + }, + [router] + ); // 설정 토글 핸들러 (API 호출) - const handleSettingToggle = useCallback((id: string, checked: boolean) => { - // Optimistic update - setData(prev => prev.map(item => - item.id === id ? { ...item, settingToggle: checked } : item - )); + const handleSettingToggle = useCallback( + (id: string, checked: boolean) => { + // Optimistic update + setData((prev) => + prev.map((item) => (item.id === id ? { ...item, settingToggle: checked } : item)) + ); - startTransition(async () => { - const result = await toggleBadDebt(id); - if (!result.success) { - // Rollback on error - setData(prev => prev.map(item => - item.id === id ? { ...item, settingToggle: !checked } : item - )); - console.error('[BadDebtCollection] Toggle failed:', result.error); - } - }); - }, []); + startTransition(async () => { + const result = await toggleBadDebt(id); + if (!result.success) { + // Rollback on error + setData((prev) => + prev.map((item) => (item.id === id ? { ...item, settingToggle: !checked } : item)) + ); + console.error('[BadDebtCollection] Toggle failed:', result.error); + } + }); + }, + [] + ); - // ===== 통계 카드 (API 통계 또는 로컬 계산) ===== - const statCards: StatCard[] = useMemo(() => { + // ===== 통계 계산 ===== + const statsData = useMemo(() => { if (initialSummary) { - // API 통계 데이터 사용 - return [ - { label: '총 악성채권', value: `${initialSummary.total_amount.toLocaleString()}원`, icon: AlertTriangle, iconColor: 'text-red-500' }, - { label: '추심중', value: `${initialSummary.collecting_amount.toLocaleString()}원`, icon: AlertTriangle, iconColor: 'text-orange-500' }, - { label: '법적조치', value: `${initialSummary.legal_action_amount.toLocaleString()}원`, icon: AlertTriangle, iconColor: 'text-red-600' }, - { label: '회수완료', value: `${initialSummary.recovered_amount.toLocaleString()}원`, icon: AlertTriangle, iconColor: 'text-green-500' }, - ]; + return { + totalAmount: initialSummary.total_amount, + collectingAmount: initialSummary.collecting_amount, + legalActionAmount: initialSummary.legal_action_amount, + recoveredAmount: initialSummary.recovered_amount, + }; } // 로컬 데이터로 계산 (fallback) const totalAmount = data.reduce((sum, d) => sum + d.debtAmount, 0); - const collectingAmount = data.filter(d => d.status === 'collecting').reduce((sum, d) => sum + d.debtAmount, 0); - const legalActionAmount = data.filter(d => d.status === 'legalAction').reduce((sum, d) => sum + d.debtAmount, 0); - const recoveredAmount = data.filter(d => d.status === 'recovered').reduce((sum, d) => sum + d.debtAmount, 0); + const collectingAmount = data + .filter((d) => d.status === 'collecting') + .reduce((sum, d) => sum + d.debtAmount, 0); + const legalActionAmount = data + .filter((d) => d.status === 'legalAction') + .reduce((sum, d) => sum + d.debtAmount, 0); + const recoveredAmount = data + .filter((d) => d.status === 'recovered') + .reduce((sum, d) => sum + d.debtAmount, 0); - return [ - { label: '총 악성채권', value: `${totalAmount.toLocaleString()}원`, icon: AlertTriangle, iconColor: 'text-red-500' }, - { label: '추심중', value: `${collectingAmount.toLocaleString()}원`, icon: AlertTriangle, iconColor: 'text-orange-500' }, - { label: '법적조치', value: `${legalActionAmount.toLocaleString()}원`, icon: AlertTriangle, iconColor: 'text-red-600' }, - { label: '회수완료', value: `${recoveredAmount.toLocaleString()}원`, icon: AlertTriangle, iconColor: 'text-green-500' }, - ]; + return { totalAmount, collectingAmount, legalActionAmount, recoveredAmount }; }, [data, initialSummary]); - // ===== 테이블 컬럼 ===== - const tableColumns: TableColumn[] = useMemo(() => [ - { key: 'no', label: 'No.', className: 'text-center w-[60px]' }, - { key: 'vendorName', label: '거래처' }, - { key: 'debtAmount', label: '채권금액', className: 'text-right w-[140px]' }, - { key: 'occurrenceDate', label: '발생일', className: 'text-center w-[110px]' }, - { key: 'overdueDays', label: '연체일수', className: 'text-center w-[100px]' }, - { key: 'managerName', label: '담당자', className: 'w-[100px]' }, - { key: 'status', label: '상태', className: 'text-center w-[100px]' }, - { key: 'setting', label: '설정', className: 'text-center w-[80px]' }, - { key: 'actions', label: '작업', className: 'text-center w-[120px]' }, - ], []); + // ===== UniversalListPage Config ===== + const config: UniversalListConfig = useMemo( + () => ({ + // 페이지 기본 정보 + title: '악성채권 추심관리', + description: '연체 및 악성채권 현황을 추적하고 관리합니다', + icon: AlertTriangle, + basePath: '/accounting/bad-debt-collection', - // ===== 테이블 행 렌더링 ===== - const renderTableRow = useCallback((item: BadDebtRecord, index: number, globalIndex: number) => { - const isSelected = selectedItems.has(item.id); + // ID 추출 + idField: 'id', - return ( - handleRowClick(item)} - > - e.stopPropagation()}> - toggleSelection(item.id)} /> - - {/* No. */} - {globalIndex} - {/* 거래처 */} - {item.vendorName} - {/* 채권금액 */} - - {item.debtAmount.toLocaleString()}원 - - {/* 발생일 */} - {item.occurrenceDate} - {/* 연체일수 */} - {item.overdueDays}일 - {/* 담당자 */} - {item.assignedManager?.name || '-'} - {/* 상태 */} - - - {COLLECTION_STATUS_LABELS[item.status]} - - - {/* 설정 */} - e.stopPropagation()}> - handleSettingToggle(item.id, checked)} - disabled={isPending} - /> - - {/* 작업 */} - e.stopPropagation()}> - {isSelected && ( -
- - -
- )} -
-
- ); - }, [selectedItems, toggleSelection, handleRowClick, handleEdit, handleDeleteClick, handleSettingToggle, isPending]); + // API 액션 + actions: { + getList: async () => { + return { + success: true, + data: data, + totalCount: data.length, + }; + }, + deleteItem: async (id: string) => { + const result = await deleteBadDebt(id); + if (result.success) { + setData((prev) => prev.filter((item) => item.id !== id)); + } + return { success: result.success, error: result.error }; + }, + }, - // ===== 모바일 카드 렌더링 ===== - const renderMobileCard = useCallback(( - item: BadDebtRecord, - index: number, - globalIndex: number, - isSelected: boolean, - onToggle: () => void - ) => { - return ( - - {COLLECTION_STATUS_LABELS[item.status]} - + // 테이블 컬럼 + columns: tableColumns, + + // 클라이언트 사이드 필터링 + clientSideFiltering: true, + itemsPerPage: 20, + + // 검색 필터 + searchPlaceholder: '거래처명, 거래처코드, 사업자번호 검색...', + searchFilter: (item, searchValue) => { + const search = searchValue.toLowerCase(); + return ( + item.vendorName.toLowerCase().includes(search) || + item.vendorCode.toLowerCase().includes(search) || + item.businessNumber.toLowerCase().includes(search) + ); + }, + + // 필터 설정 (모바일용) + filterConfig: [ + { + key: 'vendor', + label: '거래처', + type: 'single', + options: vendorOptions, + }, + { + key: 'status', + label: '상태', + type: 'single', + options: STATUS_FILTER_OPTIONS.filter((o) => o.value !== 'all').map((o) => ({ + value: o.value, + label: o.label, + })), + }, + { + key: 'sortBy', + label: '정렬', + type: 'single', + options: SORT_OPTIONS.map((o) => ({ + value: o.value, + label: o.label, + })), + }, + ], + initialFilters: { + vendor: 'all', + status: 'all', + sortBy: 'latest', + }, + filterTitle: '악성채권 필터', + + // 커스텀 필터 함수 + customFilterFn: (items) => { + let result = [...items]; + + // 거래처 필터 + if (vendorFilter !== 'all') { + result = result.filter((item) => item.vendorId === vendorFilter); } - isSelected={isSelected} - onToggleSelection={onToggle} - infoGrid={ -
- - - - -
+ + // 상태 필터 + if (statusFilter !== 'all') { + result = result.filter((item) => item.status === statusFilter); } - actions={ - isSelected ? ( -
- - - -
- ) : undefined + + return result; + }, + + // 커스텀 정렬 함수 + customSortFn: (items) => { + const sorted = [...items]; + + switch (sortOption) { + case 'oldest': + sorted.sort( + (a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime() + ); + break; + default: // latest + sorted.sort( + (a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime() + ); + break; } - onClick={() => handleRowClick(item)} - /> - ); - }, [handleRowClick, handleEdit, handleDeleteClick]); - // ===== 테이블 헤더 액션 (3개 필터) ===== - const tableHeaderActions = ( -
- {/* 거래처 필터 */} - + return sorted; + }, - {/* 상태 필터 */} - + // 테이블 헤더 액션 (3개 필터) + tableHeaderActions: () => ( +
+ {/* 거래처 필터 */} + - {/* 정렬 */} - -
- ); + {/* 상태 필터 */} + - return ( - <> - item.id} - renderTableRow={renderTableRow} - renderMobileCard={renderMobileCard} - pagination={{ - currentPage, - totalPages, - totalItems: filteredData.length, - itemsPerPage, - onPageChange: setCurrentPage, - }} - /> + {/* 정렬 */} + +
+ ), - {/* 삭제 확인 다이얼로그 */} - - - - 악성채권 삭제 - - 이 악성채권 기록을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다. - - - - 취소 - [ + { + label: '총 악성채권', + value: `${statsData.totalAmount.toLocaleString()}원`, + icon: AlertTriangle, + iconColor: 'text-red-500', + }, + { + label: '추심중', + value: `${statsData.collectingAmount.toLocaleString()}원`, + icon: AlertTriangle, + iconColor: 'text-orange-500', + }, + { + label: '법적조치', + value: `${statsData.legalActionAmount.toLocaleString()}원`, + icon: AlertTriangle, + iconColor: 'text-red-600', + }, + { + label: '회수완료', + value: `${statsData.recoveredAmount.toLocaleString()}원`, + icon: AlertTriangle, + iconColor: 'text-green-500', + }, + ], + + // 삭제 확인 메시지 + deleteConfirmMessage: { + title: '악성채권 삭제', + description: '이 악성채권 기록을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.', + }, + + // 테이블 행 렌더링 + renderTableRow: ( + item: BadDebtRecord, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => ( + handleRowClick(item)} + > + e.stopPropagation()}> + + + {/* No. */} + {globalIndex} + {/* 거래처 */} + {item.vendorName} + {/* 채권금액 */} + + {item.debtAmount.toLocaleString()}원 + + {/* 발생일 */} + {item.occurrenceDate} + {/* 연체일수 */} + {item.overdueDays}일 + {/* 담당자 */} + {item.assignedManager?.name || '-'} + {/* 상태 */} + + + {COLLECTION_STATUS_LABELS[item.status]} + + + {/* 설정 */} + e.stopPropagation()}> + handleSettingToggle(item.id, checked)} disabled={isPending} - > - {isPending ? '삭제 중...' : '삭제'} - - - - - + /> +
+ {/* 작업 */} + e.stopPropagation()}> + {handlers.isSelected && ( +
+ + +
+ )} +
+
+ ), + + // 모바일 카드 렌더링 + renderMobileCard: ( + item: BadDebtRecord, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => ( + handleRowClick(item)} + details={[ + { label: '연체일수', value: `${item.overdueDays}일` }, + { label: '발생일', value: item.occurrenceDate }, + { label: '담당자', value: item.assignedManager?.name || '-' }, + ]} + actions={ + handlers.isSelected ? ( +
+ + + +
+ ) : undefined + } + /> + ), + }), + [ + data, + vendorOptions, + vendorFilter, + statusFilter, + sortOption, + statsData, + handleRowClick, + handleEdit, + handleSettingToggle, + isPending, + ] ); -} + + return ; +} \ No newline at end of file diff --git a/src/components/accounting/BankTransactionInquiry/index.tsx b/src/components/accounting/BankTransactionInquiry/index.tsx index b93cd9d1..7b4cb393 100644 --- a/src/components/accounting/BankTransactionInquiry/index.tsx +++ b/src/components/accounting/BankTransactionInquiry/index.tsx @@ -1,14 +1,21 @@ 'use client'; +/** + * 입출금 계좌조회 - UniversalListPage 마이그레이션 + * + * 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환 + * - 서버 사이드 필터링/페이지네이션 + * - dateRangeSelector (헤더 액션) + * - beforeTableContent: 새로고침 버튼 + * - tableHeaderActions: 3개 Select 필터 (결제계좌, 입출금유형, 정렬) + * - tableFooter: 합계 행 + * - 수정 버튼 (입금/출금 상세 페이지 이동) + */ + import { useState, useMemo, useCallback, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { format, startOfMonth, endOfMonth } from 'date-fns'; -import { - Building2, - Pencil, - RefreshCw, - Loader2, -} from 'lucide-react'; +import { Building2, Pencil, RefreshCw, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Badge } from '@/components/ui/badge'; @@ -20,17 +27,15 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { MobileCard } from '@/components/molecules/MobileCard'; import { - IntegratedListTemplateV2, - type TableColumn, + UniversalListPage, + type UniversalListConfig, + type SelectionHandlers, + type RowClickHandlers, type StatCard, -} from '@/components/templates/IntegratedListTemplateV2'; -import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; -import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; -import type { - BankTransaction, - SortOption, -} from './types'; +} from '@/components/templates/UniversalListPage'; +import type { BankTransaction, SortOption } from './types'; import { TRANSACTION_KIND_LABELS, DEPOSIT_TYPE_LABELS, @@ -38,9 +43,29 @@ import { SORT_OPTIONS, TRANSACTION_TYPE_FILTER_OPTIONS, } from './types'; -import { getBankTransactionList, getBankTransactionSummary, getBankAccountOptions } from './actions'; +import { + getBankTransactionList, + getBankTransactionSummary, + getBankAccountOptions, +} from './actions'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; +// ===== 테이블 컬럼 정의 ===== +const tableColumns = [ + { key: 'bankName', label: '은행명' }, + { key: 'accountName', label: '계좌명' }, + { key: 'transactionDate', label: '거래일시' }, + { key: 'type', label: '구분', className: 'text-center' }, + { key: 'note', label: '적요' }, + { key: 'vendorName', label: '거래처' }, + { key: 'depositorName', label: '입금자/수취인' }, + { key: 'depositAmount', label: '입금', className: 'text-right' }, + { key: 'withdrawalAmount', label: '출금', className: 'text-right' }, + { key: 'balance', label: '잔액', className: 'text-right' }, + { key: 'transactionType', label: '입출금 유형', className: 'text-center' }, + { key: 'actions', label: '작업', className: 'text-center w-[80px]' }, +]; + // ===== Props ===== interface BankTransactionInquiryProps { initialData?: BankTransaction[]; @@ -65,21 +90,7 @@ export function BankTransactionInquiry({ }: BankTransactionInquiryProps) { const router = useRouter(); - // ===== 상태 관리 ===== - const [searchQuery, setSearchQuery] = useState(''); - const [sortOption, setSortOption] = useState('latest'); - const [accountFilter, setAccountFilter] = useState('all'); // 결제계좌 필터 - const [transactionTypeFilter, setTransactionTypeFilter] = useState('all'); // 입출금유형 필터 - const [selectedItems, setSelectedItems] = useState>(new Set()); - const [currentPage, setCurrentPage] = useState(initialPagination?.currentPage || 1); - const itemsPerPage = 20; - const [isLoading, setIsLoading] = useState(!initialData.length); - - // 날짜 범위 상태 - const [startDate, setStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd')); - const [endDate, setEndDate] = useState(() => format(endOfMonth(new Date()), 'yyyy-MM-dd')); - - // 데이터 상태 + // ===== 외부 상태 (UniversalListPage 외부에서 관리) ===== const [data, setData] = useState(initialData); const [summary, setSummary] = useState( initialSummary || { totalDeposit: 0, totalWithdrawal: 0, depositUnsetCount: 0, withdrawalUnsetCount: 0 } @@ -88,14 +99,25 @@ export function BankTransactionInquiry({ initialPagination || { currentPage: 1, lastPage: 1, perPage: 20, total: 0 } ); const [accountOptions, setAccountOptions] = useState<{ value: string; label: string }[]>([ - { value: 'all', label: '전체' } + { value: 'all', label: '전체' }, ]); + // 필터 상태 + const [searchQuery, setSearchQuery] = useState(''); + const [sortOption, setSortOption] = useState('latest'); + const [accountFilter, setAccountFilter] = useState('all'); + const [transactionTypeFilter, setTransactionTypeFilter] = useState('all'); + const [currentPage, setCurrentPage] = useState(initialPagination?.currentPage || 1); + const [isLoading, setIsLoading] = useState(!initialData.length); + + // 날짜 범위 상태 + const [startDate, setStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd')); + const [endDate, setEndDate] = useState(() => format(endOfMonth(new Date()), 'yyyy-MM-dd')); + // ===== 데이터 로드 ===== const loadData = useCallback(async () => { setIsLoading(true); try { - // 정렬 옵션 매핑 const sortMapping: Record = { latest: { sortBy: 'transaction_date', sortDir: 'desc' }, oldest: { sortBy: 'transaction_date', sortDir: 'asc' }, @@ -107,7 +129,7 @@ export function BankTransactionInquiry({ const [listResult, summaryResult, accountsResult] = await Promise.all([ getBankTransactionList({ page: currentPage, - perPage: itemsPerPage, + perPage: 20, startDate, endDate, bankAccountId: accountFilter !== 'all' ? parseInt(accountFilter, 10) : undefined, @@ -132,7 +154,7 @@ export function BankTransactionInquiry({ if (accountsResult.success) { setAccountOptions([ { value: 'all', label: '전체' }, - ...accountsResult.data.map(acc => ({ value: String(acc.id), label: acc.label })) + ...accountsResult.data.map((acc) => ({ value: String(acc.id), label: acc.label })), ]); } } catch (error) { @@ -148,70 +170,22 @@ export function BankTransactionInquiry({ loadData(); }, [loadData]); - // ===== 체크박스 핸들러 ===== - const toggleSelection = useCallback((id: string) => { - setSelectedItems(prev => { - const newSet = new Set(prev); - if (newSet.has(id)) newSet.delete(id); - else newSet.add(id); - return newSet; - }); - }, []); + // ===== 핸들러 ===== + const handleEditClick = useCallback( + (item: BankTransaction) => { + if (item.type === 'deposit') { + router.push(`/ko/accounting/deposits/${item.sourceId}?mode=edit`); + } else { + router.push(`/ko/accounting/withdrawals/${item.sourceId}?mode=edit`); + } + }, + [router] + ); - - // ===== 데이터 (서버 사이드 필터링/정렬/페이지네이션) ===== - // 필터링, 정렬, 페이지네이션은 서버에서 처리됨 - const totalPages = pagination.lastPage; - - // ===== 전체 선택 핸들러 ===== - const toggleSelectAll = useCallback(() => { - if (selectedItems.size === data.length && data.length > 0) { - setSelectedItems(new Set()); - } else { - setSelectedItems(new Set(data.map(item => item.id))); - } - }, [selectedItems.size, data]); - - // ===== 수정 버튼 클릭 (상세 이동) ===== - const handleEditClick = useCallback((item: BankTransaction) => { - if (item.type === 'deposit') { - router.push(`/ko/accounting/deposits/${item.sourceId}?mode=edit`); - } else { - router.push(`/ko/accounting/withdrawals/${item.sourceId}?mode=edit`); - } - }, [router]); - - // 새로고침 핸들러 const handleRefresh = useCallback(() => { loadData(); }, [loadData]); - // ===== 통계 카드 ===== - const statCards: StatCard[] = useMemo(() => { - return [ - { label: '입금', value: `${summary.totalDeposit.toLocaleString()}원`, icon: Building2, iconColor: 'text-blue-500' }, - { label: '출금', value: `${summary.totalWithdrawal.toLocaleString()}원`, icon: Building2, iconColor: 'text-red-500' }, - { label: '입금 유형 미설정', value: `${summary.depositUnsetCount}건`, icon: Building2, iconColor: 'text-green-500' }, - { label: '출금 유형 미설정', value: `${summary.withdrawalUnsetCount}건`, icon: Building2, iconColor: 'text-orange-500' }, - ]; - }, [summary]); - - // ===== 테이블 컬럼 (14개) ===== - const tableColumns: TableColumn[] = useMemo(() => [ - { key: 'bankName', label: '은행명' }, - { key: 'accountName', label: '계좌명' }, - { key: 'transactionDate', label: '거래일시' }, - { key: 'type', label: '구분', className: 'text-center' }, - { key: 'note', label: '적요' }, - { key: 'vendorName', label: '거래처' }, - { key: 'depositorName', label: '입금자/수취인' }, - { key: 'depositAmount', label: '입금', className: 'text-right' }, - { key: 'withdrawalAmount', label: '출금', className: 'text-right' }, - { key: 'balance', label: '잔액', className: 'text-right' }, - { key: 'transactionType', label: '입출금 유형', className: 'text-center' }, - { key: 'actions', label: '작업', className: 'text-center w-[80px]' }, - ], []); - // ===== 유형 라벨 가져오기 ===== const getTransactionTypeLabel = useCallback((item: BankTransaction) => { if (!item.transactionType) return '미설정'; @@ -221,216 +195,6 @@ export function BankTransactionInquiry({ return WITHDRAWAL_TYPE_LABELS[item.transactionType as keyof typeof WITHDRAWAL_TYPE_LABELS] || item.transactionType; }, []); - // ===== 테이블 행 렌더링 ===== - const renderTableRow = useCallback((item: BankTransaction, index: number, globalIndex: number) => { - const isSelected = selectedItems.has(item.id); - const isTypeUnset = item.transactionType === 'unset'; - - return ( - - {/* 체크박스 */} - e.stopPropagation()}> - toggleSelection(item.id)} /> - - {/* 번호 */} - {globalIndex} - {/* 은행명 */} - {item.bankName} - {/* 계좌명 */} - {item.accountName} - {/* 거래일시 */} - {item.transactionDate} - {/* 구분 */} - - - {TRANSACTION_KIND_LABELS[item.type]} - - - {/* 적요 */} - {item.note || '-'} - {/* 거래처 */} - {item.vendorName || '-'} - {/* 입금자/수취인 */} - {item.depositorName || '-'} - {/* 입금 */} - - {item.depositAmount > 0 ? item.depositAmount.toLocaleString() : '-'} - - {/* 출금 */} - - {item.withdrawalAmount > 0 ? item.withdrawalAmount.toLocaleString() : '-'} - - {/* 잔액 */} - - {item.balance.toLocaleString()} - - {/* 입출금 유형 */} - - - {getTransactionTypeLabel(item)} - - - {/* 작업 */} - e.stopPropagation()}> - {isSelected && ( - - )} - - - ); - }, [selectedItems, toggleSelection, handleEditClick, getTransactionTypeLabel]); - - // ===== 모바일 카드 렌더링 ===== - const renderMobileCard = useCallback(( - item: BankTransaction, - index: number, - globalIndex: number, - isSelected: boolean, - onToggle: () => void - ) => { - return ( - - - {TRANSACTION_KIND_LABELS[item.type]} - - - {getTransactionTypeLabel(item)} - - - } - isSelected={isSelected} - onToggleSelection={onToggle} - infoGrid={ -
- - - 0 ? `${item.depositAmount.toLocaleString()}원` : '-'} - /> - 0 ? `${item.withdrawalAmount.toLocaleString()}원` : '-'} - /> - - -
- } - actions={ - isSelected ? ( - - ) : undefined - } - /> - ); - }, [handleEditClick, getTransactionTypeLabel]); - - // ===== 헤더 액션 (날짜 선택만) ===== - const headerActions = ( - - ); - - // ===== 테이블 상단 새로고침 버튼 (출금관리 스타일) ===== - const beforeTableContent = ( -
- -
- ); - - // ===== 테이블 헤더 액션 (3개 필터) ===== - const tableHeaderActions = ( -
- {/* 결제계좌 필터 */} - - - {/* 입출금유형 필터 */} - - - {/* 정렬 */} - -
- ); - // ===== 테이블 합계 계산 ===== const tableTotals = useMemo(() => { const totalDeposit = data.reduce((sum, item) => sum + item.depositAmount, 0); @@ -438,58 +202,344 @@ export function BankTransactionInquiry({ return { totalDeposit, totalWithdrawal }; }, [data]); - // ===== 테이블 하단 합계 행 ===== - const tableFooter = ( - - - 합계 - - - - - - - - - {tableTotals.totalDeposit.toLocaleString()} - - - {tableTotals.totalWithdrawal.toLocaleString()} - - - - - + // ===== UniversalListPage Config ===== + const config: UniversalListConfig = useMemo( + () => ({ + // 페이지 기본 정보 + title: '입출금 계좌조회', + description: '은행 계좌 정보와 입출금 내역을 조회할 수 있습니다', + icon: Building2, + basePath: '/accounting/bank-transactions', + + // ID 추출 + idField: 'id', + + // API 액션 + actions: { + getList: async () => { + return { + success: true, + data: data, + totalCount: pagination.total, + }; + }, + }, + + // 테이블 컬럼 + columns: tableColumns, + + // 서버 사이드 필터링 (클라이언트 사이드 아님) + clientSideFiltering: false, + itemsPerPage: 20, + + // 검색 + searchPlaceholder: '은행명, 계좌명, 거래처, 입금자/수취인 검색...', + onSearchChange: setSearchQuery, + + // 필터 설정 (모바일용) + filterConfig: [ + { + key: 'account', + label: '결제계좌', + type: 'single', + options: accountOptions.filter((o) => o.value !== 'all'), + }, + { + key: 'transactionType', + label: '입출금유형', + type: 'single', + options: TRANSACTION_TYPE_FILTER_OPTIONS.filter((o) => o.value !== 'all').map((o) => ({ + value: o.value, + label: o.label, + })), + }, + { + key: 'sortBy', + label: '정렬', + type: 'single', + options: SORT_OPTIONS.map((o) => ({ + value: o.value, + label: o.label, + })), + }, + ], + initialFilters: { + account: 'all', + transactionType: 'all', + sortBy: 'latest', + }, + filterTitle: '계좌 필터', + + // 날짜 선택기 (헤더 액션) + dateRangeSelector: { + enabled: true, + startDate, + endDate, + onStartDateChange: setStartDate, + onEndDateChange: setEndDate, + }, + + // 테이블 상단 콘텐츠 (새로고침 버튼) + beforeTableContent: ( +
+ +
+ ), + + // 테이블 헤더 액션 (3개 필터) + tableHeaderActions: () => ( +
+ {/* 결제계좌 필터 */} + + + {/* 입출금유형 필터 */} + + + {/* 정렬 */} + +
+ ), + + // 테이블 푸터 (합계 행) + tableFooter: ( + + + 합계 + + + + + + + + + {tableTotals.totalDeposit.toLocaleString()} + + + {tableTotals.totalWithdrawal.toLocaleString()} + + + + + + ), + + // Stats 카드 + computeStats: (): StatCard[] => [ + { + label: '입금', + value: `${summary.totalDeposit.toLocaleString()}원`, + icon: Building2, + iconColor: 'text-blue-500', + }, + { + label: '출금', + value: `${summary.totalWithdrawal.toLocaleString()}원`, + icon: Building2, + iconColor: 'text-red-500', + }, + { + label: '입금 유형 미설정', + value: `${summary.depositUnsetCount}건`, + icon: Building2, + iconColor: 'text-green-500', + }, + { + label: '출금 유형 미설정', + value: `${summary.withdrawalUnsetCount}건`, + icon: Building2, + iconColor: 'text-orange-500', + }, + ], + + // 테이블 행 렌더링 + renderTableRow: ( + item: BankTransaction, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => { + const isTypeUnset = item.transactionType === 'unset'; + + return ( + + {/* 체크박스 */} + e.stopPropagation()}> + + + {/* 번호 */} + {globalIndex} + {/* 은행명 */} + {item.bankName} + {/* 계좌명 */} + {item.accountName} + {/* 거래일시 */} + {item.transactionDate} + {/* 구분 */} + + + {TRANSACTION_KIND_LABELS[item.type]} + + + {/* 적요 */} + {item.note || '-'} + {/* 거래처 */} + {item.vendorName || '-'} + {/* 입금자/수취인 */} + {item.depositorName || '-'} + {/* 입금 */} + + {item.depositAmount > 0 ? item.depositAmount.toLocaleString() : '-'} + + {/* 출금 */} + + {item.withdrawalAmount > 0 ? item.withdrawalAmount.toLocaleString() : '-'} + + {/* 잔액 */} + {item.balance.toLocaleString()} + {/* 입출금 유형 */} + + + {getTransactionTypeLabel(item)} + + + {/* 작업 */} + e.stopPropagation()}> + {handlers.isSelected && ( + + )} + + + ); + }, + + // 모바일 카드 렌더링 + renderMobileCard: ( + item: BankTransaction, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => ( + 0 ? `${item.depositAmount.toLocaleString()}원` : '-', + }, + { + label: '출금', + value: item.withdrawalAmount > 0 ? `${item.withdrawalAmount.toLocaleString()}원` : '-', + }, + { label: '잔액', value: `${item.balance.toLocaleString()}원` }, + { label: '거래처', value: item.vendorName || '-' }, + { label: '입출금 유형', value: getTransactionTypeLabel(item) }, + ]} + actions={ + handlers.isSelected ? ( + + ) : undefined + } + /> + ), + }), + [ + data, + pagination, + summary, + accountOptions, + accountFilter, + transactionTypeFilter, + sortOption, + startDate, + endDate, + tableTotals, + isLoading, + handleRefresh, + handleEditClick, + getTransactionTypeLabel, + ] ); return ( - item.id} - renderTableRow={renderTableRow} - renderMobileCard={renderMobileCard} - pagination={{ + diff --git a/src/components/accounting/BillManagement/BillManagementClient.tsx b/src/components/accounting/BillManagement/BillManagementClient.tsx index 66cb1060..bd8f0cda 100644 --- a/src/components/accounting/BillManagement/BillManagementClient.tsx +++ b/src/components/accounting/BillManagement/BillManagementClient.tsx @@ -1,5 +1,15 @@ 'use client'; +/** + * 어음관리 - UniversalListPage 마이그레이션 + * + * IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환 + * - 서버 사이드 필터링/페이지네이션 + * - dateRangeSelector + 등록 버튼 (headerActions) + * - beforeTableContent: 상태 선택 + 저장 버튼 + 수취/발행 라디오 + * - tableHeaderActions: 거래처, 구분, 상태 필터 + */ + import { useState, useMemo, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { @@ -33,10 +43,12 @@ import { import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Label } from '@/components/ui/label'; import { - IntegratedListTemplateV2, + UniversalListPage, + type UniversalListConfig, type TableColumn, -} from '@/components/templates/IntegratedListTemplateV2'; -import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; + type SelectionHandlers, + type RowClickHandlers, +} from '@/components/templates/UniversalListPage'; import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; import { toast } from 'sonner'; import type { @@ -197,9 +209,12 @@ export function BillManagementClient({ ], []); // ===== 테이블 행 렌더링 ===== - const renderTableRow = useCallback((item: BillRecord, index: number, globalIndex: number) => { - const isSelected = selectedItems.has(item.id); - + const renderTableRow = useCallback(( + item: BillRecord, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => { return ( handleRowClick(item)} > e.stopPropagation()}> - toggleSelection(item.id)} /> + {globalIndex} {item.billNumber} @@ -227,7 +242,7 @@ export function BillManagementClient({ e.stopPropagation()}> - {isSelected && ( + {handlers.isSelected && (
- - ); - // ===== 거래처 목록 (필터용) ===== const vendorOptions = useMemo(() => { const uniqueVendors = [...new Set(data.map(d => d.vendorName).filter(v => v))]; @@ -330,50 +328,6 @@ export function BillManagementClient({ ]; }, [data]); - // ===== 테이블 헤더 액션 ===== - const tableHeaderActions = ( -
- - - - - -
- ); - // ===== 저장 핸들러 ===== const handleSave = useCallback(async () => { if (selectedItems.size === 0) { @@ -406,73 +360,207 @@ export function BillManagementClient({ setIsLoading(false); }, [selectedItems, statusFilter, loadData, currentPage]); - // ===== beforeTableContent ===== - const billStatusSelector = ( -
- + // ===== UniversalListPage Config ===== + const config: UniversalListConfig = useMemo( + () => ({ + // 페이지 기본 정보 + title: '어음관리', + description: '어음 및 수취이음 상세 현황을 관리합니다', + icon: FileText, + basePath: '/accounting/bills', - + // ID 추출 + idField: 'id', - { setBillTypeFilter(value); loadData(1); }} - className="flex items-center gap-4" - > -
- - + // API 액션 + actions: { + getList: async () => { + return { + success: true, + data: data, + totalCount: pagination.total, + }; + }, + }, + + // 테이블 컬럼 + columns: tableColumns, + + // 서버 사이드 필터링 + clientSideFiltering: false, + itemsPerPage: pagination.perPage, + + // 검색 + searchPlaceholder: '어음번호, 거래처, 메모 검색...', + onSearchChange: setSearchQuery, + + // 모바일 필터 설정 + filterConfig: [ + { + key: 'vendorFilter', + label: '거래처', + type: 'single', + options: vendorOptions.filter(o => o.value !== 'all'), + }, + { + key: 'billType', + label: '구분', + type: 'single', + options: BILL_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all'), + }, + { + key: 'status', + label: '상태', + type: 'single', + options: BILL_STATUS_FILTER_OPTIONS.filter(o => o.value !== 'all'), + }, + ], + initialFilters: { + vendorFilter: vendorFilter, + billType: billTypeFilter, + status: statusFilter, + }, + filterTitle: '어음 필터', + + // 날짜 선택기 + dateRangeSelector: { + enabled: true, + startDate, + endDate, + onStartDateChange: setStartDate, + onEndDateChange: setEndDate, + }, + + // 등록 버튼 + createButton: { + label: '어음 등록', + onClick: () => router.push('/ko/accounting/bills/new'), + icon: Plus, + }, + + // 테이블 헤더 액션 (필터) + tableHeaderActions: ( +
+ + + + +
-
- - + ), + + // beforeTableContent: 상태 선택 + 저장 + 수취/발행 라디오 + beforeTableContent: ( +
+ + + + + { setBillTypeFilter(value); loadData(1); }} + className="flex items-center gap-4" + > +
+ + +
+
+ + +
+
- -
+ ), + + // 렌더링 함수 + renderTableRow, + renderMobileCard, + }), + [ + data, + pagination, + tableColumns, + startDate, + endDate, + vendorFilter, + vendorOptions, + billTypeFilter, + statusFilter, + isLoading, + router, + loadData, + handleSave, + renderTableRow, + renderMobileCard, + ] ); return ( <> - item.id} - renderTableRow={renderTableRow} - renderMobileCard={renderMobileCard} - pagination={{ + item.id, + }} /> diff --git a/src/components/accounting/BillManagement/index.tsx b/src/components/accounting/BillManagement/index.tsx index 2bb34800..06cf6d07 100644 --- a/src/components/accounting/BillManagement/index.tsx +++ b/src/components/accounting/BillManagement/index.tsx @@ -1,5 +1,16 @@ 'use client'; +/** + * 어음관리 - UniversalListPage 마이그레이션 + * + * 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환 + * - 서버 사이드 페이지네이션 (API에서 페이지별 데이터 로드) + * - DateRangeSelector + 등록 버튼 (dateRangeSelector + createButton) + * - beforeTableContent (상태 필터 + 저장 + 수취/발행 라디오) + * - tableHeaderActions (거래처, 구분, 상태 필터) + * - 삭제 기능 (deleteConfirmMessage) + */ + import { useState, useMemo, useCallback, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; @@ -14,16 +25,6 @@ import { import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Badge } from '@/components/ui/badge'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; import { TableRow, TableCell } from '@/components/ui/table'; import { Select, @@ -35,14 +36,14 @@ import { import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group'; import { Label } from '@/components/ui/label'; import { - IntegratedListTemplateV2, - type TableColumn, -} from '@/components/templates/IntegratedListTemplateV2'; -import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; -import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; + UniversalListPage, + type UniversalListConfig, + type SelectionHandlers, + type RowClickHandlers, +} from '@/components/templates/UniversalListPage'; +import { MobileCard } from '@/components/molecules/MobileCard'; import type { BillRecord, - BillType, BillStatus, SortOption, } from './types'; @@ -54,6 +55,20 @@ import { getBillStatusLabel, } from './types'; +// ===== 테이블 컬럼 정의 ===== +const tableColumns = [ + { key: 'no', label: '번호', className: 'text-center w-[60px]' }, + { key: 'billNumber', label: '어음번호' }, + { key: 'billType', label: '구분', className: 'text-center' }, + { key: 'vendorName', label: '거래처' }, + { key: 'amount', label: '금액', className: 'text-right' }, + { key: 'issueDate', label: '발행일' }, + { key: 'maturityDate', label: '만기일' }, + { key: 'installmentCount', label: '차수', className: 'text-center' }, + { key: 'status', label: '상태', className: 'text-center' }, + { key: 'actions', label: '작업', className: 'text-center w-[80px]' }, +]; + interface BillManagementProps { initialVendorId?: string; initialBillType?: string; @@ -62,28 +77,23 @@ interface BillManagementProps { export function BillManagement({ initialVendorId, initialBillType }: BillManagementProps = {}) { const router = useRouter(); - // ===== 상태 관리 ===== - const [searchQuery, setSearchQuery] = useState(''); - const [sortOption, setSortOption] = useState('latest'); - const [billTypeFilter, setBillTypeFilter] = useState(initialBillType || 'received'); - const [vendorFilter, setVendorFilter] = useState(initialVendorId || 'all'); - const [statusFilter, setStatusFilter] = useState('all'); - const [selectedItems, setSelectedItems] = useState>(new Set()); - const [currentPage, setCurrentPage] = useState(1); - const itemsPerPage = 20; + // ===== 외부 상태 (UniversalListPage 외부에서 관리) ===== + const [billData, setBillData] = useState([]); + const [isLoading, setIsLoading] = useState(false); + const [isSaving, setIsSaving] = useState(false); - // 삭제 다이얼로그 - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [deleteTargetId, setDeleteTargetId] = useState(null); - - // 날짜 범위 상태 + // 날짜 범위 const [startDate, setStartDate] = useState('2025-09-01'); const [endDate, setEndDate] = useState('2025-09-03'); - // 데이터 상태 - const [data, setData] = useState([]); - const [isLoading, setIsLoading] = useState(false); - const [isSaving, setIsSaving] = useState(false); + // 인라인 필터 상태 + const [billTypeFilter, setBillTypeFilter] = useState(initialBillType || 'received'); + const [vendorFilter, setVendorFilter] = useState(initialVendorId || 'all'); + const [statusFilter, setStatusFilter] = useState('all'); + const [sortOption, setSortOption] = useState('latest'); + + // 페이지네이션 + const [currentPage, setCurrentPage] = useState(1); const [pagination, setPagination] = useState({ currentPage: 1, lastPage: 1, @@ -96,18 +106,18 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem setIsLoading(true); try { const result = await getBills({ - search: searchQuery || undefined, + search: undefined, billType: billTypeFilter !== 'all' ? billTypeFilter : undefined, status: statusFilter !== 'all' ? statusFilter : undefined, clientId: vendorFilter !== 'all' ? vendorFilter : undefined, issueStartDate: startDate, issueEndDate: endDate, page: currentPage, - perPage: itemsPerPage, + perPage: 20, }); if (result.success) { - setData(result.data); + setBillData(result.data); setPagination(result.pagination); } else { toast.error(result.error || '어음 목록을 불러오는데 실패했습니다.'); @@ -117,26 +127,15 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem } finally { setIsLoading(false); } - }, [searchQuery, billTypeFilter, statusFilter, vendorFilter, startDate, endDate, currentPage, itemsPerPage]); + }, [billTypeFilter, statusFilter, vendorFilter, startDate, endDate, currentPage]); useEffect(() => { loadBills(); }, [loadBills]); - // ===== 체크박스 핸들러 ===== - const toggleSelection = useCallback((id: string) => { - setSelectedItems(prev => { - const newSet = new Set(prev); - if (newSet.has(id)) newSet.delete(id); - else newSet.add(id); - return newSet; - }); - }, []); - - // ===== API에서 이미 필터링/페이지네이션된 데이터 사용 ===== - // 로컬 정렬만 적용 (필요시) + // 정렬된 데이터 (로컬 정렬) const sortedData = useMemo(() => { - const result = [...data]; + const result = [...billData]; switch (sortOption) { case 'latest': @@ -157,265 +156,33 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem } return result; - }, [data, sortOption]); + }, [billData, sortOption]); - const totalPages = pagination.lastPage; - - // ===== 전체 선택 핸들러 ===== - const toggleSelectAll = useCallback(() => { - if (selectedItems.size === sortedData.length && sortedData.length > 0) { - setSelectedItems(new Set()); - } else { - setSelectedItems(new Set(sortedData.map(item => item.id))); - } - }, [selectedItems.size, sortedData]); - - // ===== 액션 핸들러 ===== - const handleRowClick = useCallback((item: BillRecord) => { - router.push(`/ko/accounting/bills/${item.id}`); - }, [router]); - - const handleDeleteClick = useCallback((id: string) => { - setDeleteTargetId(id); - setShowDeleteDialog(true); - }, []); - - const handleConfirmDelete = useCallback(async () => { - if (!deleteTargetId) return; - - setIsSaving(true); - try { - const result = await deleteBill(deleteTargetId); - - if (result.success) { - toast.success('어음이 삭제되었습니다.'); - setData(prev => prev.filter(item => item.id !== deleteTargetId)); - setSelectedItems(prev => { - const newSet = new Set(prev); - newSet.delete(deleteTargetId); - return newSet; - }); - } else { - toast.error(result.error || '삭제에 실패했습니다.'); - } - } catch { - toast.error('서버 오류가 발생했습니다.'); - } finally { - setIsSaving(false); - setShowDeleteDialog(false); - setDeleteTargetId(null); - } - }, [deleteTargetId]); - - - // ===== 테이블 컬럼 (사용자 요청: 번호, 어음번호, 구분, 거래처, 금액, 발행일, 만기일, 차수, 상태, 작업) ===== - const tableColumns: TableColumn[] = useMemo(() => [ - { key: 'no', label: '번호', className: 'text-center w-[60px]' }, - { key: 'billNumber', label: '어음번호' }, - { key: 'billType', label: '구분', className: 'text-center' }, - { key: 'vendorName', label: '거래처' }, - { key: 'amount', label: '금액', className: 'text-right' }, - { key: 'issueDate', label: '발행일' }, - { key: 'maturityDate', label: '만기일' }, - { key: 'installmentCount', label: '차수', className: 'text-center' }, - { key: 'status', label: '상태', className: 'text-center' }, - { key: 'actions', label: '작업', className: 'text-center w-[80px]' }, - ], []); - - // ===== 테이블 행 렌더링 (사용자 요청 순서: 번호, 어음번호, 구분, 거래처, 금액, 발행일, 만기일, 차수, 상태, 작업) ===== - const renderTableRow = useCallback((item: BillRecord, index: number, globalIndex: number) => { - const isSelected = selectedItems.has(item.id); - - return ( - handleRowClick(item)} - > - e.stopPropagation()}> - toggleSelection(item.id)} /> - - {/* 번호 */} - {globalIndex} - {/* 어음번호 */} - {item.billNumber} - {/* 구분 */} - - - {BILL_TYPE_LABELS[item.billType]} - - - {/* 거래처 */} - {item.vendorName} - {/* 금액 */} - {item.amount.toLocaleString()} - {/* 발행일 */} - {item.issueDate} - {/* 만기일 */} - {item.maturityDate} - {/* 차수 */} - {item.installmentCount || '-'} - {/* 상태 */} - - - {getBillStatusLabel(item.billType, item.status)} - - - {/* 작업 */} - e.stopPropagation()}> - {isSelected && ( -
- - -
- )} -
-
- ); - }, [selectedItems, toggleSelection, handleRowClick, handleDeleteClick, router]); - - // ===== 모바일 카드 렌더링 ===== - const renderMobileCard = useCallback(( - item: BillRecord, - index: number, - globalIndex: number, - isSelected: boolean, - onToggle: () => void - ) => { - return ( - - - {BILL_TYPE_LABELS[item.billType]} - - - {getBillStatusLabel(item.billType, item.status)} - - - } - isSelected={isSelected} - onToggleSelection={onToggle} - infoGrid={ -
- - - - -
- } - actions={ - isSelected ? ( -
- - -
- ) : undefined - } - onCardClick={() => handleRowClick(item)} - /> - ); - }, [handleRowClick, handleDeleteClick, router]); - - // ===== 헤더 액션 (매출관리와 동일한 패턴: DateRangeSelector + extraActions) ===== - const headerActions = ( - router.push('/ko/accounting/bills/new')}> - - 어음 등록 - - } - /> - ); - - // ===== 거래처 목록 (필터용) ===== + // 거래처 목록 (필터용) const vendorOptions = useMemo(() => { - const uniqueVendors = [...new Set(data.map(d => d.vendorName).filter(v => v))]; + const uniqueVendors = [...new Set(billData.map(d => d.vendorName).filter(v => v))]; return [ { value: 'all', label: '전체' }, ...uniqueVendors.map(v => ({ value: v, label: v })) ]; - }, [data]); + }, [billData]); - // ===== 테이블 헤더 액션 (거래처명 + 구분 + 보관중 필터) ===== - const tableHeaderActions = ( -
- {/* 거래처명 필터 */} - + // ===== 핸들러 ===== + const handleRowClick = useCallback((item: BillRecord) => { + router.push(`/ko/accounting/bills/${item.id}`); + }, [router]); - {/* 구분 필터 (수취/발행) */} - + const handleEdit = useCallback((item: BillRecord) => { + router.push(`/ko/accounting/bills/${item.id}?mode=edit`); + }, [router]); - {/* 보관중 상태 필터 */} - -
- ); + const handleCreate = useCallback(() => { + router.push('/ko/accounting/bills/new'); + }, [router]); - // ===== 저장 핸들러 (선택된 항목의 상태 일괄 변경) ===== - const handleSave = useCallback(async () => { - if (selectedItems.size === 0) { + // 저장 핸들러 (선택된 항목의 상태 일괄 변경) + const handleSave = useCallback(async (selectedItems: BillRecord[]) => { + if (selectedItems.length === 0) { toast.warning('선택된 항목이 없습니다.'); return; } @@ -426,12 +193,11 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem setIsSaving(true); try { - const ids = Array.from(selectedItems); let successCount = 0; let failCount = 0; - for (const id of ids) { - const result = await updateBillStatus(id, statusFilter as BillStatus); + for (const item of selectedItems) { + const result = await updateBillStatus(item.id, statusFilter as BillStatus); if (result.success) { successCount++; } else { @@ -442,7 +208,6 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem if (successCount > 0) { toast.success(`${successCount}건의 상태가 변경되었습니다.`); await loadBills(); - setSelectedItems(new Set()); } if (failCount > 0) { toast.error(`${failCount}건의 상태 변경에 실패했습니다.`); @@ -452,101 +217,301 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem } finally { setIsSaving(false); } - }, [selectedItems, statusFilter, loadBills]); + }, [statusFilter, loadBills]); - // ===== beforeTableContent (보관중 + 저장 + 수취/발행 라디오) ===== - const billStatusSelector = ( -
- {/* 상태 필터 */} - + // ===== UniversalListPage Config ===== + const config: UniversalListConfig = useMemo( + () => ({ + // 페이지 기본 정보 + title: '어음관리', + description: '어음 및 수취이음 상세 현황을 관리합니다', + icon: FileText, + basePath: '/accounting/bills', - {/* 저장 버튼 */} - + // ID 추출 + idField: 'id', - {/* 수취/발행 라디오 버튼 */} - -
- - + // API 액션 + actions: { + getList: async () => { + // 이미 useEffect에서 로드됨 + return { + success: true, + data: sortedData, + totalCount: pagination.total, + }; + }, + deleteItem: async (id: string) => { + const result = await deleteBill(id); + if (result.success) { + toast.success('어음이 삭제되었습니다.'); + setBillData(prev => prev.filter(item => item.id !== id)); + } + return { success: result.success, error: result.error }; + }, + }, + + // 테이블 컬럼 + columns: tableColumns, + + // 서버 사이드 페이지네이션 (clientSideFiltering: false가 기본값) + clientSideFiltering: false, + itemsPerPage: 20, + + // 검색 필터 + searchPlaceholder: '어음번호, 거래처, 메모 검색...', + + // 필터 설정 (모바일 필터 시트용) + filterConfig: [ + { + key: 'billType', + label: '구분', + type: 'single', + options: BILL_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all').map(o => ({ + value: o.value, + label: o.label, + })), + }, + { + key: 'status', + label: '상태', + type: 'single', + options: BILL_STATUS_FILTER_OPTIONS.filter(o => o.value !== 'all').map(o => ({ + value: o.value, + label: o.label, + })), + }, + ], + initialFilters: { + billType: initialBillType || 'received', + status: 'all', + }, + filterTitle: '어음 필터', + + // 날짜 범위 선택기 + dateRangeSelector: { + enabled: true, + startDate, + endDate, + onStartDateChange: setStartDate, + onEndDateChange: setEndDate, + }, + + // 등록 버튼 + createButton: { + label: '어음 등록', + onClick: handleCreate, + icon: Plus, + }, + + // beforeTableContent: 상태 필터 + 저장 + 수취/발행 라디오 + beforeTableContent: ( +
+ {/* 상태 필터 */} + + + {/* 수취/발행 라디오 버튼 */} + +
+ + +
+
+ + +
+
-
- - + ), + + // tableHeaderActions: 저장 버튼 + 거래처 필터 + tableHeaderActions: ({ selectedItems }) => ( +
+ {/* 저장 버튼 */} + + + {/* 거래처명 필터 */} +
- -
+ ), + + // 삭제 확인 메시지 + deleteConfirmMessage: { + title: '어음 삭제', + description: '이 어음을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.', + }, + + // 테이블 행 렌더링 + renderTableRow: ( + item: BillRecord, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => ( + handleRowClick(item)} + > + e.stopPropagation()}> + + + {/* 번호 */} + {globalIndex} + {/* 어음번호 */} + {item.billNumber} + {/* 구분 */} + + + {BILL_TYPE_LABELS[item.billType]} + + + {/* 거래처 */} + {item.vendorName} + {/* 금액 */} + {item.amount.toLocaleString()} + {/* 발행일 */} + {item.issueDate} + {/* 만기일 */} + {item.maturityDate} + {/* 차수 */} + {item.installmentCount || '-'} + {/* 상태 */} + + + {getBillStatusLabel(item.billType, item.status)} + + + {/* 작업 */} + e.stopPropagation()}> + {handlers.isSelected && ( +
+ + +
+ )} +
+
+ ), + + // 모바일 카드 렌더링 + renderMobileCard: ( + item: BillRecord, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => ( + handleRowClick(item)} + details={[ + { label: '금액', value: `${item.amount.toLocaleString()}원` }, + { label: '발행일', value: item.issueDate }, + { label: '만기일', value: item.maturityDate }, + { label: '상태', value: getBillStatusLabel(item.billType, item.status) }, + ]} + actions={ + handlers.isSelected ? ( +
+ + +
+ ) : undefined + } + /> + ), + }), + [ + sortedData, + pagination, + startDate, + endDate, + billTypeFilter, + vendorFilter, + statusFilter, + isSaving, + vendorOptions, + handleRowClick, + handleEdit, + handleCreate, + handleSave, + loadBills, + initialBillType, + ] ); return ( - <> - item.id} - renderTableRow={renderTableRow} - renderMobileCard={renderMobileCard} - pagination={{ - currentPage: pagination.currentPage, - totalPages, - totalItems: pagination.total, - itemsPerPage: pagination.perPage, - onPageChange: setCurrentPage, - }} - /> - - {/* 삭제 확인 다이얼로그 */} - - - - 어음 삭제 - - 이 어음을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다. - - - - 취소 - - {isSaving ? '삭제중...' : '삭제'} - - - - - + ); -} +} \ No newline at end of file diff --git a/src/components/accounting/CardTransactionInquiry/index.tsx b/src/components/accounting/CardTransactionInquiry/index.tsx index 272437c3..81ca1fd3 100644 --- a/src/components/accounting/CardTransactionInquiry/index.tsx +++ b/src/components/accounting/CardTransactionInquiry/index.tsx @@ -1,13 +1,22 @@ 'use client'; +/** + * 카드 내역 조회 - UniversalListPage 마이그레이션 + * + * 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환 + * - 클라이언트 사이드 필터링/페이지네이션 + * - dateRangeSelector (헤더 액션) + * - beforeTableContent: 계정과목명 선택 + 저장 버튼 + 새로고침 + * - tableHeaderActions: 2개 Select 필터 (카드명, 정렬) + * - tableFooter: 합계 행 + * - showRowNumber={false} + * - 상세 모달 (수정 기능) + * - 계정과목명 일괄 저장 다이얼로그 + */ + import { useState, useMemo, useCallback, useEffect } from 'react'; import { format, startOfMonth, endOfMonth } from 'date-fns'; -import { - CreditCard, - RefreshCw, - Save, - Loader2, -} from 'lucide-react'; +import { CreditCard, RefreshCw, Save, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; @@ -37,25 +46,34 @@ import { SelectTrigger, SelectValue, } from '@/components/ui/select'; +import { MobileCard } from '@/components/molecules/MobileCard'; import { - IntegratedListTemplateV2, - type TableColumn, + UniversalListPage, + type UniversalListConfig, + type SelectionHandlers, + type RowClickHandlers, type StatCard, -} from '@/components/templates/IntegratedListTemplateV2'; -import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; -import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; -import type { - CardTransaction, - SortOption, -} from './types'; +} from '@/components/templates/UniversalListPage'; +import type { CardTransaction, SortOption } from './types'; +import { SORT_OPTIONS, ACCOUNT_SUBJECT_OPTIONS, USAGE_TYPE_OPTIONS } from './types'; import { - SORT_OPTIONS, - ACCOUNT_SUBJECT_OPTIONS, - USAGE_TYPE_OPTIONS, -} from './types'; -import { getCardTransactionList, getCardTransactionSummary, bulkUpdateAccountCode } from './actions'; + getCardTransactionList, + getCardTransactionSummary, + bulkUpdateAccountCode, +} from './actions'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; +// ===== 테이블 컬럼 정의 ===== +const tableColumns = [ + { key: 'card', label: '카드' }, + { key: 'cardName', label: '카드명' }, + { key: 'user', label: '사용자' }, + { key: 'usedAt', label: '사용일시' }, + { key: 'merchantName', label: '가맹점명' }, + { key: 'amount', label: '사용금액', className: 'text-right' }, + { key: 'usageType', label: '사용유형' }, +]; + // ===== Props ===== interface CardTransactionInquiryProps { initialData?: CardTransaction[]; @@ -76,20 +94,29 @@ export function CardTransactionInquiry({ initialSummary, initialPagination, }: CardTransactionInquiryProps) { - // ===== 상태 관리 ===== + // ===== 외부 상태 (UniversalListPage 외부에서 관리) ===== + const [data, setData] = useState(initialData); + const [summary, setSummary] = useState( + initialSummary || { previousMonthTotal: 0, currentMonthTotal: 0 } + ); + const [pagination, setPagination] = useState( + initialPagination || { currentPage: 1, lastPage: 1, perPage: 20, total: 0 } + ); + + // 필터 상태 const [searchQuery, setSearchQuery] = useState(''); const [sortOption, setSortOption] = useState('latest'); - const [cardFilter, setCardFilter] = useState('all'); // 카드명 필터 - const [selectedItems, setSelectedItems] = useState>(new Set()); + const [cardFilter, setCardFilter] = useState('all'); const [currentPage, setCurrentPage] = useState(initialPagination?.currentPage || 1); - const itemsPerPage = 20; const [isLoading, setIsLoading] = useState(!initialData.length); + const itemsPerPage = 20; // 상단 계정과목명 선택 (저장용) const [selectedAccountSubject, setSelectedAccountSubject] = useState('unset'); // 계정과목명 저장 다이얼로그 const [showSaveDialog, setShowSaveDialog] = useState(false); + const [isSaving, setIsSaving] = useState(false); // 선택 필요 알림 다이얼로그 const [showSelectWarningDialog, setShowSelectWarningDialog] = useState(false); @@ -103,24 +130,17 @@ export function CardTransactionInquiry({ }); const [isDetailSaving, setIsDetailSaving] = useState(false); + // 선택된 항목 (외부 관리) + const [selectedItems, setSelectedItems] = useState>(new Set()); + // 날짜 범위 상태 const [startDate, setStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd')); const [endDate, setEndDate] = useState(() => format(endOfMonth(new Date()), 'yyyy-MM-dd')); - // 데이터 상태 - const [data, setData] = useState(initialData); - const [summary, setSummary] = useState( - initialSummary || { previousMonthTotal: 0, currentMonthTotal: 0 } - ); - const [pagination, setPagination] = useState( - initialPagination || { currentPage: 1, lastPage: 1, perPage: 20, total: 0 } - ); - // ===== 데이터 로드 ===== const loadData = useCallback(async () => { setIsLoading(true); try { - // 정렬 옵션 매핑 const sortMapping: Record = { latest: { sortBy: 'used_at', sortDir: 'desc' }, oldest: { sortBy: 'used_at', sortDir: 'asc' }, @@ -166,7 +186,50 @@ export function CardTransactionInquiry({ loadData(); }, [loadData]); - // ===== 상세 모달 핸들러 ===== + // ===== 카드명 옵션 ===== + const cardOptions = useMemo(() => { + const uniqueCards = [...new Set(data.map((d) => d.cardName))]; + return [ + { value: 'all', label: '전체' }, + ...uniqueCards.map((card) => ({ value: card, label: card })), + ]; + }, [data]); + + // ===== 필터링된 데이터 ===== + const filteredData = useMemo(() => { + let result = data.filter( + (item) => + item.card.includes(searchQuery) || + item.cardName.includes(searchQuery) || + item.user.includes(searchQuery) || + item.merchantName.includes(searchQuery) + ); + + // 카드명 필터 + if (cardFilter !== 'all') { + result = result.filter((item) => item.cardName === cardFilter); + } + + // 정렬 + switch (sortOption) { + case 'oldest': + result.sort((a, b) => new Date(a.usedAt).getTime() - new Date(b.usedAt).getTime()); + break; + case 'amountHigh': + result.sort((a, b) => b.amount - a.amount); + break; + case 'amountLow': + result.sort((a, b) => a.amount - b.amount); + break; + default: // latest + result.sort((a, b) => new Date(b.usedAt).getTime() - new Date(a.usedAt).getTime()); + break; + } + + return result; + }, [data, searchQuery, cardFilter, sortOption]); + + // ===== 핸들러 ===== const handleRowClick = useCallback((item: CardTransaction) => { setSelectedItem(item); setDetailFormData({ @@ -181,15 +244,14 @@ export function CardTransactionInquiry({ setIsDetailSaving(true); try { - // TODO: API 호출로 상세 정보 저장 - // const result = await updateCardTransaction(selectedItem.id, detailFormData); - // 임시: 로컬 데이터 업데이트 - setData(prev => prev.map(item => - item.id === selectedItem.id - ? { ...item, memo: detailFormData.memo, usageType: detailFormData.usageType } - : item - )); + setData((prev) => + prev.map((item) => + item.id === selectedItem.id + ? { ...item, memo: detailFormData.memo, usageType: detailFormData.usageType } + : item + ) + ); setShowDetailModal(false); setSelectedItem(null); @@ -200,79 +262,10 @@ export function CardTransactionInquiry({ } }, [selectedItem, detailFormData]); - // ===== 체크박스 핸들러 ===== - const toggleSelection = useCallback((id: string) => { - setSelectedItems(prev => { - const newSet = new Set(prev); - if (newSet.has(id)) newSet.delete(id); - else newSet.add(id); - return newSet; - }); - }, []); - - // ===== 카드명 옵션 ===== - const cardOptions = useMemo(() => { - const uniqueCards = [...new Set(data.map(d => d.cardName))]; - return [ - { value: 'all', label: '전체' }, - ...uniqueCards.map(card => ({ value: card, label: card })) - ]; - }, [data]); - - // ===== 필터링된 데이터 ===== - const filteredData = useMemo(() => { - let result = data.filter(item => - item.card.includes(searchQuery) || - item.cardName.includes(searchQuery) || - item.user.includes(searchQuery) || - item.merchantName.includes(searchQuery) - ); - - // 카드명 필터 - if (cardFilter !== 'all') { - result = result.filter(item => item.cardName === cardFilter); - } - - // 정렬 - switch (sortOption) { - case 'latest': - result.sort((a, b) => new Date(b.usedAt).getTime() - new Date(a.usedAt).getTime()); - break; - case 'oldest': - result.sort((a, b) => new Date(a.usedAt).getTime() - new Date(b.usedAt).getTime()); - break; - case 'amountHigh': - result.sort((a, b) => b.amount - a.amount); - break; - case 'amountLow': - result.sort((a, b) => a.amount - b.amount); - break; - } - - return result; - }, [data, searchQuery, cardFilter, sortOption]); - - const paginatedData = useMemo(() => { - const startIndex = (currentPage - 1) * itemsPerPage; - return filteredData.slice(startIndex, startIndex + itemsPerPage); - }, [filteredData, currentPage, itemsPerPage]); - - const totalPages = Math.ceil(filteredData.length / itemsPerPage); - - // 새로고침 핸들러 const handleRefresh = useCallback(() => { loadData(); }, [loadData]); - // ===== 전체 선택 핸들러 ===== - const toggleSelectAll = useCallback(() => { - if (selectedItems.size === filteredData.length && filteredData.length > 0) { - setSelectedItems(new Set()); - } else { - setSelectedItems(new Set(filteredData.map(item => item.id))); - } - }, [selectedItems.size, filteredData]); - // ===== 계정과목명 저장 핸들러 ===== const handleSaveAccountSubject = useCallback(() => { if (selectedItems.size === 0) { @@ -282,19 +275,15 @@ export function CardTransactionInquiry({ setShowSaveDialog(true); }, [selectedItems.size]); - // 계정과목명 저장 확정 - const [isSaving, setIsSaving] = useState(false); - const handleConfirmSaveAccountSubject = useCallback(async () => { if (selectedAccountSubject === 'unset') return; setIsSaving(true); try { - const ids = Array.from(selectedItems).map(id => parseInt(id, 10)); + const ids = Array.from(selectedItems).map((id) => parseInt(id, 10)); const result = await bulkUpdateAccountCode(ids, selectedAccountSubject); if (result.success) { - // 성공 시 데이터 새로고침 await loadData(); setSelectedItems(new Set()); setSelectedAccountSubject('unset'); @@ -309,222 +298,298 @@ export function CardTransactionInquiry({ } }, [selectedAccountSubject, selectedItems, loadData]); - // ===== 통계 카드 (전월/당월 사용액) ===== - const statCards: StatCard[] = useMemo(() => { - return [ - { label: '전월 사용액', value: `${summary.previousMonthTotal.toLocaleString()}원`, icon: CreditCard, iconColor: 'text-gray-500' }, - { label: '당월 사용액', value: `${summary.currentMonthTotal.toLocaleString()}원`, icon: CreditCard, iconColor: 'text-blue-500' }, - ]; - }, [summary]); - // ===== 사용유형 라벨 변환 함수 ===== const getUsageTypeLabel = useCallback((value: string) => { - return USAGE_TYPE_OPTIONS.find(opt => opt.value === value)?.label || '미설정'; + return USAGE_TYPE_OPTIONS.find((opt) => opt.value === value)?.label || '미설정'; }, []); - // ===== 테이블 컬럼 (체크박스/번호 없음) ===== - const tableColumns: TableColumn[] = useMemo(() => [ - { key: 'card', label: '카드' }, - { key: 'cardName', label: '카드명' }, - { key: 'user', label: '사용자' }, - { key: 'usedAt', label: '사용일시' }, - { key: 'merchantName', label: '가맹점명' }, - { key: 'amount', label: '사용금액', className: 'text-right' }, - { key: 'usageType', label: '사용유형' }, - ], []); - - // ===== 테이블 행 렌더링 ===== - const renderTableRow = useCallback((item: CardTransaction) => { - const isSelected = selectedItems.has(item.id); - - return ( - handleRowClick(item)} - > - {/* 체크박스 */} - e.stopPropagation()}> - toggleSelection(item.id)} /> - - {/* 카드 */} - {item.card} - {/* 카드명 */} - {item.cardName} - {/* 사용자 */} - {item.user} - {/* 사용일시 */} - {item.usedAt} - {/* 가맹점명 */} - {item.merchantName} - {/* 사용금액 */} - - {item.amount.toLocaleString()} - - {/* 사용유형 */} - {getUsageTypeLabel(item.usageType)} - - ); - }, [selectedItems, toggleSelection, getUsageTypeLabel, handleRowClick]); - - // ===== 모바일 카드 렌더링 ===== - const renderMobileCard = useCallback(( - item: CardTransaction, - index: number, - globalIndex: number, - isSelected: boolean, - onToggle: () => void - ) => { - return ( - - - - - -
- } - /> - ); - }, []); - - // ===== 헤더 액션 (날짜 선택만) ===== - const headerActions = ( - - ); - - // ===== 상단 계정과목명 + 저장 버튼 + 새로고침 ===== - const beforeTableContent = ( -
-
- 계정과목명 - - -
- -
- ); - - // ===== 테이블 헤더 액션 (2개 필터) ===== - const tableHeaderActions = ( -
- {/* 카드명 필터 */} - - - {/* 정렬 */} - -
- ); - // ===== 테이블 합계 계산 ===== const totalAmount = useMemo(() => { return filteredData.reduce((sum, item) => sum + item.amount, 0); }, [filteredData]); - // ===== 테이블 하단 합계 행 ===== - const tableFooter = ( - - - 합계 - - {totalAmount.toLocaleString()} - - - + // ===== UniversalListPage Config ===== + const config: UniversalListConfig = useMemo( + () => ({ + // 페이지 기본 정보 + title: '카드 내역 조회', + description: '법인카드 사용 내역을 조회합니다', + icon: CreditCard, + basePath: '/accounting/card-transactions', + + // ID 추출 + idField: 'id', + + // API 액션 + actions: { + getList: async () => { + return { + success: true, + data: filteredData, + totalCount: filteredData.length, + }; + }, + }, + + // 테이블 컬럼 + columns: tableColumns, + + // 클라이언트 사이드 필터링 + clientSideFiltering: true, + itemsPerPage, + + // 행 번호 숨기기 + showRowNumber: false, + + // 검색 + searchPlaceholder: '카드, 카드명, 사용자, 가맹점명 검색...', + searchFilter: (item, searchValue) => { + const search = searchValue.toLowerCase(); + return ( + item.card.toLowerCase().includes(search) || + item.cardName.toLowerCase().includes(search) || + item.user.toLowerCase().includes(search) || + item.merchantName.toLowerCase().includes(search) + ); + }, + + // 필터 설정 (모바일용) + filterConfig: [ + { + key: 'card', + label: '카드', + type: 'single', + options: cardOptions.filter((o) => o.value !== 'all'), + }, + { + key: 'sortBy', + label: '정렬', + type: 'single', + options: SORT_OPTIONS.map((o) => ({ + value: o.value, + label: o.label, + })), + }, + ], + initialFilters: { + card: 'all', + sortBy: 'latest', + }, + filterTitle: '카드 필터', + + // 커스텀 필터 함수 + customFilterFn: (items) => { + if (cardFilter === 'all') return items; + return items.filter((item) => item.cardName === cardFilter); + }, + + // 커스텀 정렬 함수 + customSortFn: (items) => { + const sorted = [...items]; + + switch (sortOption) { + case 'oldest': + sorted.sort((a, b) => new Date(a.usedAt).getTime() - new Date(b.usedAt).getTime()); + break; + case 'amountHigh': + sorted.sort((a, b) => b.amount - a.amount); + break; + case 'amountLow': + sorted.sort((a, b) => a.amount - b.amount); + break; + default: // latest + sorted.sort((a, b) => new Date(b.usedAt).getTime() - new Date(a.usedAt).getTime()); + break; + } + + return sorted; + }, + + // 날짜 선택기 (헤더 액션) + dateRangeSelector: { + enabled: true, + startDate, + endDate, + onStartDateChange: setStartDate, + onEndDateChange: setEndDate, + }, + + // 선택 항목 변경 콜백 + onSelectionChange: setSelectedItems, + + // 테이블 상단 콘텐츠 (계정과목명 + 저장 + 새로고침) + beforeTableContent: ( +
+
+ 계정과목명 + + +
+ +
+ ), + + // 테이블 헤더 액션 (2개 필터) + tableHeaderActions: () => ( +
+ {/* 카드명 필터 */} + + + {/* 정렬 */} + +
+ ), + + // 테이블 푸터 (합계 행) + tableFooter: ( + + + + 합계 + + {totalAmount.toLocaleString()} + + + ), + + // Stats 카드 + computeStats: (): StatCard[] => [ + { + label: '전월 사용액', + value: `${summary.previousMonthTotal.toLocaleString()}원`, + icon: CreditCard, + iconColor: 'text-gray-500', + }, + { + label: '당월 사용액', + value: `${summary.currentMonthTotal.toLocaleString()}원`, + icon: CreditCard, + iconColor: 'text-blue-500', + }, + ], + + // 테이블 행 렌더링 + renderTableRow: ( + item: CardTransaction, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => ( + handleRowClick(item)} + > + {/* 체크박스 */} + e.stopPropagation()}> + + + {/* 카드 */} + {item.card} + {/* 카드명 */} + {item.cardName} + {/* 사용자 */} + {item.user} + {/* 사용일시 */} + {item.usedAt} + {/* 가맹점명 */} + {item.merchantName} + {/* 사용금액 */} + {item.amount.toLocaleString()} + {/* 사용유형 */} + {getUsageTypeLabel(item.usageType)} + + ), + + // 모바일 카드 렌더링 + renderMobileCard: ( + item: CardTransaction, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => ( + handleRowClick(item)} + details={[ + { label: '가맹점명', value: item.merchantName }, + { label: '사용금액', value: `${item.amount.toLocaleString()}원` }, + { label: '사용유형', value: getUsageTypeLabel(item.usageType) }, + ]} + /> + ), + }), + [ + filteredData, + cardOptions, + cardFilter, + sortOption, + startDate, + endDate, + summary, + totalAmount, + selectedAccountSubject, + isLoading, + handleRowClick, + handleRefresh, + handleSaveAccountSubject, + getUsageTypeLabel, + ] ); return ( <> - item.id} - renderTableRow={renderTableRow} - renderMobileCard={renderMobileCard} - showCheckbox={true} - showRowNumber={false} - pagination={{ - currentPage, - totalPages, - totalItems: filteredData.length, - itemsPerPage, - onPageChange: setCurrentPage, - }} - /> + {/* 계정과목명 저장 확인 다이얼로그 */} @@ -534,7 +599,7 @@ export function CardTransactionInquiry({ {selectedItems.size}개의 카드 사용 내역을{' '} - {ACCOUNT_SUBJECT_OPTIONS.find(o => o.value === selectedAccountSubject)?.label} + {ACCOUNT_SUBJECT_OPTIONS.find((o) => o.value === selectedAccountSubject)?.label} (으)로 모두 변경하시겠습니까? @@ -583,9 +648,7 @@ export function CardTransactionInquiry({ 카드 내역 상세 - - 카드 사용 상세 내역을 등록합니다 - + 카드 사용 상세 내역을 등록합니다 {selectedItem && ( @@ -599,7 +662,9 @@ export function CardTransactionInquiry({
-

{selectedItem.card} ({selectedItem.cardName})

+

+ {selectedItem.card} ({selectedItem.cardName}) +

@@ -607,14 +672,20 @@ export function CardTransactionInquiry({
-

{selectedItem.amount.toLocaleString()}원

+

+ {selectedItem.amount.toLocaleString()}원 +

- + setDetailFormData(prev => ({ ...prev, memo: e.target.value }))} + onChange={(e) => + setDetailFormData((prev) => ({ ...prev, memo: e.target.value })) + } placeholder="적요" className="mt-1" /> @@ -624,11 +695,15 @@ export function CardTransactionInquiry({

{selectedItem.merchantName}

- + - - - - - {vendorOptions.map((option) => ( - - {option.label} - - ))} - - - - {/* 입금유형 필터 */} - - - {/* 정렬 */} - -
- ); - - // ===== 상단 계정과목명 + 저장 버튼 + 새로고침 ===== - const accountSubjectSelector = ( -
-
- 계정과목명 - - -
- -
- ); + }, [selectedAccountSubject, selectedItemsForSave]); // ===== 테이블 합계 계산 ===== const tableTotals = useMemo(() => { - const totalAmount = filteredData.reduce((sum, item) => sum + (item.depositAmount ?? 0), 0); + const totalAmount = depositData.reduce((sum, item) => sum + (item.depositAmount ?? 0), 0); return { totalAmount }; - }, [filteredData]); + }, [depositData]); - // ===== 테이블 하단 합계 행 ===== - const tableFooter = ( - - - 합계 - - - {tableTotals.totalAmount.toLocaleString()} - - - - - + // ===== UniversalListPage Config ===== + const config: UniversalListConfig = useMemo( + () => ({ + // 페이지 기본 정보 + title: '입금관리', + description: '입금 내역을 등록합니다', + icon: Banknote, + basePath: '/accounting/deposits', + + // ID 추출 + idField: 'id', + + // API 액션 + actions: { + getList: async () => { + return { + success: true, + data: initialData, + totalCount: initialData.length, + }; + }, + deleteItem: async (id: string) => { + const result = await deleteDeposit(id); + if (result.success) { + setDepositData(prev => prev.filter(item => item.id !== id)); + toast.success('입금 내역이 삭제되었습니다.'); + } + return { success: result.success, error: result.error }; + }, + }, + + // 테이블 컬럼 + columns: tableColumns, + + // 클라이언트 사이드 필터링 + clientSideFiltering: true, + itemsPerPage: 20, + + // 데이터 변경 콜백 + onDataChange: (data) => setDepositData(data), + + // 검색 필터 + searchPlaceholder: '입금자명, 계좌명, 적요, 거래처 검색...', + searchFilter: (item, searchValue) => { + const search = searchValue.toLowerCase(); + return ( + item.depositorName.toLowerCase().includes(search) || + item.accountName.toLowerCase().includes(search) || + (item.note?.toLowerCase().includes(search) || false) || + (item.vendorName?.toLowerCase().includes(search) || false) + ); + }, + + // 커스텀 필터 함수 (인라인 필터 사용) + customFilterFn: (items, filterValues) => { + return items.filter((item) => { + // 거래처 필터 + if (vendorFilter !== 'all' && item.vendorName !== vendorFilter) { + return false; + } + // 입금유형 필터 + if (depositTypeFilter !== 'all' && item.depositType !== depositTypeFilter) { + return false; + } + return true; + }); + }, + + // 커스텀 정렬 함수 + customSortFn: (items, filterValues) => { + const sorted = [...items]; + switch (sortOption) { + case 'oldest': + sorted.sort((a, b) => new Date(a.depositDate).getTime() - new Date(b.depositDate).getTime()); + break; + case 'amountHigh': + sorted.sort((a, b) => (b.depositAmount ?? 0) - (a.depositAmount ?? 0)); + break; + case 'amountLow': + sorted.sort((a, b) => (a.depositAmount ?? 0) - (b.depositAmount ?? 0)); + break; + default: // latest + sorted.sort((a, b) => new Date(b.depositDate).getTime() - new Date(a.depositDate).getTime()); + break; + } + return sorted; + }, + + // 공통 헤더 옵션 + dateRangeSelector: { + enabled: true, + startDate, + endDate, + onStartDateChange: setStartDate, + onEndDateChange: setEndDate, + }, + + // 모바일 필터 설정 + filterConfig: [ + { + key: 'depositType', + label: '입금유형', + type: 'single', + options: DEPOSIT_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all'), + }, + { + key: 'sortBy', + label: '정렬', + type: 'single', + options: SORT_OPTIONS, + }, + ], + initialFilters: { + depositType: depositTypeFilter, + sortBy: sortOption, + }, + filterTitle: '입금 필터', + + // Stats 카드 + computeStats: (): StatCard[] => [ + { label: '총 입금', value: `${stats.totalDeposit.toLocaleString()}원`, icon: Banknote, iconColor: 'text-blue-500' }, + { label: '당월 입금', value: `${stats.monthlyDeposit.toLocaleString()}원`, icon: Banknote, iconColor: 'text-green-500' }, + { label: '거래처 미설정', value: `${stats.vendorUnsetCount}건`, icon: Banknote, iconColor: 'text-orange-500' }, + { label: '입금유형 미설정', value: `${stats.depositTypeUnsetCount}건`, icon: Banknote, iconColor: 'text-red-500' }, + ], + + // beforeTableContent: 계정과목명 Select + 저장 버튼 + 새로고침 + beforeTableContent: ( +
+
+ 계정과목명 + +
+ +
+ ), + + // tableHeaderActions: 3개 인라인 필터 + tableHeaderActions: ({ selectedItems }) => ( +
+ {/* 저장 버튼 */} + + + {/* 거래처 필터 */} + + + {/* 입금유형 필터 */} + + + {/* 정렬 */} + +
+ ), + + // 테이블 하단 합계 행 + tableFooter: ( + + + 합계 + + + {tableTotals.totalAmount.toLocaleString()} + + + + + + ), + + // 삭제 확인 메시지 + deleteConfirmMessage: { + title: '입금 삭제', + description: '이 입금 내역을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.', + }, + + // 테이블 행 렌더링 + renderTableRow: ( + item: DepositRecord, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => { + const isVendorUnset = !item.vendorName; + const isDepositTypeUnset = item.depositType === 'unset'; + return ( + handleRowClick(item)} + > + e.stopPropagation()}> + + + {item.depositDate} + {item.accountName} + {item.depositorName} + {(item.depositAmount ?? 0).toLocaleString()} + + {item.vendorName || '미설정'} + + {item.note || '-'} + + + {DEPOSIT_TYPE_LABELS[item.depositType]} + + + e.stopPropagation()}> + {handlers.isSelected && ( +
+ + +
+ )} +
+
+ ); + }, + + // 모바일 카드 렌더링 + renderMobileCard: ( + item: DepositRecord, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => ( + handleRowClick(item)} + details={[ + { label: '입금일', value: item.depositDate }, + { label: '입금액', value: `${(item.depositAmount ?? 0).toLocaleString()}원` }, + { label: '거래처', value: item.vendorName || '-' }, + ]} + actions={ + handlers.isSelected ? ( +
+ + +
+ ) : undefined + } + /> + ), + }), + [ + initialData, + startDate, + endDate, + stats, + vendorFilter, + depositTypeFilter, + sortOption, + selectedAccountSubject, + vendorOptions, + tableTotals, + isRefreshing, + handleRowClick, + handleEdit, + handleRefresh, + handleSaveAccountSubject, + ] ); return ( <> - item.id} - renderTableRow={renderTableRow} - renderMobileCard={renderMobileCard} - pagination={{ - currentPage, - totalPages, - totalItems: filteredData.length, - itemsPerPage, - onPageChange: setCurrentPage, - }} - /> + {/* 계정과목명 저장 확인 다이얼로그 */} @@ -571,7 +564,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan 계정과목명 변경 - {selectedItems.size}개의 입금 유형을{' '} + {selectedItemsForSave.size}개의 입금 유형을{' '} {ACCOUNT_SUBJECT_OPTIONS.find(o => o.value === selectedAccountSubject)?.label} @@ -592,27 +585,6 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan - {/* 삭제 확인 다이얼로그 */} - - - - 입금 삭제 - - 이 입금 내역을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다. - - - - 취소 - - 삭제 - - - - - {/* 선택 필요 알림 다이얼로그 */} diff --git a/src/components/accounting/ExpectedExpenseManagement/index.tsx b/src/components/accounting/ExpectedExpenseManagement/index.tsx index aac4c8a7..e0336081 100644 --- a/src/components/accounting/ExpectedExpenseManagement/index.tsx +++ b/src/components/accounting/ExpectedExpenseManagement/index.tsx @@ -1,5 +1,16 @@ 'use client'; +/** + * 지출 예상 내역서 - UniversalListPage 마이그레이션 + * + * 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환 + * - 월별 그룹핑 테이블 (헤더, 소계, 합계 행 포함) + * - 등록/수정 폼 다이얼로그 + * - 예상 지급일 변경 다이얼로그 + * - 일괄 삭제 다이얼로그 + * - 외부 선택 상태 관리 (데이터 행만 선택 가능) + */ + import { useState, useMemo, useCallback, useTransition, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { format } from 'date-fns'; @@ -42,11 +53,12 @@ import { SelectValue, } from '@/components/ui/select'; import { - IntegratedListTemplateV2, - type TableColumn, + UniversalListPage, + type UniversalListConfig, + type SelectionHandlers, + type RowClickHandlers, type StatCard, -} from '@/components/templates/IntegratedListTemplateV2'; -import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; +} from '@/components/templates/UniversalListPage'; import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; import type { ExpectedExpenseRecord, @@ -550,20 +562,8 @@ export function ExpectedExpenseManagement({ return { label, totalAmount }; }, [data, selectedItems]); - // ===== 통계 카드 ===== - const statCards: StatCard[] = useMemo(() => { - const totalExpense = filteredRawData.reduce((sum, d) => sum + d.amount, 0); - const expectedBalance = 10000000; - - return [ - { label: '지출 합계', value: `${totalExpense.toLocaleString()}원`, icon: Receipt, iconColor: 'text-red-500' }, - { label: '예상 잔액', value: `${expectedBalance.toLocaleString()}원`, icon: Receipt, iconColor: 'text-blue-500' }, - ]; - }, [filteredRawData]); - // ===== 테이블 컬럼 ===== - // 순서: 예상 지급일, 항목, 지출금액, 거래처, 계좌, 전자결재, 작업 - const tableColumns: TableColumn[] = useMemo(() => [ + const tableColumns = useMemo(() => [ { key: 'no', label: '번호', className: 'w-[60px] text-center' }, { key: 'expectedPaymentDate', label: '예상 지급일' }, { key: 'accountSubject', label: '항목' }, @@ -591,7 +591,12 @@ export function ExpectedExpenseManagement({ // ===== 테이블 행 렌더링 ===== // 컬럼 순서: 체크박스 + 번호 + 예상 지급일 + 항목 + 지출금액 + 거래처 + 계좌 + 전자결재 + 작업 - const renderTableRow = useCallback((item: TableRowData, index: number, globalIndex: number) => { + const renderTableRow = useCallback(( + item: TableRowData, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => { // 월 헤더 행 (9개 컬럼) if (item.rowType === 'monthHeader') { return ( @@ -728,9 +733,10 @@ export function ExpectedExpenseManagement({ item: TableRowData, index: number, globalIndex: number, - isSelected: boolean, - onToggle: () => void + handlers: SelectionHandlers & RowClickHandlers ) => { + const isSelected = selectedItems.has(item.id); + const onToggle = () => toggleSelection(item.id); // 헤더/소계/합계 행은 모바일에서 다르게 표시 if (item.rowType !== 'data') { if (item.rowType === 'monthHeader') { @@ -807,49 +813,6 @@ export function ExpectedExpenseManagement({ ); }, []); - // ===== 헤더 액션 (날짜 선택) ===== - const headerActions = ( - - ); - - // ===== 테이블 헤더 액션 (거래처 필터/정렬 필터 - 탭 옆) ===== - const tableHeaderActions = ( -
- {/* 거래처 필터 */} - - - {/* 정렬 필터 (최신순/등록순) */} - -
- ); - // ===== 선택된 항목 데이터 가져오기 ===== const getSelectedItemsData = useCallback(() => { return data.filter(item => selectedItems.has(item.id)); @@ -863,84 +826,216 @@ export function ExpectedExpenseManagement({ router.push(`/ko/approval/draft/new?type=expected-expense&items=${encodeURIComponent(selectedIds)}`); }, [getSelectedItemsData, router]); - // ===== 테이블 앞 컨텐츠 (액션 버튼) ===== - const beforeTableContent = ( -
- {/* 등록 버튼 */} - + // ===== UniversalListPage Config ===== + const config: UniversalListConfig = useMemo( + () => ({ + // 페이지 기본 정보 + title: '지출 예상 내역서', + description: '지출 예상 내역을 등록하고 조회합니다', + icon: Receipt, + basePath: '/accounting/expected-expense', - {/* 예상 지급일 변경 버튼 - 1개 이상 선택 시 활성화 */} - + // ID 추출 + idField: 'id', - {/* 전자결재 버튼 - 1개 이상 선택 시 활성화 */} - + // API 액션 + actions: { + getList: async () => { + return { + success: true, + data: tableData, + totalCount: initialPagination.total || filteredRawData.length, + }; + }, + }, - {/* 일괄삭제 버튼 - 1개 이상 선택 시 활성화 */} - -
+ // 테이블 컬럼 + columns: tableColumns, + + // 클라이언트 사이드 필터링 (이미 tableData에서 필터링 완료) + clientSideFiltering: false, + itemsPerPage, + + // 검색 + searchPlaceholder: '거래처, 계정과목, 적요 검색...', + onSearchChange: setSearchQuery, + + // 행 번호 숨기기 (커스텀 번호 사용) + showRowNumber: false, + + // 날짜 범위 선택기 + dateRangeSelector: { + enabled: true, + startDate, + endDate, + onStartDateChange: setStartDate, + onEndDateChange: setEndDate, + }, + + // 모바일 필터 설정 + filterConfig: [ + { + key: 'transactionType', + label: '거래유형', + type: 'single', + options: TRANSACTION_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all'), + }, + { + key: 'paymentStatus', + label: '지급상태', + type: 'single', + options: PAYMENT_STATUS_FILTER_OPTIONS.filter(o => o.value !== 'all'), + }, + { + key: 'sortBy', + label: '정렬', + type: 'single', + options: SORT_OPTIONS, + }, + ], + initialFilters: { + transactionType: 'all', + paymentStatus: 'all', + sortBy: sortOption, + }, + filterTitle: '예상비용 필터', + + // 테이블 헤더 액션 (거래처/정렬 필터) + tableHeaderActions: () => ( +
+ {/* 거래처 필터 */} + + + {/* 정렬 필터 (최신순/등록순) */} + +
+ ), + + // 테이블 앞 컨텐츠 (액션 버튼) + beforeTableContent: ( +
+ {/* 등록 버튼 */} + + + {/* 예상 지급일 변경 버튼 - 1개 이상 선택 시 활성화 */} + + + {/* 전자결재 버튼 - 1개 이상 선택 시 활성화 */} + + + {/* 일괄삭제 버튼 - 1개 이상 선택 시 활성화 */} + +
+ ), + + // Stats 카드 + computeStats: (): StatCard[] => { + const totalExpense = filteredRawData.reduce((sum, d) => sum + d.amount, 0); + const expectedBalance = 10000000; + + return [ + { label: '지출 합계', value: `${totalExpense.toLocaleString()}원`, icon: Receipt, iconColor: 'text-red-500' }, + { label: '예상 잔액', value: `${expectedBalance.toLocaleString()}원`, icon: Receipt, iconColor: 'text-blue-500' }, + ]; + }, + + // 테이블 행 렌더링 + renderTableRow, + + // 모바일 카드 렌더링 + renderMobileCard, + }), + [ + tableData, + tableColumns, + filteredRawData, + initialPagination, + itemsPerPage, + startDate, + endDate, + vendorFilter, + vendorFilterOptions, + sortOption, + selectedItems, + handleOpenCreateDialog, + handleOpenDateChangeDialog, + handleElectronicApproval, + renderTableRow, + renderMobileCard, + ] ); return ( <> - item.id} - renderTableRow={renderTableRow} - renderMobileCard={renderMobileCard} - pagination={{ + item.id, + }} /> {/* 삭제 확인 다이얼로그 */} diff --git a/src/components/accounting/PurchaseManagement/index.tsx b/src/components/accounting/PurchaseManagement/index.tsx index b282f409..39759f4a 100644 --- a/src/components/accounting/PurchaseManagement/index.tsx +++ b/src/components/accounting/PurchaseManagement/index.tsx @@ -1,5 +1,19 @@ 'use client'; +/** + * 매입관리 - UniversalListPage 마이그레이션 + * + * 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환 + * - 클라이언트 사이드 필터링 (검색, 필터, 정렬) + * - Stats 카드 (단순 표시, 클릭 없음) + * - beforeTableContent (계정과목명 Select + 저장 버튼) + * - tableHeaderActions (4개 인라인 필터: 거래처, 매입유형, 발행여부, 정렬) + * - tableFooter (합계 행) + * - Switch 토글 (세금계산서 수취) + * - 커스텀 Dialog (계정과목명 저장) - UniversalListPage 외부 유지 + * - deleteConfirmMessage로 삭제 다이얼로그 처리 + */ + import { useState, useMemo, useCallback, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; @@ -21,16 +35,6 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; import { TableRow, TableCell } from '@/components/ui/table'; import { Select, @@ -40,59 +44,60 @@ import { SelectValue, } from '@/components/ui/select'; import { - IntegratedListTemplateV2, - type TableColumn, + UniversalListPage, + type UniversalListConfig, + type SelectionHandlers, + type RowClickHandlers, type StatCard, -} from '@/components/templates/IntegratedListTemplateV2'; -import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; -import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; -import type { - PurchaseRecord, - SortOption, - IssuanceFilter, -} from './types'; + type FilterFieldConfig, +} from '@/components/templates/UniversalListPage'; +import { MobileCard } from '@/components/molecules/MobileCard'; +import type { PurchaseRecord } from './types'; import { SORT_OPTIONS, PURCHASE_TYPE_LABELS, PURCHASE_TYPE_FILTER_OPTIONS, ISSUANCE_FILTER_OPTIONS, ACCOUNT_SUBJECT_SELECTOR_OPTIONS, - PURCHASE_STATUS_LABELS, - PURCHASE_STATUS_COLORS, } from './types'; import { getPurchases, togglePurchaseTaxInvoice, deletePurchase } from './actions'; +// ===== 테이블 컬럼 정의 ===== +const tableColumns = [ + { key: 'no', label: 'No.', className: 'w-[60px] text-center' }, + { key: 'purchaseNo', label: '매입번호' }, + { key: 'purchaseDate', label: '매입일' }, + { key: 'vendorName', label: '거래처' }, + { key: 'supplyAmount', label: '공급가액', className: 'text-right' }, + { key: 'vat', label: '부가세', className: 'text-right' }, + { key: 'totalAmount', label: '합계금액', className: 'text-right' }, + { key: 'purchaseType', label: '매입유형', className: 'text-center' }, + { key: 'taxInvoice', label: '세금계산서 수취 확인', className: 'text-center' }, + { key: 'actions', label: '작업', className: 'text-center w-[100px]' }, +]; + export function PurchaseManagement() { const router = useRouter(); - // ===== 상태 관리 ===== - const [searchQuery, setSearchQuery] = useState(''); - const [sortOption, setSortOption] = useState('latest'); - const [purchaseTypeFilter, setPurchaseTypeFilter] = useState('all'); - const [vendorFilter, setVendorFilter] = useState('all'); - const [issuanceFilter, setIssuanceFilter] = useState('all'); - const [selectedItems, setSelectedItems] = useState>(new Set()); - const [currentPage, setCurrentPage] = useState(1); - const itemsPerPage = 20; - - // 상단 계정과목명 선택 (저장용) - const [selectedAccountSubject, setSelectedAccountSubject] = useState('unset'); - - // 계정과목명 저장 다이얼로그 - const [showSaveDialog, setShowSaveDialog] = useState(false); - - // 삭제 다이얼로그 - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [deleteTargetId, setDeleteTargetId] = useState(null); - - // 날짜 범위 상태 + // ===== 외부 상태 (UniversalListPage 외부에서 관리) ===== const [startDate, setStartDate] = useState('2025-01-01'); const [endDate, setEndDate] = useState('2025-12-31'); - - // API 데이터 상태 - const [data, setData] = useState([]); + const [purchaseData, setPurchaseData] = useState([]); const [isLoading, setIsLoading] = useState(true); + // 통합 필터 상태 (filterConfig 기반) + const [filterValues, setFilterValues] = useState>({ + vendor: 'all', + purchaseType: 'all', + issuance: 'all', + sort: 'latest', + }); + + // 계정과목명 저장 다이얼로그 + const [selectedAccountSubject, setSelectedAccountSubject] = useState('unset'); + const [showSaveDialog, setShowSaveDialog] = useState(false); + const [selectedItemsForSave, setSelectedItemsForSave] = useState>(new Set()); + // ===== API 데이터 로드 ===== useEffect(() => { const loadData = async () => { @@ -101,17 +106,17 @@ export function PurchaseManagement() { const result = await getPurchases({ startDate, endDate, - perPage: 100, // 충분한 데이터 로드 + perPage: 100, }); if (result.success) { - setData(result.data); + setPurchaseData(result.data); } else { toast.error(result.error || '매입 목록 조회에 실패했습니다.'); - setData([]); + setPurchaseData([]); } } catch { toast.error('매입 목록 조회 중 오류가 발생했습니다.'); - setData([]); + setPurchaseData([]); } finally { setIsLoading(false); } @@ -119,489 +124,412 @@ export function PurchaseManagement() { loadData(); }, [startDate, endDate]); - // ===== 체크박스 핸들러 ===== - const toggleSelection = useCallback((id: string) => { - setSelectedItems(prev => { - const newSet = new Set(prev); - if (newSet.has(id)) newSet.delete(id); - else newSet.add(id); - return newSet; - }); - }, []); - - // ===== 필터링된 데이터 ===== - const filteredData = useMemo(() => { - let result = data.filter(item => - item.vendorName.includes(searchQuery) || - item.purchaseNo.includes(searchQuery) - ); - - // 거래처 필터 - if (vendorFilter !== 'all') { - result = result.filter(item => item.vendorName === vendorFilter); - } - - // 매입유형 필터 - if (purchaseTypeFilter !== 'all') { - result = result.filter(item => item.purchaseType === purchaseTypeFilter); - } - - // 발행여부 필터 - if (issuanceFilter === 'taxInvoicePending') { - result = result.filter(item => !item.taxInvoiceReceived); - } - - // 정렬 - switch (sortOption) { - case 'latest': - result.sort((a, b) => new Date(b.purchaseDate).getTime() - new Date(a.purchaseDate).getTime()); - break; - case 'oldest': - result.sort((a, b) => new Date(a.purchaseDate).getTime() - new Date(b.purchaseDate).getTime()); - break; - case 'amountHigh': - result.sort((a, b) => b.totalAmount - a.totalAmount); - break; - case 'amountLow': - result.sort((a, b) => a.totalAmount - b.totalAmount); - break; - } - - return result; - }, [data, searchQuery, vendorFilter, purchaseTypeFilter, issuanceFilter, sortOption]); - - const paginatedData = useMemo(() => { - const startIndex = (currentPage - 1) * itemsPerPage; - return filteredData.slice(startIndex, startIndex + itemsPerPage); - }, [filteredData, currentPage, itemsPerPage]); - - const totalPages = Math.ceil(filteredData.length / itemsPerPage); - - // ===== 전체 선택 핸들러 ===== - const toggleSelectAll = useCallback(() => { - if (selectedItems.size === filteredData.length && filteredData.length > 0) { - setSelectedItems(new Set()); - } else { - setSelectedItems(new Set(filteredData.map(item => item.id))); - } - }, [selectedItems.size, filteredData]); - - // ===== 액션 핸들러 ===== - const handleRowClick = useCallback((item: PurchaseRecord) => { - router.push(`/ko/accounting/purchase/${item.id}`); - }, [router]); - - // 개별 항목 삭제 핸들러 - const handleDeleteClick = useCallback((id: string) => { - setDeleteTargetId(id); - setShowDeleteDialog(true); - }, []); - - // 삭제 확정 핸들러 - const handleConfirmDelete = useCallback(async () => { - if (deleteTargetId) { - const result = await deletePurchase(deleteTargetId); - if (result.success) { - toast.success('매입이 삭제되었습니다.'); - setData(prev => prev.filter(item => item.id !== deleteTargetId)); - setSelectedItems(prev => { - const newSet = new Set(prev); - newSet.delete(deleteTargetId); - return newSet; - }); - } else { - toast.error(result.error || '삭제에 실패했습니다.'); - } - } - setShowDeleteDialog(false); - setDeleteTargetId(null); - }, [deleteTargetId]); - - // ===== 통계 카드 ===== - const statCards: StatCard[] = useMemo(() => { - const totalPurchaseAmount = data.reduce((sum, d) => sum + d.totalAmount, 0); - // 당월 매입 (현재 월 기준) + // ===== 통계 계산 ===== + const stats = useMemo(() => { + const totalPurchaseAmount = purchaseData.reduce((sum, d) => sum + d.totalAmount, 0); const currentMonth = new Date().getMonth(); const currentYear = new Date().getFullYear(); - const monthlyAmount = data + const monthlyAmount = purchaseData .filter(d => { const date = new Date(d.purchaseDate); return date.getMonth() === currentMonth && date.getFullYear() === currentYear; }) .reduce((sum, d) => sum + d.totalAmount, 0); - // 매입유형 미설정 건수 - const unsetTypeCount = data.filter(d => d.purchaseType === 'unset').length; - // 세금계산서 수취 미확인 건수 - const taxInvoicePendingCount = data.filter(d => !d.taxInvoiceReceived).length; + const unsetTypeCount = purchaseData.filter(d => d.purchaseType === 'unset').length; + const taxInvoicePendingCount = purchaseData.filter(d => !d.taxInvoiceReceived).length; + return { totalPurchaseAmount, monthlyAmount, unsetTypeCount, taxInvoicePendingCount }; + }, [purchaseData]); - return [ - { label: '총 매입', value: `${totalPurchaseAmount.toLocaleString()}원`, icon: Receipt, iconColor: 'text-blue-500' }, - { label: '당월 매입', value: `${monthlyAmount.toLocaleString()}원`, icon: Receipt, iconColor: 'text-green-500' }, - { label: '매입유형 미설정', value: `${unsetTypeCount}건`, icon: Receipt, iconColor: 'text-orange-500' }, - { label: '세금계산서 수취 미확인', value: `${taxInvoicePendingCount}건`, icon: Receipt, iconColor: 'text-red-500' }, - ]; - }, [data]); + // ===== 거래처 목록 (필터용) ===== + const vendorOptions = useMemo(() => { + const uniqueVendors = [...new Set(purchaseData.map(d => d.vendorName).filter(v => v && v.trim() !== ''))]; + return uniqueVendors.map(v => ({ value: v, label: v })); + }, [purchaseData]); - // ===== 테이블 컬럼 (스크린샷 기준) ===== - // No., 매입번호, 매입일, 거래처, 공급가액, 부가세, 합계금액, 매입유형, 세금계산서 수취 확인(토글), 작업 - const tableColumns: TableColumn[] = useMemo(() => [ - { key: 'no', label: 'No.', className: 'w-[60px] text-center' }, - { key: 'purchaseNo', label: '매입번호' }, - { key: 'purchaseDate', label: '매입일' }, - { key: 'vendorName', label: '거래처' }, - { key: 'supplyAmount', label: '공급가액', className: 'text-right' }, - { key: 'vat', label: '부가세', className: 'text-right' }, - { key: 'totalAmount', label: '합계금액', className: 'text-right' }, - { key: 'purchaseType', label: '매입유형', className: 'text-center' }, - { key: 'taxInvoice', label: '세금계산서 수취 확인', className: 'text-center' }, - { key: 'actions', label: '작업', className: 'text-center w-[100px]' }, - ], []); + // ===== filterConfig 정의 (PC/모바일 자동 분기) ===== + const filterConfig: FilterFieldConfig[] = useMemo(() => [ + { + key: 'vendor', + label: '거래처', + type: 'single', + options: vendorOptions, + allOptionLabel: '거래처 전체', + }, + { + key: 'purchaseType', + label: '매입유형', + type: 'single', + options: PURCHASE_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all'), + allOptionLabel: '전체', + }, + { + key: 'issuance', + label: '발행여부', + type: 'single', + options: ISSUANCE_FILTER_OPTIONS.filter(o => o.value !== 'all'), + allOptionLabel: '전체', + }, + { + key: 'sort', + label: '정렬', + type: 'single', + options: SORT_OPTIONS, + allOptionLabel: '최신순', + }, + ], [vendorOptions]); - // ===== 토글 핸들러 ===== + // 필터 변경 핸들러 + const handleFilterChange = useCallback((key: string, value: string | string[]) => { + setFilterValues(prev => ({ ...prev, [key]: value })); + }, []); + + // 필터 초기화 핸들러 + const handleFilterReset = useCallback(() => { + setFilterValues({ + vendor: 'all', + purchaseType: 'all', + issuance: 'all', + sort: 'latest', + }); + }, []); + + // ===== 핸들러 ===== + const handleRowClick = useCallback((item: PurchaseRecord) => { + router.push(`/ko/accounting/purchase/${item.id}`); + }, [router]); + + const handleEdit = useCallback((item: PurchaseRecord) => { + router.push(`/ko/accounting/purchase/${item.id}?mode=edit`); + }, [router]); + + // 토글 핸들러 const handleTaxInvoiceToggle = useCallback(async (itemId: string, checked: boolean) => { - // 낙관적 업데이트: UI 먼저 변경 - setData(prev => prev.map(item => + setPurchaseData(prev => prev.map(item => item.id === itemId ? { ...item, taxInvoiceReceived: checked } : item )); - - // API 호출 const result = await togglePurchaseTaxInvoice(itemId, checked); if (!result.success) { - // 실패 시 롤백 - setData(prev => prev.map(item => + setPurchaseData(prev => prev.map(item => item.id === itemId ? { ...item, taxInvoiceReceived: !checked } : item )); toast.error(result.error || '세금계산서 수취 상태 변경에 실패했습니다.'); } }, []); - // ===== 테이블 행 렌더링 (스크린샷 기준 컬럼) ===== - const renderTableRow = useCallback((item: PurchaseRecord, index: number, globalIndex: number) => { - const isSelected = selectedItems.has(item.id); - const isUnsetType = item.purchaseType === 'unset'; - - return ( - handleRowClick(item)} - > - e.stopPropagation()}> - toggleSelection(item.id)} /> - - {/* No. */} - {globalIndex} - {/* 매입번호 */} - {item.purchaseNo} - {/* 매입일 */} - {item.purchaseDate} - {/* 거래처 */} - {item.vendorName} - {/* 공급가액 */} - {item.supplyAmount.toLocaleString()} - {/* 부가세 */} - {item.vat.toLocaleString()} - {/* 합계금액 */} - {item.totalAmount.toLocaleString()} - {/* 매입유형 - 미설정일 경우 빨간색 */} - - - {PURCHASE_TYPE_LABELS[item.purchaseType]} - - - {/* 세금계산서 수취 확인 (토글) */} - e.stopPropagation()}> -
- handleTaxInvoiceToggle(item.id, checked)} - className="data-[state=checked]:bg-orange-500" - /> - {item.taxInvoiceReceived && 수취} -
-
- {/* 작업 */} - e.stopPropagation()}> - {isSelected && ( -
- - -
- )} -
-
- ); - }, [selectedItems, toggleSelection, handleRowClick, handleTaxInvoiceToggle, handleDeleteClick]); - - // ===== 모바일 카드 렌더링 ===== - const renderMobileCard = useCallback(( - item: PurchaseRecord, - index: number, - globalIndex: number, - isSelected: boolean, - onToggle: () => void - ) => { - return ( - - {PURCHASE_TYPE_LABELS[item.purchaseType]} - - {PURCHASE_STATUS_LABELS[item.status]} - - - } - isSelected={isSelected} - onToggleSelection={onToggle} - infoGrid={ -
- - - - -
- } - actions={ - isSelected ? ( -
- - -
- ) : undefined - } - onClick={() => handleRowClick(item)} - /> - ); - }, [handleRowClick, handleDeleteClick]); - - // ===== 헤더 액션 ===== - const headerActions = ( - - ); - - // ===== 계정과목명 저장 핸들러 ===== - const handleSaveAccountSubject = useCallback(() => { + // 계정과목명 저장 핸들러 + const handleSaveAccountSubject = useCallback((selectedItems: Set) => { if (selectedItems.size === 0) { toast.warning('변경할 매입 항목을 선택해주세요.'); return; } + setSelectedItemsForSave(selectedItems); setShowSaveDialog(true); - }, [selectedItems.size]); + }, []); - // 계정과목명 저장 확정 const handleConfirmSaveAccountSubject = useCallback(() => { // TODO: API 호출로 저장 toast.success('계정과목명이 변경되었습니다.'); setShowSaveDialog(false); - setSelectedItems(new Set()); - }, [selectedAccountSubject, selectedItems]); - - // ===== 거래처 목록 (필터용) ===== - const vendorOptions = useMemo(() => { - const uniqueVendors = [...new Set(data.map(d => d.vendorName).filter(v => v && v.trim() !== ''))]; - return [ - { value: 'all', label: '거래처 전체' }, - ...uniqueVendors.map(v => ({ value: v, label: v })) - ]; - }, [data]); - - // ===== 테이블 헤더 액션 (4개 필터: 거래처, 매입유형, 발행여부, 정렬) ===== - const tableHeaderActions = ( -
- {/* 거래처 필터 */} - - - {/* 매입유형 필터 */} - - - {/* 발행여부 필터 */} - - - {/* 정렬 */} - -
- ); + setSelectedItemsForSave(new Set()); + }, [selectedAccountSubject]); // ===== 테이블 합계 계산 ===== const tableTotals = useMemo(() => { - const totalSupplyAmount = filteredData.reduce((sum, item) => sum + item.supplyAmount, 0); - const totalVat = filteredData.reduce((sum, item) => sum + item.vat, 0); - const totalAmount = filteredData.reduce((sum, item) => sum + item.totalAmount, 0); + const totalSupplyAmount = purchaseData.reduce((sum, item) => sum + item.supplyAmount, 0); + const totalVat = purchaseData.reduce((sum, item) => sum + item.vat, 0); + const totalAmount = purchaseData.reduce((sum, item) => sum + item.totalAmount, 0); return { totalSupplyAmount, totalVat, totalAmount }; - }, [filteredData]); + }, [purchaseData]); - // ===== 테이블 하단 합계 행 ===== - const tableFooter = ( - - - - 합계 - - - {tableTotals.totalSupplyAmount.toLocaleString()} - {tableTotals.totalVat.toLocaleString()} - {tableTotals.totalAmount.toLocaleString()} - - - - - ); + // ===== UniversalListPage Config ===== + const config: UniversalListConfig = useMemo( + () => ({ + // 페이지 기본 정보 + title: '매입관리', + description: '매입 내역을 등록하고 관리합니다', + icon: Receipt, + basePath: '/accounting/purchase', - // ===== 상단 계정과목명 + 저장 버튼 ===== - const accountSubjectSelector = ( -
- 계정과목명 - - -
+ // ID 추출 + idField: 'id', + + // API 액션 + actions: { + getList: async () => { + return { + success: true, + data: purchaseData, + totalCount: purchaseData.length, + }; + }, + deleteItem: async (id: string) => { + const result = await deletePurchase(id); + if (result.success) { + setPurchaseData(prev => prev.filter(item => item.id !== id)); + toast.success('매입이 삭제되었습니다.'); + } + return { success: result.success, error: result.error }; + }, + }, + + // 테이블 컬럼 + columns: tableColumns, + + // 클라이언트 사이드 필터링 + clientSideFiltering: true, + itemsPerPage: 20, + + // 데이터 변경 콜백 + onDataChange: (data) => setPurchaseData(data), + + // 검색 필터 + searchPlaceholder: '매입번호, 거래처명 검색...', + searchFilter: (item, searchValue) => { + const search = searchValue.toLowerCase(); + return ( + item.purchaseNo.toLowerCase().includes(search) || + item.vendorName.toLowerCase().includes(search) + ); + }, + + // 커스텀 필터 함수 (filterValues 파라미터 사용) + customFilterFn: (items, fv) => { + return items.filter((item) => { + const vendorVal = fv.vendor as string; + const purchaseTypeVal = fv.purchaseType as string; + const issuanceVal = fv.issuance as string; + // 거래처 필터 + if (vendorVal !== 'all' && item.vendorName !== vendorVal) { + return false; + } + // 매입유형 필터 + if (purchaseTypeVal !== 'all' && item.purchaseType !== purchaseTypeVal) { + return false; + } + // 발행여부 필터 + if (issuanceVal === 'taxInvoicePending' && item.taxInvoiceReceived) { + return false; + } + return true; + }); + }, + + // 커스텀 정렬 함수 (filterValues 파라미터 사용) + customSortFn: (items, fv) => { + const sorted = [...items]; + const sortVal = fv.sort as string; + switch (sortVal) { + case 'oldest': + sorted.sort((a, b) => new Date(a.purchaseDate).getTime() - new Date(b.purchaseDate).getTime()); + break; + case 'amountHigh': + sorted.sort((a, b) => b.totalAmount - a.totalAmount); + break; + case 'amountLow': + sorted.sort((a, b) => a.totalAmount - b.totalAmount); + break; + default: // latest + sorted.sort((a, b) => new Date(b.purchaseDate).getTime() - new Date(a.purchaseDate).getTime()); + break; + } + return sorted; + }, + + // 공통 헤더 옵션 + dateRangeSelector: { + enabled: true, + startDate, + endDate, + onStartDateChange: setStartDate, + onEndDateChange: setEndDate, + }, + + // 통합 필터 시스템 (PC: 인라인, 모바일: 바텀시트 자동 분기) + filterConfig, + initialFilters: filterValues, + filterTitle: '매입 필터', + + // Stats 카드 + computeStats: (): StatCard[] => [ + { label: '총 매입', value: `${stats.totalPurchaseAmount.toLocaleString()}원`, icon: Receipt, iconColor: 'text-blue-500' }, + { label: '당월 매입', value: `${stats.monthlyAmount.toLocaleString()}원`, icon: Receipt, iconColor: 'text-green-500' }, + { label: '매입유형 미설정', value: `${stats.unsetTypeCount}건`, icon: Receipt, iconColor: 'text-orange-500' }, + { label: '세금계산서 수취 미확인', value: `${stats.taxInvoicePendingCount}건`, icon: Receipt, iconColor: 'text-red-500' }, + ], + + // beforeTableContent: 계정과목명 Select + 저장 버튼 (테이블 밖에 위치) + beforeTableContent: ({ selectedItems }) => ( +
+ 계정과목명 + + +
+ ), + + // 테이블 하단 합계 행 + tableFooter: ( + + + + 합계 + + + {tableTotals.totalSupplyAmount.toLocaleString()} + {tableTotals.totalVat.toLocaleString()} + {tableTotals.totalAmount.toLocaleString()} + + + + + ), + + // 삭제 확인 메시지 + deleteConfirmMessage: { + title: '매입 삭제', + description: '이 매입 항목을 삭제하시겠습니까? 삭제된 데이터는 복구할 수 없습니다.', + }, + + // 테이블 행 렌더링 + renderTableRow: ( + item: PurchaseRecord, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => { + const isUnsetType = item.purchaseType === 'unset'; + return ( + handleRowClick(item)} + > + e.stopPropagation()}> + + + {globalIndex} + {item.purchaseNo} + {item.purchaseDate} + {item.vendorName} + {item.supplyAmount.toLocaleString()} + {item.vat.toLocaleString()} + {item.totalAmount.toLocaleString()} + + + {PURCHASE_TYPE_LABELS[item.purchaseType]} + + + e.stopPropagation()}> +
+ handleTaxInvoiceToggle(item.id, checked)} + className="data-[state=checked]:bg-orange-500" + /> + {item.taxInvoiceReceived && 수취} +
+
+ e.stopPropagation()}> + {handlers.isSelected && ( +
+ + +
+ )} +
+
+ ); + }, + + // 모바일 카드 렌더링 + renderMobileCard: ( + item: PurchaseRecord, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => ( + handleRowClick(item)} + details={[ + { label: '매입일', value: item.purchaseDate }, + { label: '공급가액', value: `${item.supplyAmount.toLocaleString()}원` }, + { label: '합계금액', value: `${item.totalAmount.toLocaleString()}원` }, + ]} + actions={ + handlers.isSelected ? ( +
+ + +
+ ) : undefined + } + /> + ), + }), + [ + purchaseData, + startDate, + endDate, + stats, + filterConfig, + filterValues, + selectedAccountSubject, + tableTotals, + handleRowClick, + handleEdit, + handleTaxInvoiceToggle, + handleSaveAccountSubject, + ] ); return ( <> - item.id} - renderTableRow={renderTableRow} - renderMobileCard={renderMobileCard} - pagination={{ - currentPage, - totalPages, - totalItems: filteredData.length, - itemsPerPage, - onPageChange: setCurrentPage, - }} - /> - - {/* 삭제 확인 다이얼로그 */} - - - - 매입 삭제 - - 이 매입 항목을 삭제하시겠습니까?
- 삭제된 데이터는 복구할 수 없습니다. -
-
- - setShowDeleteDialog(false)}> - 취소 - - - 삭제 - - -
-
+ {/* 계정과목명 저장 확인 다이얼로그 */} @@ -609,7 +537,7 @@ export function PurchaseManagement() { 계정과목명 변경 - {selectedItems.size}개의 매입유형을{' '} + {selectedItemsForSave.size}개의 매입유형을{' '} {ACCOUNT_SUBJECT_SELECTOR_OPTIONS.find(o => o.value === selectedAccountSubject)?.label} @@ -631,4 +559,4 @@ export function PurchaseManagement() { ); -} \ No newline at end of file +} diff --git a/src/components/accounting/SalesManagement/index.tsx b/src/components/accounting/SalesManagement/index.tsx index 2d3188d3..4081aabc 100644 --- a/src/components/accounting/SalesManagement/index.tsx +++ b/src/components/accounting/SalesManagement/index.tsx @@ -1,11 +1,24 @@ 'use client'; +/** + * 매출관리 - UniversalListPage 마이그레이션 + * + * 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환 + * - 클라이언트 사이드 필터링 (검색, 필터, 정렬) + * - Stats 카드 (단순 표시, 클릭 없음) + * - beforeTableContent (계정과목명 Select + 저장 버튼) + * - filterConfig (4개 필터: 거래처, 매출유형, 발행여부, 정렬) - PC 인라인, 모바일 바텀시트 + * - tableFooter (합계 행) + * - Switch 토글 (세금계산서/거래명세서 발행) + * - 커스텀 Dialog (계정과목명 저장) - UniversalListPage 외부 유지 + * - deleteConfirmMessage로 삭제 다이얼로그 처리 + */ + import { useState, useMemo, useCallback, useTransition } from 'react'; import { useRouter } from 'next/navigation'; import { toast } from 'sonner'; import { Receipt, - Plus, Pencil, Save, Trash2, @@ -22,16 +35,6 @@ import { DialogHeader, DialogTitle, } from '@/components/ui/dialog'; -import { - AlertDialog, - AlertDialogAction, - AlertDialogCancel, - AlertDialogContent, - AlertDialogDescription, - AlertDialogFooter, - AlertDialogHeader, - AlertDialogTitle, -} from '@/components/ui/alert-dialog'; import { TableRow, TableCell } from '@/components/ui/table'; import { Select, @@ -41,17 +44,15 @@ import { SelectValue, } from '@/components/ui/select'; import { - IntegratedListTemplateV2, - type TableColumn, + UniversalListPage, + type UniversalListConfig, + type SelectionHandlers, + type RowClickHandlers, type StatCard, -} from '@/components/templates/IntegratedListTemplateV2'; -import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; -import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; -import type { - SalesRecord, - SortOption, - IssuanceFilter, -} from './types'; + type FilterFieldConfig, +} from '@/components/templates/UniversalListPage'; +import { MobileCard } from '@/components/molecules/MobileCard'; +import type { SalesRecord } from './types'; import { SORT_OPTIONS, SALES_STATUS_LABELS, @@ -61,7 +62,22 @@ import { ISSUANCE_FILTER_OPTIONS, ACCOUNT_SUBJECT_SELECTOR_OPTIONS, } from './types'; -import { getSales, deleteSale, toggleSaleIssuance } from './actions'; +import { deleteSale, toggleSaleIssuance } from './actions'; + +// ===== 테이블 컬럼 정의 ===== +const tableColumns = [ + { key: 'no', label: '번호', className: 'text-center w-[60px]' }, + { key: 'salesNo', label: '매출번호' }, + { key: 'salesDate', label: '매출일' }, + { key: 'vendorName', label: '거래처' }, + { key: 'supplyAmount', label: '공급가액', className: 'text-right' }, + { key: 'vat', label: '부가세', className: 'text-right' }, + { key: 'totalAmount', label: '합계금액', className: 'text-right' }, + { key: 'salesType', label: '매출유형', className: 'text-center' }, + { key: 'taxInvoice', label: '세금계산서 발행완료', className: 'text-center' }, + { key: 'transactionStatement', label: '거래명세서 발행완료', className: 'text-center' }, + { key: 'actions', label: '작업', className: 'text-center w-[80px]' }, +]; // ===== Props 타입 ===== interface SalesManagementProps { @@ -78,203 +94,113 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem const router = useRouter(); const [isPending, startTransition] = useTransition(); - // ===== 상태 관리 ===== - const [searchQuery, setSearchQuery] = useState(''); - const [sortOption, setSortOption] = useState('latest'); - const [salesTypeFilter, setSalesTypeFilter] = useState('all'); - const [vendorFilter, setVendorFilter] = useState('all'); - const [issuanceFilter, setIssuanceFilter] = useState('all'); - const [selectedItems, setSelectedItems] = useState>(new Set()); - const [currentPage, setCurrentPage] = useState(1); - const itemsPerPage = 20; - - // 상단 계정과목명 선택 (저장용) - const [selectedAccountSubject, setSelectedAccountSubject] = useState('unset'); - - // 계정과목명 저장 다이얼로그 - const [showSaveDialog, setShowSaveDialog] = useState(false); - - // 삭제 다이얼로그 - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [deleteTargetId, setDeleteTargetId] = useState(null); - - // 날짜 범위 상태 + // ===== 외부 상태 (UniversalListPage 외부에서 관리) ===== const [startDate, setStartDate] = useState('2025-01-01'); const [endDate, setEndDate] = useState('2025-12-31'); + const [salesData, setSalesData] = useState(initialData); - // API 데이터 (initialData로 초기화) - const [data, setData] = useState(initialData); + // 통합 필터 상태 (filterConfig 사용) + const [filterValues, setFilterValues] = useState>({ + vendor: 'all', + salesType: 'all', + issuance: 'all', + sort: 'latest', + }); - // ===== 체크박스 핸들러 ===== - const toggleSelection = useCallback((id: string) => { - setSelectedItems(prev => { - const newSet = new Set(prev); - if (newSet.has(id)) newSet.delete(id); - else newSet.add(id); - return newSet; - }); - }, []); + // 계정과목명 저장 다이얼로그 + const [selectedAccountSubject, setSelectedAccountSubject] = useState('unset'); + const [showSaveDialog, setShowSaveDialog] = useState(false); + const [selectedItemsForSave, setSelectedItemsForSave] = useState>(new Set()); - // ===== 필터링된 데이터 ===== - const filteredData = useMemo(() => { - let result = data.filter(item => - item.salesNo.includes(searchQuery) || - item.vendorName.includes(searchQuery) || - item.note.includes(searchQuery) - ); - - // 거래처 필터 - if (vendorFilter !== 'all') { - result = result.filter(item => item.vendorName === vendorFilter); - } - - // 매출 유형 필터 - if (salesTypeFilter !== 'all') { - result = result.filter(item => item.salesType === salesTypeFilter); - } - - // 발행여부 필터 - if (issuanceFilter === 'taxInvoicePending') { - result = result.filter(item => !item.taxInvoiceIssued); - } else if (issuanceFilter === 'transactionStatementPending') { - result = result.filter(item => !item.transactionStatementIssued); - } - - // 정렬 - switch (sortOption) { - case 'latest': - result.sort((a, b) => new Date(b.salesDate).getTime() - new Date(a.salesDate).getTime()); - break; - case 'oldest': - result.sort((a, b) => new Date(a.salesDate).getTime() - new Date(b.salesDate).getTime()); - break; - case 'amountHigh': - result.sort((a, b) => b.totalAmount - a.totalAmount); - break; - case 'amountLow': - result.sort((a, b) => a.totalAmount - b.totalAmount); - break; - } - - return result; - }, [data, searchQuery, vendorFilter, salesTypeFilter, issuanceFilter, sortOption]); - - const paginatedData = useMemo(() => { - const startIndex = (currentPage - 1) * itemsPerPage; - return filteredData.slice(startIndex, startIndex + itemsPerPage); - }, [filteredData, currentPage, itemsPerPage]); - - const totalPages = Math.ceil(filteredData.length / itemsPerPage); - - // ===== 전체 선택 핸들러 ===== - const toggleSelectAll = useCallback(() => { - if (selectedItems.size === filteredData.length && filteredData.length > 0) { - setSelectedItems(new Set()); - } else { - setSelectedItems(new Set(filteredData.map(item => item.id))); - } - }, [selectedItems.size, filteredData]); - - // ===== 액션 핸들러 ===== - const handleDelete = useCallback(() => { - // 선택된 항목 일괄 삭제 (TODO: API 구현 필요) - setSelectedItems(new Set()); - }, [selectedItems]); - - const handleNewSales = useCallback(() => { - router.push('/ko/accounting/sales/new'); - }, [router]); - - const handleRowClick = useCallback((item: SalesRecord) => { - router.push(`/ko/accounting/sales/${item.id}`); - }, [router]); - - // 개별 항목 삭제 핸들러 - const handleDeleteClick = useCallback((id: string) => { - setDeleteTargetId(id); - setShowDeleteDialog(true); - }, []); - - // 삭제 확정 핸들러 - const handleConfirmDelete = useCallback(async () => { - if (deleteTargetId) { - startTransition(async () => { - const result = await deleteSale(deleteTargetId); - if (result.success) { - setData(prev => prev.filter(item => item.id !== deleteTargetId)); - setSelectedItems(prev => { - const newSet = new Set(prev); - newSet.delete(deleteTargetId); - return newSet; - }); - } else { - toast.error(result.error || '삭제에 실패했습니다.'); - } - }); - } - setShowDeleteDialog(false); - setDeleteTargetId(null); - }, [deleteTargetId]); - - // ===== 통계 카드 ===== - const statCards: StatCard[] = useMemo(() => { - const totalSalesAmount = data.reduce((sum, d) => sum + d.totalAmount, 0); - // 당월 매출 (현재 월 기준) + // ===== 통계 계산 ===== + const stats = useMemo(() => { + const totalSalesAmount = salesData.reduce((sum, d) => sum + d.totalAmount, 0); const currentMonth = new Date().getMonth(); const currentYear = new Date().getFullYear(); - const monthlyAmount = data + const monthlyAmount = salesData .filter(d => { const date = new Date(d.salesDate); return date.getMonth() === currentMonth && date.getFullYear() === currentYear; }) .reduce((sum, d) => sum + d.totalAmount, 0); - // 세금계산서 발행대기 (미발행 건수) - const taxInvoicePendingCount = data.filter(d => !d.taxInvoiceIssued).length; - // 거래명세서 발행대기 (미발행 건수) - const transactionStatementPendingCount = data.filter(d => !d.transactionStatementIssued).length; + const taxInvoicePendingCount = salesData.filter(d => !d.taxInvoiceIssued).length; + const transactionStatementPendingCount = salesData.filter(d => !d.transactionStatementIssued).length; + return { totalSalesAmount, monthlyAmount, taxInvoicePendingCount, transactionStatementPendingCount }; + }, [salesData]); - return [ - { label: '총 매출', value: `${totalSalesAmount.toLocaleString()}원`, icon: Receipt, iconColor: 'text-blue-500' }, - { label: '당월 매출', value: `${monthlyAmount.toLocaleString()}원`, icon: Receipt, iconColor: 'text-green-500' }, - { label: '세금계산서 발행대기', value: `${taxInvoicePendingCount}건`, icon: Receipt, iconColor: 'text-orange-500' }, - { label: '거래명세서 발행대기', value: `${transactionStatementPendingCount}건`, icon: Receipt, iconColor: 'text-orange-500' }, - ]; - }, [data]); + // ===== 거래처 목록 (필터용) ===== + const vendorOptions = useMemo(() => { + const uniqueVendors = [...new Set(salesData.map(d => d.vendorName))]; + return uniqueVendors.map(v => ({ value: v, label: v })); + }, [salesData]); - // ===== 테이블 컬럼 (스크린샷 기준) ===== - // 번호, 매출번호, 매출일, 거래처, 공급가액, 부가세, 합계금액, 매출유형, 세금계산서 발행완료, 거래명세서 발행완료, 작업 - const tableColumns: TableColumn[] = useMemo(() => [ - { key: 'no', label: '번호', className: 'text-center w-[60px]' }, - { key: 'salesNo', label: '매출번호' }, - { key: 'salesDate', label: '매출일' }, - { key: 'vendorName', label: '거래처' }, - { key: 'supplyAmount', label: '공급가액', className: 'text-right' }, - { key: 'vat', label: '부가세', className: 'text-right' }, - { key: 'totalAmount', label: '합계금액', className: 'text-right' }, - { key: 'salesType', label: '매출유형', className: 'text-center' }, - { key: 'taxInvoice', label: '세금계산서 발행완료', className: 'text-center' }, - { key: 'transactionStatement', label: '거래명세서 발행완료', className: 'text-center' }, - { key: 'actions', label: '작업', className: 'text-center w-[80px]' }, - ], []); + // ===== filterConfig 정의 ===== + const filterConfig: FilterFieldConfig[] = useMemo(() => [ + { + key: 'vendor', + label: '거래처', + type: 'single', + options: vendorOptions, + allOptionLabel: '거래처 전체', + }, + { + key: 'salesType', + label: '매출유형', + type: 'single', + options: SALES_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all'), + allOptionLabel: '전체', + }, + { + key: 'issuance', + label: '발행여부', + type: 'single', + options: ISSUANCE_FILTER_OPTIONS.filter(o => o.value !== 'all'), + allOptionLabel: '전체', + }, + { + key: 'sort', + label: '정렬', + type: 'single', + options: SORT_OPTIONS, + }, + ], [vendorOptions]); - // ===== 금액 포맷 ===== - const formatAmount = (amount: number): string => { - return amount.toLocaleString() + '원'; - }; + // ===== 필터 핸들러 ===== + const handleFilterChange = useCallback((key: string, value: string | string[]) => { + setFilterValues(prev => ({ ...prev, [key]: value })); + }, []); - // ===== 토글 핸들러 ===== + const handleFilterReset = useCallback(() => { + setFilterValues({ + vendor: 'all', + salesType: 'all', + issuance: 'all', + sort: 'latest', + }); + }, []); + + // ===== 핸들러 ===== + const handleRowClick = useCallback((item: SalesRecord) => { + router.push(`/ko/accounting/sales/${item.id}`); + }, [router]); + + const handleEdit = useCallback((item: SalesRecord) => { + router.push(`/ko/accounting/sales/${item.id}?mode=edit`); + }, [router]); + + const handleCreate = useCallback(() => { + router.push('/ko/accounting/sales/new'); + }, [router]); + + // 토글 핸들러 const handleTaxInvoiceToggle = useCallback((itemId: string, checked: boolean) => { - // 낙관적 업데이트: 먼저 UI 갱신 - setData(prev => prev.map(item => + setSalesData(prev => prev.map(item => item.id === itemId ? { ...item, taxInvoiceIssued: checked } : item )); - - // API 호출 startTransition(async () => { const result = await toggleSaleIssuance(itemId, 'taxInvoiceIssued', checked); if (!result.success) { - // 실패 시 롤백 - setData(prev => prev.map(item => + setSalesData(prev => prev.map(item => item.id === itemId ? { ...item, taxInvoiceIssued: !checked } : item )); toast.error(result.error || '세금계산서 발행 상태 변경에 실패했습니다.'); @@ -283,17 +209,13 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem }, []); const handleTransactionStatementToggle = useCallback((itemId: string, checked: boolean) => { - // 낙관적 업데이트: 먼저 UI 갱신 - setData(prev => prev.map(item => + setSalesData(prev => prev.map(item => item.id === itemId ? { ...item, transactionStatementIssued: checked } : item )); - - // API 호출 startTransition(async () => { const result = await toggleSaleIssuance(itemId, 'transactionStatementIssued', checked); if (!result.success) { - // 실패 시 롤백 - setData(prev => prev.map(item => + setSalesData(prev => prev.map(item => item.id === itemId ? { ...item, transactionStatementIssued: !checked } : item )); toast.error(result.error || '거래명세서 발행 상태 변경에 실패했습니다.'); @@ -301,326 +223,334 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem }); }, []); - // ===== 테이블 행 렌더링 (스크린샷 기준 컬럼) ===== - // 번호, 매출번호, 매출일, 거래처, 공급가액, 부가세, 합계금액, 매출유형, 세금계산서(토글), 거래명세서(토글), 작업 - const renderTableRow = useCallback((item: SalesRecord, index: number, globalIndex: number) => { - const isSelected = selectedItems.has(item.id); - - return ( - handleRowClick(item)} - > - e.stopPropagation()}> - toggleSelection(item.id)} /> - - {/* 번호 */} - {globalIndex} - {/* 매출번호 */} - {item.salesNo} - {/* 매출일 */} - {item.salesDate} - {/* 거래처 */} - {item.vendorName} - {/* 공급가액 */} - {item.totalSupplyAmount.toLocaleString()} - {/* 부가세 */} - {item.totalVat.toLocaleString()} - {/* 합계금액 */} - {item.totalAmount.toLocaleString()} - {/* 매출유형 */} - - {SALES_TYPE_LABELS[item.salesType]} - - {/* 세금계산서 발행완료 (토글) */} - e.stopPropagation()}> -
- handleTaxInvoiceToggle(item.id, checked)} - className="data-[state=checked]:bg-orange-500" - /> - {item.taxInvoiceIssued && 발행} -
-
- {/* 거래명세서 발행완료 (토글) */} - e.stopPropagation()}> -
- handleTransactionStatementToggle(item.id, checked)} - className="data-[state=checked]:bg-orange-500" - /> - {item.transactionStatementIssued && 발행} -
-
- {/* 작업 */} - e.stopPropagation()}> - {isSelected && ( -
- - -
- )} -
-
- ); - }, [selectedItems, toggleSelection, handleRowClick, handleTaxInvoiceToggle, handleTransactionStatementToggle, handleDeleteClick]); - - // ===== 모바일 카드 렌더링 ===== - const renderMobileCard = useCallback(( - item: SalesRecord, - index: number, - globalIndex: number, - isSelected: boolean, - onToggle: () => void - ) => { - return ( - - {SALES_TYPE_LABELS[item.salesType]} - - {SALES_STATUS_LABELS[item.status]} - - - } - isSelected={isSelected} - onToggleSelection={onToggle} - infoGrid={ -
- - - - 0 ? 'text-red-600' : ''} - /> -
- } - actions={ - isSelected ? ( -
- - -
- ) : undefined - } - onClick={() => handleRowClick(item)} - /> - ); - }, [handleRowClick, handleDeleteClick]); - - // ===== 헤더 액션 ===== - const headerActions = ( - <> - - - - ); - - // ===== 계정과목명 저장 핸들러 ===== - const handleSaveAccountSubject = useCallback(() => { + // 계정과목명 저장 핸들러 + const handleSaveAccountSubject = useCallback((selectedItems: Set) => { if (selectedItems.size === 0) { toast.warning('변경할 매출 항목을 선택해주세요.'); return; } + setSelectedItemsForSave(selectedItems); setShowSaveDialog(true); - }, [selectedItems.size]); + }, []); - // 계정과목명 저장 확정 const handleConfirmSaveAccountSubject = useCallback(() => { // TODO: API 호출로 저장 setShowSaveDialog(false); - setSelectedItems(new Set()); + setSelectedItemsForSave(new Set()); toast.success('계정과목명이 변경되었습니다.'); - }, [selectedAccountSubject, selectedItems]); - - // ===== 거래처 목록 (필터용) ===== - const vendorOptions = useMemo(() => { - const uniqueVendors = [...new Set(data.map(d => d.vendorName))]; - return [ - { value: 'all', label: '거래처 전체' }, - ...uniqueVendors.map(v => ({ value: v, label: v })) - ]; - }, [data]); - - // ===== 테이블 헤더 액션 (4개 필터: 거래처, 매출유형, 발행여부, 정렬) ===== - const tableHeaderActions = ( -
- {/* 거래처 필터 */} - - - {/* 매출유형 필터 */} - - - {/* 발행여부 필터 */} - - - {/* 정렬 */} - -
- ); + }, [selectedAccountSubject]); // ===== 테이블 합계 계산 ===== const tableTotals = useMemo(() => { - const totalSupplyAmount = filteredData.reduce((sum, item) => sum + item.totalSupplyAmount, 0); - const totalVat = filteredData.reduce((sum, item) => sum + item.totalVat, 0); - const totalAmount = filteredData.reduce((sum, item) => sum + item.totalAmount, 0); + const totalSupplyAmount = salesData.reduce((sum, item) => sum + item.totalSupplyAmount, 0); + const totalVat = salesData.reduce((sum, item) => sum + item.totalVat, 0); + const totalAmount = salesData.reduce((sum, item) => sum + item.totalAmount, 0); return { totalSupplyAmount, totalVat, totalAmount }; - }, [filteredData]); + }, [salesData]); - // ===== 테이블 하단 합계 행 ===== - const tableFooter = ( - - - - 합계 - - - {tableTotals.totalSupplyAmount.toLocaleString()} - {tableTotals.totalVat.toLocaleString()} - {tableTotals.totalAmount.toLocaleString()} - - - - - - ); + // ===== UniversalListPage Config ===== + const config: UniversalListConfig = useMemo( + () => ({ + // 페이지 기본 정보 + title: '매출관리', + description: '매출 내역을 등록하고 관리합니다', + icon: Receipt, + basePath: '/accounting/sales', - // ===== 상단 계정과목명 + 저장 버튼 ===== - const accountSubjectSelector = ( -
- 계정과목명 - - -
+ // ID 추출 + idField: 'id', + + // API 액션 + actions: { + getList: async () => { + return { + success: true, + data: initialData, + totalCount: initialData.length, + }; + }, + deleteItem: async (id: string) => { + const result = await deleteSale(id); + if (result.success) { + setSalesData(prev => prev.filter(item => item.id !== id)); + toast.success('매출이 삭제되었습니다.'); + } + return { success: result.success, error: result.error }; + }, + }, + + // 테이블 컬럼 + columns: tableColumns, + + // 클라이언트 사이드 필터링 + clientSideFiltering: true, + itemsPerPage: 20, + + // 데이터 변경 콜백 + onDataChange: (data) => setSalesData(data), + + // 검색 필터 + searchPlaceholder: '매출번호, 거래처명, 비고 검색...', + searchFilter: (item, searchValue) => { + const search = searchValue.toLowerCase(); + return ( + item.salesNo.toLowerCase().includes(search) || + item.vendorName.toLowerCase().includes(search) || + item.note.toLowerCase().includes(search) + ); + }, + + // 커스텀 필터 함수 (filterConfig 사용) + customFilterFn: (items, fv) => { + return items.filter((item) => { + const vendorVal = fv.vendor as string; + const salesTypeVal = fv.salesType as string; + const issuanceVal = fv.issuance as string; + + // 거래처 필터 + if (vendorVal !== 'all' && item.vendorName !== vendorVal) { + return false; + } + // 매출유형 필터 + if (salesTypeVal !== 'all' && item.salesType !== salesTypeVal) { + return false; + } + // 발행여부 필터 + if (issuanceVal === 'taxInvoicePending' && item.taxInvoiceIssued) { + return false; + } + if (issuanceVal === 'transactionStatementPending' && item.transactionStatementIssued) { + return false; + } + return true; + }); + }, + + // 커스텀 정렬 함수 + customSortFn: (items, fv) => { + const sorted = [...items]; + const sortVal = fv.sort as string; + switch (sortVal) { + case 'oldest': + sorted.sort((a, b) => new Date(a.salesDate).getTime() - new Date(b.salesDate).getTime()); + break; + case 'amountHigh': + sorted.sort((a, b) => b.totalAmount - a.totalAmount); + break; + case 'amountLow': + sorted.sort((a, b) => a.totalAmount - b.totalAmount); + break; + default: // latest + sorted.sort((a, b) => new Date(b.salesDate).getTime() - new Date(a.salesDate).getTime()); + break; + } + return sorted; + }, + + // 공통 헤더 옵션 + dateRangeSelector: { + enabled: true, + startDate, + endDate, + onStartDateChange: setStartDate, + onEndDateChange: setEndDate, + }, + createButton: { + label: '매출 등록', + onClick: handleCreate, + }, + + // Stats 카드 + computeStats: (): StatCard[] => [ + { label: '총 매출', value: `${stats.totalSalesAmount.toLocaleString()}원`, icon: Receipt, iconColor: 'text-blue-500' }, + { label: '당월 매출', value: `${stats.monthlyAmount.toLocaleString()}원`, icon: Receipt, iconColor: 'text-green-500' }, + { label: '세금계산서 발행대기', value: `${stats.taxInvoicePendingCount}건`, icon: Receipt, iconColor: 'text-orange-500' }, + { label: '거래명세서 발행대기', value: `${stats.transactionStatementPendingCount}건`, icon: Receipt, iconColor: 'text-orange-500' }, + ], + + // 필터 설정 (filterConfig 사용 - PC 인라인, 모바일 바텀시트) + filterConfig, + initialFilters: filterValues, + filterTitle: '매출 필터', + + // beforeTableContent: 계정과목명 Select + 저장 버튼 (테이블 밖에 위치) + beforeTableContent: ({ selectedItems }) => ( +
+ 계정과목명 + + +
+ ), + + // 테이블 하단 합계 행 + tableFooter: ( + + + + 합계 + + + {tableTotals.totalSupplyAmount.toLocaleString()} + {tableTotals.totalVat.toLocaleString()} + {tableTotals.totalAmount.toLocaleString()} + + + + + + ), + + // 삭제 확인 메시지 + deleteConfirmMessage: { + title: '매출 삭제', + description: '이 매출 내역을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.', + }, + + // 테이블 행 렌더링 + renderTableRow: ( + item: SalesRecord, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => ( + handleRowClick(item)} + > + e.stopPropagation()}> + + + {globalIndex} + {item.salesNo} + {item.salesDate} + {item.vendorName} + {item.totalSupplyAmount.toLocaleString()} + {item.totalVat.toLocaleString()} + {item.totalAmount.toLocaleString()} + + {SALES_TYPE_LABELS[item.salesType]} + + e.stopPropagation()}> +
+ handleTaxInvoiceToggle(item.id, checked)} + className="data-[state=checked]:bg-orange-500" + /> + {item.taxInvoiceIssued && 발행} +
+
+ e.stopPropagation()}> +
+ handleTransactionStatementToggle(item.id, checked)} + className="data-[state=checked]:bg-orange-500" + /> + {item.transactionStatementIssued && 발행} +
+
+ e.stopPropagation()}> + {handlers.isSelected && ( +
+ + +
+ )} +
+
+ ), + + // 모바일 카드 렌더링 + renderMobileCard: ( + item: SalesRecord, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => ( + handleRowClick(item)} + details={[ + { label: '매출일', value: item.salesDate }, + { label: '매출금액', value: `${item.totalAmount.toLocaleString()}원` }, + { label: '미수금액', value: item.outstandingAmount > 0 ? `${item.outstandingAmount.toLocaleString()}원` : '-' }, + ]} + actions={ + handlers.isSelected ? ( +
+ + +
+ ) : undefined + } + /> + ), + }), + [ + initialData, + startDate, + endDate, + stats, + filterConfig, + filterValues, + selectedAccountSubject, + tableTotals, + handleRowClick, + handleEdit, + handleCreate, + handleTaxInvoiceToggle, + handleTransactionStatementToggle, + handleSaveAccountSubject, + ] ); return ( <> - item.id} - renderTableRow={renderTableRow} - renderMobileCard={renderMobileCard} - pagination={{ - currentPage, - totalPages, - totalItems: filteredData.length, - itemsPerPage, - onPageChange: setCurrentPage, - }} - /> + {/* 계정과목명 저장 확인 다이얼로그 */} @@ -628,7 +558,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem 계정과목명 변경 - {selectedItems.size}개의 매출유형을{' '} + {selectedItemsForSave.size}개의 매출유형을{' '} {ACCOUNT_SUBJECT_SELECTOR_OPTIONS.find(o => o.value === selectedAccountSubject)?.label} @@ -648,27 +578,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem - - {/* 삭제 확인 다이얼로그 */} - - - - 매출 삭제 - - 이 매출 내역을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다. - - - - 취소 - - 삭제 - - - - ); } \ No newline at end of file diff --git a/src/components/accounting/VendorLedger/index.tsx b/src/components/accounting/VendorLedger/index.tsx index b80865c0..3298bf00 100644 --- a/src/components/accounting/VendorLedger/index.tsx +++ b/src/components/accounting/VendorLedger/index.tsx @@ -1,5 +1,15 @@ 'use client'; +/** + * 거래처원장 - UniversalListPage 마이그레이션 + * + * 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환 + * - 서버 사이드 필터링/페이지네이션 + * - dateRangeSelector + 엑셀 다운로드 버튼 (headerActions) + * - tableFooter: 합계 행 + * - Stats 카드 (API 통계) + */ + import { useState, useMemo, useCallback, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { format, startOfMonth, endOfMonth } from 'date-fns'; @@ -7,18 +17,30 @@ import { Download, FileText } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { TableRow, TableCell } from '@/components/ui/table'; +import { MobileCard } from '@/components/molecules/MobileCard'; import { - IntegratedListTemplateV2, - type TableColumn, + UniversalListPage, + type UniversalListConfig, + type SelectionHandlers, + type RowClickHandlers, type StatCard, -} from '@/components/templates/IntegratedListTemplateV2'; -import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; -import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; +} from '@/components/templates/UniversalListPage'; import type { VendorLedgerItem, VendorLedgerSummary } from './types'; import { getVendorLedgerList, getVendorLedgerSummary, exportVendorLedgerExcel } from './actions'; import { toast } from 'sonner'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; +// ===== 테이블 컬럼 정의 ===== +const tableColumns = [ + { key: 'no', label: 'No.', className: 'text-center w-[60px]' }, + { key: 'vendorName', label: '거래처명' }, + { key: 'carryoverBalance', label: '이월잔액', className: 'text-right w-[120px]' }, + { key: 'sales', label: '매출', className: 'text-right w-[120px]' }, + { key: 'collection', label: '수금', className: 'text-right w-[120px]' }, + { key: 'balance', label: '잔액', className: 'text-right w-[120px]' }, + { key: 'paymentDate', label: '결제일', className: 'text-center w-[100px]' }, +]; + // ===== Props ===== interface VendorLedgerProps { initialData?: VendorLedgerItem[]; @@ -38,15 +60,7 @@ export function VendorLedger({ }: VendorLedgerProps) { const router = useRouter(); - // ===== 상태 관리 ===== - const [searchQuery, setSearchQuery] = useState(''); - const [startDate, setStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd')); - const [endDate, setEndDate] = useState(() => format(endOfMonth(new Date()), 'yyyy-MM-dd')); - const [selectedItems, setSelectedItems] = useState>(new Set()); - const [currentPage, setCurrentPage] = useState(initialPagination?.currentPage || 1); - const [isLoading, setIsLoading] = useState(false); - - // 데이터 상태 + // ===== 외부 상태 (UniversalListPage 외부에서 관리) ===== const [data, setData] = useState(initialData); const [summary, setSummary] = useState( initialSummary || { carryoverBalance: 0, totalSales: 0, totalCollection: 0, balance: 0 } @@ -55,6 +69,13 @@ export function VendorLedger({ initialPagination || { currentPage: 1, lastPage: 1, perPage: 20, total: 0 } ); + // 필터 상태 + const [searchQuery, setSearchQuery] = useState(''); + const [startDate, setStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd')); + const [endDate, setEndDate] = useState(() => format(endOfMonth(new Date()), 'yyyy-MM-dd')); + const [currentPage, setCurrentPage] = useState(initialPagination?.currentPage || 1); + const [isLoading, setIsLoading] = useState(false); + // ===== 데이터 로드 ===== const loadData = useCallback(async () => { setIsLoading(true); @@ -94,25 +115,6 @@ export function VendorLedger({ loadData(); }, [loadData]); - // ===== 체크박스 핸들러 ===== - const toggleSelection = useCallback((id: string) => { - setSelectedItems(prev => { - const newSet = new Set(prev); - if (newSet.has(id)) newSet.delete(id); - else newSet.add(id); - return newSet; - }); - }, []); - - // ===== 전체 선택 핸들러 ===== - const toggleSelectAll = useCallback(() => { - if (selectedItems.size === data.length && data.length > 0) { - setSelectedItems(new Set()); - } else { - setSelectedItems(new Set(data.map(item => item.id))); - } - }, [selectedItems.size, data]); - // ===== 합계 계산 ===== const totals = useMemo(() => { return data.reduce( @@ -126,15 +128,17 @@ export function VendorLedger({ ); }, [data]); - // ===== 액션 핸들러 ===== - const handleRowClick = useCallback((item: VendorLedgerItem) => { - // 상세 페이지로 이동 시 날짜 범위 파라미터도 전달 - const params = new URLSearchParams(); - if (startDate) params.set('start_date', startDate); - if (endDate) params.set('end_date', endDate); - const queryString = params.toString(); - router.push(`/ko/accounting/vendor-ledger/${item.id}${queryString ? `?${queryString}` : ''}`); - }, [router, startDate, endDate]); + // ===== 핸들러 ===== + const handleRowClick = useCallback( + (item: VendorLedgerItem) => { + const params = new URLSearchParams(); + if (startDate) params.set('start_date', startDate); + if (endDate) params.set('end_date', endDate); + const queryString = params.toString(); + router.push(`/ko/accounting/vendor-ledger/${item.id}${queryString ? `?${queryString}` : ''}`); + }, + [router, startDate, endDate] + ); const handleExcelDownload = useCallback(async () => { const result = await exportVendorLedgerExcel({ @@ -164,160 +168,189 @@ export function VendorLedger({ return amount.toLocaleString(); }; - // ===== 통계 카드 ===== - const statCards: StatCard[] = useMemo(() => [ - { label: '전기 이월', value: `${summary.carryoverBalance.toLocaleString()}원`, icon: FileText, iconColor: 'text-blue-500' }, - { label: '매출', value: `${summary.totalSales.toLocaleString()}원`, icon: FileText, iconColor: 'text-green-500' }, - { label: '수금', value: `${summary.totalCollection.toLocaleString()}원`, icon: FileText, iconColor: 'text-orange-500' }, - { label: '잔액', value: `${summary.balance.toLocaleString()}원`, icon: FileText, iconColor: 'text-red-500' }, - ], [summary]); + // ===== UniversalListPage Config ===== + const config: UniversalListConfig = useMemo( + () => ({ + // 페이지 기본 정보 + title: '거래처원장', + description: '거래처별 기간 내역을 조회합니다.', + icon: FileText, + basePath: '/accounting/vendor-ledger', - // ===== 테이블 컬럼 ===== - const tableColumns: TableColumn[] = useMemo(() => [ - { key: 'no', label: 'No.', className: 'text-center w-[60px]' }, - { key: 'vendorName', label: '거래처명' }, - { key: 'carryoverBalance', label: '이월잔액', className: 'text-right w-[120px]' }, - { key: 'sales', label: '매출', className: 'text-right w-[120px]' }, - { key: 'collection', label: '수금', className: 'text-right w-[120px]' }, - { key: 'balance', label: '잔액', className: 'text-right w-[120px]' }, - { key: 'paymentDate', label: '결제일', className: 'text-center w-[100px]' }, - ], []); + // ID 추출 + idField: 'id', - // ===== 테이블 행 렌더링 ===== - const renderTableRow = useCallback((item: VendorLedgerItem, index: number, globalIndex: number) => { - const isSelected = selectedItems.has(item.id); + // API 액션 + actions: { + getList: async () => { + return { + success: true, + data: data, + totalCount: pagination.total, + }; + }, + }, - return ( - handleRowClick(item)} - > - e.stopPropagation()}> - toggleSelection(item.id)} /> - - {/* No. */} - - {globalIndex} - - {/* 거래처명 */} - {item.vendorName} - {/* 이월잔액 */} - - {item.carryoverBalance !== 0 && ( - - {formatAmount(item.carryoverBalance)} + // 테이블 컬럼 + columns: tableColumns, + + // 서버 사이드 필터링 + clientSideFiltering: false, + itemsPerPage: 20, + isLoading, + + // 검색 + searchPlaceholder: '거래처명 검색...', + onSearchChange: setSearchQuery, + + // 날짜 선택기 + dateRangeSelector: { + enabled: true, + startDate, + endDate, + onStartDateChange: setStartDate, + onEndDateChange: setEndDate, + }, + + // 헤더 액션 (엑셀 다운로드) - 함수로 변환 + headerActions: () => ( + + ), + + // 테이블 푸터 (합계 행) + tableFooter: ( + + + 합계 + + {formatAmount(totals.carryoverBalance)} + {formatAmount(totals.sales)} + {formatAmount(totals.collection)} + {formatAmount(totals.balance)} + + + ), + + // Stats 카드 + computeStats: (): StatCard[] => [ + { + label: '전기 이월', + value: `${summary.carryoverBalance.toLocaleString()}원`, + icon: FileText, + iconColor: 'text-blue-500', + }, + { + label: '매출', + value: `${summary.totalSales.toLocaleString()}원`, + icon: FileText, + iconColor: 'text-green-500', + }, + { + label: '수금', + value: `${summary.totalCollection.toLocaleString()}원`, + icon: FileText, + iconColor: 'text-orange-500', + }, + { + label: '잔액', + value: `${summary.balance.toLocaleString()}원`, + icon: FileText, + iconColor: 'text-red-500', + }, + ], + + // 테이블 행 렌더링 + renderTableRow: ( + item: VendorLedgerItem, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => ( + handleRowClick(item)} + > + e.stopPropagation()}> + + + {/* No. */} + {globalIndex} + {/* 거래처명 */} + {item.vendorName} + {/* 이월잔액 */} + + {item.carryoverBalance !== 0 && ( + + {formatAmount(item.carryoverBalance)} + + )} + + {/* 매출 */} + {formatAmount(item.sales)} + {/* 수금 */} + {formatAmount(item.collection)} + {/* 잔액 */} + + + {formatAmount(item.balance)} - )} - - {/* 매출 */} - {formatAmount(item.sales)} - {/* 수금 */} - {formatAmount(item.collection)} - {/* 잔액 */} - - - {formatAmount(item.balance)} - - - {/* 결제일 */} - {item.paymentDate} - - ); - }, [selectedItems, toggleSelection, handleRowClick]); + + {/* 결제일 */} + {item.paymentDate} + + ), - // ===== 모바일 카드 렌더링 ===== - const renderMobileCard = useCallback(( - item: VendorLedgerItem, - index: number, - globalIndex: number, - isSelected: boolean, - onToggle: () => void - ) => { - return ( - - - - - - -
- } - onCardClick={() => handleRowClick(item)} - /> - ); - }, [handleRowClick]); - - // ===== 헤더 액션 ===== - const headerActions = ( - <> - - - - ); - - // ===== 테이블 하단 합계 행 ===== - const tableFooter = ( - - - 합계 - - {formatAmount(totals.carryoverBalance)} - {formatAmount(totals.sales)} - {formatAmount(totals.collection)} - {formatAmount(totals.balance)} - - + // 모바일 카드 렌더링 + renderMobileCard: ( + item: VendorLedgerItem, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => ( + handleRowClick(item)} + details={[ + { label: '이월잔액', value: formatAmount(item.carryoverBalance) || '-' }, + { label: '매출', value: formatAmount(item.sales) || '-' }, + { label: '수금', value: formatAmount(item.collection) || '-' }, + { label: '잔액', value: formatAmount(item.balance) || '-' }, + ]} + /> + ), + }), + [ + data, + pagination, + summary, + totals, + startDate, + endDate, + isLoading, + handleRowClick, + handleExcelDownload, + ] ); return ( - item.id} - renderTableRow={renderTableRow} - renderMobileCard={renderMobileCard} - pagination={{ - currentPage: pagination.currentPage, + ); -} +} \ No newline at end of file diff --git a/src/components/accounting/VendorManagement/VendorManagementClient.tsx b/src/components/accounting/VendorManagement/VendorManagementClient.tsx index c4fd5038..98e0cc05 100644 --- a/src/components/accounting/VendorManagement/VendorManagementClient.tsx +++ b/src/components/accounting/VendorManagement/VendorManagementClient.tsx @@ -1,5 +1,14 @@ 'use client'; +/** + * 거래처관리 - UniversalListPage 마이그레이션 + * + * IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환 + * - 클라이언트 사이드 필터링/페이지네이션 + * - computeStats: 통계 카드 (전체/매출/매입 거래처) + * - tableHeaderActions: 5개 필터 (구분, 신용등급, 거래등급, 악성채권, 정렬) + */ + import { useState, useMemo, useCallback } from 'react'; import { useRouter } from 'next/navigation'; import { @@ -30,10 +39,13 @@ import { SelectValue, } from '@/components/ui/select'; import { - IntegratedListTemplateV2, + UniversalListPage, + type UniversalListConfig, type TableColumn, type StatCard, -} from '@/components/templates/IntegratedListTemplateV2'; + type SelectionHandlers, + type RowClickHandlers, +} from '@/components/templates/UniversalListPage'; import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; import { toast } from 'sonner'; import { deleteClient } from './actions'; @@ -231,9 +243,12 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana ], []); // ===== 테이블 행 렌더링 ===== - const renderTableRow = useCallback((item: Vendor, index: number, globalIndex: number) => { - const isSelected = selectedItems.has(item.id); - + const renderTableRow = useCallback(( + item: Vendor, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => { return ( handleRowClick(item)} > e.stopPropagation()}> - toggleSelection(item.id)} /> + {/* 번호 */} {globalIndex} @@ -289,7 +304,7 @@ export function VendorManagementClient({ initialData, initialTotal }: VendorMana {/* 작업 */} e.stopPropagation()}> - {isSelected && ( + {handlers.isSelected && (
- -
- )} -
-
- ); - }, [selectedItems, toggleSelection, handleRowClick, handleEdit, handleDeleteClick]); - - // ===== 모바일 카드 렌더링 ===== - const renderMobileCard = useCallback(( - item: Vendor, - index: number, - globalIndex: number, - isSelected: boolean, - onToggle: () => void - ) => { - return ( - - - {VENDOR_CATEGORY_LABELS[item.category]} - - - {item.creditRating} - - - } - isSelected={isSelected} - onToggleSelection={onToggle} - infoGrid={ -
- - - 0 ? `${item.outstandingAmount.toLocaleString()}원` : '-'} - className={item.outstandingAmount > 0 ? 'text-red-600' : ''} - /> - -
- } - actions={ - isSelected ? ( -
- - - -
- ) : undefined - } - onClick={() => handleRowClick(item)} - /> - ); - }, [handleRowClick, handleEdit, handleDeleteClick]); - - // ===== filterConfig 방식 모바일 필터 ===== - const filterConfig: FilterFieldConfig[] = useMemo(() => [ - { - key: 'category', - label: '구분', - type: 'single', - options: VENDOR_CATEGORY_OPTIONS.filter(o => o.value !== 'all').map(o => ({ - value: o.value, - label: o.label, - })), - allOptionLabel: '전체', + // ===== 핸들러 ===== + const handleRowClick = useCallback( + (vendor: Vendor) => { + router.push(`/ko/accounting/vendors/${vendor.id}`); }, - { - key: 'creditRating', - label: '신용등급', - type: 'single', - options: CREDIT_RATING_OPTIONS.filter(o => o.value !== 'all').map(o => ({ - value: o.value, - label: o.label, - })), - allOptionLabel: '전체', - }, - { - key: 'transactionGrade', - label: '거래등급', - type: 'single', - options: TRANSACTION_GRADE_OPTIONS.filter(o => o.value !== 'all').map(o => ({ - value: o.value, - label: o.label, - })), - allOptionLabel: '전체', - }, - { - key: 'badDebt', - label: '악성채권', - type: 'single', - options: BAD_DEBT_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({ - value: o.value, - label: o.label, - })), - allOptionLabel: '전체', - }, - { - key: 'sort', - label: '정렬', - type: 'single', - options: SORT_OPTIONS.map(o => ({ - value: o.value, - label: o.label, - })), - }, - ], []); - - const filterValues: FilterValues = useMemo(() => ({ - category: categoryFilter, - creditRating: creditRatingFilter, - transactionGrade: transactionGradeFilter, - badDebt: badDebtFilter, - sort: sortOption, - }), [categoryFilter, creditRatingFilter, transactionGradeFilter, badDebtFilter, sortOption]); - - const handleFilterChange = useCallback((key: string, value: string | string[]) => { - switch (key) { - case 'category': - setCategoryFilter(value as string); - break; - case 'creditRating': - setCreditRatingFilter(value as string); - break; - case 'transactionGrade': - setTransactionGradeFilter(value as string); - break; - case 'badDebt': - setBadDebtFilter(value as string); - break; - case 'sort': - setSortOption(value as SortOption); - break; - } - setCurrentPage(1); - }, []); - - const handleFilterReset = useCallback(() => { - setCategoryFilter('all'); - setCreditRatingFilter('all'); - setTransactionGradeFilter('all'); - setBadDebtFilter('all'); - setSortOption('latest'); - setCurrentPage(1); - }, []); - - return ( - <> - item.id} - renderTableRow={renderTableRow} - renderMobileCard={renderMobileCard} - pagination={{ - currentPage, - totalPages, - totalItems: filteredData.length, - itemsPerPage, - onPageChange: setCurrentPage, - }} - /> - - {/* 삭제 확인 다이얼로그 */} - - - - 거래처 삭제 - - 이 거래처를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다. - - - - 취소 - - 삭제 - - - - - + [router] ); -} \ No newline at end of file + + const handleEdit = useCallback( + (vendor: Vendor) => { + router.push(`/ko/accounting/vendors/${vendor.id}?mode=edit`); + }, + [router] + ); + + // ===== UniversalListPage Config ===== + const config: UniversalListConfig = useMemo( + () => ({ + // 페이지 기본 정보 + title: '거래처관리', + description: '거래처 정보를 등록하고 관리합니다', + icon: Building2, + basePath: '/accounting/vendors', + + // ID 추출 + idField: 'id', + + // API 액션 + actions: { + getList: async () => { + // 이미 initialData로 로드됨, 클라이언트 사이드 필터링 + return { + success: true, + data: initialData, + totalCount: initialData.length, + }; + }, + deleteItem: async (id: string) => { + const result = await deleteClient(id); + if (result.success) { + toast.success('거래처가 삭제되었습니다.'); + } + return { success: result.success, error: result.error }; + }, + }, + + // 테이블 컬럼 + columns: tableColumns, + + // 클라이언트 사이드 필터링 + clientSideFiltering: true, + itemsPerPage: 20, + + // 데이터 변경 콜백 (Stats 계산용) + onDataChange: (data) => setVendorData(data), + + // 검색 필터 + searchPlaceholder: '거래처명, 거래처코드, 사업자번호 검색...', + searchFilter: (item, searchValue) => { + const search = searchValue.toLowerCase(); + return ( + item.vendorName.toLowerCase().includes(search) || + item.vendorCode.toLowerCase().includes(search) || + item.businessNumber.toLowerCase().includes(search) + ); + }, + + // 필터 설정 (5개 single 필터) + filterConfig: [ + { + key: 'category', + label: '구분', + type: 'single', + options: VENDOR_CATEGORY_OPTIONS.filter(o => o.value !== 'all').map(o => ({ + value: o.value, + label: o.label, + })), + }, + { + key: 'creditRating', + label: '신용등급', + type: 'single', + options: CREDIT_RATING_OPTIONS.filter(o => o.value !== 'all').map(o => ({ + value: o.value, + label: o.label, + })), + }, + { + key: 'transactionGrade', + label: '거래등급', + type: 'single', + options: TRANSACTION_GRADE_OPTIONS.filter(o => o.value !== 'all').map(o => ({ + value: o.value, + label: o.label, + })), + }, + { + key: 'badDebt', + label: '악성채권', + type: 'single', + options: BAD_DEBT_STATUS_OPTIONS.filter(o => o.value !== 'all').map(o => ({ + value: o.value, + label: o.label, + })), + }, + { + key: 'sortBy', + label: '정렬', + type: 'single', + options: SORT_OPTIONS.map(o => ({ + value: o.value, + label: o.label, + })), + }, + ], + initialFilters: { + category: 'all', + creditRating: 'all', + transactionGrade: 'all', + badDebt: 'all', + sortBy: 'latest', + }, + filterTitle: '거래처 필터', + + // 커스텀 필터 함수 + customFilterFn: (items, filterValues) => { + return items.filter((item) => { + // 구분 필터 + const categoryFilter = filterValues.category as string; + if (categoryFilter && categoryFilter !== 'all' && item.category !== categoryFilter) { + return false; + } + + // 신용등급 필터 + const creditRatingFilter = filterValues.creditRating as string; + if (creditRatingFilter && creditRatingFilter !== 'all' && item.creditRating !== creditRatingFilter) { + return false; + } + + // 거래등급 필터 + const transactionGradeFilter = filterValues.transactionGrade as string; + if (transactionGradeFilter && transactionGradeFilter !== 'all' && item.transactionGrade !== transactionGradeFilter) { + return false; + } + + // 악성채권 필터 + const badDebtFilter = filterValues.badDebt as string; + if (badDebtFilter && badDebtFilter !== 'all' && item.badDebtStatus !== badDebtFilter) { + return false; + } + + return true; + }); + }, + + // 커스텀 정렬 함수 + customSortFn: (items, filterValues) => { + const sorted = [...items]; + const sortBy = (filterValues.sortBy as SortOption) || 'latest'; + + switch (sortBy) { + case 'oldest': + sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()); + break; + case 'nameAsc': + sorted.sort((a, b) => a.vendorName.localeCompare(b.vendorName)); + break; + case 'nameDesc': + sorted.sort((a, b) => b.vendorName.localeCompare(a.vendorName)); + break; + case 'outstandingHigh': + sorted.sort((a, b) => b.outstandingAmount - a.outstandingAmount); + break; + case 'outstandingLow': + sorted.sort((a, b) => a.outstandingAmount - b.outstandingAmount); + break; + default: // latest + sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); + break; + } + return sorted; + }, + + // Stats 카드 + computeStats: (): StatCard[] => [ + { + label: '전체 거래처', + value: `${stats.totalCount}개`, + icon: Building2, + iconColor: 'text-blue-500', + }, + { + label: '매출 거래처', + value: `${stats.salesCount}개`, + icon: Building2, + iconColor: 'text-green-500', + }, + { + label: '매입 거래처', + value: `${stats.purchaseCount}개`, + icon: Building2, + iconColor: 'text-orange-500', + }, + ], + + // 삭제 확인 메시지 + deleteConfirmMessage: { + title: '거래처 삭제', + description: '이 거래처를 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.', + }, + + // 테이블 행 렌더링 + renderTableRow: ( + vendor: Vendor, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => ( + handleRowClick(vendor)} + > + e.stopPropagation()}> + + + {/* 번호 */} + {globalIndex} + {/* 구분 */} + + + {VENDOR_CATEGORY_LABELS[vendor.category]} + + + {/* 거래처명 */} + {vendor.vendorName} + {/* 매입 결제일 */} + {vendor.purchasePaymentDay}일 + {/* 매출 결제일 */} + {vendor.salesPaymentDay}일 + {/* 신용등급 */} + + + {vendor.creditRating} + + + {/* 거래등급 */} + + + {TRANSACTION_GRADE_LABELS[vendor.transactionGrade]} + + + {/* 미수금 */} + + {vendor.outstandingAmount > 0 ? ( + {vendor.outstandingAmount.toLocaleString()}원 + ) : ( + - + )} + + {/* 악성채권 */} + + {vendor.badDebtStatus === 'none' ? ( + - + ) : ( + + {BAD_DEBT_STATUS_LABELS[vendor.badDebtStatus]} + + )} + + {/* 작업 */} + e.stopPropagation()}> + {handlers.isSelected && ( +
+ + +
+ )} +
+
+ ), + + // 모바일 카드 렌더링 + renderMobileCard: ( + vendor: Vendor, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => ( + handleRowClick(vendor)} + details={[ + { label: '거래등급', value: TRANSACTION_GRADE_LABELS[vendor.transactionGrade] }, + { + label: '미수금', + value: vendor.outstandingAmount > 0 ? `${vendor.outstandingAmount.toLocaleString()}원` : '-', + }, + { label: '결제일', value: `매입 ${vendor.purchasePaymentDay}일 / 매출 ${vendor.salesPaymentDay}일` }, + ]} + actions={ + handlers.isSelected ? ( +
+ + + +
+ ) : undefined + } + /> + ), + }), + [initialData, stats, handleRowClick, handleEdit] + ); + + return ; +} diff --git a/src/components/accounting/WithdrawalManagement/index.tsx b/src/components/accounting/WithdrawalManagement/index.tsx index 5bcb8856..2c518aa4 100644 --- a/src/components/accounting/WithdrawalManagement/index.tsx +++ b/src/components/accounting/WithdrawalManagement/index.tsx @@ -1,8 +1,19 @@ 'use client'; +/** + * 출금관리 - UniversalListPage 마이그레이션 + * + * 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환 + * - 클라이언트 사이드 필터링 (검색, 필터, 정렬) + * - Stats 카드 (총 출금, 당월 출금, 거래처 미설정, 출금유형 미설정) + * - DateRangeSelector + beforeTableContent (계정과목명 + 저장 + 새로고침) + * - tableHeaderActions (거래처, 출금유형, 정렬) + * - tableFooter (합계) + * - 삭제 기능 (deleteConfirmMessage) + */ + import { useState, useMemo, useCallback } from 'react'; import { useRouter } from 'next/navigation'; -import { format } from 'date-fns'; import { Banknote, Pencil, @@ -24,7 +35,6 @@ import { import { AlertDialog, AlertDialogAction, - AlertDialogCancel, AlertDialogContent, AlertDialogDescription, AlertDialogFooter, @@ -40,12 +50,13 @@ import { SelectValue, } from '@/components/ui/select'; import { - IntegratedListTemplateV2, - type TableColumn, + UniversalListPage, + type UniversalListConfig, + type SelectionHandlers, + type RowClickHandlers, type StatCard, -} from '@/components/templates/IntegratedListTemplateV2'; -import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; -import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; +} from '@/components/templates/UniversalListPage'; +import { MobileCard } from '@/components/molecules/MobileCard'; import type { WithdrawalRecord, SortOption, @@ -59,6 +70,18 @@ import { import { deleteWithdrawal, updateWithdrawalTypes, getWithdrawals } from './actions'; import { toast } from 'sonner'; +// ===== 테이블 컬럼 정의 ===== +const tableColumns = [ + { key: 'withdrawalDate', label: '출금일' }, + { key: 'accountName', label: '출금계좌' }, + { key: 'recipientName', label: '수취인명' }, + { key: 'withdrawalAmount', label: '출금금액', className: 'text-right' }, + { key: 'vendorName', label: '거래처' }, + { key: 'note', label: '적요' }, + { key: 'withdrawalType', label: '출금유형', className: 'text-center' }, + { key: 'actions', label: '작업', className: 'text-center w-[80px]' }, +]; + // ===== 컴포넌트 Props ===== interface WithdrawalManagementProps { initialData: WithdrawalRecord[]; @@ -73,132 +96,69 @@ interface WithdrawalManagementProps { export function WithdrawalManagement({ initialData, initialPagination }: WithdrawalManagementProps) { const router = useRouter(); - // ===== 상태 관리 ===== - const [searchQuery, setSearchQuery] = useState(''); - const [sortOption, setSortOption] = useState('latest'); - const [withdrawalTypeFilter, setWithdrawalTypeFilter] = useState('all'); + // ===== 외부 상태 (UniversalListPage 외부에서 관리) ===== + const [withdrawalData, setWithdrawalData] = useState(initialData); + + // 날짜 범위 + const [startDate, setStartDate] = useState('2025-09-01'); + const [endDate, setEndDate] = useState('2025-09-03'); + + // 인라인 필터 상태 const [vendorFilter, setVendorFilter] = useState('all'); - const [selectedItems, setSelectedItems] = useState>(new Set()); - const [currentPage, setCurrentPage] = useState(1); - const itemsPerPage = 20; + const [withdrawalTypeFilter, setWithdrawalTypeFilter] = useState('all'); + const [sortOption, setSortOption] = useState('latest'); // 상단 계정과목명 선택 (저장용) const [selectedAccountSubject, setSelectedAccountSubject] = useState('unset'); - // 계정과목명 저장 다이얼로그 - const [showSaveDialog, setShowSaveDialog] = useState(false); - - // 삭제 다이얼로그 - const [showDeleteDialog, setShowDeleteDialog] = useState(false); - const [deleteTargetId, setDeleteTargetId] = useState(null); - - // 선택 필요 알림 다이얼로그 - const [showSelectWarningDialog, setShowSelectWarningDialog] = useState(false); - - // 날짜 범위 상태 - const [startDate, setStartDate] = useState('2025-09-01'); - const [endDate, setEndDate] = useState('2025-09-03'); - - // 출금 데이터 (서버에서 전달받은 initialData 사용) - const [data, setData] = useState(initialData); - // 로딩 상태 const [isRefreshing, setIsRefreshing] = useState(false); - // ===== 체크박스 핸들러 ===== - const toggleSelection = useCallback((id: string) => { - setSelectedItems(prev => { - const newSet = new Set(prev); - if (newSet.has(id)) newSet.delete(id); - else newSet.add(id); - return newSet; - }); - }, []); + // 다이얼로그 상태 + const [showSaveDialog, setShowSaveDialog] = useState(false); + const [saveTargetIds, setSaveTargetIds] = useState([]); + const [showSelectWarningDialog, setShowSelectWarningDialog] = useState(false); - // ===== 필터링된 데이터 ===== - const filteredData = useMemo(() => { - let result = data.filter(item => - item.recipientName.includes(searchQuery) || - item.accountName.includes(searchQuery) || - item.note.includes(searchQuery) || - item.vendorName.includes(searchQuery) - ); + // ===== Stats 계산 ===== + const stats = useMemo(() => { + const totalWithdrawal = withdrawalData.reduce((sum, d) => sum + (d.withdrawalAmount ?? 0), 0); - // 거래처 필터 - if (vendorFilter !== 'all') { - result = result.filter(item => item.vendorName === vendorFilter); - } + // 당월 출금 + const currentMonth = new Date().getMonth(); + const currentYear = new Date().getFullYear(); + const monthlyWithdrawal = withdrawalData + .filter(d => { + const date = new Date(d.withdrawalDate); + return date.getMonth() === currentMonth && date.getFullYear() === currentYear; + }) + .reduce((sum, d) => sum + (d.withdrawalAmount ?? 0), 0); - // 출금 유형 필터 - if (withdrawalTypeFilter !== 'all') { - result = result.filter(item => item.withdrawalType === withdrawalTypeFilter); - } + // 거래처 미설정 건수 + const vendorUnsetCount = withdrawalData.filter(d => !d.vendorName).length; - // 정렬 - switch (sortOption) { - case 'latest': - result.sort((a, b) => new Date(b.withdrawalDate).getTime() - new Date(a.withdrawalDate).getTime()); - break; - case 'oldest': - result.sort((a, b) => new Date(a.withdrawalDate).getTime() - new Date(b.withdrawalDate).getTime()); - break; - case 'amountHigh': - result.sort((a, b) => b.withdrawalAmount - a.withdrawalAmount); - break; - case 'amountLow': - result.sort((a, b) => a.withdrawalAmount - b.withdrawalAmount); - break; - } + // 출금유형 미설정 건수 + const withdrawalTypeUnsetCount = withdrawalData.filter(d => d.withdrawalType === 'unset').length; - return result; - }, [data, searchQuery, vendorFilter, withdrawalTypeFilter, sortOption]); + return { totalWithdrawal, monthlyWithdrawal, vendorUnsetCount, withdrawalTypeUnsetCount }; + }, [withdrawalData]); - const paginatedData = useMemo(() => { - const startIndex = (currentPage - 1) * itemsPerPage; - return filteredData.slice(startIndex, startIndex + itemsPerPage); - }, [filteredData, currentPage, itemsPerPage]); + // 거래처 목록 (필터용) + const vendorOptions = useMemo(() => { + const uniqueVendors = [...new Set(withdrawalData.map(d => d.vendorName).filter(v => v))]; + return [ + { value: 'all', label: '전체' }, + ...uniqueVendors.map(v => ({ value: v, label: v })) + ]; + }, [withdrawalData]); - const totalPages = Math.ceil(filteredData.length / itemsPerPage); - - // ===== 전체 선택 핸들러 ===== - const toggleSelectAll = useCallback(() => { - if (selectedItems.size === filteredData.length && filteredData.length > 0) { - setSelectedItems(new Set()); - } else { - setSelectedItems(new Set(filteredData.map(item => item.id))); - } - }, [selectedItems.size, filteredData]); - - // ===== 액션 핸들러 ===== + // ===== 핸들러 ===== const handleRowClick = useCallback((item: WithdrawalRecord) => { router.push(`/ko/accounting/withdrawals/${item.id}`); }, [router]); - // 개별 항목 삭제 핸들러 - const handleDeleteClick = useCallback((id: string) => { - setDeleteTargetId(id); - setShowDeleteDialog(true); - }, []); - - // 삭제 확정 핸들러 - const handleConfirmDelete = useCallback(async () => { - if (deleteTargetId) { - const result = await deleteWithdrawal(deleteTargetId); - if (result.success) { - toast.success('출금 내역이 삭제되었습니다.'); - setData(prev => prev.filter(item => item.id !== deleteTargetId)); - setSelectedItems(prev => { - const newSet = new Set(prev); - newSet.delete(deleteTargetId); - return newSet; - }); - } else { - toast.error(result.error || '삭제에 실패했습니다.'); - } - } - setShowDeleteDialog(false); - setDeleteTargetId(null); - }, [deleteTargetId]); + const handleEdit = useCallback((item: WithdrawalRecord) => { + router.push(`/ko/accounting/withdrawals/${item.id}?mode=edit`); + }, [router]); // 새로고침 핸들러 const handleRefresh = useCallback(async () => { @@ -209,12 +169,9 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra startDate, endDate, withdrawalType: withdrawalTypeFilter !== 'all' ? withdrawalTypeFilter : undefined, - search: searchQuery || undefined, }); if (result.success) { - setData(result.data); - setSelectedItems(new Set()); - setCurrentPage(1); + setWithdrawalData(result.data); toast.success('데이터를 새로고침했습니다.'); } else { toast.error(result.error || '새로고침에 실패했습니다.'); @@ -224,340 +181,408 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra } finally { setIsRefreshing(false); } - }, [startDate, endDate, withdrawalTypeFilter, searchQuery]); + }, [startDate, endDate, withdrawalTypeFilter]); - // ===== 통계 카드 (총 출금, 당월 출금, 거래처 미설정, 출금유형 미설정) ===== - const statCards: StatCard[] = useMemo(() => { - const totalWithdrawal = data.reduce((sum, d) => sum + (d.withdrawalAmount ?? 0), 0); - - // 당월 출금 - const currentMonth = new Date().getMonth(); - const currentYear = new Date().getFullYear(); - const monthlyWithdrawal = data - .filter(d => { - const date = new Date(d.withdrawalDate); - return date.getMonth() === currentMonth && date.getFullYear() === currentYear; - }) - .reduce((sum, d) => sum + (d.withdrawalAmount ?? 0), 0); - - // 거래처 미설정 건수 - const vendorUnsetCount = data.filter(d => !d.vendorName).length; - - // 출금유형 미설정 건수 - const withdrawalTypeUnsetCount = data.filter(d => d.withdrawalType === 'unset').length; - - return [ - { label: '총 출금', value: `${totalWithdrawal.toLocaleString()}원`, icon: Banknote, iconColor: 'text-blue-500' }, - { label: '당월 출금', value: `${monthlyWithdrawal.toLocaleString()}원`, icon: Banknote, iconColor: 'text-green-500' }, - { label: '거래처 미설정', value: `${vendorUnsetCount}건`, icon: Banknote, iconColor: 'text-orange-500' }, - { label: '출금유형 미설정', value: `${withdrawalTypeUnsetCount}건`, icon: Banknote, iconColor: 'text-red-500' }, - ]; - }, [data]); - - // ===== 테이블 컬럼 ===== - const tableColumns: TableColumn[] = useMemo(() => [ - { key: 'withdrawalDate', label: '출금일' }, - { key: 'accountName', label: '출금계좌' }, - { key: 'recipientName', label: '수취인명' }, - { key: 'withdrawalAmount', label: '출금금액', className: 'text-right' }, - { key: 'vendorName', label: '거래처' }, - { key: 'note', label: '적요' }, - { key: 'withdrawalType', label: '출금유형', className: 'text-center' }, - { key: 'actions', label: '작업', className: 'text-center w-[80px]' }, - ], []); - - // ===== 테이블 행 렌더링 ===== - const renderTableRow = useCallback((item: WithdrawalRecord, index: number, globalIndex: number) => { - const isSelected = selectedItems.has(item.id); - const isVendorUnset = !item.vendorName; - const isWithdrawalTypeUnset = item.withdrawalType === 'unset'; - - return ( - handleRowClick(item)} - > - e.stopPropagation()}> - toggleSelection(item.id)} /> - - {/* 출금일 */} - {item.withdrawalDate} - {/* 출금계좌 */} - {item.accountName} - {/* 수취인명 */} - {item.recipientName} - {/* 출금금액 */} - {(item.withdrawalAmount ?? 0).toLocaleString()} - {/* 거래처 */} - - {item.vendorName || '미설정'} - - {/* 적요 */} - {item.note || '-'} - {/* 출금유형 */} - - - {WITHDRAWAL_TYPE_LABELS[item.withdrawalType]} - - - {/* 작업 */} - e.stopPropagation()}> - {isSelected && ( -
- - -
- )} -
-
- ); - }, [selectedItems, toggleSelection, handleRowClick, handleDeleteClick, router]); - - // ===== 모바일 카드 렌더링 ===== - const renderMobileCard = useCallback(( - item: WithdrawalRecord, - index: number, - globalIndex: number, - isSelected: boolean, - onToggle: () => void - ) => { - return ( - - {WITHDRAWAL_TYPE_LABELS[item.withdrawalType]} - - } - isSelected={isSelected} - onToggleSelection={onToggle} - infoGrid={ -
- - - - -
- } - actions={ - isSelected ? ( -
- - -
- ) : undefined - } - onCardClick={() => handleRowClick(item)} - /> - ); - }, [handleRowClick, handleDeleteClick]); - - // ===== 헤더 액션 ===== - const headerActions = ( - - ); - - // ===== 계정과목명 저장 핸들러 ===== - const handleSaveAccountSubject = useCallback(() => { - if (selectedItems.size === 0) { + // 계정과목명 저장 핸들러 + const handleSaveAccountSubject = useCallback((selectedItems: WithdrawalRecord[]) => { + if (selectedItems.length === 0) { setShowSelectWarningDialog(true); return; } + setSaveTargetIds(selectedItems.map(item => item.id)); setShowSaveDialog(true); - }, [selectedItems.size]); + }, []); // 계정과목명 저장 확정 const handleConfirmSaveAccountSubject = useCallback(async () => { - const ids = Array.from(selectedItems); - const result = await updateWithdrawalTypes(ids, selectedAccountSubject); + const result = await updateWithdrawalTypes(saveTargetIds, selectedAccountSubject); if (result.success) { toast.success('계정과목명이 저장되었습니다.'); - setData(prev => prev.map(item => - selectedItems.has(item.id) + setWithdrawalData(prev => prev.map(item => + saveTargetIds.includes(item.id) ? { ...item, withdrawalType: selectedAccountSubject as WithdrawalRecord['withdrawalType'] } : item )); - setSelectedItems(new Set()); } else { toast.error(result.error || '계정과목명 저장에 실패했습니다.'); } setShowSaveDialog(false); - }, [selectedAccountSubject, selectedItems]); + setSaveTargetIds([]); + }, [selectedAccountSubject, saveTargetIds]); - // ===== 거래처 목록 (필터용) ===== - const vendorOptions = useMemo(() => { - const uniqueVendors = [...new Set(data.map(d => d.vendorName).filter(v => v))]; - return [ - { value: 'all', label: '전체' }, - ...uniqueVendors.map(v => ({ value: v, label: v })) - ]; - }, [data]); + // ===== UniversalListPage Config ===== + const config: UniversalListConfig = useMemo( + () => ({ + // 페이지 기본 정보 + title: '출금관리', + description: '출금 내역을 등록합니다', + icon: Banknote, + basePath: '/accounting/withdrawals', - // ===== 테이블 헤더 액션 (필터들) ===== - const tableHeaderActions = ( -
- {/* 거래처 필터 */} - + // ID 추출 + idField: 'id', - {/* 출금유형 필터 */} - + // API 액션 + actions: { + getList: async () => { + return { + success: true, + data: initialData, + totalCount: initialData.length, + }; + }, + deleteItem: async (id: string) => { + const result = await deleteWithdrawal(id); + if (result.success) { + toast.success('출금 내역이 삭제되었습니다.'); + setWithdrawalData(prev => prev.filter(item => item.id !== id)); + } + return { success: result.success, error: result.error }; + }, + }, - {/* 정렬 */} - -
- ); + // 테이블 컬럼 + columns: tableColumns, - // ===== 상단 계정과목명 + 저장 버튼 + 새로고침 ===== - const accountSubjectSelector = ( -
-
- 계정과목명 - - -
- -
- ); + // 클라이언트 사이드 필터링 + clientSideFiltering: true, + itemsPerPage: 20, - // ===== 테이블 합계 계산 ===== - const tableTotals = useMemo(() => { - const totalAmount = filteredData.reduce((sum, item) => sum + (item.withdrawalAmount ?? 0), 0); - return { totalAmount }; - }, [filteredData]); + // 데이터 변경 콜백 (Stats 계산용) + onDataChange: (data) => setWithdrawalData(data), - // ===== 테이블 하단 합계 행 ===== - const tableFooter = ( - - - 합계 - - - {tableTotals.totalAmount.toLocaleString()} - - - - - + // 검색 필터 + searchPlaceholder: '수취인명, 계좌명, 적요, 거래처 검색...', + searchFilter: (item, searchValue) => { + const search = searchValue.toLowerCase(); + return ( + item.recipientName.toLowerCase().includes(search) || + item.accountName.toLowerCase().includes(search) || + item.note.toLowerCase().includes(search) || + item.vendorName.toLowerCase().includes(search) + ); + }, + + // 필터 설정 (모바일 필터 시트용) + filterConfig: [ + { + key: 'withdrawalType', + label: '출금유형', + type: 'single', + options: WITHDRAWAL_TYPE_FILTER_OPTIONS.filter(o => o.value !== 'all').map(o => ({ + value: o.value, + label: o.label, + })), + }, + { + key: 'sortBy', + label: '정렬', + type: 'single', + options: SORT_OPTIONS.map(o => ({ + value: o.value, + label: o.label, + })), + }, + ], + initialFilters: { + withdrawalType: 'all', + sortBy: 'latest', + }, + filterTitle: '출금 필터', + + // 커스텀 필터 함수 + customFilterFn: (items) => { + return items.filter((item) => { + // 거래처 필터 + if (vendorFilter !== 'all' && item.vendorName !== vendorFilter) { + return false; + } + + // 출금유형 필터 + if (withdrawalTypeFilter !== 'all' && item.withdrawalType !== withdrawalTypeFilter) { + return false; + } + + return true; + }); + }, + + // 커스텀 정렬 함수 + customSortFn: (items) => { + const sorted = [...items]; + switch (sortOption) { + case 'oldest': + sorted.sort((a, b) => new Date(a.withdrawalDate).getTime() - new Date(b.withdrawalDate).getTime()); + break; + case 'amountHigh': + sorted.sort((a, b) => b.withdrawalAmount - a.withdrawalAmount); + break; + case 'amountLow': + sorted.sort((a, b) => a.withdrawalAmount - b.withdrawalAmount); + break; + default: // latest + sorted.sort((a, b) => new Date(b.withdrawalDate).getTime() - new Date(a.withdrawalDate).getTime()); + break; + } + return sorted; + }, + + // 날짜 범위 선택기 + dateRangeSelector: { + enabled: true, + startDate, + endDate, + onStartDateChange: setStartDate, + onEndDateChange: setEndDate, + }, + + // beforeTableContent: 계정과목명 + 저장 + 새로고침 + beforeTableContent: ( +
+
+ 계정과목명 + +
+ +
+ ), + + // tableHeaderActions: 저장 버튼 + 인라인 필터들 + tableHeaderActions: ({ selectedItems }) => ( +
+ + + {/* 거래처 필터 */} + + + {/* 출금유형 필터 */} + + + {/* 정렬 */} + +
+ ), + + // tableFooter: 합계 행 + tableFooter: (filteredData: WithdrawalRecord[]) => { + const totalAmount = filteredData.reduce((sum, item) => sum + (item.withdrawalAmount ?? 0), 0); + return ( + + + 합계 + + + {totalAmount.toLocaleString()} + + + + + + ); + }, + + // Stats 카드 + computeStats: (): StatCard[] => [ + { label: '총 출금', value: `${stats.totalWithdrawal.toLocaleString()}원`, icon: Banknote, iconColor: 'text-blue-500' }, + { label: '당월 출금', value: `${stats.monthlyWithdrawal.toLocaleString()}원`, icon: Banknote, iconColor: 'text-green-500' }, + { label: '거래처 미설정', value: `${stats.vendorUnsetCount}건`, icon: Banknote, iconColor: 'text-orange-500' }, + { label: '출금유형 미설정', value: `${stats.withdrawalTypeUnsetCount}건`, icon: Banknote, iconColor: 'text-red-500' }, + ], + + // 삭제 확인 메시지 + deleteConfirmMessage: { + title: '출금 삭제', + description: '이 출금 내역을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.', + }, + + // 테이블 행 렌더링 + renderTableRow: ( + item: WithdrawalRecord, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => { + const isVendorUnset = !item.vendorName; + const isWithdrawalTypeUnset = item.withdrawalType === 'unset'; + + return ( + handleRowClick(item)} + > + e.stopPropagation()}> + + + {/* 출금일 */} + {item.withdrawalDate} + {/* 출금계좌 */} + {item.accountName} + {/* 수취인명 */} + {item.recipientName} + {/* 출금금액 */} + {(item.withdrawalAmount ?? 0).toLocaleString()} + {/* 거래처 */} + + {item.vendorName || '미설정'} + + {/* 적요 */} + {item.note || '-'} + {/* 출금유형 */} + + + {WITHDRAWAL_TYPE_LABELS[item.withdrawalType]} + + + {/* 작업 */} + e.stopPropagation()}> + {handlers.isSelected && ( +
+ + +
+ )} +
+
+ ); + }, + + // 모바일 카드 렌더링 + renderMobileCard: ( + item: WithdrawalRecord, + index: number, + globalIndex: number, + handlers: SelectionHandlers & RowClickHandlers + ) => ( + handleRowClick(item)} + details={[ + { label: '출금일', value: item.withdrawalDate || '-' }, + { label: '출금액', value: `${(item.withdrawalAmount ?? 0).toLocaleString()}원` }, + { label: '거래처', value: item.vendorName || '-' }, + { label: '적요', value: item.note || '-' }, + ]} + actions={ + handlers.isSelected ? ( +
+ + +
+ ) : undefined + } + /> + ), + }), + [ + initialData, + stats, + startDate, + endDate, + vendorFilter, + withdrawalTypeFilter, + sortOption, + selectedAccountSubject, + isRefreshing, + vendorOptions, + handleRowClick, + handleEdit, + handleRefresh, + handleSaveAccountSubject, + ] ); return ( <> - item.id} - renderTableRow={renderTableRow} - renderMobileCard={renderMobileCard} - pagination={{ - currentPage, - totalPages, - totalItems: filteredData.length, - itemsPerPage, - onPageChange: setCurrentPage, - }} - /> + {/* 계정과목명 저장 확인 다이얼로그 */} @@ -565,7 +590,7 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra 계정과목명 변경 - {selectedItems.size}개의 출금 유형을{' '} + {saveTargetIds.length}개의 출금 유형을{' '} {ACCOUNT_SUBJECT_OPTIONS.find(o => o.value === selectedAccountSubject)?.label} @@ -586,27 +611,6 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra - {/* 삭제 확인 다이얼로그 */} - - - - 출금 삭제 - - 이 출금 내역을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다. - - - - 취소 - - 삭제 - - - - - {/* 선택 필요 알림 다이얼로그 */} diff --git a/src/components/approval/ApprovalBox/index.tsx b/src/components/approval/ApprovalBox/index.tsx index d05f4a63..79edbf8e 100644 --- a/src/components/approval/ApprovalBox/index.tsx +++ b/src/components/approval/ApprovalBox/index.tsx @@ -44,19 +44,21 @@ import { AlertDialogTitle, } from '@/components/ui/alert-dialog'; import { - IntegratedListTemplateV2, - type TableColumn, - type StatCard, + UniversalListPage, + type UniversalListConfig, type TabOption, -} from '@/components/templates/IntegratedListTemplateV2'; -import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; +} from '@/components/templates/UniversalListPage'; import { ListMobileCard, InfoField } from '@/components/organisms/ListMobileCard'; import { DocumentDetailModal } from '@/components/approval/DocumentDetail'; -import type { DocumentType, ProposalDocumentData, ExpenseReportDocumentData, ExpenseEstimateDocumentData } from '@/components/approval/DocumentDetail/types'; +import type { + DocumentType, + ProposalDocumentData, + ExpenseReportDocumentData, + ExpenseEstimateDocumentData, +} from '@/components/approval/DocumentDetail/types'; import type { ApprovalTabType, ApprovalRecord, - ApprovalStatus, ApprovalType, SortOption, FilterOption, @@ -88,7 +90,6 @@ export function ApprovalBox() { const [searchQuery, setSearchQuery] = useState(''); const [filterOption, setFilterOption] = useState('all'); const [sortOption, setSortOption] = useState('latest'); - const [selectedItems, setSelectedItems] = useState>(new Set()); const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 20; @@ -100,6 +101,8 @@ export function ApprovalBox() { const [approveDialogOpen, setApproveDialogOpen] = useState(false); const [rejectDialogOpen, setRejectDialogOpen] = useState(false); const [rejectComment, setRejectComment] = useState(''); + const [pendingSelectedItems, setPendingSelectedItems] = useState>(new Set()); + const [pendingClearSelection, setPendingClearSelection] = useState<(() => void) | null>(null); // ===== 문서 상세 모달 상태 ===== const [isModalOpen, setIsModalOpen] = useState(false); @@ -111,22 +114,25 @@ export function ApprovalBox() { const [totalPages, setTotalPages] = useState(1); const [isLoading, setIsLoading] = useState(true); - // 통계 데이터 (전체 탭 기준으로 고정 유지) - const [summary, setSummary] = useState(null); + // 통계 데이터 const [fixedStats, setFixedStats] = useState({ all: 0, pending: 0, approved: 0, rejected: 0 }); // ===== 데이터 로드 ===== const loadData = useCallback(async () => { setIsLoading(true); try { - // 정렬 옵션 변환 const sortConfig: { sort_by: string; sort_dir: 'asc' | 'desc' } = (() => { switch (sortOption) { - case 'latest': return { sort_by: 'created_at', sort_dir: 'desc' }; - case 'oldest': return { sort_by: 'created_at', sort_dir: 'asc' }; - case 'draftDateAsc': return { sort_by: 'created_at', sort_dir: 'asc' }; - case 'draftDateDesc': return { sort_by: 'created_at', sort_dir: 'desc' }; - default: return { sort_by: 'created_at', sort_dir: 'desc' }; + case 'latest': + return { sort_by: 'created_at', sort_dir: 'desc' }; + case 'oldest': + return { sort_by: 'created_at', sort_dir: 'asc' }; + case 'draftDateAsc': + return { sort_by: 'created_at', sort_dir: 'asc' }; + case 'draftDateDesc': + return { sort_by: 'created_at', sort_dir: 'desc' }; + default: + return { sort_by: 'created_at', sort_dir: 'desc' }; } })(); @@ -151,26 +157,11 @@ export function ApprovalBox() { } }, [currentPage, itemsPerPage, searchQuery, filterOption, sortOption, activeTab]); - // ===== 통계 로드 ===== - const loadSummary = useCallback(async () => { - try { - const result = await getInboxSummary(); - setSummary(result); - } catch (error) { - if (isNextRedirectError(error)) throw error; - console.error('Failed to load summary:', error); - } - }, []); - - // ===== 초기 로드 및 필터 변경 시 데이터 재로드 ===== + // ===== 초기 로드 ===== useEffect(() => { loadData(); }, [loadData]); - useEffect(() => { - loadSummary(); - }, [loadSummary]); - // ===== 검색어/필터/탭 변경 시 페이지 초기화 ===== useEffect(() => { setCurrentPage(1); @@ -179,34 +170,15 @@ export function ApprovalBox() { // ===== 탭 변경 핸들러 ===== const handleTabChange = useCallback((value: string) => { setActiveTab(value as ApprovalTabType); - setSelectedItems(new Set()); setSearchQuery(''); }, []); - // ===== 체크박스 핸들러 ===== - const toggleSelection = useCallback((id: string) => { - setSelectedItems(prev => { - const newSet = new Set(prev); - if (newSet.has(id)) newSet.delete(id); - else newSet.add(id); - return newSet; - }); - }, []); - - const toggleSelectAll = useCallback(() => { - if (selectedItems.size === data.length && data.length > 0) { - setSelectedItems(new Set()); - } else { - setSelectedItems(new Set(data.map(item => item.id))); - } - }, [selectedItems.size, data]); - // ===== 전체 탭일 때만 통계 업데이트 ===== useEffect(() => { if (activeTab === 'all' && data.length > 0) { - const pending = data.filter(item => item.status === 'pending').length; - const approved = data.filter(item => item.status === 'approved').length; - const rejected = data.filter(item => item.status === 'rejected').length; + const pending = data.filter((item) => item.status === 'pending').length; + const approved = data.filter((item) => item.status === 'approved').length; + const rejected = data.filter((item) => item.status === 'rejected').length; setFixedStats({ all: totalCount, @@ -217,17 +189,19 @@ export function ApprovalBox() { } }, [data, totalCount, activeTab]); - // ===== 통계 데이터 (고정 값 사용) ===== - const stats = fixedStats; - // ===== 승인/반려 핸들러 ===== - const handleApproveClick = useCallback(() => { - if (selectedItems.size === 0) return; - setApproveDialogOpen(true); - }, [selectedItems.size]); + const handleApproveClick = useCallback( + (selectedItems: Set, onClearSelection: () => void) => { + if (selectedItems.size === 0) return; + setPendingSelectedItems(selectedItems); + setPendingClearSelection(() => onClearSelection); + setApproveDialogOpen(true); + }, + [] + ); const handleApproveConfirm = useCallback(async () => { - const ids = Array.from(selectedItems); + const ids = Array.from(pendingSelectedItems); startTransition(async () => { try { @@ -236,9 +210,8 @@ export function ApprovalBox() { toast.success('승인 완료', { description: '결재 승인이 완료되었습니다.', }); - setSelectedItems(new Set()); + pendingClearSelection?.(); loadData(); - loadSummary(); } else { toast.error(result.error || '승인 처리에 실패했습니다.'); } @@ -250,13 +223,20 @@ export function ApprovalBox() { }); setApproveDialogOpen(false); - }, [selectedItems, loadData, loadSummary]); + setPendingSelectedItems(new Set()); + setPendingClearSelection(null); + }, [pendingSelectedItems, pendingClearSelection, loadData]); - const handleRejectClick = useCallback(() => { - if (selectedItems.size === 0) return; - setRejectComment(''); - setRejectDialogOpen(true); - }, [selectedItems.size]); + const handleRejectClick = useCallback( + (selectedItems: Set, onClearSelection: () => void) => { + if (selectedItems.size === 0) return; + setPendingSelectedItems(selectedItems); + setPendingClearSelection(() => onClearSelection); + setRejectComment(''); + setRejectDialogOpen(true); + }, + [] + ); const handleRejectConfirm = useCallback(async () => { if (!rejectComment.trim()) { @@ -264,7 +244,7 @@ export function ApprovalBox() { return; } - const ids = Array.from(selectedItems); + const ids = Array.from(pendingSelectedItems); startTransition(async () => { try { @@ -273,10 +253,9 @@ export function ApprovalBox() { toast.success('반려 완료', { description: '결재 반려가 완료되었습니다.', }); - setSelectedItems(new Set()); + pendingClearSelection?.(); setRejectComment(''); loadData(); - loadSummary(); } else { toast.error(result.error || '반려 처리에 실패했습니다.'); } @@ -288,39 +267,11 @@ export function ApprovalBox() { }); setRejectDialogOpen(false); - }, [selectedItems, rejectComment, loadData, loadSummary]); + setPendingSelectedItems(new Set()); + setPendingClearSelection(null); + }, [pendingSelectedItems, rejectComment, pendingClearSelection, loadData]); - // ===== 통계 카드 ===== - const statCards: StatCard[] = useMemo(() => [ - { label: '전체결재', value: `${stats.all}건`, icon: Files, iconColor: 'text-blue-500' }, - { label: '미결재', value: `${stats.pending}건`, icon: Clock, iconColor: 'text-yellow-500' }, - { label: '결재완료', value: `${stats.approved}건`, icon: FileCheck, iconColor: 'text-green-500' }, - { label: '결재반려', value: `${stats.rejected}건`, icon: FileX, iconColor: 'text-red-500' }, - ], [stats]); - - // ===== 탭 옵션 ===== - const tabs: TabOption[] = useMemo(() => [ - { value: 'all', label: APPROVAL_TAB_LABELS.all, count: stats.all, color: 'blue' }, - { value: 'pending', label: APPROVAL_TAB_LABELS.pending, count: stats.pending, color: 'yellow' }, - { value: 'approved', label: APPROVAL_TAB_LABELS.approved, count: stats.approved, color: 'green' }, - { value: 'rejected', label: APPROVAL_TAB_LABELS.rejected, count: stats.rejected, color: 'red' }, - ], [stats]); - - // ===== 테이블 컬럼 ===== - // 문서번호, 문서유형, 제목, 기안자, 결재자, 기안일시, 상태, 작업 - const tableColumns: TableColumn[] = useMemo(() => [ - { key: 'no', label: '번호', className: 'w-[60px] text-center' }, - { key: 'documentNo', label: '문서번호' }, - { key: 'approvalType', label: '문서유형' }, - { key: 'title', label: '제목' }, - { key: 'drafter', label: '기안자' }, - { key: 'approver', label: '결재자' }, - { key: 'draftDate', label: '기안일시' }, - { key: 'status', label: '상태', className: 'text-center' }, - { key: 'actions', label: '작업', className: 'w-[80px] text-center' }, - ], []); - - // ===== 문서 클릭/상세 보기 핸들러 ===== + // ===== 문서 클릭 핸들러 ===== const handleDocumentClick = useCallback((item: ApprovalRecord) => { setSelectedDocument(item); setIsModalOpen(true); @@ -333,17 +284,17 @@ export function ApprovalBox() { } }, [selectedDocument, router]); - // 리스트에서 수정 버튼 클릭 시 핸들러 - const handleEditClick = useCallback((item: ApprovalRecord) => { - router.push(`/ko/approval/draft/new?id=${item.id}&mode=edit`); - }, [router]); + const handleEditClick = useCallback( + (item: ApprovalRecord) => { + router.push(`/ko/approval/draft/new?id=${item.id}&mode=edit`); + }, + [router] + ); const handleModalCopy = useCallback(() => { - // TODO: 문서 복제 API 개발 필요 - POST /api/v1/approvals/{id}/copy - console.log('[ApprovalBox] 문서 복제 - API 미구현:', selectedDocument?.id); toast.info('문서 복제 기능은 준비 중입니다.'); setIsModalOpen(false); - }, [selectedDocument]); + }, []); const handleModalApprove = useCallback(async () => { if (!selectedDocument?.id) return; @@ -369,17 +320,22 @@ export function ApprovalBox() { setIsModalOpen(false); }, [selectedDocument, loadData]); - // ===== ApprovalType → DocumentType 변환 ===== + // ===== 문서 타입 변환 ===== const getDocumentType = (approvalType: ApprovalType): DocumentType => { switch (approvalType) { - case 'expense_estimate': return 'expenseEstimate'; - case 'expense_report': return 'expenseReport'; - default: return 'proposal'; + case 'expense_estimate': + return 'expenseEstimate'; + case 'expense_report': + return 'expenseReport'; + default: + return 'proposal'; } }; - // ===== ApprovalRecord → 모달용 데이터 변환 ===== - const convertToModalData = (item: ApprovalRecord): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => { + // ===== 모달용 데이터 변환 ===== + const convertToModalData = ( + item: ApprovalRecord + ): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => { const docType = getDocumentType(item.approvalType); const drafter = { id: 'drafter-1', @@ -388,32 +344,30 @@ export function ApprovalBox() { department: item.drafterDepartment, status: 'approved' as const, }; - const approvers = [{ - id: 'approver-1', - name: item.approver || '미지정', - position: '부장', - department: '경영지원팀', - status: item.status === 'approved' ? 'approved' as const : item.status === 'rejected' ? 'rejected' as const : 'pending' as const, - }]; + const approvers = [ + { + id: 'approver-1', + name: item.approver || '미지정', + position: '부장', + department: '경영지원팀', + status: + item.status === 'approved' + ? ('approved' as const) + : item.status === 'rejected' + ? ('rejected' as const) + : ('pending' as const), + }, + ]; switch (docType) { case 'expenseEstimate': return { documentNo: item.documentNo, createdAt: item.draftDate, - items: [ - { id: '1', expectedPaymentDate: '2025-11-05', category: '통신 서비스', amount: 550000, vendor: 'KT', account: '국민 123-456-789012 홍길동' }, - { id: '2', expectedPaymentDate: '2025-11-12', category: '인건 대행', amount: 2500000, vendor: '에이치알코리아', account: '신한 110-123-456789 (주)에이치알' }, - { id: '3', expectedPaymentDate: '2025-11-15', category: '사무용품', amount: 350000, vendor: '오피스디포', account: '우리 1002-123-456789 오피스디포' }, - { id: '4', expectedPaymentDate: '2025-11-20', category: '임대료', amount: 3000000, vendor: '강남빌딩', account: '하나 123-12-12345 강남빌딩관리' }, - { id: '5', expectedPaymentDate: '2025-12-05', category: '통신 서비스', amount: 550000, vendor: 'KT', account: '국민 123-456-789012 홍길동' }, - { id: '6', expectedPaymentDate: '2025-12-10', category: '소프트웨어 구독', amount: 890000, vendor: 'Microsoft', account: '기업 123-456-78901234 MS코리아' }, - { id: '7', expectedPaymentDate: '2025-12-15', category: '인건 대행', amount: 2500000, vendor: '에이치알코리아', account: '신한 110-123-456789 (주)에이치알' }, - { id: '8', expectedPaymentDate: '2025-12-20', category: '임대료', amount: 3000000, vendor: '강남빌딩', account: '하나 123-12-12345 강남빌딩관리' }, - ], - totalExpense: 13340000, - accountBalance: 25000000, - finalDifference: 11660000, + items: [], + totalExpense: 0, + accountBalance: 0, + finalDifference: 0, approvers, drafter, }; @@ -423,12 +377,9 @@ export function ApprovalBox() { createdAt: item.draftDate, requestDate: item.draftDate, paymentDate: item.draftDate, - items: [ - { id: '1', no: 1, description: '업무용 택시비', amount: 50000, note: '고객사 미팅' }, - { id: '2', no: 2, description: '식대', amount: 30000, note: '팀 회식' }, - ], - cardInfo: '삼성카드 **** 1234', - totalAmount: 80000, + items: [], + cardInfo: '', + totalAmount: 0, attachments: [], approvers, drafter, @@ -442,7 +393,7 @@ export function ApprovalBox() { title: item.title, description: item.title, reason: '업무상 필요', - estimatedCost: 1000000, + estimatedCost: 0, attachments: [], approvers, drafter, @@ -450,256 +401,418 @@ export function ApprovalBox() { } }; - // ===== 테이블 행 렌더링 ===== - // 컬럼 순서: 번호, 문서번호, 문서유형, 제목, 기안자, 결재자, 기안일시, 상태, 작업 - const renderTableRow = useCallback((item: ApprovalRecord, index: number, globalIndex: number) => { - const isSelected = selectedItems.has(item.id); + // ===== 탭 옵션 ===== + const tabs: TabOption[] = useMemo( + () => [ + { + value: 'all', + label: APPROVAL_TAB_LABELS.all, + count: fixedStats.all, + color: 'blue', + }, + { + value: 'pending', + label: APPROVAL_TAB_LABELS.pending, + count: fixedStats.pending, + color: 'yellow', + }, + { + value: 'approved', + label: APPROVAL_TAB_LABELS.approved, + count: fixedStats.approved, + color: 'green', + }, + { + value: 'rejected', + label: APPROVAL_TAB_LABELS.rejected, + count: fixedStats.rejected, + color: 'red', + }, + ], + [fixedStats] + ); - return ( - handleDocumentClick(item)} - > - e.stopPropagation()}> - toggleSelection(item.id)} /> - - {globalIndex} - {item.documentNo} - - {APPROVAL_TYPE_LABELS[item.approvalType]} - - {item.title} - {item.drafter} - {item.approver || '-'} - {item.draftDate} - - - {APPROVAL_STATUS_LABELS[item.status]} - - - e.stopPropagation()}> - {isSelected && ( - - )} - - - ); - }, [selectedItems, toggleSelection, handleDocumentClick, handleEditClick]); + // ===== UniversalListPage 설정 ===== + const approvalBoxConfig: UniversalListConfig = useMemo( + () => ({ + title: '결재함', + description: '결재 문서를 관리합니다', + icon: FileCheck, + basePath: '/approval/inbox', - // ===== 모바일 카드 렌더링 ===== - const renderMobileCard = useCallback(( - item: ApprovalRecord, - index: number, - globalIndex: number, - isSelected: boolean, - onToggle: () => void - ) => { - return ( - - {APPROVAL_TYPE_LABELS[item.approvalType]} - - {APPROVAL_STATUS_LABELS[item.status]} - -
- } - isSelected={isSelected} - onToggleSelection={onToggle} - infoGrid={ -
- - - - - - -
- } - actions={ - item.status === 'pending' && ( -
- -
- ) - } - /> - ); - }, [handleApproveClick, handleRejectClick]); + )} + + ), - // ===== 헤더 액션 (DateRangeSelector + 승인/반려 버튼) ===== - const headerActions = ( - <> - - {selectedItems.size > 0 && ( -
- - + tableHeaderActions: ( +
+ + +
- )} - + ), + + renderTableRow: (item, index, globalIndex, handlers) => { + const { isSelected, onToggle } = handlers; + + return ( + handleDocumentClick(item)} + > + e.stopPropagation()}> + + + {globalIndex} + {item.documentNo} + + {APPROVAL_TYPE_LABELS[item.approvalType]} + + + {item.title} + + {item.drafter} + {item.approver || '-'} + {item.draftDate} + + + {APPROVAL_STATUS_LABELS[item.status]} + + + e.stopPropagation()}> + {isSelected && ( + + )} + + + ); + }, + + renderMobileCard: (item, index, globalIndex, handlers) => { + const { isSelected, onToggle } = handlers; + + return ( + + {APPROVAL_TYPE_LABELS[item.approvalType]} + + {APPROVAL_STATUS_LABELS[item.status]} + +
+ } + isSelected={isSelected} + onToggleSelection={onToggle} + infoGrid={ +
+ + + + + + +
+ } + actions={ + item.status === 'pending' && isSelected ? ( +
+ + +
+ ) : undefined + } + onClick={() => handleDocumentClick(item)} + /> + ); + }, + + renderDialogs: () => ( + <> + {/* 승인 확인 다이얼로그 */} + + + + 결재 승인 + + 정말 {pendingSelectedItems.size}건을 승인하시겠습니까? + + + + 취소 + 승인 + + + + + {/* 반려 확인 다이얼로그 */} + + + + 결재 반려 + + {pendingSelectedItems.size}건의 결재를 반려합니다. 반려 사유를 + 입력해주세요. + + +
+ +