From e76fac0ab1e47fc108130a311aa6334f762453e9 Mon Sep 17 00:00:00 2001 From: byeongcheolryu Date: Wed, 14 Jan 2026 15:27:59 +0900 Subject: [PATCH] =?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} -
- ))} -
-
-
+ {/* 알림 버튼 - 클릭하면 애니메이션 토글 */} + {/* 유저 프로필 드롭다운 */}