feat(WEB): UniversalListPage 컴포넌트 및 파일럿 마이그레이션
- UniversalListPage 템플릿 컴포넌트 생성 - 카드관리(HR) 파일럿 마이그레이션 (기본 케이스) - 게시판목록 파일럿 마이그레이션 (동적 탭 fetchTabs) - 발주관리 파일럿 마이그레이션 (ScheduleCalendar beforeTableContent) - 클라이언트 사이드 필터링 지원 (customFilterFn, customSortFn) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -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<T>` 인터페이스 정의 ✅
|
||||||
|
- [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<Tab[]>` |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 변경 이력
|
||||||
|
|
||||||
|
| 날짜 | 작업 내용 |
|
||||||
|
|------|----------|
|
||||||
|
| 2026-01-14 | 체크리스트 문서 생성, 작업 시작 |
|
||||||
|
| 2026-01-14 | 영업 도메인 3개 추가 (총 56개 → 59개) |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## 백업 스크린샷 위치
|
||||||
|
|
||||||
|
| 폴더 | 개수 | 설명 |
|
||||||
|
|------|------|------|
|
||||||
|
| `~/Desktop/test-urls_리스트 게시판 스샷/` | 34개 | 일반 도메인 |
|
||||||
|
| `~/Desktop/construction-test-urls_리스트 게시판 스샷/` | 18개 | 건설 도메인 |
|
||||||
|
| `~/Desktop/추가_리스트_스샷/` | 7개 | 누락 페이지 |
|
||||||
|
| **합계** | **59개** | |
|
||||||
@@ -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<string, string | string[]>) => T[];
|
||||||
|
customSortFn?: (items: T[], filterValues: Record<string, string | string[]>) => 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<T> {
|
||||||
|
title: string;
|
||||||
|
basePath: string;
|
||||||
|
idField: keyof T | ((item: T) => string);
|
||||||
|
actions: ListActions<T>;
|
||||||
|
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<TabOption[]>;
|
||||||
|
|
||||||
|
// 슬롯
|
||||||
|
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` |
|
||||||
14
src/app/[locale]/(protected)/board-test/page.tsx
Normal file
14
src/app/[locale]/(protected)/board-test/page.tsx
Normal file
@@ -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 <BoardListUnified />;
|
||||||
|
}
|
||||||
@@ -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 <OrderManagementUnified />;
|
||||||
|
}
|
||||||
@@ -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 <CardManagementUnified />;
|
||||||
|
}
|
||||||
371
src/components/board/BoardList/BoardListUnified.tsx
Normal file
371
src/components/board/BoardList/BoardListUnified.tsx
Normal file
@@ -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<Board[]>([]);
|
||||||
|
const [activeTab, setActiveTab] = useState<string>('');
|
||||||
|
const [currentUserId, setCurrentUserId] = useState<string>('');
|
||||||
|
|
||||||
|
// 날짜 범위 필터 상태
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [endDate, setEndDate] = useState('');
|
||||||
|
|
||||||
|
// 현재 사용자 ID 가져오기
|
||||||
|
useEffect(() => {
|
||||||
|
const userId = localStorage.getItem('user_id') || '';
|
||||||
|
setCurrentUserId(userId);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// 게시판 목록 로드 및 동적 탭 생성
|
||||||
|
const fetchTabs = useCallback(async (): Promise<TabOption[]> => {
|
||||||
|
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<Post> = 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 }) => (
|
||||||
|
<>
|
||||||
|
<DateRangeSelector
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
onStartDateChange={setStartDate}
|
||||||
|
onEndDateChange={setEndDate}
|
||||||
|
/>
|
||||||
|
<Button
|
||||||
|
className="ml-auto"
|
||||||
|
onClick={() => {
|
||||||
|
const boardCode = activeTab !== 'my' ? activeTab : boards[0]?.boardCode;
|
||||||
|
if (boardCode) {
|
||||||
|
router.push(`/ko/board/${boardCode}/create`);
|
||||||
|
} else {
|
||||||
|
router.push('/ko/board/create');
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
게시글 등록
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
),
|
||||||
|
|
||||||
|
// ===== 삭제 확인 메시지 =====
|
||||||
|
deleteConfirmMessage: {
|
||||||
|
title: '게시글 삭제',
|
||||||
|
description: '정말 삭제하시겠습니까?',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== 테이블 행 렌더링 =====
|
||||||
|
renderTableRow: (
|
||||||
|
item: Post,
|
||||||
|
index: number,
|
||||||
|
globalIndex: number,
|
||||||
|
handlers: SelectionHandlers & RowClickHandlers<Post>
|
||||||
|
) => {
|
||||||
|
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 (
|
||||||
|
<TableRow
|
||||||
|
key={item.id}
|
||||||
|
className="hover:bg-muted/50 cursor-pointer"
|
||||||
|
onClick={handleRowClick}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
|
||||||
|
</TableCell>
|
||||||
|
{/* No. */}
|
||||||
|
<TableCell className="text-center text-sm text-gray-500">
|
||||||
|
{item.isPinned ? (
|
||||||
|
<span className="inline-flex items-center justify-center w-6 h-6 bg-red-500 text-white rounded-full text-xs font-bold">
|
||||||
|
공지
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
globalIndex
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
{/* 제목 */}
|
||||||
|
<TableCell className="font-medium">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{item.isPinned && (
|
||||||
|
<span className="text-xs text-red-500 font-medium">[공지]</span>
|
||||||
|
)}
|
||||||
|
{item.isSecret && (
|
||||||
|
<span className="text-xs text-gray-500 font-medium">[비밀]</span>
|
||||||
|
)}
|
||||||
|
<span className="truncate">{item.title}</span>
|
||||||
|
</div>
|
||||||
|
</TableCell>
|
||||||
|
{/* 작성자 */}
|
||||||
|
<TableCell>{item.authorName}</TableCell>
|
||||||
|
{/* 등록일 */}
|
||||||
|
<TableCell>{format(new Date(item.createdAt), 'yyyy-MM-dd')}</TableCell>
|
||||||
|
{/* 조회수 */}
|
||||||
|
<TableCell className="text-center">{item.viewCount}</TableCell>
|
||||||
|
{/* 작업 */}
|
||||||
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{isSelected && isMyPost && (
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-gray-600 hover:text-gray-700 hover:bg-gray-50"
|
||||||
|
onClick={handleEdit}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== 모바일 카드 렌더링 =====
|
||||||
|
renderMobileCard: (
|
||||||
|
item: Post,
|
||||||
|
index: number,
|
||||||
|
globalIndex: number,
|
||||||
|
handlers: SelectionHandlers & RowClickHandlers<Post>
|
||||||
|
) => {
|
||||||
|
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 (
|
||||||
|
<ListMobileCard
|
||||||
|
key={item.id}
|
||||||
|
id={item.id}
|
||||||
|
title={
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
{item.isPinned && (
|
||||||
|
<span className="text-xs text-red-500 font-medium">[공지]</span>
|
||||||
|
)}
|
||||||
|
{item.isSecret && (
|
||||||
|
<span className="text-xs text-gray-500 font-medium">[비밀]</span>
|
||||||
|
)}
|
||||||
|
<span>{item.title}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onToggleSelection={onToggle}
|
||||||
|
infoGrid={
|
||||||
|
<div className="grid grid-cols-2 gap-3">
|
||||||
|
<InfoField label="작성자" value={item.authorName} />
|
||||||
|
<InfoField label="등록일" value={format(new Date(item.createdAt), 'yyyy-MM-dd')} />
|
||||||
|
<InfoField label="조회수" value={String(item.viewCount)} />
|
||||||
|
<InfoField label="게시판" value={item.boardName} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
isSelected && isMyPost ? (
|
||||||
|
<div className="flex gap-2 w-full">
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1"
|
||||||
|
onClick={handleEdit}
|
||||||
|
>
|
||||||
|
<Pencil className="w-4 h-4 mr-2" /> 수정
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className="flex-1 text-red-500 border-red-200 hover:bg-red-50 hover:text-red-600"
|
||||||
|
onClick={handleDelete}
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 mr-2" /> 삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
onClick={handleRowClick}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== 추가 옵션 =====
|
||||||
|
showCheckbox: true,
|
||||||
|
showRowNumber: true,
|
||||||
|
itemsPerPage: 20,
|
||||||
|
}), [activeTab, boards, currentUserId, fetchTabs, router, startDate, endDate]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UniversalListPage<Post>
|
||||||
|
config={config}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default BoardListUnified;
|
||||||
@@ -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<Date | null>(null);
|
||||||
|
const [calendarDate, setCalendarDate] = useState<Date>(new Date());
|
||||||
|
const [siteFilters, setSiteFilters] = useState<string[]>([]);
|
||||||
|
const [workTeamFilters, setWorkTeamFilters] = useState<string[]>([]);
|
||||||
|
|
||||||
|
// 날짜 범위 필터 상태
|
||||||
|
const [startDate, setStartDate] = useState('');
|
||||||
|
const [endDate, setEndDate] = useState('');
|
||||||
|
|
||||||
|
// 전체 데이터 (달력 이벤트용)
|
||||||
|
const [allOrders, setAllOrders] = useState<Order[]>(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 = (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={siteOptions}
|
||||||
|
value={siteFilters}
|
||||||
|
onChange={setSiteFilters}
|
||||||
|
placeholder="현장"
|
||||||
|
searchPlaceholder="현장 검색..."
|
||||||
|
className="w-[160px]"
|
||||||
|
/>
|
||||||
|
<MultiSelectCombobox
|
||||||
|
options={workTeamOptions}
|
||||||
|
value={workTeamFilters}
|
||||||
|
onChange={setWorkTeamFilters}
|
||||||
|
placeholder="작업반장"
|
||||||
|
searchPlaceholder="작업반장 검색..."
|
||||||
|
className="w-[130px]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
|
||||||
|
// UniversalListPage Config 정의
|
||||||
|
const config: UniversalListConfig<Order> = 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<string, string | string[]>) => {
|
||||||
|
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<string, string | string[]>) => {
|
||||||
|
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 }) => (
|
||||||
|
<DateRangeSelector
|
||||||
|
startDate={startDate}
|
||||||
|
endDate={endDate}
|
||||||
|
onStartDateChange={setStartDate}
|
||||||
|
onEndDateChange={setEndDate}
|
||||||
|
extraActions={
|
||||||
|
<Button onClick={onCreate}>
|
||||||
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
|
발주 등록
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
),
|
||||||
|
|
||||||
|
// ===== 테이블 헤더 추가 액션 =====
|
||||||
|
tableHeaderActions: (
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
{selectedCalendarDate && (
|
||||||
|
<>
|
||||||
|
<span className="text-sm text-primary">
|
||||||
|
({format(selectedCalendarDate, 'M/d')} 필터 적용중)
|
||||||
|
</span>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setSelectedCalendarDate(null)}
|
||||||
|
>
|
||||||
|
날짜 필터 해제
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
|
||||||
|
// ===== 삭제 확인 메시지 =====
|
||||||
|
deleteConfirmMessage: {
|
||||||
|
title: '발주 삭제',
|
||||||
|
description: '선택한 발주를 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== 테이블 행 렌더링 =====
|
||||||
|
renderTableRow: (
|
||||||
|
item: Order,
|
||||||
|
index: number,
|
||||||
|
globalIndex: number,
|
||||||
|
handlers: SelectionHandlers & RowClickHandlers<Order>
|
||||||
|
) => {
|
||||||
|
const { isSelected, onToggle, onRowClick, onEdit, onDelete } = handlers;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={item.id}
|
||||||
|
className="cursor-pointer hover:bg-muted/50"
|
||||||
|
onClick={() => onRowClick(item)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={onToggle}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||||
|
<TableCell>{item.contractNumber}</TableCell>
|
||||||
|
<TableCell>{item.partnerName}</TableCell>
|
||||||
|
<TableCell>{item.siteName}</TableCell>
|
||||||
|
<TableCell>{item.name}</TableCell>
|
||||||
|
<TableCell>{item.constructionPM}</TableCell>
|
||||||
|
<TableCell>{item.orderManager}</TableCell>
|
||||||
|
<TableCell>{item.orderNumber}</TableCell>
|
||||||
|
<TableCell>{item.orderCompany}</TableCell>
|
||||||
|
<TableCell>{item.workTeamLeader}</TableCell>
|
||||||
|
<TableCell>{formatDate(item.constructionStartDate)}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<span className="px-2 py-1 rounded-full text-xs font-medium bg-gray-100 text-gray-800">
|
||||||
|
{ORDER_TYPE_LABELS[item.orderType]}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.item}</TableCell>
|
||||||
|
<TableCell className="text-right">{item.quantity}</TableCell>
|
||||||
|
<TableCell>{formatDate(item.orderDate)}</TableCell>
|
||||||
|
<TableCell>{formatDate(item.plannedDeliveryDate)}</TableCell>
|
||||||
|
<TableCell>{formatDate(item.actualDeliveryDate)}</TableCell>
|
||||||
|
<TableCell className="text-center">
|
||||||
|
<span className={`px-2 py-1 rounded-full text-xs font-medium ${ORDER_STATUS_STYLES[item.status]}`}>
|
||||||
|
{ORDER_STATUS_LABELS[item.status]}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="flex items-center justify-center gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8"
|
||||||
|
onClick={() => onEdit?.(item)}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||||
|
onClick={() => onDelete?.(item)}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== 모바일 카드 렌더링 =====
|
||||||
|
renderMobileCard: (
|
||||||
|
item: Order,
|
||||||
|
index: number,
|
||||||
|
globalIndex: number,
|
||||||
|
handlers: SelectionHandlers & RowClickHandlers<Order>
|
||||||
|
) => {
|
||||||
|
const { isSelected, onToggle, onRowClick, onEdit, onDelete } = handlers;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListMobileCard
|
||||||
|
key={item.id}
|
||||||
|
id={item.id}
|
||||||
|
title={item.siteName}
|
||||||
|
headerBadges={
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
#{globalIndex}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{item.orderNumber}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
statusBadge={
|
||||||
|
<Badge className={ORDER_STATUS_STYLES[item.status]}>
|
||||||
|
{ORDER_STATUS_LABELS[item.status]}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onToggleSelection={onToggle}
|
||||||
|
onCardClick={() => onRowClick(item)}
|
||||||
|
infoGrid={
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||||
|
<InfoField label="거래처" value={item.partnerName} />
|
||||||
|
<InfoField label="발주담당" value={item.orderManager} />
|
||||||
|
<InfoField label="계획납품일" value={formatDate(item.plannedDeliveryDate)} />
|
||||||
|
<InfoField label="구분" value={ORDER_TYPE_LABELS[item.orderType]} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
isSelected ? (
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="default"
|
||||||
|
className="flex-1 min-w-[100px] h-11"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onEdit?.(item); }}
|
||||||
|
>
|
||||||
|
<Pencil className="h-4 w-4 mr-2" />
|
||||||
|
수정
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDelete?.(item); }}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== 테이블 전 콘텐츠 (달력) =====
|
||||||
|
beforeTableContent: (
|
||||||
|
<div className="w-full flex-shrink-0 mb-6">
|
||||||
|
<ScheduleCalendar
|
||||||
|
events={calendarEvents}
|
||||||
|
badges={calendarBadges}
|
||||||
|
currentDate={calendarDate}
|
||||||
|
selectedDate={selectedCalendarDate}
|
||||||
|
onDateClick={handleCalendarDateClick}
|
||||||
|
onEventClick={handleCalendarEventClick}
|
||||||
|
onMonthChange={handleCalendarMonthChange}
|
||||||
|
titleSlot="발주 스케줄"
|
||||||
|
filterSlot={calendarFilterSlot}
|
||||||
|
maxEventsPerDay={5}
|
||||||
|
weekStartsOn={0}
|
||||||
|
isLoading={isLoading}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
),
|
||||||
|
|
||||||
|
// ===== 추가 옵션 =====
|
||||||
|
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 (
|
||||||
|
<UniversalListPage<Order>
|
||||||
|
config={config}
|
||||||
|
initialData={initialData}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default OrderManagementUnified;
|
||||||
266
src/components/hr/CardManagement/CardManagementUnified.tsx
Normal file
266
src/components/hr/CardManagement/CardManagementUnified.tsx
Normal file
@@ -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<Card> = 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 }) => (
|
||||||
|
<Button className="ml-auto" onClick={onCreate}>
|
||||||
|
<Plus className="w-4 h-4 mr-2" />
|
||||||
|
카드 등록
|
||||||
|
</Button>
|
||||||
|
),
|
||||||
|
|
||||||
|
// ===== 삭제 확인 메시지 =====
|
||||||
|
deleteConfirmMessage: {
|
||||||
|
title: '카드 삭제',
|
||||||
|
description: '삭제된 카드 정보는 복구할 수 없습니다.',
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== 테이블 행 렌더링 =====
|
||||||
|
renderTableRow: (
|
||||||
|
item: Card,
|
||||||
|
index: number,
|
||||||
|
globalIndex: number,
|
||||||
|
handlers: SelectionHandlers & RowClickHandlers<Card>
|
||||||
|
) => {
|
||||||
|
const { isSelected, onToggle, onRowClick, onEdit, onDelete } = handlers;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TableRow
|
||||||
|
key={item.id}
|
||||||
|
className="hover:bg-muted/50 cursor-pointer"
|
||||||
|
onClick={() => onRowClick(item)}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
|
<Checkbox
|
||||||
|
checked={isSelected}
|
||||||
|
onCheckedChange={onToggle}
|
||||||
|
/>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className="text-muted-foreground text-center">
|
||||||
|
{globalIndex}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{getCardCompanyLabel(item.cardCompany)}</TableCell>
|
||||||
|
<TableCell className="font-mono">{maskCardNumber(item.cardNumber)}</TableCell>
|
||||||
|
<TableCell>{item.cardName}</TableCell>
|
||||||
|
<TableCell>
|
||||||
|
<Badge className={CARD_STATUS_COLORS[item.status]}>
|
||||||
|
{CARD_STATUS_LABELS[item.status]}
|
||||||
|
</Badge>
|
||||||
|
</TableCell>
|
||||||
|
<TableCell>{item.user?.departmentName || '-'}</TableCell>
|
||||||
|
<TableCell>{item.user?.employeeName || '-'}</TableCell>
|
||||||
|
<TableCell>{item.user?.positionName || '-'}</TableCell>
|
||||||
|
<TableCell className="text-right" onClick={(e) => e.stopPropagation()}>
|
||||||
|
{isSelected && (
|
||||||
|
<div className="flex items-center justify-end gap-1">
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onEdit?.(item)}
|
||||||
|
title="수정"
|
||||||
|
>
|
||||||
|
<Edit className="w-4 h-4" />
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => onDelete?.(item)}
|
||||||
|
title="삭제"
|
||||||
|
>
|
||||||
|
<Trash2 className="w-4 h-4 text-red-500" />
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== 모바일 카드 렌더링 =====
|
||||||
|
renderMobileCard: (
|
||||||
|
item: Card,
|
||||||
|
index: number,
|
||||||
|
globalIndex: number,
|
||||||
|
handlers: SelectionHandlers & RowClickHandlers<Card>
|
||||||
|
) => {
|
||||||
|
const { isSelected, onToggle, onRowClick, onEdit, onDelete } = handlers;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ListMobileCard
|
||||||
|
key={item.id}
|
||||||
|
id={item.id}
|
||||||
|
title={item.cardName}
|
||||||
|
headerBadges={
|
||||||
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
<Badge variant="outline" className="text-xs">
|
||||||
|
#{globalIndex}
|
||||||
|
</Badge>
|
||||||
|
<span className="text-xs text-muted-foreground">
|
||||||
|
{getCardCompanyLabel(item.cardCompany)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
statusBadge={
|
||||||
|
<Badge className={CARD_STATUS_COLORS[item.status]}>
|
||||||
|
{CARD_STATUS_LABELS[item.status]}
|
||||||
|
</Badge>
|
||||||
|
}
|
||||||
|
isSelected={isSelected}
|
||||||
|
onToggleSelection={onToggle}
|
||||||
|
onCardClick={() => onRowClick(item)}
|
||||||
|
infoGrid={
|
||||||
|
<div className="grid grid-cols-2 gap-x-4 gap-y-3">
|
||||||
|
<InfoField label="카드번호" value={maskCardNumber(item.cardNumber)} />
|
||||||
|
<InfoField label="유효기간" value={`${item.expiryDate.slice(0, 2)}/${item.expiryDate.slice(2)}`} />
|
||||||
|
<InfoField label="부서" value={item.user?.departmentName || '-'} />
|
||||||
|
<InfoField label="사용자" value={item.user?.employeeName || '-'} />
|
||||||
|
<InfoField label="직책" value={item.user?.positionName || '-'} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
actions={
|
||||||
|
isSelected ? (
|
||||||
|
<div className="flex gap-2 flex-wrap">
|
||||||
|
<Button
|
||||||
|
variant="default"
|
||||||
|
size="default"
|
||||||
|
className="flex-1 min-w-[100px] h-11"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onEdit?.(item); }}
|
||||||
|
>
|
||||||
|
<Edit className="h-4 w-4 mr-2" />
|
||||||
|
수정
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="default"
|
||||||
|
className="flex-1 min-w-[100px] h-11 border-red-200 text-red-600 hover:border-red-300 bg-transparent"
|
||||||
|
onClick={(e) => { e.stopPropagation(); onDelete?.(item); }}
|
||||||
|
>
|
||||||
|
<Trash2 className="h-4 w-4 mr-2" />
|
||||||
|
삭제
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
) : undefined
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
},
|
||||||
|
|
||||||
|
// ===== 추가 옵션 =====
|
||||||
|
showCheckbox: true,
|
||||||
|
showRowNumber: true,
|
||||||
|
itemsPerPage: 20,
|
||||||
|
}), []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<UniversalListPage<Card>
|
||||||
|
config={config}
|
||||||
|
initialData={initialData}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
525
src/components/templates/UniversalListPage/index.tsx
Normal file
525
src/components/templates/UniversalListPage/index.tsx
Normal file
@@ -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<T>({
|
||||||
|
config,
|
||||||
|
initialData,
|
||||||
|
initialTotalCount,
|
||||||
|
}: UniversalListPageProps<T>) {
|
||||||
|
const router = useRouter();
|
||||||
|
const params = useParams();
|
||||||
|
const locale = (params.locale as string) || 'ko';
|
||||||
|
|
||||||
|
// ===== 상태 관리 =====
|
||||||
|
// 원본 데이터 (클라이언트 사이드 필터링용)
|
||||||
|
const [rawData, setRawData] = useState<T[]>(initialData || []);
|
||||||
|
// UI 상태
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
const [isLoading, setIsLoading] = useState(!initialData);
|
||||||
|
const [searchValue, setSearchValue] = useState('');
|
||||||
|
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||||
|
// 탭이 없으면 IntegratedListTemplateV2의 기본값 'default'와 일치해야 함
|
||||||
|
const [activeTab, setActiveTab] = useState(
|
||||||
|
config.defaultTab || config.tabs?.[0]?.value || 'default'
|
||||||
|
);
|
||||||
|
const [filters, setFilters] = useState<Record<string, string | string[]>>(
|
||||||
|
config.initialFilters || {}
|
||||||
|
);
|
||||||
|
const [tabs, setTabs] = useState<TabOption[]>(config.tabs || []);
|
||||||
|
|
||||||
|
// 모달 상태 (detailMode === 'modal'일 때 사용)
|
||||||
|
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||||
|
const [selectedItem, setSelectedItem] = useState<T | null>(null);
|
||||||
|
|
||||||
|
// 삭제 다이얼로그 상태
|
||||||
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
|
const [itemToDelete, setItemToDelete] = useState<T | null>(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 (
|
||||||
|
<>
|
||||||
|
<IntegratedListTemplateV2<T>
|
||||||
|
// 페이지 헤더
|
||||||
|
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}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
|
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||||
|
<AlertDialogContent>
|
||||||
|
<AlertDialogHeader>
|
||||||
|
<AlertDialogTitle>{deleteConfirmTitle}</AlertDialogTitle>
|
||||||
|
<AlertDialogDescription>{deleteConfirmDescription}</AlertDialogDescription>
|
||||||
|
</AlertDialogHeader>
|
||||||
|
<AlertDialogFooter>
|
||||||
|
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||||
|
<AlertDialogAction onClick={handleDeleteConfirm}>삭제</AlertDialogAction>
|
||||||
|
</AlertDialogFooter>
|
||||||
|
</AlertDialogContent>
|
||||||
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* 상세 모달 (detailMode === 'modal'일 때) */}
|
||||||
|
{config.detailMode === 'modal' && config.DetailModalComponent && (
|
||||||
|
<config.DetailModalComponent
|
||||||
|
isOpen={isModalOpen}
|
||||||
|
onClose={() => {
|
||||||
|
setIsModalOpen(false);
|
||||||
|
setSelectedItem(null);
|
||||||
|
}}
|
||||||
|
item={selectedItem}
|
||||||
|
onRefresh={fetchData}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// 타입 re-export
|
||||||
|
export * from './types';
|
||||||
257
src/components/templates/UniversalListPage/types.ts
Normal file
257
src/components/templates/UniversalListPage/types.ts
Normal file
@@ -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<T> {
|
||||||
|
success: boolean;
|
||||||
|
data?: T[];
|
||||||
|
totalCount?: number;
|
||||||
|
totalPages?: number;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface DeleteResult {
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 액션 설정 =====
|
||||||
|
export interface ListActions<T> {
|
||||||
|
/** 목록 조회 API */
|
||||||
|
getList: (params?: ListParams) => Promise<ListResult<T>>;
|
||||||
|
/** 단일 삭제 API (선택) */
|
||||||
|
deleteItem?: (id: string) => Promise<DeleteResult>;
|
||||||
|
/** 일괄 삭제 API (선택) */
|
||||||
|
deleteBulk?: (ids: string[]) => Promise<DeleteResult>;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ListParams {
|
||||||
|
page?: number;
|
||||||
|
pageSize?: number;
|
||||||
|
search?: string;
|
||||||
|
filters?: Record<string, string | string[]>;
|
||||||
|
tab?: string;
|
||||||
|
sortBy?: string;
|
||||||
|
sortOrder?: 'asc' | 'desc';
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 상세 보기 모드 =====
|
||||||
|
export type DetailMode = 'page' | 'modal' | 'none';
|
||||||
|
|
||||||
|
// ===== 커스텀 액션 버튼 =====
|
||||||
|
export interface CustomAction<T> {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
icon?: LucideIcon;
|
||||||
|
variant?: 'default' | 'destructive' | 'outline' | 'secondary' | 'ghost' | 'link';
|
||||||
|
/** 단일 아이템에 대한 액션 */
|
||||||
|
onClick?: (item: T) => void | Promise<void>;
|
||||||
|
/** 선택된 아이템들에 대한 일괄 액션 */
|
||||||
|
onBulkClick?: (items: T[]) => void | Promise<void>;
|
||||||
|
/** 액션 표시 조건 */
|
||||||
|
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<T> {
|
||||||
|
onRowClick: (item: T) => void;
|
||||||
|
onEdit?: (item: T) => void;
|
||||||
|
onDelete?: (item: T) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 메인 Config 타입 =====
|
||||||
|
export interface UniversalListConfig<T> {
|
||||||
|
// ===== 페이지 기본 정보 =====
|
||||||
|
/** 페이지 제목 */
|
||||||
|
title: string;
|
||||||
|
/** 페이지 설명 (선택) */
|
||||||
|
description?: string;
|
||||||
|
/** 페이지 아이콘 (선택) */
|
||||||
|
icon?: LucideIcon;
|
||||||
|
/** 기본 경로 (예: '/hr/employee-management') */
|
||||||
|
basePath: string;
|
||||||
|
|
||||||
|
// ===== ID 추출 =====
|
||||||
|
/** 아이템에서 ID 추출 (string 키 또는 함수) */
|
||||||
|
idField: keyof T | ((item: T) => string);
|
||||||
|
|
||||||
|
// ===== API 액션 =====
|
||||||
|
actions: ListActions<T>;
|
||||||
|
|
||||||
|
// ===== 테이블 컬럼 =====
|
||||||
|
columns: TableColumn[];
|
||||||
|
|
||||||
|
// ===== 필터 설정 =====
|
||||||
|
/** 필터 필드 설정 */
|
||||||
|
filterConfig?: FilterFieldConfig[];
|
||||||
|
/** 필터 초기값 */
|
||||||
|
initialFilters?: Record<string, string | string[]>;
|
||||||
|
/** 필터 바텀시트 제목 (모바일) */
|
||||||
|
filterTitle?: string;
|
||||||
|
|
||||||
|
// ===== 탭 설정 =====
|
||||||
|
/** 고정 탭 목록 */
|
||||||
|
tabs?: TabOption[];
|
||||||
|
/** 동적 탭 (API에서 가져오기) */
|
||||||
|
fetchTabs?: () => Promise<TabOption[]>;
|
||||||
|
/** 기본 활성 탭 */
|
||||||
|
defaultTab?: string;
|
||||||
|
|
||||||
|
// ===== 통계 카드 =====
|
||||||
|
/** 고정 통계 카드 */
|
||||||
|
stats?: StatCard[];
|
||||||
|
/** 동적 통계 (데이터 기반 계산) */
|
||||||
|
computeStats?: (data: T[], totalCount: number) => StatCard[];
|
||||||
|
|
||||||
|
// ===== 렌더링 함수 =====
|
||||||
|
/** 테이블 행 렌더링 */
|
||||||
|
renderTableRow: (
|
||||||
|
item: T,
|
||||||
|
index: number,
|
||||||
|
globalIndex: number,
|
||||||
|
handlers: SelectionHandlers & RowClickHandlers<T>
|
||||||
|
) => ReactNode;
|
||||||
|
/** 모바일 카드 렌더링 */
|
||||||
|
renderMobileCard: (
|
||||||
|
item: T,
|
||||||
|
index: number,
|
||||||
|
globalIndex: number,
|
||||||
|
handlers: SelectionHandlers & RowClickHandlers<T>
|
||||||
|
) => ReactNode;
|
||||||
|
|
||||||
|
// ===== 상세 보기 설정 =====
|
||||||
|
/** 상세 보기 모드 (기본: 'page') */
|
||||||
|
detailMode?: DetailMode;
|
||||||
|
/** 모달 모드일 때 사용할 모달 컴포넌트 */
|
||||||
|
DetailModalComponent?: React.ComponentType<{
|
||||||
|
isOpen: boolean;
|
||||||
|
onClose: () => void;
|
||||||
|
item: T | null;
|
||||||
|
onRefresh?: () => void;
|
||||||
|
}>;
|
||||||
|
|
||||||
|
// ===== 커스텀 액션 =====
|
||||||
|
/** 헤더 액션 (등록 버튼 등) */
|
||||||
|
headerActions?: (params: { onCreate?: () => void }) => ReactNode;
|
||||||
|
/** 커스텀 액션 버튼 (상신, 승인 등) */
|
||||||
|
customActions?: CustomAction<T>[];
|
||||||
|
|
||||||
|
// ===== 추가 옵션 =====
|
||||||
|
/** 검색 플레이스홀더 */
|
||||||
|
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<string, string | string[]>) => T[];
|
||||||
|
/** 커스텀 정렬 함수 */
|
||||||
|
customSortFn?: (items: T[], filterValues: Record<string, string | string[]>) => T[];
|
||||||
|
|
||||||
|
// ===== 테이블 헤더 액션 =====
|
||||||
|
/** 테이블 헤더 우측 추가 액션 (총건, 필터 해제 버튼 등) */
|
||||||
|
tableHeaderActions?: ReactNode;
|
||||||
|
|
||||||
|
// ===== 추가 슬롯 =====
|
||||||
|
/** 테이블 앞 커스텀 콘텐츠 */
|
||||||
|
beforeTableContent?: ReactNode;
|
||||||
|
/** 테이블 하단 푸터 */
|
||||||
|
tableFooter?: ReactNode;
|
||||||
|
/** 경고 배너 */
|
||||||
|
alertBanner?: ReactNode;
|
||||||
|
/** 헤더 액션 영역 아래, 검색 위 커스텀 탭 */
|
||||||
|
tabsContent?: ReactNode;
|
||||||
|
/** 추가 필터 (Select, DatePicker 등) */
|
||||||
|
extraFilters?: ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 컴포넌트 Props =====
|
||||||
|
export interface UniversalListPageProps<T> {
|
||||||
|
config: UniversalListConfig<T>;
|
||||||
|
/** 초기 데이터 (SSR용, 선택) */
|
||||||
|
initialData?: T[];
|
||||||
|
/** 초기 총 개수 */
|
||||||
|
initialTotalCount?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ===== 내부 상태 타입 =====
|
||||||
|
export interface ListState<T> {
|
||||||
|
data: T[];
|
||||||
|
totalCount: number;
|
||||||
|
totalPages: number;
|
||||||
|
currentPage: number;
|
||||||
|
isLoading: boolean;
|
||||||
|
searchValue: string;
|
||||||
|
selectedItems: Set<string>;
|
||||||
|
activeTab: string;
|
||||||
|
filters: Record<string, string | string[]>;
|
||||||
|
tabs: TabOption[];
|
||||||
|
}
|
||||||
@@ -8,4 +8,21 @@ export type {
|
|||||||
VersionHistoryItem,
|
VersionHistoryItem,
|
||||||
DevMetadata,
|
DevMetadata,
|
||||||
IntegratedListTemplateV2Props,
|
IntegratedListTemplateV2Props,
|
||||||
} from "./IntegratedListTemplateV2";
|
} from "./IntegratedListTemplateV2";
|
||||||
|
|
||||||
|
// UniversalListPage - 통합 리스트 페이지 컴포넌트
|
||||||
|
export { UniversalListPage } from "./UniversalListPage";
|
||||||
|
export type {
|
||||||
|
UniversalListConfig,
|
||||||
|
UniversalListPageProps,
|
||||||
|
ListActions,
|
||||||
|
ListParams,
|
||||||
|
ListResult,
|
||||||
|
DeleteResult,
|
||||||
|
CustomAction,
|
||||||
|
DetailMode,
|
||||||
|
SelectionHandlers,
|
||||||
|
RowClickHandlers,
|
||||||
|
FilterFieldConfig,
|
||||||
|
FilterValues,
|
||||||
|
} from "./UniversalListPage";
|
||||||
@@ -93,6 +93,9 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
|||||||
// 회사 선택 상태 (목업)
|
// 회사 선택 상태 (목업)
|
||||||
const [selectedCompany, setSelectedCompany] = useState<string>("all");
|
const [selectedCompany, setSelectedCompany] = useState<string>("all");
|
||||||
|
|
||||||
|
// 알림 벨 애니메이션 상태 (클릭으로 토글)
|
||||||
|
const [bellAnimating, setBellAnimating] = useState(true);
|
||||||
|
|
||||||
// 🔧 클라이언트 마운트 상태 (새로고침 시 스피너 표시용)
|
// 🔧 클라이언트 마운트 상태 (새로고침 시 스피너 표시용)
|
||||||
const [isMounted, setIsMounted] = useState(false);
|
const [isMounted, setIsMounted] = useState(false);
|
||||||
|
|
||||||
@@ -438,52 +441,20 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
|||||||
<Award className="h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5" />
|
<Award className="h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5" />
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* 알림 버튼 */}
|
{/* 알림 버튼 - 클릭하면 애니메이션 토글 */}
|
||||||
<DropdownMenu>
|
<Button
|
||||||
<DropdownMenuTrigger asChild>
|
variant="ghost"
|
||||||
<Button
|
size="sm"
|
||||||
variant="ghost"
|
onClick={() => setBellAnimating(!bellAnimating)}
|
||||||
size="sm"
|
className="min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px] p-0 rounded-lg min-[320px]:rounded-xl hover:bg-accent transition-all duration-200 flex items-center justify-center relative"
|
||||||
className="min-w-[28px] min-h-[28px] min-[320px]:min-w-[36px] min-[320px]:min-h-[36px] sm:min-w-[44px] sm:min-h-[44px] p-0 rounded-lg min-[320px]:rounded-xl hover:bg-accent transition-all duration-200 flex items-center justify-center relative"
|
title={bellAnimating ? '알림 애니메이션 끄기' : '알림 애니메이션 켜기'}
|
||||||
>
|
>
|
||||||
<Bell className={`h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5 text-amber-600 ${MOCK_NOTIFICATIONS.some(n => n.isNew) ? 'animate-bell-ring' : ''}`} />
|
<Bell className={`h-3.5 w-3.5 min-[320px]:h-4 min-[320px]:w-4 sm:h-5 sm:w-5 text-amber-600 ${bellAnimating ? 'animate-bell-ring' : ''}`} />
|
||||||
{/* 알림 있을 때 빨간 점 */}
|
{/* 애니메이션 상태 표시 */}
|
||||||
{MOCK_NOTIFICATIONS.some(n => n.isNew) && (
|
{bellAnimating && (
|
||||||
<span className="absolute top-1 right-1 min-[320px]:top-1.5 min-[320px]:right-1.5 sm:top-2 sm:right-2 w-1 h-1 min-[320px]:w-1.5 min-[320px]:h-1.5 sm:w-2 sm:h-2 bg-red-500 rounded-full" />
|
<span className="absolute top-1 right-1 min-[320px]:top-1.5 min-[320px]:right-1.5 sm:top-2 sm:right-2 w-1 h-1 min-[320px]:w-1.5 min-[320px]:h-1.5 sm:w-2 sm:h-2 bg-red-500 rounded-full" />
|
||||||
)}
|
)}
|
||||||
</Button>
|
</Button>
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent align="end" className="w-80 p-0">
|
|
||||||
{/* 알림 리스트 */}
|
|
||||||
<div className="max-h-80 overflow-y-auto">
|
|
||||||
{MOCK_NOTIFICATIONS.map((notification) => (
|
|
||||||
<div
|
|
||||||
key={notification.id}
|
|
||||||
className="flex items-start gap-3 p-3 border-b border-border hover:bg-accent/50 cursor-pointer"
|
|
||||||
>
|
|
||||||
{/* 이미지 플레이스홀더 */}
|
|
||||||
<div className="flex-shrink-0 w-12 h-12 bg-muted border border-border rounded-lg flex items-center justify-center text-muted-foreground text-xs">
|
|
||||||
IMG
|
|
||||||
</div>
|
|
||||||
{/* 내용 */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<span className="text-muted-foreground text-xs">{notification.category}</span>
|
|
||||||
{notification.isNew && (
|
|
||||||
<span className="w-4 h-4 rounded-full bg-red-500 text-white text-xs flex items-center justify-center font-bold">
|
|
||||||
N
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-foreground font-medium text-sm mt-1 truncate">{notification.title}</p>
|
|
||||||
</div>
|
|
||||||
{/* 날짜 */}
|
|
||||||
<span className="flex-shrink-0 text-muted-foreground text-xs">{notification.date}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
{/* 유저 프로필 드롭다운 */}
|
{/* 유저 프로필 드롭다운 */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
@@ -647,50 +618,22 @@ export default function AuthenticatedLayout({ children }: AuthenticatedLayoutPro
|
|||||||
<span className="hidden xl:inline">품질인정심사</span>
|
<span className="hidden xl:inline">품질인정심사</span>
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{/* 알림 버튼 */}
|
{/* 알림 버튼 - 클릭하면 애니메이션 토글 */}
|
||||||
<DropdownMenu>
|
<Button
|
||||||
<DropdownMenuTrigger asChild>
|
variant="ghost"
|
||||||
<Button variant="ghost" size="sm" className="p-1 rounded-xl hover:bg-accent transition-all duration-200 relative">
|
size="sm"
|
||||||
<div className="w-14 h-14 bg-amber-50 rounded-full flex items-center justify-center">
|
onClick={() => setBellAnimating(!bellAnimating)}
|
||||||
<Bell className={`text-amber-500 ${MOCK_NOTIFICATIONS.some(n => n.isNew) ? 'animate-bell-ring' : ''}`} style={{ width: 23, height: 23 }} />
|
className="p-1 rounded-xl hover:bg-accent transition-all duration-200 relative"
|
||||||
</div>
|
title={bellAnimating ? '알림 애니메이션 끄기' : '알림 애니메이션 켜기'}
|
||||||
{/* 알림 있을 때 빨간 점 */}
|
>
|
||||||
{MOCK_NOTIFICATIONS.some(n => n.isNew) && (
|
<div className="w-14 h-14 bg-amber-50 rounded-full flex items-center justify-center">
|
||||||
<span className="absolute top-0 right-0 w-3.5 h-3.5 bg-red-500 rounded-full border-2 border-background" />
|
<Bell className={`text-amber-500 ${bellAnimating ? 'animate-bell-ring' : ''}`} style={{ width: 23, height: 23 }} />
|
||||||
)}
|
</div>
|
||||||
</Button>
|
{/* 애니메이션 상태 표시 */}
|
||||||
</DropdownMenuTrigger>
|
{bellAnimating && (
|
||||||
<DropdownMenuContent align="end" className="w-96 p-0">
|
<span className="absolute top-0 right-0 w-3.5 h-3.5 bg-red-500 rounded-full border-2 border-background" />
|
||||||
{/* 알림 리스트 */}
|
)}
|
||||||
<div className="max-h-80 overflow-y-auto">
|
</Button>
|
||||||
{MOCK_NOTIFICATIONS.map((notification) => (
|
|
||||||
<div
|
|
||||||
key={notification.id}
|
|
||||||
className="flex items-start gap-3 p-4 border-b border-border hover:bg-accent/50 cursor-pointer"
|
|
||||||
>
|
|
||||||
{/* 이미지 플레이스홀더 */}
|
|
||||||
<div className="flex-shrink-0 w-16 h-16 bg-muted border border-border rounded-lg flex items-center justify-center text-muted-foreground text-xs">
|
|
||||||
IMG
|
|
||||||
</div>
|
|
||||||
{/* 내용 */}
|
|
||||||
<div className="flex-1 min-w-0">
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<span className="text-muted-foreground text-sm">{notification.category}</span>
|
|
||||||
{notification.isNew && (
|
|
||||||
<span className="w-5 h-5 rounded-full bg-red-500 text-white text-xs flex items-center justify-center font-bold">
|
|
||||||
N
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<p className="text-foreground font-medium mt-1 truncate">{notification.title}</p>
|
|
||||||
</div>
|
|
||||||
{/* 날짜 */}
|
|
||||||
<span className="flex-shrink-0 text-muted-foreground text-sm">{notification.date}</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
|
|
||||||
{/* 유저 프로필 드롭다운 */}
|
{/* 유저 프로필 드롭다운 */}
|
||||||
<DropdownMenu>
|
<DropdownMenu>
|
||||||
|
|||||||
Reference in New Issue
Block a user