From 1f6b592b9fbc034eafb8e05e2f55d2c9fa5ed3f9 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Mon, 26 Jan 2026 22:04:36 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20=EB=A6=AC=EC=8A=A4=ED=8A=B8=20?= =?UTF-8?q?=ED=8E=98=EC=9D=B4=EC=A7=80=20UI=20=EB=A0=88=EC=9D=B4=EC=95=84?= =?UTF-8?q?=EC=9B=83=20=ED=91=9C=EC=A4=80=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 공통 레이아웃 패턴 적용: [달력] → [프리셋] → [검색창] → [버튼들] - beforeTableContent → headerActions + createButton 마이그레이션 - DateRangeSelector extraActions prop 활용하여 검색창 통합 - PricingListClient 테이블 행 클릭 → 상세 이동 기능 추가 - 회계 관련 페이지 (입금/출금/매입/매출/어음/카드/예상지출 등) 정리 - 건설 관련 페이지 검색 영역 정리 - 부모 메뉴 리다이렉트 컴포넌트 추가 Co-Authored-By: Claude Opus 4.5 --- .gitignore | 3 + ... list-page-ui-standardization-checklist.md | 180 ++++++ .../[locale]/(protected)/accounting/page.tsx | 14 + .../[locale]/(protected)/approval/page.tsx | 14 + .../(protected)/boards/[boardCode]/page.tsx | 32 +- .../(protected)/construction/page.tsx | 14 + .../(protected)/customer-center/page.tsx | 14 + src/app/[locale]/(protected)/hr/page.tsx | 14 + .../[locale]/(protected)/master-data/page.tsx | 14 + .../[locale]/(protected)/material/page.tsx | 14 + .../[locale]/(protected)/outbound/page.tsx | 14 + .../[locale]/(protected)/production/page.tsx | 14 + src/app/[locale]/(protected)/quality/page.tsx | 14 + src/app/[locale]/(protected)/sales/page.tsx | 14 + .../[locale]/(protected)/settings/page.tsx | 14 + .../accounting/BadDebtCollection/index.tsx | 1 + .../BankTransactionInquiry/actions.ts | 3 +- .../BankTransactionInquiry/index.tsx | 22 +- .../BillManagement/BillManagementClient.tsx | 74 ++- .../CardTransactionInquiry/index.tsx | 107 ++-- .../accounting/DepositManagement/index.tsx | 103 ++-- .../ExpectedExpenseManagement/index.tsx | 88 ++- .../accounting/PurchaseManagement/index.tsx | 67 ++- .../accounting/SalesManagement/index.tsx | 67 ++- .../accounting/VendorManagement/index.tsx | 1 + .../accounting/WithdrawalManagement/index.tsx | 131 +++-- src/components/approval/ApprovalBox/index.tsx | 5 + src/components/approval/DraftBox/index.tsx | 5 + .../approval/ReferenceBox/index.tsx | 5 + src/components/atoms/TabChip.tsx | 18 +- .../bidding/BiddingListClient.tsx | 12 +- .../contract/ContractListClient.tsx | 11 +- .../estimates/EstimateListClient.tsx | 12 +- .../HandoverReportListClient.tsx | 11 +- .../IssueManagementListClient.tsx | 11 +- .../item-management/ItemManagementClient.tsx | 1 + .../LaborManagementClient.tsx | 10 +- .../ConstructionManagementListClient.tsx | 10 +- .../management/ProjectDetailClient.tsx | 24 +- .../OrderManagementListClient.tsx | 10 +- .../partners/PartnerListClient.tsx | 1 + .../ProgressBillingManagementListClient.tsx | 11 +- .../site-briefings/SiteBriefingListClient.tsx | 11 +- .../SiteManagementListClient.tsx | 11 +- .../StructureReviewListClient.tsx | 11 +- .../UtilityManagementListClient.tsx | 11 +- .../worker-status/WorkerStatusListClient.tsx | 11 +- src/components/common/ParentMenuRedirect.tsx | 80 +++ .../EventManagement/EventList.tsx | 1 + .../InquiryManagement/InquiryList.tsx | 1 + .../NoticeManagement/NoticeList.tsx | 1 + .../hr/AttendanceManagement/index.tsx | 8 + src/components/hr/CardManagement/index.tsx | 13 +- .../hr/EmployeeManagement/index.tsx | 9 +- src/components/hr/SalaryManagement/index.tsx | 7 + .../hr/VacationManagement/index.tsx | 82 +-- src/components/items/ItemListClient.tsx | 176 +++++- .../molecules/DateRangeSelector.tsx | 134 +++-- src/components/organisms/StatCards.tsx | 14 +- src/components/pricing/PricingListClient.tsx | 17 +- .../process-management/ProcessListClient.tsx | 67 ++- .../templates/IntegratedListTemplateV2.tsx | 80 ++- .../templates/UniversalListPage/index.tsx | 2 +- .../templates/UniversalListPage/types.ts | 6 + src/lib/utils/excel-download.ts | 520 ++++++++++++++++++ 65 files changed, 1974 insertions(+), 503 deletions(-) create mode 100644 claudedocs/[IMPL-2025-01-26] list-page-ui-standardization-checklist.md create mode 100644 src/app/[locale]/(protected)/accounting/page.tsx create mode 100644 src/app/[locale]/(protected)/approval/page.tsx create mode 100644 src/app/[locale]/(protected)/construction/page.tsx create mode 100644 src/app/[locale]/(protected)/customer-center/page.tsx create mode 100644 src/app/[locale]/(protected)/hr/page.tsx create mode 100644 src/app/[locale]/(protected)/master-data/page.tsx create mode 100644 src/app/[locale]/(protected)/material/page.tsx create mode 100644 src/app/[locale]/(protected)/outbound/page.tsx create mode 100644 src/app/[locale]/(protected)/production/page.tsx create mode 100644 src/app/[locale]/(protected)/quality/page.tsx create mode 100644 src/app/[locale]/(protected)/sales/page.tsx create mode 100644 src/app/[locale]/(protected)/settings/page.tsx create mode 100644 src/components/common/ParentMenuRedirect.tsx create mode 100644 src/lib/utils/excel-download.ts diff --git a/.gitignore b/.gitignore index 56a00b16..a5045aad 100644 --- a/.gitignore +++ b/.gitignore @@ -116,3 +116,6 @@ tsconfig.tsbuildinfo # ---> Dev Page Builder (프로토타입 - 로컬 전용) src/app/**/dev/page-builder/ + +# ---> Dev Dashboard Prototypes (디자인 프로토타입 - 로컬 전용) +src/app/**/dev/dashboard/ diff --git a/claudedocs/[IMPL-2025-01-26] list-page-ui-standardization-checklist.md b/claudedocs/[IMPL-2025-01-26] list-page-ui-standardization-checklist.md new file mode 100644 index 00000000..e43a7a17 --- /dev/null +++ b/claudedocs/[IMPL-2025-01-26] list-page-ui-standardization-checklist.md @@ -0,0 +1,180 @@ +# 리스트 페이지 UI 표준화 체크리스트 + +## 📋 프로젝트 개요 +- **목적**: 모든 리스트 페이지에 공정관리/출금관리 UI 패턴 적용 +- **시작일**: 2025-01-26 +- **프로토타입**: ProcessListClient, WithdrawalManagement + +## 🎯 적용 패턴 + +### 1. 검색창 패턴 +```tsx +dateRangeSelector: { + enabled: true, + showPresets: true, + startDate, + endDate, + onStartDateChange: setStartDate, + onEndDateChange: setEndDate, + extraActions: ( +
+ + setSearchQuery(e.target.value)} + className="pl-9 w-full bg-white" + /> +
+ ), +}, +hideSearch: true, +``` + +### 2. beforeTableContent 모바일 레이아웃 +```tsx +beforeTableContent: ({ selectedItems }) => ( +
+
+ {/* 버튼들 */} +
+
+), +``` + +### 3. customFilterFn 방어적 코딩 +```tsx +customFilterFn: (items) => { + if (!items || items.length === 0) return items; + return items.filter((item) => { + // 필터 로직 + }); +}, +``` + +--- + +## 그룹 A: DateRangeSelector 사용 페이지 (26개) + +### 회계관리 (5개) +- [x] `src/components/accounting/SalesManagement/index.tsx` +- [x] `src/components/accounting/PurchaseManagement/index.tsx` +- [x] `src/components/accounting/DepositManagement/index.tsx` +- [x] `src/components/accounting/CardTransactionInquiry/index.tsx` +- [x] `src/components/accounting/BadDebtCollection/index.tsx` ※dateRangeSelector 없음, customFilterFn만 수정 + +### 인사관리 (4개) +- [x] `src/components/hr/EmployeeManagement/index.tsx` +- [x] `src/components/hr/AttendanceManagement/index.tsx` +- [x] `src/components/hr/SalaryManagement/index.tsx` +- [x] `src/components/hr/VacationManagement/index.tsx` ※headerActions 패턴 사용 + +### 건설관리 (17개) +- [x] `src/components/business/construction/bidding/BiddingListClient.tsx` +- [x] `src/components/business/construction/contract/ContractListClient.tsx` +- [x] `src/components/business/construction/estimates/EstimateListClient.tsx` +- [x] `src/components/business/construction/handover-report/HandoverReportListClient.tsx` +- [x] `src/components/business/construction/issue-management/IssueManagementListClient.tsx` +- [x] `src/components/business/construction/item-management/ItemManagementClient.tsx` ※externalSearch 패턴 - hideSearch만 추가 +- [x] `src/components/business/construction/labor-management/LaborManagementClient.tsx` +- [x] `src/components/business/construction/management/ConstructionManagementListClient.tsx` +- [N/A] `src/components/business/construction/management/ProjectListClient.tsx` ※UniversalListPage 미사용 (커스텀 Gantt차트 구현) +- [x] `src/components/business/construction/order-management/OrderManagementListClient.tsx` +- [x] `src/components/business/construction/partners/PartnerListClient.tsx` ※dateRangeSelector 없음, customFilterFn 방어코드만 추가 +- [x] `src/components/business/construction/progress-billing/ProgressBillingManagementListClient.tsx` +- [x] `src/components/business/construction/site-briefings/SiteBriefingListClient.tsx` +- [x] `src/components/business/construction/site-management/SiteManagementListClient.tsx` +- [x] `src/components/business/construction/structure-review/StructureReviewListClient.tsx` +- [x] `src/components/business/construction/utility-management/UtilityManagementListClient.tsx` +- [x] `src/components/business/construction/worker-status/WorkerStatusListClient.tsx` + +--- + +## 그룹 B: DateRangeSelector 미사용 페이지 (11개) + +### 기준정보 (2개) +- [N/A] `src/components/items/ItemListClient.tsx` ※useItemList 훅 패턴, customFilterFn 없음 +- [N/A] `src/components/pricing/PricingListClient.tsx` ※tabFilter/searchFilter 패턴, customFilterFn 없음 + +### 회계관리 (1개) +- [x] `src/components/accounting/VendorManagement/index.tsx` ※customFilterFn 방어코드 추가 + +### 인사관리 (1개) +- [N/A] `src/components/hr/CardManagement/index.tsx` ※tabFilter/searchFilter 패턴, customFilterFn 없음 + +### 게시판 (1개) +- [N/A] `src/components/board/BoardManagement/index.tsx` ※tabFilter/searchFilter 패턴, customFilterFn 없음 + +### 설정 (2개) +- [N/A] `src/components/settings/PermissionManagement/index.tsx` ※externalSearch 패턴, customFilterFn 없음 +- [N/A] `src/components/settings/AccountManagement/index.tsx` ※getList 내 검색 처리, customFilterFn 없음 + +### 고객센터 (3개) +- [x] `src/components/customer-center/NoticeManagement/NoticeList.tsx` ※customFilterFn 방어코드 추가 +- [x] `src/components/customer-center/InquiryManagement/InquiryList.tsx` ※customFilterFn 방어코드 추가 +- [x] `src/components/customer-center/EventManagement/EventList.tsx` ※customFilterFn 방어코드 추가 + +--- + +## 그룹 C: IntegratedListTemplateV2 마이그레이션 (7개) +※ 분석 결과: 모두 UniversalListPage 이미 사용 중, customFilterFn 없거나 그룹 B에서 처리됨 + +- [N/A] `src/components/accounting/ExpectedExpenseManagement/index.tsx` ※이미 UniversalListPage, tableData에서 필터링 +- [N/A] `src/components/accounting/BillManagement/index.tsx` ※이미 UniversalListPage, 서버 사이드 필터링 +- [x] `src/components/customer-center/NoticeManagement/NoticeList.tsx` ※그룹 B에서 처리됨 +- [x] `src/components/customer-center/InquiryManagement/InquiryList.tsx` ※그룹 B에서 처리됨 +- [x] `src/components/customer-center/EventManagement/EventList.tsx` ※그룹 B에서 처리됨 +- [N/A] `src/components/quotes/QuoteManagementClient.tsx` ※이미 UniversalListPage, tabFilter/searchFilter만 사용 +- [N/A] `src/components/settings/PopupManagement/PopupList.tsx` ※이미 UniversalListPage, searchFilter만 사용 + +--- + +## 📊 진행 현황 + +| 그룹 | 총 개수 | 완료 | N/A | 진행률 | +|-----|--------|-----|-----|-------| +| A | 26 | 25 | 1 | 100% | +| B | 11 | 4 | 7 | 100% | +| C | 7 | 3 | 4 | 100% | +| **합계** | **44** | **32** | **12** | **100%** | + +--- + +## 📝 작업 로그 + +### 2025-01-26 +- 체크리스트 생성 +- 프로토타입 분석 완료 (ProcessListClient, WithdrawalManagement) +- 그룹 A 회계관리 5개 완료: + - SalesManagement: extraActions 검색창, hideSearch, 모바일 레이아웃, 방어적 코딩 + - PurchaseManagement: extraActions 검색창, hideSearch, 모바일 레이아웃, 방어적 코딩 + - DepositManagement: extraActions 검색창, hideSearch, 모바일 레이아웃, 방어적 코딩 + - CardTransactionInquiry: extraActions 검색창, hideSearch, 모바일 레이아웃, 방어적 코딩 + - BadDebtCollection: 방어적 코딩만 (dateRangeSelector 없음) +- 그룹 A 인사관리 4개 완료: + - EmployeeManagement: extraActions 검색창, hideSearch, 방어적 코딩 + - AttendanceManagement: extraActions 검색창, hideSearch, 방어적 코딩 + - SalaryManagement: extraActions 검색창, hideSearch + - VacationManagement: headerActions 내 검색창, hideSearch (다른 구조 사용) +- 그룹 A 건설관리 16개 완료 (1개 N/A): + - BiddingListClient, ContractListClient, EstimateListClient, HandoverReportListClient: 전체 패턴 적용 + - IssueManagementListClient, LaborManagementClient, ConstructionManagementListClient, OrderManagementListClient: 전체 패턴 적용 + - ProgressBillingManagementListClient, SiteBriefingListClient, SiteManagementListClient: 전체 패턴 적용 + - StructureReviewListClient, UtilityManagementListClient, WorkerStatusListClient: 전체 패턴 적용 + - ItemManagementClient: externalSearch 패턴 사용 - hideSearch만 추가 + - PartnerListClient: dateRangeSelector 없음 - 방어적 코딩만 추가 + - ProjectListClient: UniversalListPage 미사용 (커스텀 Gantt차트) - 제외 +- 그룹 B 분석 및 수정 완료 (11개 분석, 4개 수정): + - 대부분 tabFilter/searchFilter 또는 externalSearch 패턴 사용 (customFilterFn 없음) + - VendorManagement: customFilterFn 방어코드 추가 + - NoticeList: customFilterFn 방어코드 추가 + - InquiryList: customFilterFn 방어코드 추가 + - EventList: customFilterFn 방어코드 추가 + - N/A 처리: ItemListClient, PricingListClient, CardManagement, BoardManagement, PermissionManagement, AccountManagement (다른 패턴 사용) +- 그룹 C 분석 완료 (7개 분석): + - 모든 파일이 이미 UniversalListPage 사용 중 (IntegratedListTemplateV2 아님) + - NoticeList, InquiryList, EventList: 그룹 B에서 방어코드 추가됨 + - ExpectedExpenseManagement, BillManagement: 서버 사이드/테이블 데이터 필터링 사용 + - QuoteManagementClient, PopupList: tabFilter/searchFilter 패턴만 사용 +- **프로젝트 완료**: 총 44개 파일 분석, 32개 수정, 12개 N/A (다른 패턴 사용) diff --git a/src/app/[locale]/(protected)/accounting/page.tsx b/src/app/[locale]/(protected)/accounting/page.tsx new file mode 100644 index 00000000..7a1778e5 --- /dev/null +++ b/src/app/[locale]/(protected)/accounting/page.tsx @@ -0,0 +1,14 @@ +import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect'; + +/** + * 회계관리 - 부모 메뉴 동적 리다이렉트 + * 메뉴 구조에서 첫 번째 자식으로 자동 이동 + */ +export default function AccountingPage() { + return ( + + ); +} diff --git a/src/app/[locale]/(protected)/approval/page.tsx b/src/app/[locale]/(protected)/approval/page.tsx new file mode 100644 index 00000000..3d71b8c8 --- /dev/null +++ b/src/app/[locale]/(protected)/approval/page.tsx @@ -0,0 +1,14 @@ +import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect'; + +/** + * 결재관리 - 부모 메뉴 동적 리다이렉트 + * 메뉴 구조에서 첫 번째 자식으로 자동 이동 + */ +export default function ApprovalPage() { + return ( + + ); +} diff --git a/src/app/[locale]/(protected)/boards/[boardCode]/page.tsx b/src/app/[locale]/(protected)/boards/[boardCode]/page.tsx index 5a711d7d..ef76aa48 100644 --- a/src/app/[locale]/(protected)/boards/[boardCode]/page.tsx +++ b/src/app/[locale]/(protected)/boards/[boardCode]/page.tsx @@ -12,7 +12,6 @@ import { format } from 'date-fns'; import { TableCell, TableRow } from '@/components/ui/table'; import { Card, CardContent } from '@/components/ui/card'; import { Checkbox } from '@/components/ui/checkbox'; -import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; import { Select, @@ -26,7 +25,6 @@ import { type UniversalListConfig, type TableColumn, } from '@/components/templates/UniversalListPage'; -import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { getDynamicBoardPosts } from '@/components/board/DynamicBoard/actions'; import { getBoardByCode } from '@/components/board/BoardManagement/actions'; import type { PostApiData } from '@/components/customer-center/shared/types'; @@ -340,20 +338,22 @@ export default function DynamicBoardListPage() { }, columns: tableColumns, - headerActions: () => ( - <> - - - - ), + + // 공통 패턴: dateRangeSelector + createButton + onSearchChange + dateRangeSelector: { + enabled: true, + startDate, + endDate, + onStartDateChange: setStartDate, + onEndDateChange: setEndDate, + }, + createButton: { + label: '글쓰기', + icon: Plus, + onClick: handleCreate, + }, + onSearchChange: setSearchValue, + tableHeaderActions: (
diff --git a/src/app/[locale]/(protected)/construction/page.tsx b/src/app/[locale]/(protected)/construction/page.tsx new file mode 100644 index 00000000..6791a869 --- /dev/null +++ b/src/app/[locale]/(protected)/construction/page.tsx @@ -0,0 +1,14 @@ +import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect'; + +/** + * 건설관리 - 부모 메뉴 동적 리다이렉트 + * 메뉴 구조에서 첫 번째 자식으로 자동 이동 + */ +export default function ConstructionPage() { + return ( + + ); +} diff --git a/src/app/[locale]/(protected)/customer-center/page.tsx b/src/app/[locale]/(protected)/customer-center/page.tsx new file mode 100644 index 00000000..ce5da169 --- /dev/null +++ b/src/app/[locale]/(protected)/customer-center/page.tsx @@ -0,0 +1,14 @@ +import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect'; + +/** + * 고객센터 - 부모 메뉴 동적 리다이렉트 + * 메뉴 구조에서 첫 번째 자식으로 자동 이동 + */ +export default function CustomerCenterPage() { + return ( + + ); +} diff --git a/src/app/[locale]/(protected)/hr/page.tsx b/src/app/[locale]/(protected)/hr/page.tsx new file mode 100644 index 00000000..de632239 --- /dev/null +++ b/src/app/[locale]/(protected)/hr/page.tsx @@ -0,0 +1,14 @@ +import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect'; + +/** + * 인사관리 - 부모 메뉴 동적 리다이렉트 + * 메뉴 구조에서 첫 번째 자식으로 자동 이동 + */ +export default function HrPage() { + return ( + + ); +} diff --git a/src/app/[locale]/(protected)/master-data/page.tsx b/src/app/[locale]/(protected)/master-data/page.tsx new file mode 100644 index 00000000..7ca8b563 --- /dev/null +++ b/src/app/[locale]/(protected)/master-data/page.tsx @@ -0,0 +1,14 @@ +import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect'; + +/** + * 기준정보 - 부모 메뉴 동적 리다이렉트 + * 메뉴 구조에서 첫 번째 자식으로 자동 이동 + */ +export default function MasterDataPage() { + return ( + + ); +} diff --git a/src/app/[locale]/(protected)/material/page.tsx b/src/app/[locale]/(protected)/material/page.tsx new file mode 100644 index 00000000..7dfce9dd --- /dev/null +++ b/src/app/[locale]/(protected)/material/page.tsx @@ -0,0 +1,14 @@ +import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect'; + +/** + * 자재관리 - 부모 메뉴 동적 리다이렉트 + * 메뉴 구조에서 첫 번째 자식으로 자동 이동 + */ +export default function MaterialPage() { + return ( + + ); +} diff --git a/src/app/[locale]/(protected)/outbound/page.tsx b/src/app/[locale]/(protected)/outbound/page.tsx new file mode 100644 index 00000000..22b5c289 --- /dev/null +++ b/src/app/[locale]/(protected)/outbound/page.tsx @@ -0,0 +1,14 @@ +import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect'; + +/** + * 출고관리 - 부모 메뉴 동적 리다이렉트 + * 메뉴 구조에서 첫 번째 자식으로 자동 이동 + */ +export default function OutboundPage() { + return ( + + ); +} diff --git a/src/app/[locale]/(protected)/production/page.tsx b/src/app/[locale]/(protected)/production/page.tsx new file mode 100644 index 00000000..e7075351 --- /dev/null +++ b/src/app/[locale]/(protected)/production/page.tsx @@ -0,0 +1,14 @@ +import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect'; + +/** + * 생산관리 - 부모 메뉴 동적 리다이렉트 + * 메뉴 구조에서 첫 번째 자식으로 자동 이동 + */ +export default function ProductionPage() { + return ( + + ); +} diff --git a/src/app/[locale]/(protected)/quality/page.tsx b/src/app/[locale]/(protected)/quality/page.tsx new file mode 100644 index 00000000..965c2c77 --- /dev/null +++ b/src/app/[locale]/(protected)/quality/page.tsx @@ -0,0 +1,14 @@ +import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect'; + +/** + * 품질관리 - 부모 메뉴 동적 리다이렉트 + * 메뉴 구조에서 첫 번째 자식으로 자동 이동 + */ +export default function QualityPage() { + return ( + + ); +} diff --git a/src/app/[locale]/(protected)/sales/page.tsx b/src/app/[locale]/(protected)/sales/page.tsx new file mode 100644 index 00000000..647b74e0 --- /dev/null +++ b/src/app/[locale]/(protected)/sales/page.tsx @@ -0,0 +1,14 @@ +import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect'; + +/** + * 영업관리 - 부모 메뉴 동적 리다이렉트 + * 메뉴 구조에서 첫 번째 자식으로 자동 이동 + */ +export default function SalesPage() { + return ( + + ); +} diff --git a/src/app/[locale]/(protected)/settings/page.tsx b/src/app/[locale]/(protected)/settings/page.tsx new file mode 100644 index 00000000..0efe2db5 --- /dev/null +++ b/src/app/[locale]/(protected)/settings/page.tsx @@ -0,0 +1,14 @@ +import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect'; + +/** + * 설정 - 부모 메뉴 동적 리다이렉트 + * 메뉴 구조에서 첫 번째 자식으로 자동 이동 + */ +export default function SettingsPage() { + return ( + + ); +} diff --git a/src/components/accounting/BadDebtCollection/index.tsx b/src/components/accounting/BadDebtCollection/index.tsx index cefce929..3efb85b6 100644 --- a/src/components/accounting/BadDebtCollection/index.tsx +++ b/src/components/accounting/BadDebtCollection/index.tsx @@ -242,6 +242,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec // 커스텀 필터 함수 customFilterFn: (items) => { + if (!items || items.length === 0) return items; let result = [...items]; // 거래처 필터 diff --git a/src/components/accounting/BankTransactionInquiry/actions.ts b/src/components/accounting/BankTransactionInquiry/actions.ts index d02dee21..14054b47 100644 --- a/src/components/accounting/BankTransactionInquiry/actions.ts +++ b/src/components/accounting/BankTransactionInquiry/actions.ts @@ -48,7 +48,8 @@ interface PaginationMeta { // ===== API → Frontend 변환 ===== function transformItem(item: BankTransactionApiItem): BankTransaction { return { - id: String(item.id), + // 입금/출금 테이블이 별도이므로 type을 접두어로 붙여 고유 ID 생성 + id: `${item.type}-${item.id}`, bankName: item.bank_name, accountName: item.account_name, transactionDate: item.transaction_date, diff --git a/src/components/accounting/BankTransactionInquiry/index.tsx b/src/components/accounting/BankTransactionInquiry/index.tsx index b9d9b7ff..0e26cc59 100644 --- a/src/components/accounting/BankTransactionInquiry/index.tsx +++ b/src/components/accounting/BankTransactionInquiry/index.tsx @@ -279,18 +279,16 @@ export function BankTransactionInquiry({ onEndDateChange: setEndDate, }, - // 테이블 상단 콘텐츠 (새로고침 버튼) - beforeTableContent: ( -
- -
+ // 헤더 액션: 새로고침 버튼 + headerActions: () => ( + ), // 테이블 헤더 액션 (3개 필터) diff --git a/src/components/accounting/BillManagement/BillManagementClient.tsx b/src/components/accounting/BillManagement/BillManagementClient.tsx index b1b6e704..af8a0a2f 100644 --- a/src/components/accounting/BillManagement/BillManagementClient.tsx +++ b/src/components/accounting/BillManagement/BillManagementClient.tsx @@ -429,6 +429,42 @@ export function BillManagementClient({ icon: Plus, }, + // 헤더 액션: 상태 선택 + 저장 + 수취/발행 라디오 + headerActions: () => ( +
+ { setBillTypeFilter(value); loadData(1); }} + className="flex items-center gap-3" + > +
+ + +
+
+ + +
+
+ + +
+ ), + // 테이블 헤더 액션 (필터) tableHeaderActions: (
@@ -473,44 +509,6 @@ export function BillManagementClient({
), - // beforeTableContent: 상태 선택 + 저장 + 수취/발행 라디오 - beforeTableContent: ( -
- - - - - { setBillTypeFilter(value); loadData(1); }} - className="flex items-center gap-4" - > -
- - -
-
- - -
-
-
- ), - // 렌더링 함수 renderTableRow, renderMobileCard, diff --git a/src/components/accounting/CardTransactionInquiry/index.tsx b/src/components/accounting/CardTransactionInquiry/index.tsx index e885033e..76403490 100644 --- a/src/components/accounting/CardTransactionInquiry/index.tsx +++ b/src/components/accounting/CardTransactionInquiry/index.tsx @@ -17,7 +17,7 @@ import { useState, useMemo, useCallback, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import { format, startOfMonth, endOfMonth } from 'date-fns'; -import { CreditCard, Plus, RefreshCw, Save, Loader2 } from 'lucide-react'; +import { CreditCard, Plus, RefreshCw, Save, Loader2, Search } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Checkbox } from '@/components/ui/checkbox'; import { Input } from '@/components/ui/input'; @@ -380,18 +380,66 @@ export function CardTransactionInquiry({ }, filterTitle: '카드 필터', - // 헤더 액션 (등록 버튼) + // 헤더 액션: 계정과목명 Select + 저장 + 새로고침 headerActions: () => ( - +
+ 계정과목명 + + + +
), + // 등록 버튼 + createButton: { + label: '카드내역 등록', + icon: Plus, + onClick: () => router.push('/ko/accounting/card-transactions?mode=new'), + }, + // 커스텀 필터 함수 customFilterFn: (items) => { - if (cardFilter === 'all') return items; - return items.filter((item) => item.cardName === cardFilter); + if (!items || items.length === 0) return items; + let result = [...items]; + + // 검색어 필터 + if (searchQuery) { + const search = searchQuery.toLowerCase(); + result = result.filter((item) => + item.card.toLowerCase().includes(search) || + item.cardName.toLowerCase().includes(search) || + item.user.toLowerCase().includes(search) || + item.merchantName.toLowerCase().includes(search) + ); + } + + // 카드명 필터 + if (cardFilter !== 'all') { + result = result.filter((item) => item.cardName === cardFilter); + } + + return result; }, // 커스텀 정렬 함수 @@ -417,8 +465,15 @@ export function CardTransactionInquiry({ }, // 날짜 선택기 (헤더 액션) + // 검색창 (공통 컴포넌트에서 자동 생성) + hideSearch: true, + searchValue: searchQuery, + onSearchChange: setSearchQuery, + searchPlaceholder: '카드, 카드명, 사용자, 가맹점명 검색...', + dateRangeSelector: { enabled: true, + showPresets: true, startDate, endDate, onStartDateChange: setStartDate, @@ -428,42 +483,6 @@ export function CardTransactionInquiry({ // 선택 항목 변경 콜백 onSelectionChange: setSelectedItems, - // 테이블 상단 콘텐츠 (계정과목명 + 저장 + 새로고침) - beforeTableContent: ( -
-
- 계정과목명 - - -
- -
- ), - // 테이블 헤더 액션 (2개 필터) tableHeaderActions: () => (
diff --git a/src/components/accounting/DepositManagement/index.tsx b/src/components/accounting/DepositManagement/index.tsx index f8a9bbde..0bcf1594 100644 --- a/src/components/accounting/DepositManagement/index.tsx +++ b/src/components/accounting/DepositManagement/index.tsx @@ -22,8 +22,10 @@ import { Save, Trash2, RefreshCw, + Search, } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import { Checkbox } from '@/components/ui/checkbox'; import { Badge } from '@/components/ui/badge'; import { @@ -103,6 +105,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan const [endDate, setEndDate] = useState('2025-09-03'); const [depositData, setDepositData] = useState(initialData); const [isRefreshing, setIsRefreshing] = useState(false); + const [searchQuery, setSearchQuery] = useState(''); // 인라인 필터 상태 (tableHeaderActions에서 사용) const [vendorFilter, setVendorFilter] = useState('all'); @@ -262,7 +265,19 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan // 커스텀 필터 함수 (인라인 필터 사용) customFilterFn: (items, filterValues) => { + if (!items || items.length === 0) return items; return items.filter((item) => { + // 검색어 필터 + if (searchQuery) { + const search = searchQuery.toLowerCase(); + const matchesSearch = + item.depositorName.toLowerCase().includes(search) || + item.accountName.toLowerCase().includes(search) || + (item.note?.toLowerCase().includes(search) || false) || + (item.vendorName?.toLowerCase().includes(search) || false); + if (!matchesSearch) return false; + } + // 거래처 필터 if (vendorFilter !== 'all' && item.vendorName !== vendorFilter) { return false; @@ -295,9 +310,16 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan return sorted; }, + // 검색창 (공통 컴포넌트에서 자동 생성) + hideSearch: true, + searchValue: searchQuery, + onSearchChange: setSearchQuery, + searchPlaceholder: '입금자명, 계좌명, 적요, 거래처 검색...', + // 공통 헤더 옵션 dateRangeSelector: { enabled: true, + showPresets: true, startDate, endDate, onStartDateChange: setStartDate, @@ -325,14 +347,45 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan }, filterTitle: '입금 필터', - // 헤더 액션 (등록 버튼) - headerActions: () => ( - + // 헤더 액션: 계정과목명 Select + 저장 + 새로고침 + headerActions: ({ selectedItems }) => ( +
+ 계정과목명 + + + +
), + // 등록 버튼 + createButton: { + label: '입금등록', + icon: Plus, + onClick: () => router.push('/ko/accounting/deposits?mode=new'), + }, + // Stats 카드 computeStats: (): StatCard[] => [ { label: '총 입금', value: `${stats.totalDeposit.toLocaleString()}원`, icon: Banknote, iconColor: 'text-blue-500' }, @@ -341,44 +394,9 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan { label: '입금유형 미설정', value: `${stats.depositTypeUnsetCount}건`, icon: Banknote, iconColor: 'text-red-500' }, ], - // beforeTableContent: 계정과목명 Select + 저장 버튼 + 새로고침 - beforeTableContent: ( -
-
- 계정과목명 - -
- -
- ), - // tableHeaderActions: 3개 인라인 필터 - tableHeaderActions: ({ selectedItems }) => ( + tableHeaderActions: (
- {/* 저장 버튼 */} - - {/* 거래처 필터 */} - - - - - {vendorFilterOptions.map((option) => ( - - {option.label} - - ))} - - - - {/* 정렬 필터 (최신순/등록순) */} - -
- ), - - // 테이블 앞 컨텐츠 (액션 버튼) - beforeTableContent: ( -
- {/* 등록 버튼 */} - - {/* 예상 지급일 변경 버튼 - 1개 이상 선택 시 활성화 */} +
+ ), + // 통합 필터 시스템 (PC: 인라인, 모바일: 바텀시트 자동 분기) filterConfig, initialFilters: filterValues, @@ -358,29 +401,6 @@ export function PurchaseManagement() { { label: '세금계산서 수취 미확인', value: `${stats.taxInvoicePendingCount}건`, icon: Receipt, iconColor: 'text-red-500' }, ], - // beforeTableContent: 계정과목명 Select + 저장 버튼 (테이블 밖에 위치) - beforeTableContent: ({ selectedItems }) => ( -
- 계정과목명 - - -
- ), - // 테이블 하단 합계 행 tableFooter: ( @@ -532,6 +552,7 @@ export function PurchaseManagement() { filterValues, selectedAccountSubject, tableTotals, + searchQuery, handleRowClick, handleEdit, handleTaxInvoiceToggle, diff --git a/src/components/accounting/SalesManagement/index.tsx b/src/components/accounting/SalesManagement/index.tsx index a42caa11..5f2dd34a 100644 --- a/src/components/accounting/SalesManagement/index.tsx +++ b/src/components/accounting/SalesManagement/index.tsx @@ -22,8 +22,10 @@ import { Pencil, Save, Trash2, + Search, } from 'lucide-react'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import { Checkbox } from '@/components/ui/checkbox'; import { Badge } from '@/components/ui/badge'; import { Switch } from '@/components/ui/switch'; @@ -97,6 +99,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem const [startDate, setStartDate] = useState('2025-01-01'); const [endDate, setEndDate] = useState('2025-12-31'); const [salesData, setSalesData] = useState(initialData); + const [searchQuery, setSearchQuery] = useState(''); // 통합 필터 상태 (filterConfig 사용) const [filterValues, setFilterValues] = useState>({ @@ -297,7 +300,18 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem // 커스텀 필터 함수 (filterConfig 사용) customFilterFn: (items, fv) => { + if (!items || items.length === 0) return items; return items.filter((item) => { + // 검색어 필터 + if (searchQuery) { + const search = searchQuery.toLowerCase(); + const matchesSearch = + item.salesNo.toLowerCase().includes(search) || + item.vendorName.toLowerCase().includes(search) || + item.note.toLowerCase().includes(search); + if (!matchesSearch) return false; + } + const vendorVal = fv.vendor as string; const salesTypeVal = fv.salesType as string; const issuanceVal = fv.issuance as string; @@ -342,14 +356,43 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem return sorted; }, + // 검색창 (공통 컴포넌트에서 자동 생성) + hideSearch: true, + searchValue: searchQuery, + onSearchChange: setSearchQuery, + searchPlaceholder: '매출번호, 거래처명, 비고 검색...', + // 공통 헤더 옵션 dateRangeSelector: { enabled: true, + showPresets: true, startDate, endDate, onStartDateChange: setStartDate, onEndDateChange: setEndDate, }, + // 헤더 액션 (계정과목명 Select + 저장 버튼) + headerActions: ({ selectedItems }) => ( +
+ 계정과목명 + + +
+ ), createButton: { label: '매출 등록', onClick: handleCreate, @@ -368,29 +411,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem initialFilters: filterValues, filterTitle: '매출 필터', - // beforeTableContent: 계정과목명 Select + 저장 버튼 (테이블 밖에 위치) - beforeTableContent: ({ selectedItems }) => ( -
- 계정과목명 - - -
- ), - // 테이블 하단 합계 행 tableFooter: ( @@ -534,6 +554,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem filterValues, selectedAccountSubject, tableTotals, + searchQuery, handleRowClick, handleEdit, handleCreate, diff --git a/src/components/accounting/VendorManagement/index.tsx b/src/components/accounting/VendorManagement/index.tsx index 51e34123..bee05bba 100644 --- a/src/components/accounting/VendorManagement/index.tsx +++ b/src/components/accounting/VendorManagement/index.tsx @@ -211,6 +211,7 @@ export function VendorManagement({ initialData, initialTotal }: VendorManagement // 커스텀 필터 함수 customFilterFn: (items, filterValues) => { + if (!items || items.length === 0) return items; return items.filter((item) => { // 구분 필터 const categoryFilter = filterValues.category as string; diff --git a/src/components/accounting/WithdrawalManagement/index.tsx b/src/components/accounting/WithdrawalManagement/index.tsx index 8de18de4..bd7b757f 100644 --- a/src/components/accounting/WithdrawalManagement/index.tsx +++ b/src/components/accounting/WithdrawalManagement/index.tsx @@ -73,14 +73,13 @@ import { toast } from 'sonner'; // ===== 테이블 컬럼 정의 ===== const tableColumns = [ - { key: 'withdrawalDate', label: '출금일' }, - { key: 'accountName', label: '출금계좌' }, - { key: 'recipientName', label: '수취인명' }, - { key: 'withdrawalAmount', label: '출금금액', className: 'text-right' }, - { key: 'vendorName', label: '거래처' }, - { key: 'note', label: '적요' }, - { key: 'withdrawalType', label: '출금유형', className: 'text-center' }, - { key: 'actions', label: '작업', className: 'text-center w-[80px]' }, + { key: 'withdrawalDate', label: '출금일', className: 'w-[100px]' }, + { key: 'accountName', label: '출금계좌', className: 'min-w-[120px]' }, + { key: 'recipientName', label: '수취인명', className: 'min-w-[100px]' }, + { key: 'withdrawalAmount', label: '출금금액', className: 'text-right w-[110px]' }, + { key: 'vendorName', label: '거래처', className: 'min-w-[100px]' }, + { key: 'note', label: '적요', className: 'min-w-[150px]' }, + { key: 'withdrawalType', label: '출금유형', className: 'text-center w-[90px]' }, ]; // ===== 컴포넌트 Props ===== @@ -112,6 +111,9 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra // 상단 계정과목명 선택 (저장용) const [selectedAccountSubject, setSelectedAccountSubject] = useState('unset'); + // 검색어 상태 (헤더에서 직접 관리) + const [searchQuery, setSearchQuery] = useState(''); + // 로딩 상태 const [isRefreshing, setIsRefreshing] = useState(false); @@ -297,17 +299,23 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra }, filterTitle: '출금 필터', - // 헤더 액션 (등록 버튼) - headerActions: () => ( - - ), + // 검색창 숨김 (dateRangeSelector extraActions로 렌더링) + hideSearch: true, - // 커스텀 필터 함수 + // 커스텀 필터 함수 (검색 + 필터) customFilterFn: (items) => { return items.filter((item) => { + // 검색어 필터 + if (searchQuery) { + const search = searchQuery.toLowerCase(); + const matchesSearch = + item.recipientName.toLowerCase().includes(search) || + item.accountName.toLowerCase().includes(search) || + item.note.toLowerCase().includes(search) || + item.vendorName.toLowerCase().includes(search); + if (!matchesSearch) return false; + } + // 거래처 필터 if (vendorFilter !== 'all' && item.vendorName !== vendorFilter) { return false; @@ -342,23 +350,30 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra return sorted; }, - // 날짜 범위 선택기 + // 검색창 (공통 컴포넌트에서 자동 생성) + hideSearch: true, + searchValue: searchQuery, + onSearchChange: setSearchQuery, + + // 날짜 범위 선택기 (달력 | 프리셋버튼 | 검색창(자동) - 한 줄) dateRangeSelector: { enabled: true, + showPresets: true, startDate, endDate, onStartDateChange: setStartDate, onEndDateChange: setEndDate, }, - // beforeTableContent: 계정과목명 + 저장 + 새로고침 - beforeTableContent: ( -
+ // 헤더 액션: 계정과목명 Select + 저장 + 새로고침 + headerActions: ({ selectedItems }) => { + const selectedArray = withdrawalData.filter(item => selectedItems.has(item.id)); + return (
- 계정과목명 + 계정과목명 + +
- -
- ), + ); + }, - // tableHeaderActions: 저장 버튼 + 인라인 필터들 - tableHeaderActions: ({ selectedItems }) => ( + // 등록 버튼 + createButton: { + label: '출금등록', + icon: Plus, + onClick: () => router.push('/ko/accounting/withdrawals?mode=new'), + }, + + // tableHeaderActions: 필터만 (거래처, 출금유형, 정렬) + tableHeaderActions: () => (
- - {/* 거래처 필터 */} setSearchQuery(e.target.value)} + className="pl-10" + /> +
+ } /> {/* 상태 카드 */} @@ -155,17 +166,6 @@ export default function ProjectDetailClient({ projectId }: ProjectDetailClientPr
- {/* 검색 영역 */} -
- - setSearchQuery(e.target.value)} - className="pl-10" - /> -
- {/* 칸반 보드 */} diff --git a/src/components/business/construction/order-management/OrderManagementListClient.tsx b/src/components/business/construction/order-management/OrderManagementListClient.tsx index a857db3d..06d56acd 100644 --- a/src/components/business/construction/order-management/OrderManagementListClient.tsx +++ b/src/components/business/construction/order-management/OrderManagementListClient.tsx @@ -89,6 +89,7 @@ export default function OrderManagementListClient({ // ===== 외부 상태 (UniversalListPage 외부에서 관리) ===== const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); // 달력 관련 상태 const [selectedCalendarDate, setSelectedCalendarDate] = useState(null); @@ -304,6 +305,7 @@ export default function OrderManagementListClient({ // 커스텀 필터 함수 customFilterFn: (items, filterValues) => { + if (!items || items.length === 0) return items; return items.filter((item) => { // 거래처 필터 (다중선택) const partnerFilters = filterValues.partners as string[]; @@ -423,6 +425,11 @@ export default function OrderManagementListClient({ return sorted; }, + // 검색창 (공통 컴포넌트에서 자동 생성) + hideSearch: true, + searchValue: searchQuery, + onSearchChange: setSearchQuery, + // 공통 헤더 옵션 dateRangeSelector: { enabled: true, @@ -584,6 +591,7 @@ export default function OrderManagementListClient({ [ startDate, endDate, + searchQuery, selectedCalendarDate, calendarEvents, calendarBadges, @@ -606,5 +614,5 @@ export default function OrderManagementListClient({ ] ); - return ; + return ; } diff --git a/src/components/business/construction/partners/PartnerListClient.tsx b/src/components/business/construction/partners/PartnerListClient.tsx index 40ab7219..cce614f4 100644 --- a/src/components/business/construction/partners/PartnerListClient.tsx +++ b/src/components/business/construction/partners/PartnerListClient.tsx @@ -188,6 +188,7 @@ export default function PartnerListClient({ initialData = [], initialStats }: Pa // 커스텀 필터 함수 customFilterFn: (items, filterValues) => { + if (!items || items.length === 0) return items; return items.filter((item) => { // 악성채권 필터 const badDebtFilter = filterValues.badDebt as string; diff --git a/src/components/business/construction/progress-billing/ProgressBillingManagementListClient.tsx b/src/components/business/construction/progress-billing/ProgressBillingManagementListClient.tsx index e3ca015e..54407a8a 100644 --- a/src/components/business/construction/progress-billing/ProgressBillingManagementListClient.tsx +++ b/src/components/business/construction/progress-billing/ProgressBillingManagementListClient.tsx @@ -75,6 +75,7 @@ export default function ProgressBillingManagementListClient({ const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); const [stats, setStats] = useState(initialStats || null); + const [searchQuery, setSearchQuery] = useState(''); // Stats 로드 useEffect(() => { @@ -188,6 +189,7 @@ export default function ProgressBillingManagementListClient({ // 커스텀 필터 함수 customFilterFn: (items, filterValues) => { + if (!items || items.length === 0) return items; return items.filter((item) => { // Stats 탭 필터 if (activeStatTab === 'contractWaiting' && @@ -239,6 +241,11 @@ export default function ProgressBillingManagementListClient({ return sorted; }, + // 검색창 (공통 컴포넌트에서 자동 생성) + hideSearch: true, + searchValue: searchQuery, + onSearchChange: setSearchQuery, + // 공통 헤더 옵션 dateRangeSelector: { enabled: true, @@ -350,8 +357,8 @@ export default function ProgressBillingManagementListClient({ /> ), }), - [startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit] + [startDate, endDate, searchQuery, activeStatTab, stats, handleRowClick, handleEdit] ); - return ; + return ; } diff --git a/src/components/business/construction/site-briefings/SiteBriefingListClient.tsx b/src/components/business/construction/site-briefings/SiteBriefingListClient.tsx index 75760c00..2e0df114 100644 --- a/src/components/business/construction/site-briefings/SiteBriefingListClient.tsx +++ b/src/components/business/construction/site-briefings/SiteBriefingListClient.tsx @@ -97,6 +97,7 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin // 날짜 범위 const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); // ===== 핸들러 ===== const handleRowClick = useCallback( @@ -187,6 +188,7 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin // 커스텀 필터 함수 (activeStatTab 필터링 포함) customFilterFn: (items, filterValues) => { + if (!items || items.length === 0) return items; return items.filter((item) => { // Stats 탭 필터 if (activeStatTab === 'scheduled' && item.attendanceStatus !== 'scheduled') return false; @@ -216,6 +218,11 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin return sorted; }, + // 검색창 (공통 컴포넌트에서 자동 생성) + hideSearch: true, + searchValue: searchQuery, + onSearchChange: setSearchQuery, + // 날짜 범위 선택기 dateRangeSelector: { enabled: true, @@ -350,8 +357,8 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin ); }, }), - [handleRowClick, handleEdit, handleCreate, activeStatTab, startDate, endDate] + [handleRowClick, handleEdit, handleCreate, activeStatTab, startDate, endDate, searchQuery] ); - return ; + return ; } diff --git a/src/components/business/construction/site-management/SiteManagementListClient.tsx b/src/components/business/construction/site-management/SiteManagementListClient.tsx index 5c6137f0..cb30a8b4 100644 --- a/src/components/business/construction/site-management/SiteManagementListClient.tsx +++ b/src/components/business/construction/site-management/SiteManagementListClient.tsx @@ -68,6 +68,7 @@ export default function SiteManagementListClient({ const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); const [stats, setStats] = useState(initialStats || null); + const [searchQuery, setSearchQuery] = useState(''); // Stats 로드 useEffect(() => { @@ -183,6 +184,7 @@ export default function SiteManagementListClient({ // 커스텀 필터 함수 customFilterFn: (items, filterValues) => { + if (!items || items.length === 0) return items; return items.filter((item) => { // Stats 탭 필터 if (activeStatTab === 'construction' && item.status !== 'active') return false; @@ -228,6 +230,11 @@ export default function SiteManagementListClient({ return sorted; }, + // 검색창 (공통 컴포넌트에서 자동 생성) + hideSearch: true, + searchValue: searchQuery, + onSearchChange: setSearchQuery, + // 공통 헤더 옵션 dateRangeSelector: { enabled: true, @@ -337,8 +344,8 @@ export default function SiteManagementListClient({ /> ), }), - [startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit] + [startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit, searchQuery] ); - return ; + return ; } diff --git a/src/components/business/construction/structure-review/StructureReviewListClient.tsx b/src/components/business/construction/structure-review/StructureReviewListClient.tsx index d954a8b1..a393187a 100644 --- a/src/components/business/construction/structure-review/StructureReviewListClient.tsx +++ b/src/components/business/construction/structure-review/StructureReviewListClient.tsx @@ -82,6 +82,7 @@ export default function StructureReviewListClient({ const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); const [stats, setStats] = useState(initialStats || null); + const [searchQuery, setSearchQuery] = useState(''); // Stats 로드 useEffect(() => { @@ -201,6 +202,7 @@ export default function StructureReviewListClient({ // 커스텀 필터 함수 customFilterFn: (items, filterValues) => { + if (!items || items.length === 0) return items; return items.filter((item) => { // Stats 탭 필터 if (activeStatTab === 'pending' && item.status !== 'pending') return false; @@ -246,6 +248,11 @@ export default function StructureReviewListClient({ return sorted; }, + // 검색창 (공통 컴포넌트에서 자동 생성) + hideSearch: true, + searchValue: searchQuery, + onSearchChange: setSearchQuery, + // 공통 헤더 옵션 dateRangeSelector: { enabled: true, @@ -376,8 +383,8 @@ export default function StructureReviewListClient({ /> ), }), - [startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit, handleCreate] + [startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit, handleCreate, searchQuery] ); - return ; + return ; } diff --git a/src/components/business/construction/utility-management/UtilityManagementListClient.tsx b/src/components/business/construction/utility-management/UtilityManagementListClient.tsx index 749c5a8c..f2ff2637 100644 --- a/src/components/business/construction/utility-management/UtilityManagementListClient.tsx +++ b/src/components/business/construction/utility-management/UtilityManagementListClient.tsx @@ -84,6 +84,7 @@ export default function UtilityManagementListClient({ const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); const [stats, setStats] = useState(initialStats || null); + const [searchQuery, setSearchQuery] = useState(''); // Stats 로드 useEffect(() => { @@ -212,6 +213,7 @@ export default function UtilityManagementListClient({ // 커스텀 필터 함수 customFilterFn: (items, filterValues) => { + if (!items || items.length === 0) return items; return items.filter((item) => { // Stats 탭 필터 if (activeStatTab === 'waiting' && item.status !== 'scheduled' && item.status !== 'issued') return false; @@ -279,6 +281,11 @@ export default function UtilityManagementListClient({ return sorted; }, + // 검색창 (공통 컴포넌트에서 자동 생성) + hideSearch: true, + searchValue: searchQuery, + onSearchChange: setSearchQuery, + // 공통 헤더 옵션 dateRangeSelector: { enabled: true, @@ -389,8 +396,8 @@ export default function UtilityManagementListClient({ /> ), }), - [startDate, endDate, activeStatTab, stats] + [startDate, endDate, activeStatTab, stats, searchQuery] ); - return ; + return ; } diff --git a/src/components/business/construction/worker-status/WorkerStatusListClient.tsx b/src/components/business/construction/worker-status/WorkerStatusListClient.tsx index 7d807114..19a49491 100644 --- a/src/components/business/construction/worker-status/WorkerStatusListClient.tsx +++ b/src/components/business/construction/worker-status/WorkerStatusListClient.tsx @@ -84,6 +84,7 @@ export default function WorkerStatusListClient({ const [startDate, setStartDate] = useState(''); const [endDate, setEndDate] = useState(''); const [stats, setStats] = useState(initialStats || null); + const [searchQuery, setSearchQuery] = useState(''); // Stats 로드 useEffect(() => { @@ -220,6 +221,7 @@ export default function WorkerStatusListClient({ // 커스텀 필터 함수 customFilterFn: (items, filterValues) => { + if (!items || items.length === 0) return items; return items.filter((item) => { // Stats 탭 필터 (계약상태) if (activeStatTab !== 'all' && item.contractStatus !== activeStatTab) return false; @@ -300,6 +302,11 @@ export default function WorkerStatusListClient({ return sorted; }, + // 검색창 (공통 컴포넌트에서 자동 생성) + hideSearch: true, + searchValue: searchQuery, + onSearchChange: setSearchQuery, + // 공통 헤더 옵션 dateRangeSelector: { enabled: true, @@ -415,8 +422,8 @@ export default function WorkerStatusListClient({ /> ), }), - [startDate, endDate, activeStatTab, stats, handleRowClick, handleViewDetail] + [startDate, endDate, activeStatTab, stats, handleRowClick, handleViewDetail, searchQuery] ); - return ; + return ; } diff --git a/src/components/common/ParentMenuRedirect.tsx b/src/components/common/ParentMenuRedirect.tsx new file mode 100644 index 00000000..a3bd9a92 --- /dev/null +++ b/src/components/common/ParentMenuRedirect.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { useEffect } from 'react'; +import { useRouter, usePathname } from 'next/navigation'; + +interface ParentMenuRedirectProps { + /** 현재 부모 메뉴 경로 (예: '/accounting') */ + parentPath: string; + /** 메뉴 데이터를 찾지 못했을 때 사용할 기본 첫 번째 자식 경로 */ + fallbackPath: string; +} + +/** + * 부모 메뉴 URL 접근 시 첫 번째 자식 메뉴로 동적 리다이렉트 + * + * localStorage에 저장된 메뉴 구조를 읽어서 해당 부모의 첫 번째 자식으로 이동합니다. + * 메뉴 구조가 변경되어도 자동으로 대응됩니다. + */ +export function ParentMenuRedirect({ parentPath, fallbackPath }: ParentMenuRedirectProps) { + const router = useRouter(); + const pathname = usePathname(); + + useEffect(() => { + try { + // localStorage에서 user 데이터 읽기 + const userData = localStorage.getItem('user'); + if (!userData) { + router.replace(fallbackPath); + return; + } + + const parsed = JSON.parse(userData); + const menuItems = parsed.menu; + + if (!menuItems || !Array.isArray(menuItems)) { + router.replace(fallbackPath); + return; + } + + // 현재 부모 메뉴 찾기 (재귀적으로 검색) + const findParentMenu = (items: any[], targetPath: string): any | null => { + for (const item of items) { + // 경로가 일치하는지 확인 (locale prefix 제거 후 비교) + const itemPath = item.path?.replace(/^\/[a-z]{2}\//, '/') || ''; + if (itemPath === targetPath || item.path === targetPath) { + return item; + } + // 자식 메뉴에서 검색 + if (item.children && item.children.length > 0) { + const found = findParentMenu(item.children, targetPath); + if (found) return found; + } + } + return null; + }; + + const parentMenu = findParentMenu(menuItems, parentPath); + + if (parentMenu && parentMenu.children && parentMenu.children.length > 0) { + // 첫 번째 자식 메뉴의 경로로 리다이렉트 + const firstChild = parentMenu.children[0]; + const firstChildPath = firstChild.path?.replace(/^\/[a-z]{2}\//, '/') || fallbackPath; + router.replace(firstChildPath); + } else { + // 자식이 없으면 fallback으로 이동 + router.replace(fallbackPath); + } + } catch (error) { + console.error('[ParentMenuRedirect] Error:', error); + router.replace(fallbackPath); + } + }, [router, parentPath, fallbackPath]); + + // 리다이렉트 중 로딩 표시 + return ( +
+
+
+ ); +} diff --git a/src/components/customer-center/EventManagement/EventList.tsx b/src/components/customer-center/EventManagement/EventList.tsx index 51a7c062..f536bd95 100644 --- a/src/components/customer-center/EventManagement/EventList.tsx +++ b/src/components/customer-center/EventManagement/EventList.tsx @@ -130,6 +130,7 @@ export function EventList() { // 커스텀 필터 (날짜) customFilterFn: (items, filterValues) => { + if (!items || items.length === 0) return items; if (!startDate || !endDate) return items; return items.filter((item) => { // 이벤트 기간이 선택한 기간과 겹치는지 확인 diff --git a/src/components/customer-center/InquiryManagement/InquiryList.tsx b/src/components/customer-center/InquiryManagement/InquiryList.tsx index 4ce8a6d5..86d115b8 100644 --- a/src/components/customer-center/InquiryManagement/InquiryList.tsx +++ b/src/components/customer-center/InquiryManagement/InquiryList.tsx @@ -124,6 +124,7 @@ export function InquiryList() { // 커스텀 필터 (날짜 + 카테고리 + 상태) customFilterFn: (items, filterValues) => { + if (!items || items.length === 0) return items; let result = [...items]; // 날짜 필터 diff --git a/src/components/customer-center/NoticeManagement/NoticeList.tsx b/src/components/customer-center/NoticeManagement/NoticeList.tsx index 537a1ddb..086f7b03 100644 --- a/src/components/customer-center/NoticeManagement/NoticeList.tsx +++ b/src/components/customer-center/NoticeManagement/NoticeList.tsx @@ -97,6 +97,7 @@ export function NoticeList() { // 커스텀 필터 (날짜) customFilterFn: (items, filterValues) => { + if (!items || items.length === 0) return items; // 날짜 필터는 외부 상태 사용 if (!startDate || !endDate) return items; return items.filter((item) => { diff --git a/src/components/hr/AttendanceManagement/index.tsx b/src/components/hr/AttendanceManagement/index.tsx index f3d52b04..12aa8ea2 100644 --- a/src/components/hr/AttendanceManagement/index.tsx +++ b/src/components/hr/AttendanceManagement/index.tsx @@ -11,9 +11,11 @@ import { Plus, FileText, Edit, + Search, } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import { Checkbox } from '@/components/ui/checkbox'; import { TableRow, TableCell } from '@/components/ui/table'; import { format } from 'date-fns'; @@ -434,6 +436,11 @@ export function AttendanceManagement() { computeStats: () => statCards, + // 검색창 (공통 컴포넌트에서 자동 생성) + hideSearch: true, + searchValue: searchValue, + onSearchChange: setSearchValue, + dateRangeSelector: { enabled: true, showPresets: true, @@ -482,6 +489,7 @@ export function AttendanceManagement() { }, customFilterFn: (items, filterValues) => { + if (!items || items.length === 0) return items; let filtered = items; const filterOption = filterValues.filter as string; if (filterOption && filterOption !== 'all') { diff --git a/src/components/hr/CardManagement/index.tsx b/src/components/hr/CardManagement/index.tsx index 02ec5b6f..bf932817 100644 --- a/src/components/hr/CardManagement/index.tsx +++ b/src/components/hr/CardManagement/index.tsx @@ -2,9 +2,10 @@ import { useState, useMemo, useCallback, useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { CreditCard, Edit, Trash2, Plus } from 'lucide-react'; -import { Badge } from '@/components/ui/badge'; +import { CreditCard, Edit, Trash2, Plus, Search, RefreshCw } from 'lucide-react'; +import { Input } from '@/components/ui/input'; import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; import { Checkbox } from '@/components/ui/checkbox'; import { TableRow, TableCell } from '@/components/ui/table'; import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog'; @@ -58,7 +59,7 @@ export function CardManagement({ initialData }: CardManagementProps) { }; // 검색 및 필터 상태 - const [searchValue, setSearchValue] = useState(''); + const [searchQuery, setSearchQuery] = useState(''); const [activeTab, setActiveTab] = useState('all'); const [currentPage, setCurrentPage] = useState(1); const itemsPerPage = 20; @@ -78,8 +79,8 @@ export function CardManagement({ initialData }: CardManagementProps) { } // 검색 필터 - if (searchValue) { - const search = searchValue.toLowerCase(); + if (searchQuery) { + const search = searchQuery.toLowerCase(); filtered = filtered.filter(c => c.cardName.toLowerCase().includes(search) || c.cardNumber.includes(search) || @@ -89,7 +90,7 @@ export function CardManagement({ initialData }: CardManagementProps) { } return filtered; - }, [cards, activeTab, searchValue]); + }, [cards, activeTab, searchQuery]); // 페이지네이션된 데이터 const paginatedData = useMemo(() => { diff --git a/src/components/hr/EmployeeManagement/index.tsx b/src/components/hr/EmployeeManagement/index.tsx index e8a831bc..11ffb51e 100644 --- a/src/components/hr/EmployeeManagement/index.tsx +++ b/src/components/hr/EmployeeManagement/index.tsx @@ -2,10 +2,11 @@ import { useState, useMemo, useCallback, useEffect } from 'react'; import { useRouter } from 'next/navigation'; -import { Users, Edit, Trash2, UserCheck, UserX, Clock, Calendar, Mail, Plus, Upload, Loader2 } from 'lucide-react'; +import { Users, Edit, Trash2, UserCheck, UserX, Clock, Calendar, Mail, Plus, Upload, Loader2, Search } from 'lucide-react'; import { getEmployees, deleteEmployee, deleteEmployees, getEmployeeStats } from './actions'; import { Badge } from '@/components/ui/badge'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import { Checkbox } from '@/components/ui/checkbox'; import { TableRow, TableCell } from '@/components/ui/table'; import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog'; @@ -437,6 +438,11 @@ export function EmployeeManagement() { computeStats: () => statCards, + // 검색창 (공통 컴포넌트에서 자동 생성) + hideSearch: true, + searchValue: searchValue, + onSearchChange: setSearchValue, + dateRangeSelector: { enabled: true, showPresets: true, @@ -489,6 +495,7 @@ export function EmployeeManagement() { }, customFilterFn: (items, filterValues) => { + if (!items || items.length === 0) return items; let filtered = items; const filterOption = filterValues.filter as FilterOption; if (filterOption && filterOption !== 'all') { diff --git a/src/components/hr/SalaryManagement/index.tsx b/src/components/hr/SalaryManagement/index.tsx index 41331e47..e2964e35 100644 --- a/src/components/hr/SalaryManagement/index.tsx +++ b/src/components/hr/SalaryManagement/index.tsx @@ -13,9 +13,11 @@ import { Gift, MinusCircle, Loader2, + Search, } from 'lucide-react'; import { toast } from 'sonner'; import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; import { Checkbox } from '@/components/ui/checkbox'; import { Badge } from '@/components/ui/badge'; import { TableRow, TableCell } from '@/components/ui/table'; @@ -374,6 +376,11 @@ export function SalaryManagement() { itemsPerPage: itemsPerPage, + // 검색창 (공통 컴포넌트에서 자동 생성) + hideSearch: true, + searchValue: searchQuery, + onSearchChange: setSearchQuery, + // 날짜 범위 선택 (DateRangeSelector 사용) dateRangeSelector: { enabled: true, diff --git a/src/components/hr/VacationManagement/index.tsx b/src/components/hr/VacationManagement/index.tsx index a320cc1a..7f9e05cd 100644 --- a/src/components/hr/VacationManagement/index.tsx +++ b/src/components/hr/VacationManagement/index.tsx @@ -38,7 +38,6 @@ import { type FilterFieldConfig, type FilterValues, } from '@/components/templates/UniversalListPage'; -import { DateRangeSelector } from '@/components/molecules/DateRangeSelector'; import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard'; import { VacationGrantDialog } from './VacationGrantDialog'; import { VacationRequestDialog } from './VacationRequestDialog'; @@ -587,48 +586,40 @@ export function VacationManagement() { } }, [mainTab, handleApproveClick, handleRejectClick]); - // ===== 헤더 액션 (DateRangeSelector + 버튼들) ===== + // ===== 헤더 액션 (탭별 버튼들만 - DateRangeSelector와 검색창은 공통 옵션 사용) ===== const headerActions = useCallback(({ selectedItems: selected }: { selectedItems: Set; onClearSelection?: () => void; onRefresh?: () => void }) => ( - <> - -
- {/* 탭별 액션 버튼 */} - {mainTab === 'grant' && ( - - )} +
+ {/* 탭별 액션 버튼 */} + {mainTab === 'grant' && ( + + )} - {mainTab === 'request' && ( - <> - {/* 버튼 순서: 승인 → 거절 → 휴가신청 (휴가신청 버튼 위치 고정) */} - {selected.size > 0 && ( - <> - - - - )} - - - )} -
- - ), [startDate, endDate, mainTab, handleApproveClick, handleRejectClick]); + {mainTab === 'request' && ( + <> + {/* 버튼 순서: 승인 → 거절 → 휴가신청 (휴가신청 버튼 위치 고정) */} + {selected.size > 0 && ( + <> + + + + )} + + + )} +
+ ), [mainTab, handleApproveClick, handleRejectClick]); // ===== filterConfig 기반 통합 필터 시스템 ===== const filterConfig: FilterFieldConfig[] = useMemo(() => [ @@ -693,6 +684,15 @@ export function VacationManagement() { columns: tableColumns, + // 공통 패턴: dateRangeSelector + dateRangeSelector: { + enabled: true, + startDate, + endDate, + onStartDateChange: setStartDate, + onEndDateChange: setEndDate, + }, + tabs: tabs, defaultTab: mainTab, diff --git a/src/components/items/ItemListClient.tsx b/src/components/items/ItemListClient.tsx index c9985a66..b9726559 100644 --- a/src/components/items/ItemListClient.tsx +++ b/src/components/items/ItemListClient.tsx @@ -17,7 +17,8 @@ import { Badge } from '@/components/ui/badge'; import { Checkbox } from '@/components/ui/checkbox'; import { TableRow, TableCell } from '@/components/ui/table'; import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog'; -import { Search, Plus, Edit, Trash2, Package } from 'lucide-react'; +import { Search, Plus, Edit, Trash2, Package, Download, FileDown, Upload } from 'lucide-react'; +import { downloadExcel, downloadSelectedExcel, downloadExcelTemplate, parseExcelFile, type ExcelColumn, type TemplateColumn } from '@/lib/utils/excel-download'; import { useItemList } from '@/hooks/useItemList'; import { handleApiError } from '@/lib/api/error-handler'; import { isNextRedirectError } from '@/lib/utils/redirect-error'; @@ -237,6 +238,120 @@ export default function ItemListClient() { } }; + // 엑셀 다운로드용 컬럼 정의 + const excelColumns: ExcelColumn[] = [ + { header: '품목코드', key: 'itemCode', width: 15 }, + { header: '품목유형', key: 'itemType', width: 10, transform: (v) => ITEM_TYPE_LABELS[v as keyof typeof ITEM_TYPE_LABELS] || String(v) }, + { header: '품목명', key: 'itemName', width: 30 }, + { header: '규격', key: 'specification', width: 20 }, + { header: '단위', key: 'unit', width: 8 }, + { header: '대분류', key: 'category1', width: 12 }, + { header: '중분류', key: 'category2', width: 12 }, + { header: '소분류', key: 'category3', width: 12 }, + { header: '구매단가', key: 'purchasePrice', width: 12 }, + { header: '판매단가', key: 'salesPrice', width: 12 }, + { header: '활성상태', key: 'isActive', width: 10, transform: (v) => v ? '활성' : '비활성' }, + ]; + + // 전체 엑셀 다운로드 + const handleExcelDownload = () => { + if (items.length === 0) { + alert('다운로드할 데이터가 없습니다.'); + return; + } + downloadExcel({ + data: items, + columns: excelColumns, + filename: '품목목록', + sheetName: '품목', + }); + }; + + // 선택 항목 엑셀 다운로드 + const handleSelectedExcelDownload = (selectedIds: string[]) => { + if (selectedIds.length === 0) { + alert('선택된 항목이 없습니다.'); + return; + } + downloadSelectedExcel({ + data: items, + columns: excelColumns, + selectedIds, + idField: 'id', + filename: '품목목록_선택', + sheetName: '품목', + }); + }; + + // 업로드용 템플릿 컬럼 정의 + const templateColumns: TemplateColumn[] = [ + { header: '품목코드', key: 'itemCode', required: true, type: 'text', sampleValue: 'KD-FG-001', description: '고유 코드', width: 15 }, + { header: '품목유형', key: 'itemType', required: true, type: 'select', options: ['FG', 'PT', 'SM', 'RM', 'CS'], sampleValue: 'FG', description: 'FG:제품/PT:부품/SM:부자재/RM:원자재/CS:소모품', width: 12 }, + { header: '품목명', key: 'itemName', required: true, type: 'text', sampleValue: '스크린도어 본체', width: 25 }, + { header: '규격', key: 'specification', type: 'text', sampleValue: '1800x2100', width: 15 }, + { header: '단위', key: 'unit', required: true, type: 'select', options: ['EA', 'SET', 'KG', 'M', 'M2', 'BOX'], sampleValue: 'EA', width: 10 }, + { header: '대분류', key: 'category1', type: 'text', sampleValue: '스크린도어', width: 12 }, + { header: '중분류', key: 'category2', type: 'text', sampleValue: '본체류', width: 12 }, + { header: '소분류', key: 'category3', type: 'text', sampleValue: '프레임', width: 12 }, + { header: '구매단가', key: 'purchasePrice', type: 'number', sampleValue: 150000, width: 12 }, + { header: '판매단가', key: 'salesPrice', type: 'number', sampleValue: 200000, width: 12 }, + { header: '활성상태', key: 'isActive', type: 'boolean', sampleValue: 'Y', description: 'Y:활성/N:비활성', width: 10 }, + ]; + + // 양식 다운로드 + const handleTemplateDownload = () => { + downloadExcelTemplate({ + columns: templateColumns, + filename: '품목등록_양식', + sheetName: '품목등록', + includeSampleRow: true, + includeGuideRow: true, + }); + }; + + // 파일 업로드 input ref + const fileInputRef = useRef(null); + + // 양식 업로드 핸들러 + const handleFileUpload = async (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + try { + const result = await parseExcelFile(file, { + columns: templateColumns, + skipRows: 2, // 헤더 + 안내 행 스킵 + }); + + if (!result.success || result.errors.length > 0) { + const errorMessages = result.errors.slice(0, 5).map( + (err) => `${err.row}행: ${err.message}` + ).join('\n'); + alert(`업로드 오류:\n${errorMessages}${result.errors.length > 5 ? `\n... 외 ${result.errors.length - 5}건` : ''}`); + return; + } + + if (result.data.length === 0) { + alert('업로드할 데이터가 없습니다.'); + return; + } + + // TODO: 실제 API 호출로 데이터 저장 + // 지금은 파싱 결과만 확인 + console.log('[Excel Upload] 파싱 결과:', result.data); + alert(`${result.data.length}건의 데이터가 파싱되었습니다.\n(실제 등록 기능은 추후 구현 예정)`); + + } catch (error) { + console.error('[Excel Upload] 오류:', error); + alert('파일 업로드에 실패했습니다.'); + } finally { + // input 초기화 (같은 파일 재선택 가능하도록) + if (fileInputRef.current) { + fileInputRef.current.value = ''; + } + } + }; + // 탭 옵션 (품목 유형별) const tabs: TabOption[] = [ { value: 'all', label: '전체', count: totalStats.totalAll, color: 'gray' }, @@ -301,6 +416,56 @@ export default function ItemListClient() { icon: Plus, }, + // 헤더 액션 (엑셀 다운로드) + headerActions: ({ selectedItems }) => ( +
+ {/* 양식 다운로드 버튼 - 추후 활성화 + + */} + {/* 양식 업로드 버튼 - 추후 활성화 + + */} + {/* 엑셀 데이터 다운로드 버튼 */} + {selectedItems.size > 0 ? ( + + ) : ( + + )} +
+ ), + // API 액션 (일괄 삭제 포함) actions: { getList: async () => ({ success: true, data: items }), @@ -488,6 +653,15 @@ export default function ItemListClient() { externalIsLoading={isLoading} /> + {/* 숨겨진 파일 업로드 input */} + + {/* 개별 삭제 확인 다이얼로그 */} - {/* 1줄: 날짜 + 프리셋 */} - {/* 태블릿/모바일(~1279px): 세로 배치 / PC(1280px+): 가로 한 줄 */} -
- {/* 날짜 범위 선택 (Input type="date") */} - {!hideDateInputs && ( -
- onStartDateChange(e.target.value)} - className="w-[165px]" - /> - ~ - onEndDateChange(e.target.value)} - className="w-[165px]" - /> -
- )} - - {/* 기간 버튼들 - 모바일에서 가로 스크롤 */} - {!hidePresets && presets.length > 0 && ( -
-
- {presets.map((preset) => ( - - ))} -
-
- )} + // 프리셋 버튼 렌더링 + const renderPresets = () => { + if (hidePresets || presets.length === 0) return null; + return ( +
+
+ {presets.map((preset) => ( + + ))} +
+ ); + }; - {/* 2줄: 추가 액션 버튼들 - 항상 별도 줄, 오른쪽 정렬 */} - {extraActions && ( -
+ // presetsPosition이 'below'일 때: 달력+extraActions 같은 줄, 프리셋은 아래 줄 + if (presetsPosition === 'below') { + return ( +
+ {/* 1줄: 날짜 + extraActions */} +
+ {/* 날짜 범위 선택 */} + {!hideDateInputs && ( +
+ onStartDateChange(e.target.value)} + className="w-[165px]" + /> + ~ + onEndDateChange(e.target.value)} + className="w-[165px]" + /> +
+ )} + {/* extraActions (검색창 등) */} {extraActions}
+ + {/* 2줄: 프리셋 버튼들 */} + {renderPresets()} +
+ ); + } + + // presetsPosition이 'inline' (기본값) + // PC(1280px+): 달력 | 프리셋버튼 | 검색창 (한 줄) + // 태블릿: 달력 / 프리셋버튼 / 검색창 (세 줄) + return ( +
+ {/* 날짜 범위 선택 */} + {!hideDateInputs && ( +
+ onStartDateChange(e.target.value)} + className="w-[165px]" + /> + ~ + onEndDateChange(e.target.value)} + className="w-[165px]" + /> +
)} + + {/* 기간 버튼들 - 달력 바로 옆 */} + {renderPresets()} + + {/* extraActions (검색창 등) - 마지막에 배치 */} + {extraActions}
); } diff --git a/src/components/organisms/StatCards.tsx b/src/components/organisms/StatCards.tsx index 6732b08b..216c987f 100644 --- a/src/components/organisms/StatCards.tsx +++ b/src/components/organisms/StatCards.tsx @@ -22,7 +22,7 @@ interface StatCardsProps { export function StatCards({ stats }: StatCardsProps) { return ( -
+
{stats.map((stat, index) => { const Icon = stat.icon; const isClickable = !!stat.onClick; @@ -37,24 +37,24 @@ export function StatCards({ stats }: StatCardsProps) { }`} onClick={stat.onClick} > - +
-
-

+

+

{stat.label}

-

+

{stat.value}

{stat.trend && ( -

+

{stat.trend.value}

)}
{Icon && ( )}
diff --git a/src/components/pricing/PricingListClient.tsx b/src/components/pricing/PricingListClient.tsx index 1100900c..5fc2b29d 100644 --- a/src/components/pricing/PricingListClient.tsx +++ b/src/components/pricing/PricingListClient.tsx @@ -223,9 +223,22 @@ export function PricingListClient({ ) => { const { isSelected, onToggle } = handlers; + // 행 클릭 핸들러: 등록되지 않은 항목은 등록, 등록된 항목은 수정 + const handleRowClick = () => { + if (item.status === 'not_registered') { + handleRegister(item); + } else { + handleEdit(item); + } + }; + return ( - - + + e.stopPropagation()}> (null); + // 날짜 범위 상태 + const [startDate, setStartDate] = useState('2025-01-01'); + const [endDate, setEndDate] = useState('2025-12-31'); + + // 검색어 상태 + const [searchQuery, setSearchQuery] = useState(''); + // ===== 데이터 로드 ===== const loadData = useCallback(async () => { setIsLoading(true); @@ -249,36 +256,48 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr clientSideFiltering: true, itemsPerPage: 20, - // 탭 필터 함수 - tabFilter: (item: Process, activeTab: string) => { - if (activeTab === 'all') return true; - return item.status === activeTab; + // 검색창 (공통 컴포넌트에서 자동 생성) + hideSearch: true, + searchValue: searchQuery, + onSearchChange: setSearchQuery, + + // 날짜 범위 선택기 + dateRangeSelector: { + enabled: true, + showPresets: true, + startDate, + endDate, + onStartDateChange: setStartDate, + onEndDateChange: setEndDate, }, - // 검색 필터 함수 - searchFilter: (item: Process, searchValue: string) => { - const search = searchValue.toLowerCase(); + // 탭 필터 (공통 컴포넌트에서 처리) + tabFilter: (item, tabValue) => { + if (tabValue === 'all') return true; + return item.status === tabValue; + }, + + // 검색 필터 + searchFilter: (item, searchValue) => { + if (!searchValue || !searchValue.trim()) return true; + const search = searchValue.toLowerCase().trim(); return ( - item.processCode.toLowerCase().includes(search) || - item.processName.toLowerCase().includes(search) || - item.department.toLowerCase().includes(search) + (item.processCode || '').toLowerCase().includes(search) || + (item.processName || '').toLowerCase().includes(search) || + (item.department || '').toLowerCase().includes(search) ); }, - // 탭 설정 + // 탭 (공통 컴포넌트에서 Card 안에 렌더링) tabs, defaultTab: 'all', - // 검색 - searchPlaceholder: '공정코드, 공정명, 담당부서 검색', - - // 헤더 액션 - headerActions: () => ( - - ), + // 등록 버튼 (공통 컴포넌트에서 오른쪽에 렌더링) + createButton: { + label: '공정 등록', + onClick: handleCreate, + icon: Plus, + }, // 일괄 삭제 핸들러 onBulkDelete: handleBulkDelete, @@ -448,12 +467,12 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr ); }, }), - [tabs, handleCreate, handleRowClick, handleEdit, handleDeleteClick, handleToggleStatus, handleBulkDelete] + [tabs, handleCreate, handleRowClick, handleEdit, handleDeleteClick, handleToggleStatus, handleBulkDelete, startDate, endDate, searchQuery] ); return ( <> - + {/* 삭제 확인 다이얼로그 */} { dateRangeSelector?: { enabled: boolean; showPresets?: boolean; + /** 날짜 입력 숨김 (검색창만 표시하고 싶을 때) */ + hideDateInputs?: boolean; startDate?: string; endDate?: string; onStartDateChange?: (date: string) => void; onEndDateChange?: (date: string) => void; + /** 추가 액션 (검색창 등) - 프리셋 버튼 옆에 배치 */ + extraActions?: ReactNode; }; /** * 등록 버튼 (오른쪽 끝 배치) @@ -237,7 +242,7 @@ export function IntegratedListTemplateV2({ onSearchChange, searchPlaceholder = "검색...", extraFilters, - hideSearch = false, + hideSearch = true, // 기본값: 타이틀 아래에 검색창 표시 (Card 안 SearchFilter 숨김) tabs, activeTab, onTabChange, @@ -536,32 +541,71 @@ export function IntegratedListTemplateV2({ /> {/* 헤더 액션 (달력, 버튼 등) - 타이틀 아래 배치 */} - {/* 레이아웃: [달력 (왼쪽)] -------------- [등록 버튼 (오른쪽 끝)] */} - {(dateRangeSelector?.enabled || createButton || headerActions) && ( + {/* 레이아웃: [달력] [프리셋버튼] [검색창] -------------- [추가버튼들] [등록버튼] (오른쪽 끝) */} + {(dateRangeSelector?.enabled || createButton || headerActions || (hideSearch && onSearchChange)) && ( isLoading ? renderHeaderActionSkeleton() : ( -
- {/* 날짜 범위 선택기 (왼쪽) */} - {dateRangeSelector?.enabled && ( +
+ {/* 날짜 범위 선택기 + 검색창 (왼쪽) */} + {dateRangeSelector?.enabled ? ( + {/* hideSearch=true면 검색창 자동 추가 (extraActions 앞에) */} + {hideSearch && onSearchChange && ( +
+ + onSearchChange(e.target.value)} + className="pl-9 w-full bg-gray-50 border-gray-200" + /> +
+ )} + {/* 기존 extraActions (추가 버튼 등) */} + {dateRangeSelector.extraActions} + + } /> + ) : ( + /* dateRangeSelector 없어도 hideSearch=true면 검색창 표시 */ + hideSearch && onSearchChange && ( +
+ + onSearchChange(e.target.value)} + className="pl-9 w-full bg-gray-50 border-gray-200" + /> +
+ ) )} - {/* 레거시 헤더 액션 (기존 호환성 유지) */} - {headerActions} - {/* 등록 버튼 (오른쪽 끝) */} - {createButton && ( - )} - {createButton.label} - +
)}
) diff --git a/src/components/templates/UniversalListPage/index.tsx b/src/components/templates/UniversalListPage/index.tsx index ef8451dd..c513c9a5 100644 --- a/src/components/templates/UniversalListPage/index.tsx +++ b/src/components/templates/UniversalListPage/index.tsx @@ -578,7 +578,7 @@ export function UniversalListPage({ return ( <> - + { dateRangeSelector?: { enabled: boolean; showPresets?: boolean; + /** 날짜 입력 숨김 (검색창만 표시하고 싶을 때) */ + hideDateInputs?: boolean; + /** 프리셋 버튼 위치: 'inline' (날짜 옆), 'below' (별도 줄) */ + presetsPosition?: 'inline' | 'below'; startDate?: string; endDate?: string; onStartDateChange?: (date: string) => void; onEndDateChange?: (date: string) => void; + /** 추가 액션 (검색창 등) - presetsPosition이 'below'일 때 달력 옆에 배치됨 */ + extraActions?: ReactNode; }; /** * 등록 버튼 (오른쪽 끝 배치) diff --git a/src/lib/utils/excel-download.ts b/src/lib/utils/excel-download.ts new file mode 100644 index 00000000..1b5df27b --- /dev/null +++ b/src/lib/utils/excel-download.ts @@ -0,0 +1,520 @@ +/** + * 프론트엔드 엑셀 다운로드 유틸리티 + * + * xlsx 라이브러리를 사용하여 브라우저에서 직접 엑셀 파일을 생성합니다. + * 모든 리스트 화면에서 공통으로 사용할 수 있습니다. + * + * 사용 예시: + * ```tsx + * import { downloadExcel } from '@/lib/utils/excel-download'; + * + * const columns = [ + * { header: '품목코드', key: 'itemCode' }, + * { header: '품목명', key: 'itemName' }, + * ]; + * + * downloadExcel({ + * data: items, + * columns, + * filename: '품목목록', + * sheetName: '품목', + * }); + * ``` + */ + +import * as XLSX from 'xlsx'; + +/** + * 엑셀 컬럼 정의 + */ +export interface ExcelColumn> { + /** 엑셀 헤더에 표시될 이름 */ + header: string; + /** 데이터 객체에서 가져올 키 */ + key: keyof T | string; + /** 값 변환 함수 (선택) */ + transform?: (value: unknown, row: T) => string | number | boolean | null; + /** 컬럼 너비 (문자 수 기준, 기본값: 15) */ + width?: number; +} + +/** + * 엑셀 다운로드 옵션 + */ +export interface ExcelDownloadOptions> { + /** 다운로드할 데이터 배열 */ + data: T[]; + /** 컬럼 정의 */ + columns: ExcelColumn[]; + /** 파일명 (확장자 제외, 기본값: 'export') */ + filename?: string; + /** 시트명 (기본값: 'Sheet1') */ + sheetName?: string; + /** 파일명에 날짜 추가 여부 (기본값: true) */ + appendDate?: boolean; +} + +/** + * 중첩 객체에서 값 추출 (예: 'vendor.name' → vendor 객체의 name 값) + */ +function getNestedValue(obj: Record, path: string): unknown { + return path.split('.').reduce((current: unknown, key: string) => { + if (current && typeof current === 'object' && key in current) { + return (current as Record)[key]; + } + return undefined; + }, obj); +} + +/** + * 날짜 형식의 파일명 생성 + */ +function generateFilename(baseName: string, appendDate: boolean): string { + if (!appendDate) { + return `${baseName}.xlsx`; + } + + const now = new Date(); + const dateStr = now.toISOString().slice(0, 10).replace(/-/g, ''); + const timeStr = now.toTimeString().slice(0, 5).replace(/:/g, ''); + + return `${baseName}_${dateStr}_${timeStr}.xlsx`; +} + +/** + * 데이터를 엑셀 파일로 다운로드 + */ +export function downloadExcel>({ + data, + columns, + filename = 'export', + sheetName = 'Sheet1', + appendDate = true, +}: ExcelDownloadOptions): void { + if (!data || data.length === 0) { + console.warn('[Excel] 다운로드할 데이터가 없습니다.'); + return; + } + + try { + // 1. 헤더 행 생성 + const headers = columns.map((col) => col.header); + + // 2. 데이터 행 생성 + const rows = data.map((item) => { + return columns.map((col) => { + // 값 추출 (중첩 객체 지원) + const rawValue = getNestedValue(item as Record, col.key as string); + + // 변환 함수가 있으면 적용 + if (col.transform) { + return col.transform(rawValue, item); + } + + // 기본 값 처리 + if (rawValue === null || rawValue === undefined) { + return ''; + } + + // boolean 처리 + if (typeof rawValue === 'boolean') { + return rawValue ? 'Y' : 'N'; + } + + // 배열 처리 + if (Array.isArray(rawValue)) { + return rawValue.join(', '); + } + + // 객체 처리 (JSON 문자열로) + if (typeof rawValue === 'object') { + return JSON.stringify(rawValue); + } + + return rawValue; + }); + }); + + // 3. 워크시트 생성 + const wsData = [headers, ...rows]; + const ws = XLSX.utils.aoa_to_sheet(wsData); + + // 4. 컬럼 너비 설정 + const colWidths = columns.map((col) => ({ + wch: col.width || Math.max(15, col.header.length * 2), + })); + ws['!cols'] = colWidths; + + // 5. 워크북 생성 + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, sheetName); + + // 6. 파일 다운로드 + const finalFilename = generateFilename(filename, appendDate); + XLSX.writeFile(wb, finalFilename); + + console.log(`[Excel] 다운로드 완료: ${finalFilename} (${data.length}건)`); + } catch (error) { + console.error('[Excel] 다운로드 실패:', error); + throw new Error('엑셀 파일 생성에 실패했습니다.'); + } +} + +/** + * 선택된 항목만 엑셀로 다운로드 + */ +export function downloadSelectedExcel>({ + data, + selectedIds, + idField = 'id', + ...options +}: ExcelDownloadOptions & { + selectedIds: string[]; + idField?: keyof T | string; +}): void { + const selectedData = data.filter((item) => { + const id = getNestedValue(item as Record, idField as string); + return selectedIds.includes(String(id)); + }); + + if (selectedData.length === 0) { + console.warn('[Excel] 선택된 항목이 없습니다.'); + return; + } + + downloadExcel({ + ...options, + data: selectedData, + }); +} + +// ========================================== +// 엑셀 템플릿(양식) 다운로드 +// ========================================== + +/** + * 템플릿 컬럼 정의 (업로드용) + */ +export interface TemplateColumn { + /** 엑셀 헤더에 표시될 이름 */ + header: string; + /** 데이터 키 (업로드 시 매핑용) */ + key: string; + /** 필수 여부 */ + required?: boolean; + /** 데이터 타입 설명 */ + type?: 'text' | 'number' | 'date' | 'boolean' | 'select'; + /** 선택 옵션 (type이 'select'일 때) */ + options?: string[]; + /** 안내 문구 (예: "YYYY-MM-DD 형식") */ + description?: string; + /** 샘플 값 */ + sampleValue?: string | number | boolean; + /** 컬럼 너비 */ + width?: number; +} + +/** + * 템플릿 다운로드 옵션 + */ +export interface TemplateDownloadOptions { + /** 컬럼 정의 */ + columns: TemplateColumn[]; + /** 파일명 (확장자 제외) */ + filename?: string; + /** 시트명 */ + sheetName?: string; + /** 샘플 데이터 행 포함 여부 (기본값: true) */ + includeSampleRow?: boolean; + /** 안내 행 포함 여부 (기본값: true) */ + includeGuideRow?: boolean; +} + +/** + * 업로드용 엑셀 템플릿(양식) 다운로드 + * + * 사용 예시: + * ```tsx + * downloadExcelTemplate({ + * columns: [ + * { header: '품목코드', key: 'itemCode', required: true, type: 'text', sampleValue: 'KD-FG-001' }, + * { header: '품목명', key: 'itemName', required: true, type: 'text', sampleValue: '스크린도어' }, + * { header: '단위', key: 'unit', type: 'select', options: ['EA', 'SET', 'KG'], sampleValue: 'EA' }, + * ], + * filename: '품목등록_양식', + * }); + * ``` + */ +export function downloadExcelTemplate({ + columns, + filename = '업로드_양식', + sheetName = 'Sheet1', + includeSampleRow = true, + includeGuideRow = true, +}: TemplateDownloadOptions): void { + try { + const wsData: (string | number | boolean)[][] = []; + + // 1. 헤더 행 (필수 표시 포함) + const headers = columns.map((col) => { + return col.required ? `${col.header} *` : col.header; + }); + wsData.push(headers); + + // 2. 안내 행 (데이터 타입, 옵션 등) + if (includeGuideRow) { + const guideRow = columns.map((col) => { + const parts: string[] = []; + + // 타입 표시 + if (col.type === 'select' && col.options) { + parts.push(`[${col.options.join('/')}]`); + } else if (col.type === 'date') { + parts.push('[YYYY-MM-DD]'); + } else if (col.type === 'number') { + parts.push('[숫자]'); + } else if (col.type === 'boolean') { + parts.push('[Y/N]'); + } + + // 추가 설명 + if (col.description) { + parts.push(col.description); + } + + return parts.join(' ') || ''; + }); + wsData.push(guideRow); + } + + // 3. 샘플 데이터 행 + if (includeSampleRow) { + const sampleRow = columns.map((col) => { + if (col.sampleValue !== undefined) { + return col.sampleValue; + } + // 기본 샘플 값 + if (col.type === 'select' && col.options?.[0]) { + return col.options[0]; + } + if (col.type === 'date') { + return new Date().toISOString().slice(0, 10); + } + if (col.type === 'number') { + return 0; + } + if (col.type === 'boolean') { + return 'Y'; + } + return ''; + }); + wsData.push(sampleRow); + } + + // 4. 워크시트 생성 + const ws = XLSX.utils.aoa_to_sheet(wsData); + + // 5. 컬럼 너비 설정 + const colWidths = columns.map((col) => ({ + wch: col.width || Math.max(15, (col.header.length + (col.required ? 2 : 0)) * 2), + })); + ws['!cols'] = colWidths; + + // 6. 헤더 스타일 (볼드) - xlsx 라이브러리 기본 기능으로는 제한적 + // 추후 xlsx-style 라이브러리로 확장 가능 + + // 7. 워크북 생성 및 다운로드 + const wb = XLSX.utils.book_new(); + XLSX.utils.book_append_sheet(wb, ws, sheetName); + + const finalFilename = `${filename}.xlsx`; + XLSX.writeFile(wb, finalFilename); + + console.log(`[Excel] 템플릿 다운로드 완료: ${finalFilename}`); + } catch (error) { + console.error('[Excel] 템플릿 다운로드 실패:', error); + throw new Error('엑셀 템플릿 생성에 실패했습니다.'); + } +} + +// ========================================== +// 엑셀 업로드 (파싱) +// ========================================== + +/** + * 엑셀 파일 파싱 결과 + */ +export interface ExcelParseResult> { + /** 파싱 성공 여부 */ + success: boolean; + /** 파싱된 데이터 */ + data: T[]; + /** 에러 목록 (행별) */ + errors: Array<{ + row: number; + column?: string; + message: string; + }>; + /** 전체 행 수 */ + totalRows: number; + /** 유효한 행 수 */ + validRows: number; +} + +/** + * 엑셀 파일을 파싱하여 데이터 배열로 변환 + * + * 사용 예시: + * ```tsx + * const result = await parseExcelFile(file, { + * columns: [ + * { header: '품목코드', key: 'itemCode', required: true }, + * { header: '품목명', key: 'itemName', required: true }, + * ], + * skipRows: 2, // 헤더 + 안내 행 스킵 + * }); + * ``` + */ +export async function parseExcelFile>( + file: File, + options: { + columns: TemplateColumn[]; + /** 스킵할 행 수 (헤더, 안내 행 등) */ + skipRows?: number; + /** 시트 인덱스 (기본값: 0) */ + sheetIndex?: number; + } +): Promise> { + const { columns, skipRows = 1, sheetIndex = 0 } = options; + + return new Promise((resolve) => { + const reader = new FileReader(); + + reader.onload = (e) => { + try { + const data = new Uint8Array(e.target?.result as ArrayBuffer); + const workbook = XLSX.read(data, { type: 'array' }); + + // 시트 선택 + const sheetName = workbook.SheetNames[sheetIndex]; + const worksheet = workbook.Sheets[sheetName]; + + // JSON으로 변환 + const jsonData = XLSX.utils.sheet_to_json>(worksheet, { + header: 1, // 배열로 반환 + defval: '', // 빈 셀 기본값 + }) as unknown[][]; + + // 헤더 행에서 컬럼 인덱스 매핑 + const headerRow = jsonData[0] as string[]; + const columnIndexMap = new Map(); + + columns.forEach((col) => { + // 필수 표시(*)가 있을 수 있으므로 정규화 + const headerIndex = headerRow.findIndex( + (h) => h?.toString().replace(' *', '').trim() === col.header + ); + if (headerIndex !== -1) { + columnIndexMap.set(col.key, headerIndex); + } + }); + + // 데이터 행 파싱 + const parsedData: T[] = []; + const errors: ExcelParseResult['errors'] = []; + const dataRows = jsonData.slice(skipRows); + + dataRows.forEach((row, rowIndex) => { + const rowNumber = rowIndex + skipRows + 1; + const rowData: Record = {}; + let hasError = false; + + columns.forEach((col) => { + const colIndex = columnIndexMap.get(col.key); + if (colIndex === undefined) return; + + const rawValue = (row as unknown[])[colIndex]; + const value = rawValue?.toString().trim() || ''; + + // 필수 검사 + if (col.required && !value) { + errors.push({ + row: rowNumber, + column: col.header, + message: `${col.header}은(는) 필수입니다`, + }); + hasError = true; + } + + // 타입 검사 + if (value) { + if (col.type === 'number' && isNaN(Number(value))) { + errors.push({ + row: rowNumber, + column: col.header, + message: `${col.header}은(는) 숫자여야 합니다`, + }); + hasError = true; + } + + if (col.type === 'select' && col.options && !col.options.includes(value)) { + errors.push({ + row: rowNumber, + column: col.header, + message: `${col.header}은(는) [${col.options.join(', ')}] 중 하나여야 합니다`, + }); + hasError = true; + } + } + + // 값 변환 + if (col.type === 'number' && value) { + rowData[col.key] = Number(value); + } else if (col.type === 'boolean') { + rowData[col.key] = value.toUpperCase() === 'Y' || value === 'true'; + } else { + rowData[col.key] = value; + } + }); + + // 빈 행 스킵 + const hasData = Object.values(rowData).some((v) => v !== '' && v !== undefined); + if (hasData) { + parsedData.push(rowData as T); + } + }); + + resolve({ + success: errors.length === 0, + data: parsedData, + errors, + totalRows: dataRows.length, + validRows: parsedData.length - errors.filter((e, i, arr) => + arr.findIndex((x) => x.row === e.row) === i + ).length, + }); + } catch (error) { + console.error('[Excel] 파싱 실패:', error); + resolve({ + success: false, + data: [], + errors: [{ row: 0, message: '파일 형식이 올바르지 않습니다.' }], + totalRows: 0, + validRows: 0, + }); + } + }; + + reader.onerror = () => { + resolve({ + success: false, + data: [], + errors: [{ row: 0, message: '파일을 읽는데 실패했습니다.' }], + totalRows: 0, + validRows: 0, + }); + }; + + reader.readAsArrayBuffer(file); + }); +}