feat(WEB): 리스트 페이지 UI 레이아웃 표준화
- 공통 레이아웃 패턴 적용: [달력] → [프리셋] → [검색창] → [버튼들] - beforeTableContent → headerActions + createButton 마이그레이션 - DateRangeSelector extraActions prop 활용하여 검색창 통합 - PricingListClient 테이블 행 클릭 → 상세 이동 기능 추가 - 회계 관련 페이지 (입금/출금/매입/매출/어음/카드/예상지출 등) 정리 - 건설 관련 페이지 검색 영역 정리 - 부모 메뉴 리다이렉트 컴포넌트 추가 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
3
.gitignore
vendored
3
.gitignore
vendored
@@ -116,3 +116,6 @@ tsconfig.tsbuildinfo
|
||||
|
||||
# ---> Dev Page Builder (프로토타입 - 로컬 전용)
|
||||
src/app/**/dev/page-builder/
|
||||
|
||||
# ---> Dev Dashboard Prototypes (디자인 프로토타입 - 로컬 전용)
|
||||
src/app/**/dev/dashboard/
|
||||
|
||||
@@ -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: (
|
||||
<div className="relative w-full xl:flex-1">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder="검색..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9 w-full bg-white"
|
||||
/>
|
||||
</div>
|
||||
),
|
||||
},
|
||||
hideSearch: true,
|
||||
```
|
||||
|
||||
### 2. beforeTableContent 모바일 레이아웃
|
||||
```tsx
|
||||
beforeTableContent: ({ selectedItems }) => (
|
||||
<div className="flex flex-col xl:flex-row xl:items-center xl:justify-end w-full gap-3">
|
||||
<div className="flex items-center justify-start xl:justify-end gap-2">
|
||||
{/* 버튼들 */}
|
||||
</div>
|
||||
</div>
|
||||
),
|
||||
```
|
||||
|
||||
### 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 (다른 패턴 사용)
|
||||
14
src/app/[locale]/(protected)/accounting/page.tsx
Normal file
14
src/app/[locale]/(protected)/accounting/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect';
|
||||
|
||||
/**
|
||||
* 회계관리 - 부모 메뉴 동적 리다이렉트
|
||||
* 메뉴 구조에서 첫 번째 자식으로 자동 이동
|
||||
*/
|
||||
export default function AccountingPage() {
|
||||
return (
|
||||
<ParentMenuRedirect
|
||||
parentPath="/accounting"
|
||||
fallbackPath="/accounting/vendors"
|
||||
/>
|
||||
);
|
||||
}
|
||||
14
src/app/[locale]/(protected)/approval/page.tsx
Normal file
14
src/app/[locale]/(protected)/approval/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect';
|
||||
|
||||
/**
|
||||
* 결재관리 - 부모 메뉴 동적 리다이렉트
|
||||
* 메뉴 구조에서 첫 번째 자식으로 자동 이동
|
||||
*/
|
||||
export default function ApprovalPage() {
|
||||
return (
|
||||
<ParentMenuRedirect
|
||||
parentPath="/approval"
|
||||
fallbackPath="/approval/inbox"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
<Button className="ml-auto" onClick={handleCreate}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
글쓰기
|
||||
</Button>
|
||||
</>
|
||||
),
|
||||
|
||||
// 공통 패턴: dateRangeSelector + createButton + onSearchChange
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
createButton: {
|
||||
label: '글쓰기',
|
||||
icon: Plus,
|
||||
onClick: handleCreate,
|
||||
},
|
||||
onSearchChange: setSearchValue,
|
||||
|
||||
tableHeaderActions: (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
|
||||
14
src/app/[locale]/(protected)/construction/page.tsx
Normal file
14
src/app/[locale]/(protected)/construction/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect';
|
||||
|
||||
/**
|
||||
* 건설관리 - 부모 메뉴 동적 리다이렉트
|
||||
* 메뉴 구조에서 첫 번째 자식으로 자동 이동
|
||||
*/
|
||||
export default function ConstructionPage() {
|
||||
return (
|
||||
<ParentMenuRedirect
|
||||
parentPath="/construction"
|
||||
fallbackPath="/construction/project"
|
||||
/>
|
||||
);
|
||||
}
|
||||
14
src/app/[locale]/(protected)/customer-center/page.tsx
Normal file
14
src/app/[locale]/(protected)/customer-center/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect';
|
||||
|
||||
/**
|
||||
* 고객센터 - 부모 메뉴 동적 리다이렉트
|
||||
* 메뉴 구조에서 첫 번째 자식으로 자동 이동
|
||||
*/
|
||||
export default function CustomerCenterPage() {
|
||||
return (
|
||||
<ParentMenuRedirect
|
||||
parentPath="/customer-center"
|
||||
fallbackPath="/customer-center/notices"
|
||||
/>
|
||||
);
|
||||
}
|
||||
14
src/app/[locale]/(protected)/hr/page.tsx
Normal file
14
src/app/[locale]/(protected)/hr/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect';
|
||||
|
||||
/**
|
||||
* 인사관리 - 부모 메뉴 동적 리다이렉트
|
||||
* 메뉴 구조에서 첫 번째 자식으로 자동 이동
|
||||
*/
|
||||
export default function HrPage() {
|
||||
return (
|
||||
<ParentMenuRedirect
|
||||
parentPath="/hr"
|
||||
fallbackPath="/hr/attendance-management"
|
||||
/>
|
||||
);
|
||||
}
|
||||
14
src/app/[locale]/(protected)/master-data/page.tsx
Normal file
14
src/app/[locale]/(protected)/master-data/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect';
|
||||
|
||||
/**
|
||||
* 기준정보 - 부모 메뉴 동적 리다이렉트
|
||||
* 메뉴 구조에서 첫 번째 자식으로 자동 이동
|
||||
*/
|
||||
export default function MasterDataPage() {
|
||||
return (
|
||||
<ParentMenuRedirect
|
||||
parentPath="/master-data"
|
||||
fallbackPath="/master-data/item-master-data-management"
|
||||
/>
|
||||
);
|
||||
}
|
||||
14
src/app/[locale]/(protected)/material/page.tsx
Normal file
14
src/app/[locale]/(protected)/material/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect';
|
||||
|
||||
/**
|
||||
* 자재관리 - 부모 메뉴 동적 리다이렉트
|
||||
* 메뉴 구조에서 첫 번째 자식으로 자동 이동
|
||||
*/
|
||||
export default function MaterialPage() {
|
||||
return (
|
||||
<ParentMenuRedirect
|
||||
parentPath="/material"
|
||||
fallbackPath="/material/stock-status"
|
||||
/>
|
||||
);
|
||||
}
|
||||
14
src/app/[locale]/(protected)/outbound/page.tsx
Normal file
14
src/app/[locale]/(protected)/outbound/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect';
|
||||
|
||||
/**
|
||||
* 출고관리 - 부모 메뉴 동적 리다이렉트
|
||||
* 메뉴 구조에서 첫 번째 자식으로 자동 이동
|
||||
*/
|
||||
export default function OutboundPage() {
|
||||
return (
|
||||
<ParentMenuRedirect
|
||||
parentPath="/outbound"
|
||||
fallbackPath="/outbound/shipments"
|
||||
/>
|
||||
);
|
||||
}
|
||||
14
src/app/[locale]/(protected)/production/page.tsx
Normal file
14
src/app/[locale]/(protected)/production/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect';
|
||||
|
||||
/**
|
||||
* 생산관리 - 부모 메뉴 동적 리다이렉트
|
||||
* 메뉴 구조에서 첫 번째 자식으로 자동 이동
|
||||
*/
|
||||
export default function ProductionPage() {
|
||||
return (
|
||||
<ParentMenuRedirect
|
||||
parentPath="/production"
|
||||
fallbackPath="/production/dashboard"
|
||||
/>
|
||||
);
|
||||
}
|
||||
14
src/app/[locale]/(protected)/quality/page.tsx
Normal file
14
src/app/[locale]/(protected)/quality/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect';
|
||||
|
||||
/**
|
||||
* 품질관리 - 부모 메뉴 동적 리다이렉트
|
||||
* 메뉴 구조에서 첫 번째 자식으로 자동 이동
|
||||
*/
|
||||
export default function QualityPage() {
|
||||
return (
|
||||
<ParentMenuRedirect
|
||||
parentPath="/quality"
|
||||
fallbackPath="/quality/inspections"
|
||||
/>
|
||||
);
|
||||
}
|
||||
14
src/app/[locale]/(protected)/sales/page.tsx
Normal file
14
src/app/[locale]/(protected)/sales/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect';
|
||||
|
||||
/**
|
||||
* 영업관리 - 부모 메뉴 동적 리다이렉트
|
||||
* 메뉴 구조에서 첫 번째 자식으로 자동 이동
|
||||
*/
|
||||
export default function SalesPage() {
|
||||
return (
|
||||
<ParentMenuRedirect
|
||||
parentPath="/sales"
|
||||
fallbackPath="/sales/client-management-sales-admin"
|
||||
/>
|
||||
);
|
||||
}
|
||||
14
src/app/[locale]/(protected)/settings/page.tsx
Normal file
14
src/app/[locale]/(protected)/settings/page.tsx
Normal file
@@ -0,0 +1,14 @@
|
||||
import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect';
|
||||
|
||||
/**
|
||||
* 설정 - 부모 메뉴 동적 리다이렉트
|
||||
* 메뉴 구조에서 첫 번째 자식으로 자동 이동
|
||||
*/
|
||||
export default function SettingsPage() {
|
||||
return (
|
||||
<ParentMenuRedirect
|
||||
parentPath="/settings"
|
||||
fallbackPath="/settings/accounts"
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -242,6 +242,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
let result = [...items];
|
||||
|
||||
// 거래처 필터
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -279,18 +279,16 @@ export function BankTransactionInquiry({
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// 테이블 상단 콘텐츠 (새로고침 버튼)
|
||||
beforeTableContent: (
|
||||
<div className="flex items-center justify-end w-full">
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
// 헤더 액션: 새로고침 버튼
|
||||
headerActions: () => (
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{isLoading ? '조회중...' : '새로고침'}
|
||||
</Button>
|
||||
),
|
||||
|
||||
// 테이블 헤더 액션 (3개 필터)
|
||||
|
||||
@@ -429,6 +429,42 @@ export function BillManagementClient({
|
||||
icon: Plus,
|
||||
},
|
||||
|
||||
// 헤더 액션: 상태 선택 + 저장 + 수취/발행 라디오
|
||||
headerActions: () => (
|
||||
<div className="flex items-center gap-3">
|
||||
<RadioGroup
|
||||
value={billTypeFilter}
|
||||
onValueChange={(value) => { setBillTypeFilter(value); loadData(1); }}
|
||||
className="flex items-center gap-3"
|
||||
>
|
||||
<div className="flex items-center space-x-1">
|
||||
<RadioGroupItem value="received" id="received" />
|
||||
<Label htmlFor="received" className="cursor-pointer text-sm">수취</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-1">
|
||||
<RadioGroupItem value="issued" id="issued" />
|
||||
<Label htmlFor="issued" className="cursor-pointer text-sm">발행</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BILL_STATUS_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleSave} size="sm" disabled={isLoading}>
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 테이블 헤더 액션 (필터)
|
||||
tableHeaderActions: (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
@@ -473,44 +509,6 @@ export function BillManagementClient({
|
||||
</div>
|
||||
),
|
||||
|
||||
// beforeTableContent: 상태 선택 + 저장 + 수취/발행 라디오
|
||||
beforeTableContent: (
|
||||
<div className="flex items-center gap-4">
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[110px]">
|
||||
<SelectValue placeholder="보관중" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BILL_STATUS_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
|
||||
<RadioGroup
|
||||
value={billTypeFilter}
|
||||
onValueChange={(value) => { setBillTypeFilter(value); loadData(1); }}
|
||||
className="flex items-center gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="received" id="received" />
|
||||
<Label htmlFor="received" className="cursor-pointer">수취</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="issued" id="issued" />
|
||||
<Label htmlFor="issued" className="cursor-pointer">발행</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 렌더링 함수
|
||||
renderTableRow,
|
||||
renderMobileCard,
|
||||
|
||||
@@ -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: () => (
|
||||
<Button className="ml-auto" onClick={() => router.push('/ko/accounting/card-transactions?mode=new')}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
카드내역 등록
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700 whitespace-nowrap">계정과목명</span>
|
||||
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleSaveAccountSubject} size="sm">
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
저장
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{isLoading ? '조회중...' : '새로고침'}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 등록 버튼
|
||||
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: (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">계정과목명</span>
|
||||
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="계정과목명 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
onClick={handleSaveAccountSubject}
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
<Button variant="outline" onClick={handleRefresh} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
새로고침
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 테이블 헤더 액션 (2개 필터)
|
||||
tableHeaderActions: () => (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
|
||||
@@ -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<DepositRecord[]>(initialData);
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 인라인 필터 상태 (tableHeaderActions에서 사용)
|
||||
const [vendorFilter, setVendorFilter] = useState<string>('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: () => (
|
||||
<Button className="ml-auto" onClick={() => router.push('/ko/accounting/deposits?mode=new')}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
입금등록
|
||||
</Button>
|
||||
// 헤더 액션: 계정과목명 Select + 저장 + 새로고침
|
||||
headerActions: ({ selectedItems }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700 whitespace-nowrap">계정과목명</span>
|
||||
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => handleSaveAccountSubject(selectedItems)} size="sm">
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
저장
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-1 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{isRefreshing ? '조회중...' : '새로고침'}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 등록 버튼
|
||||
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: (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">계정과목명</span>
|
||||
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="계정과목명 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{isRefreshing ? '조회중...' : '새로고침'}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
// tableHeaderActions: 3개 인라인 필터
|
||||
tableHeaderActions: ({ selectedItems }) => (
|
||||
tableHeaderActions: (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 저장 버튼 */}
|
||||
<Button onClick={() => handleSaveAccountSubject(selectedItems)} className="bg-blue-500 hover:bg-blue-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
|
||||
{/* 거래처 필터 */}
|
||||
<Select value={vendorFilter} onValueChange={setVendorFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
@@ -556,6 +574,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
|
||||
vendorOptions,
|
||||
tableTotals,
|
||||
isRefreshing,
|
||||
searchQuery,
|
||||
handleRowClick,
|
||||
handleEdit,
|
||||
handleRefresh,
|
||||
|
||||
@@ -902,51 +902,9 @@ export function ExpectedExpenseManagement({
|
||||
},
|
||||
filterTitle: '예상비용 필터',
|
||||
|
||||
// 테이블 헤더 액션 (거래처/정렬 필터)
|
||||
tableHeaderActions: () => (
|
||||
// 헤더 액션: 선택 기반 액션 버튼들
|
||||
headerActions: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 거래처 필터 */}
|
||||
<Select value={vendorFilter} onValueChange={setVendorFilter}>
|
||||
<SelectTrigger className="w-[140px] h-8 text-sm">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vendorFilterOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 필터 (최신순/등록순) */}
|
||||
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
|
||||
<SelectTrigger className="w-[100px] h-8 text-sm">
|
||||
<SelectValue placeholder="최신순" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 테이블 앞 컨텐츠 (액션 버튼)
|
||||
beforeTableContent: (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 등록 버튼 */}
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleOpenCreateDialog}
|
||||
>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
등록
|
||||
</Button>
|
||||
|
||||
{/* 예상 지급일 변경 버튼 - 1개 이상 선택 시 활성화 */}
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -983,6 +941,46 @@ export function ExpectedExpenseManagement({
|
||||
</div>
|
||||
),
|
||||
|
||||
// 등록 버튼
|
||||
createButton: {
|
||||
label: '등록',
|
||||
icon: Plus,
|
||||
onClick: handleOpenCreateDialog,
|
||||
},
|
||||
|
||||
// 테이블 헤더 액션 (거래처/정렬 필터)
|
||||
tableHeaderActions: () => (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 거래처 필터 */}
|
||||
<Select value={vendorFilter} onValueChange={setVendorFilter}>
|
||||
<SelectTrigger className="w-[140px] h-8 text-sm">
|
||||
<SelectValue placeholder="전체" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{vendorFilterOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 필터 (최신순/등록순) */}
|
||||
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
|
||||
<SelectTrigger className="w-[100px] h-8 text-sm">
|
||||
<SelectValue placeholder="최신순" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
),
|
||||
|
||||
// Stats 카드
|
||||
computeStats: (): StatCard[] => {
|
||||
const totalExpense = filteredRawData.reduce((sum, d) => sum + d.amount, 0);
|
||||
@@ -1011,7 +1009,7 @@ export function ExpectedExpenseManagement({
|
||||
vendorFilter,
|
||||
vendorFilterOptions,
|
||||
sortOption,
|
||||
selectedItems,
|
||||
selectedItems.size,
|
||||
handleOpenCreateDialog,
|
||||
handleOpenDateChangeDialog,
|
||||
handleElectronicApproval,
|
||||
|
||||
@@ -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';
|
||||
@@ -85,6 +87,7 @@ export function PurchaseManagement() {
|
||||
const [endDate, setEndDate] = useState('2025-12-31');
|
||||
const [purchaseData, setPurchaseData] = useState<PurchaseRecord[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 통합 필터 상태 (filterConfig 기반)
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
|
||||
@@ -295,7 +298,17 @@ export function PurchaseManagement() {
|
||||
|
||||
// 커스텀 필터 함수 (filterValues 파라미터 사용)
|
||||
customFilterFn: (items, fv) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// 검색어 필터
|
||||
if (searchQuery) {
|
||||
const search = searchQuery.toLowerCase();
|
||||
const matchesSearch =
|
||||
item.purchaseNo.toLowerCase().includes(search) ||
|
||||
item.vendorName.toLowerCase().includes(search);
|
||||
if (!matchesSearch) return false;
|
||||
}
|
||||
|
||||
const vendorVal = fv.vendor as string;
|
||||
const purchaseTypeVal = fv.purchaseType as string;
|
||||
const issuanceVal = fv.issuance as string;
|
||||
@@ -336,15 +349,45 @@ export function PurchaseManagement() {
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
searchPlaceholder: '매입번호, 거래처명 검색...',
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: true,
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// 헤더 액션: 계정과목명 Select + 저장 버튼
|
||||
headerActions: ({ selectedItems }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700 whitespace-nowrap">계정과목명</span>
|
||||
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_SELECTOR_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => handleSaveAccountSubject(selectedItems)} size="sm">
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 통합 필터 시스템 (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 }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">계정과목명</span>
|
||||
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="계정과목명 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_SELECTOR_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => handleSaveAccountSubject(selectedItems)} className="bg-blue-500 hover:bg-blue-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 테이블 하단 합계 행
|
||||
tableFooter: (
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
@@ -532,6 +552,7 @@ export function PurchaseManagement() {
|
||||
filterValues,
|
||||
selectedAccountSubject,
|
||||
tableTotals,
|
||||
searchQuery,
|
||||
handleRowClick,
|
||||
handleEdit,
|
||||
handleTaxInvoiceToggle,
|
||||
|
||||
@@ -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<SalesRecord[]>(initialData);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 통합 필터 상태 (filterConfig 사용)
|
||||
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
|
||||
@@ -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 }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700 whitespace-nowrap">계정과목명</span>
|
||||
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_SELECTOR_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => handleSaveAccountSubject(selectedItems)} size="sm">
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
createButton: {
|
||||
label: '매출 등록',
|
||||
onClick: handleCreate,
|
||||
@@ -368,29 +411,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
initialFilters: filterValues,
|
||||
filterTitle: '매출 필터',
|
||||
|
||||
// beforeTableContent: 계정과목명 Select + 저장 버튼 (테이블 밖에 위치)
|
||||
beforeTableContent: ({ selectedItems }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">계정과목명</span>
|
||||
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="계정과목명 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_SELECTOR_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => handleSaveAccountSubject(selectedItems)} className="bg-blue-500 hover:bg-blue-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 테이블 하단 합계 행
|
||||
tableFooter: (
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
@@ -534,6 +554,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
filterValues,
|
||||
selectedAccountSubject,
|
||||
tableTotals,
|
||||
searchQuery,
|
||||
handleRowClick,
|
||||
handleEdit,
|
||||
handleCreate,
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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<string>('unset');
|
||||
|
||||
// 검색어 상태 (헤더에서 직접 관리)
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 로딩 상태
|
||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||
|
||||
@@ -297,17 +299,23 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
||||
},
|
||||
filterTitle: '출금 필터',
|
||||
|
||||
// 헤더 액션 (등록 버튼)
|
||||
headerActions: () => (
|
||||
<Button className="ml-auto" onClick={() => router.push('/ko/accounting/withdrawals?mode=new')}>
|
||||
<Plus className="w-4 h-4 mr-2" />
|
||||
출금등록
|
||||
</Button>
|
||||
),
|
||||
// 검색창 숨김 (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: (
|
||||
<div className="flex items-center justify-between w-full">
|
||||
// 헤더 액션: 계정과목명 Select + 저장 + 새로고침
|
||||
headerActions: ({ selectedItems }) => {
|
||||
const selectedArray = withdrawalData.filter(item => selectedItems.has(item.id));
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium text-gray-700">계정과목명</span>
|
||||
<span className="text-sm font-medium text-gray-700 whitespace-nowrap">계정과목명</span>
|
||||
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="계정과목명 선택" />
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ACCOUNT_SUBJECT_OPTIONS.map((option) => (
|
||||
@@ -368,29 +383,33 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={() => handleSaveAccountSubject(selectedArray)} size="sm">
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
저장
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-1 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{isRefreshing ? '조회중...' : '새로고침'}
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRefresh}
|
||||
disabled={isRefreshing}
|
||||
>
|
||||
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
|
||||
{isRefreshing ? '조회중...' : '새로고침'}
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
);
|
||||
},
|
||||
|
||||
// tableHeaderActions: 저장 버튼 + 인라인 필터들
|
||||
tableHeaderActions: ({ selectedItems }) => (
|
||||
// 등록 버튼
|
||||
createButton: {
|
||||
label: '출금등록',
|
||||
icon: Plus,
|
||||
onClick: () => router.push('/ko/accounting/withdrawals?mode=new'),
|
||||
},
|
||||
|
||||
// tableHeaderActions: 필터만 (거래처, 출금유형, 정렬)
|
||||
tableHeaderActions: () => (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<Button
|
||||
onClick={() => handleSaveAccountSubject(selectedItems)}
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
|
||||
{/* 거래처 필터 */}
|
||||
<Select value={vendorFilter} onValueChange={setVendorFilter}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
@@ -446,7 +465,6 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
<TableCell></TableCell>
|
||||
</TableRow>
|
||||
),
|
||||
|
||||
@@ -506,29 +524,6 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
||||
{WITHDRAWAL_TYPE_LABELS[item.withdrawalType]}
|
||||
</Badge>
|
||||
</TableCell>
|
||||
{/* 작업 */}
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
{handlers.isSelected && (
|
||||
<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(item)}
|
||||
>
|
||||
<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={() => handlers.onDelete?.(item)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
@@ -576,9 +571,11 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
||||
}),
|
||||
[
|
||||
initialData,
|
||||
withdrawalData,
|
||||
stats,
|
||||
startDate,
|
||||
endDate,
|
||||
searchQuery,
|
||||
vendorFilter,
|
||||
withdrawalTypeFilter,
|
||||
sortOption,
|
||||
|
||||
@@ -510,6 +510,11 @@ export function ApprovalBox() {
|
||||
tabs: tabs,
|
||||
defaultTab: activeTab,
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: false,
|
||||
|
||||
@@ -464,6 +464,11 @@ export function DraftBox() {
|
||||
{ key: 'actions', label: '작업', className: 'text-center' },
|
||||
],
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: false,
|
||||
|
||||
@@ -445,6 +445,11 @@ export function ReferenceBox() {
|
||||
|
||||
computeStats: () => statCards,
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: false,
|
||||
|
||||
@@ -14,8 +14,10 @@ export interface TabChipProps {
|
||||
label: string;
|
||||
/** 카운트 숫자 */
|
||||
count?: number;
|
||||
/** 활성 상태 */
|
||||
/** 활성 상태 (active 또는 isActive 둘 다 지원) */
|
||||
active?: boolean;
|
||||
/** 활성 상태 (active의 별칭) */
|
||||
isActive?: boolean;
|
||||
/** 클릭 이벤트 */
|
||||
onClick?: () => void;
|
||||
/** 색상 테마 */
|
||||
@@ -28,26 +30,30 @@ export function TabChip({
|
||||
label,
|
||||
count,
|
||||
active = false,
|
||||
isActive,
|
||||
onClick,
|
||||
color = "blue",
|
||||
className = "",
|
||||
}: TabChipProps) {
|
||||
// isActive가 전달되면 isActive 사용, 아니면 active 사용
|
||||
const isActiveState = isActive ?? active;
|
||||
|
||||
return (
|
||||
<button
|
||||
onClick={onClick}
|
||||
className={`
|
||||
flex items-center gap-2 px-4 py-2.5 rounded-full border transition-all
|
||||
${
|
||||
active
|
||||
? "border-primary bg-primary/5"
|
||||
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
|
||||
isActiveState
|
||||
? "border-primary bg-primary text-white shadow-sm"
|
||||
: "border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50"
|
||||
}
|
||||
${className}
|
||||
`}
|
||||
>
|
||||
<span
|
||||
className={`text-sm ${
|
||||
active ? "text-primary font-medium" : "text-gray-600 font-normal"
|
||||
isActiveState ? "text-white font-medium" : "text-gray-600 font-normal"
|
||||
}`}
|
||||
>
|
||||
{label}
|
||||
@@ -55,7 +61,7 @@ export function TabChip({
|
||||
{count !== undefined && (
|
||||
<span
|
||||
className={`text-sm font-semibold ${
|
||||
active ? "text-primary" : "text-gray-900"
|
||||
isActiveState ? "text-white" : "text-gray-900"
|
||||
}`}
|
||||
>
|
||||
{count}
|
||||
|
||||
@@ -97,6 +97,8 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
|
||||
// 날짜 범위
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
// 검색어
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
// Stats 데이터
|
||||
const [stats, setStats] = useState<BiddingStats | null>(initialStats || null);
|
||||
|
||||
@@ -220,6 +222,7 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
|
||||
|
||||
// 커스텀 필터 함수 (activeStatTab + filterValues 기반)
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'waiting' && item.status !== 'waiting') return false;
|
||||
@@ -291,6 +294,11 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 공통 헤더 옵션: 날짜 선택기
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
@@ -410,8 +418,8 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit]
|
||||
[startDate, endDate, searchQuery, activeStatTab, stats, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
|
||||
@@ -106,6 +106,7 @@ export default function ContractListClient({ initialData = [], initialStats }: C
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [stats, setStats] = useState<ContractStats | null>(initialStats || null);
|
||||
|
||||
// Stats 로드
|
||||
@@ -235,6 +236,7 @@ export default function ContractListClient({ initialData = [], initialStats }: C
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
|
||||
@@ -304,6 +306,11 @@ export default function ContractListClient({ initialData = [], initialStats }: C
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
@@ -433,8 +440,8 @@ export default function ContractListClient({ initialData = [], initialStats }: C
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit]
|
||||
[startDate, endDate, searchQuery, activeStatTab, stats, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,8 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
// 날짜 범위
|
||||
const [startDate, setStartDate] = useState('');
|
||||
const [endDate, setEndDate] = useState('');
|
||||
// 검색어
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
// Stats 데이터
|
||||
const [stats, setStats] = useState<EstimateStats | null>(initialStats || null);
|
||||
// 필터 옵션 데이터
|
||||
@@ -210,6 +212,7 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
|
||||
// 커스텀 필터 함수 (activeStatTab + filterValues 기반)
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
|
||||
@@ -274,6 +277,11 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 공통 헤더 옵션: 날짜 선택기
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
@@ -389,8 +397,8 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, partnerOptions, estimatorOptions, handleRowClick, handleEdit]
|
||||
[startDate, endDate, searchQuery, activeStatTab, stats, partnerOptions, estimatorOptions, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
@@ -113,6 +113,7 @@ export default function HandoverReportListClient({
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [stats, setStats] = useState<HandoverReportStats | null>(initialStats || null);
|
||||
|
||||
// Stats 로드
|
||||
@@ -234,6 +235,7 @@ export default function HandoverReportListClient({
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
|
||||
@@ -297,6 +299,11 @@ export default function HandoverReportListClient({
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
@@ -410,8 +417,8 @@ export default function HandoverReportListClient({
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit]
|
||||
[startDate, endDate, searchQuery, activeStatTab, stats, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
@@ -89,6 +89,7 @@ export default function IssueManagementListClient({
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [stats, setStats] = useState<IssueStats | null>(initialStats || null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [withdrawDialogOpen, setWithdrawDialogOpen] = useState(false);
|
||||
const [itemsToWithdraw, setItemsToWithdraw] = useState<Set<string>>(new Set());
|
||||
const [clearSelectionFn, setClearSelectionFn] = useState<(() => void) | null>(null);
|
||||
@@ -271,6 +272,7 @@ export default function IssueManagementListClient({
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab !== 'all' && item.status !== activeStatTab) return false;
|
||||
@@ -346,6 +348,11 @@ export default function IssueManagementListClient({
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
@@ -494,12 +501,12 @@ export default function IssueManagementListClient({
|
||||
/>
|
||||
),
|
||||
}),
|
||||
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit, handleCreate, handleWithdrawClick]
|
||||
[startDate, endDate, searchQuery, activeStatTab, stats, handleRowClick, handleEdit, handleCreate, handleWithdrawClick]
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<UniversalListPage config={config} initialData={initialData} />
|
||||
<UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />
|
||||
|
||||
{/* 철회 확인 다이얼로그 */}
|
||||
<ConfirmDialog
|
||||
|
||||
@@ -555,6 +555,7 @@ export default function ItemManagementClient({
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
hideSearch: true,
|
||||
|
||||
// 등록 버튼
|
||||
createButton: {
|
||||
|
||||
@@ -59,6 +59,7 @@ export default function LaborManagementClient({
|
||||
const [startDate, setStartDate] = useState(format(startOfYear(today), 'yyyy-MM-dd'));
|
||||
const [endDate, setEndDate] = useState(format(endOfYear(today), 'yyyy-MM-dd'));
|
||||
const [stats, setStats] = useState<LaborStats>(initialStats ?? { total: 0, active: 0 });
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// Stats 로드
|
||||
useEffect(() => {
|
||||
@@ -211,6 +212,7 @@ export default function LaborManagementClient({
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// 구분 필터
|
||||
const categoryFilter = filterValues.category as string;
|
||||
@@ -242,6 +244,11 @@ export default function LaborManagementClient({
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
@@ -361,6 +368,7 @@ export default function LaborManagementClient({
|
||||
[
|
||||
startDate,
|
||||
endDate,
|
||||
searchQuery,
|
||||
stats,
|
||||
handleRowClick,
|
||||
handleEdit,
|
||||
@@ -371,5 +379,5 @@ export default function LaborManagementClient({
|
||||
]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
@@ -82,6 +82,7 @@ export default function ConstructionManagementListClient({
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [stats, setStats] = useState<ConstructionManagementStats | null>(initialStats || null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 달력 관련 상태
|
||||
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | null>(null);
|
||||
@@ -289,6 +290,7 @@ export default function ConstructionManagementListClient({
|
||||
|
||||
// 커스텀 필터 함수
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
return items.filter((item) => {
|
||||
// Stats 탭 필터
|
||||
if (activeStatTab === 'in_progress' && item.status !== 'in_progress') return false;
|
||||
@@ -379,6 +381,11 @@ export default function ConstructionManagementListClient({
|
||||
return sorted;
|
||||
},
|
||||
|
||||
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||
hideSearch: true,
|
||||
searchValue: searchQuery,
|
||||
onSearchChange: setSearchQuery,
|
||||
|
||||
// 공통 헤더 옵션
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
@@ -530,6 +537,7 @@ export default function ConstructionManagementListClient({
|
||||
[
|
||||
startDate,
|
||||
endDate,
|
||||
searchQuery,
|
||||
activeStatTab,
|
||||
stats,
|
||||
selectedCalendarDate,
|
||||
@@ -550,5 +558,5 @@ export default function ConstructionManagementListClient({
|
||||
]
|
||||
);
|
||||
|
||||
return <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
@@ -104,12 +104,23 @@ export default function ProjectDetailClient({ projectId }: ProjectDetailClientPr
|
||||
icon={FolderKanban}
|
||||
/>
|
||||
|
||||
{/* 기간 선택 (달력 + 프리셋 버튼) */}
|
||||
{/* 기간 선택 + 검색 영역 */}
|
||||
<DateRangeSelector
|
||||
startDate={filterStartDate}
|
||||
endDate={filterEndDate}
|
||||
onStartDateChange={setFilterStartDate}
|
||||
onEndDateChange={setFilterEndDate}
|
||||
extraActions={
|
||||
<div className="relative w-full sm:w-[300px]">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="프로젝트 검색 (현장명, 거래처, 계약번호)"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
}
|
||||
/>
|
||||
|
||||
{/* 상태 카드 */}
|
||||
@@ -155,17 +166,6 @@ export default function ProjectDetailClient({ projectId }: ProjectDetailClientPr
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 검색 영역 */}
|
||||
<div className="relative max-w-md">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="프로젝트 검색 (현장명, 거래처, 계약번호)"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-10"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 칸반 보드 */}
|
||||
<Card>
|
||||
<CardContent className="p-4 min-h-[600px]">
|
||||
|
||||
@@ -89,6 +89,7 @@ export default function OrderManagementListClient({
|
||||
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
|
||||
// 달력 관련 상태
|
||||
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | null>(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 <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -75,6 +75,7 @@ export default function ProgressBillingManagementListClient({
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [stats, setStats] = useState<ProgressBillingStats | null>(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 <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
|
||||
@@ -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 <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
|
||||
@@ -68,6 +68,7 @@ export default function SiteManagementListClient({
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [stats, setStats] = useState<SiteStats | null>(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 <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
|
||||
@@ -82,6 +82,7 @@ export default function StructureReviewListClient({
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [stats, setStats] = useState<StructureReviewStats | null>(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 <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ export default function UtilityManagementListClient({
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [stats, setStats] = useState<UtilityStats | null>(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 <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
|
||||
@@ -84,6 +84,7 @@ export default function WorkerStatusListClient({
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [stats, setStats] = useState<WorkerStatusStats | null>(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 <UniversalListPage config={config} initialData={initialData} />;
|
||||
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
|
||||
}
|
||||
|
||||
80
src/components/common/ParentMenuRedirect.tsx
Normal file
80
src/components/common/ParentMenuRedirect.tsx
Normal file
@@ -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 (
|
||||
<div className="flex items-center justify-center min-h-[200px]">
|
||||
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-primary"></div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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) => {
|
||||
// 이벤트 기간이 선택한 기간과 겹치는지 확인
|
||||
|
||||
@@ -124,6 +124,7 @@ export function InquiryList() {
|
||||
|
||||
// 커스텀 필터 (날짜 + 카테고리 + 상태)
|
||||
customFilterFn: (items, filterValues) => {
|
||||
if (!items || items.length === 0) return items;
|
||||
let result = [...items];
|
||||
|
||||
// 날짜 필터
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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<string>('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(() => {
|
||||
|
||||
@@ -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') {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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<string>; onClearSelection?: () => void; onRefresh?: () => void }) => (
|
||||
<>
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
<div className="ml-auto flex gap-2">
|
||||
{/* 탭별 액션 버튼 */}
|
||||
{mainTab === 'grant' && (
|
||||
<Button onClick={() => setGrantDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
부여등록
|
||||
</Button>
|
||||
)}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 탭별 액션 버튼 */}
|
||||
{mainTab === 'grant' && (
|
||||
<Button onClick={() => setGrantDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
부여등록
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{mainTab === 'request' && (
|
||||
<>
|
||||
{/* 버튼 순서: 승인 → 거절 → 휴가신청 (휴가신청 버튼 위치 고정) */}
|
||||
{selected.size > 0 && (
|
||||
<>
|
||||
<Button variant="default" onClick={() => handleApproveClick(selected)}>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
승인
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => handleRejectClick(selected)}>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
거절
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button onClick={() => setRequestDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
휴가신청
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
), [startDate, endDate, mainTab, handleApproveClick, handleRejectClick]);
|
||||
{mainTab === 'request' && (
|
||||
<>
|
||||
{/* 버튼 순서: 승인 → 거절 → 휴가신청 (휴가신청 버튼 위치 고정) */}
|
||||
{selected.size > 0 && (
|
||||
<>
|
||||
<Button variant="default" onClick={() => handleApproveClick(selected)}>
|
||||
<Check className="h-4 w-4 mr-2" />
|
||||
승인
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={() => handleRejectClick(selected)}>
|
||||
<X className="h-4 w-4 mr-2" />
|
||||
거절
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
<Button onClick={() => setRequestDialogOpen(true)}>
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
휴가신청
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
), [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,
|
||||
|
||||
|
||||
@@ -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<ItemMaster>[] = [
|
||||
{ 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<HTMLInputElement>(null);
|
||||
|
||||
// 양식 업로드 핸들러
|
||||
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
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 }) => (
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 양식 다운로드 버튼 - 추후 활성화
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleTemplateDownload}
|
||||
className="gap-2"
|
||||
>
|
||||
<FileDown className="h-4 w-4" />
|
||||
양식 다운로드
|
||||
</Button>
|
||||
*/}
|
||||
{/* 양식 업로드 버튼 - 추후 활성화
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
className="gap-2"
|
||||
>
|
||||
<Upload className="h-4 w-4" />
|
||||
양식 업로드
|
||||
</Button>
|
||||
*/}
|
||||
{/* 엑셀 데이터 다운로드 버튼 */}
|
||||
{selectedItems.size > 0 ? (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handleSelectedExcelDownload(Array.from(selectedItems))}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
선택 다운로드 ({selectedItems.size})
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleExcelDownload}
|
||||
className="gap-2"
|
||||
>
|
||||
<Download className="h-4 w-4" />
|
||||
엑셀 다운로드
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
),
|
||||
|
||||
// API 액션 (일괄 삭제 포함)
|
||||
actions: {
|
||||
getList: async () => ({ success: true, data: items }),
|
||||
@@ -488,6 +653,15 @@ export default function ItemListClient() {
|
||||
externalIsLoading={isLoading}
|
||||
/>
|
||||
|
||||
{/* 숨겨진 파일 업로드 input */}
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".xlsx,.xls"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* 개별 삭제 확인 다이얼로그 */}
|
||||
<DeleteConfirmDialog
|
||||
open={deleteDialogOpen}
|
||||
|
||||
@@ -46,6 +46,8 @@ interface DateRangeSelectorProps {
|
||||
hideDateInputs?: boolean;
|
||||
/** 날짜 입력 너비 */
|
||||
dateInputWidth?: string;
|
||||
/** 프리셋 버튼 위치: 'inline' (날짜 옆), 'below' (별도 줄) */
|
||||
presetsPosition?: 'inline' | 'below';
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -79,6 +81,7 @@ export function DateRangeSelector({
|
||||
hidePresets = false,
|
||||
hideDateInputs = false,
|
||||
dateInputWidth = 'w-[140px]',
|
||||
presetsPosition = 'inline',
|
||||
}: DateRangeSelectorProps) {
|
||||
|
||||
// 프리셋 클릭 핸들러
|
||||
@@ -119,59 +122,94 @@ export function DateRangeSelector({
|
||||
}
|
||||
}, [onStartDateChange, onEndDateChange]);
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{/* 1줄: 날짜 + 프리셋 */}
|
||||
{/* 태블릿/모바일(~1279px): 세로 배치 / PC(1280px+): 가로 한 줄 */}
|
||||
<div className="flex flex-col xl:flex-row xl:items-center gap-2">
|
||||
{/* 날짜 범위 선택 (Input type="date") */}
|
||||
{!hideDateInputs && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => onStartDateChange(e.target.value)}
|
||||
className="w-[165px]"
|
||||
/>
|
||||
<span className="text-muted-foreground shrink-0">~</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => onEndDateChange(e.target.value)}
|
||||
className="w-[165px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기간 버튼들 - 모바일에서 가로 스크롤 */}
|
||||
{!hidePresets && presets.length > 0 && (
|
||||
<div
|
||||
className="overflow-x-auto -mx-1 px-1 xl:overflow-visible xl:mx-0 xl:px-0"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
<div className="flex items-center gap-1 min-w-max [&::-webkit-scrollbar]:hidden">
|
||||
{presets.map((preset) => (
|
||||
<Button
|
||||
key={preset}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePresetClick(preset)}
|
||||
className="shrink-0 text-xs sm:text-sm px-2 sm:px-3"
|
||||
>
|
||||
{PRESET_LABELS[preset]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
// 프리셋 버튼 렌더링
|
||||
const renderPresets = () => {
|
||||
if (hidePresets || presets.length === 0) return null;
|
||||
return (
|
||||
<div
|
||||
className="overflow-x-auto -mx-1 px-1 xl:overflow-visible xl:mx-0 xl:px-0"
|
||||
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||
>
|
||||
<div className="flex items-center gap-1 min-w-max [&::-webkit-scrollbar]:hidden">
|
||||
{presets.map((preset) => (
|
||||
<Button
|
||||
key={preset}
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => handlePresetClick(preset)}
|
||||
className="shrink-0 text-xs sm:text-sm px-2 sm:px-3"
|
||||
>
|
||||
{PRESET_LABELS[preset]}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
{/* 2줄: 추가 액션 버튼들 - 항상 별도 줄, 오른쪽 정렬 */}
|
||||
{extraActions && (
|
||||
<div className="flex items-center gap-2 justify-end">
|
||||
// presetsPosition이 'below'일 때: 달력+extraActions 같은 줄, 프리셋은 아래 줄
|
||||
if (presetsPosition === 'below') {
|
||||
return (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
{/* 1줄: 날짜 + extraActions */}
|
||||
<div className="flex items-center gap-2">
|
||||
{/* 날짜 범위 선택 */}
|
||||
{!hideDateInputs && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => onStartDateChange(e.target.value)}
|
||||
className="w-[165px]"
|
||||
/>
|
||||
<span className="text-muted-foreground shrink-0">~</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => onEndDateChange(e.target.value)}
|
||||
className="w-[165px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* extraActions (검색창 등) */}
|
||||
{extraActions}
|
||||
</div>
|
||||
|
||||
{/* 2줄: 프리셋 버튼들 */}
|
||||
{renderPresets()}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// presetsPosition이 'inline' (기본값)
|
||||
// PC(1280px+): 달력 | 프리셋버튼 | 검색창 (한 줄)
|
||||
// 태블릿: 달력 / 프리셋버튼 / 검색창 (세 줄)
|
||||
return (
|
||||
<div className="flex flex-col xl:flex-row xl:items-center gap-2 w-full">
|
||||
{/* 날짜 범위 선택 */}
|
||||
{!hideDateInputs && (
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
<Input
|
||||
type="date"
|
||||
value={startDate}
|
||||
onChange={(e) => onStartDateChange(e.target.value)}
|
||||
className="w-[165px]"
|
||||
/>
|
||||
<span className="text-muted-foreground shrink-0">~</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={endDate}
|
||||
onChange={(e) => onEndDateChange(e.target.value)}
|
||||
className="w-[165px]"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기간 버튼들 - 달력 바로 옆 */}
|
||||
{renderPresets()}
|
||||
|
||||
{/* extraActions (검색창 등) - 마지막에 배치 */}
|
||||
{extraActions}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -22,7 +22,7 @@ interface StatCardsProps {
|
||||
|
||||
export function StatCards({ stats }: StatCardsProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 md:gap-4">
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-4 gap-2">
|
||||
{stats.map((stat, index) => {
|
||||
const Icon = stat.icon;
|
||||
const isClickable = !!stat.onClick;
|
||||
@@ -37,24 +37,24 @@ export function StatCards({ stats }: StatCardsProps) {
|
||||
}`}
|
||||
onClick={stat.onClick}
|
||||
>
|
||||
<CardContent className="p-3 md:p-4 lg:p-6">
|
||||
<CardContent className="p-2 md:p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex-1">
|
||||
<p className="sm:text-xs md:text-sm text-muted-foreground mb-1 md:mb-2 uppercase tracking-wide text-[12px]">
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5 uppercase tracking-wide truncate">
|
||||
{stat.label}
|
||||
</p>
|
||||
<p className="font-bold text-[24px]">
|
||||
<p className="font-bold text-base md:text-lg truncate">
|
||||
{stat.value}
|
||||
</p>
|
||||
{stat.trend && (
|
||||
<p className={`text-[10px] sm:text-xs md:text-sm mt-1 md:mt-2 font-medium ${stat.trend.isPositive ? 'text-green-600' : 'text-red-600'}`}>
|
||||
<p className={`text-[9px] md:text-[10px] mt-0.5 font-medium ${stat.trend.isPositive ? 'text-green-600' : 'text-red-600'}`}>
|
||||
{stat.trend.value}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
{Icon && (
|
||||
<Icon
|
||||
className={`w-8 h-8 sm:w-9 sm:h-9 md:w-10 md:h-10 lg:w-12 lg:h-12 opacity-15 ${stat.iconColor || 'text-blue-600'}`}
|
||||
className={`w-6 h-6 md:w-8 md:h-8 opacity-15 flex-shrink-0 ${stat.iconColor || 'text-blue-600'}`}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@@ -223,9 +223,22 @@ export function PricingListClient({
|
||||
) => {
|
||||
const { isSelected, onToggle } = handlers;
|
||||
|
||||
// 행 클릭 핸들러: 등록되지 않은 항목은 등록, 등록된 항목은 수정
|
||||
const handleRowClick = () => {
|
||||
if (item.status === 'not_registered') {
|
||||
handleRegister(item);
|
||||
} else {
|
||||
handleEdit(item);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableCell className="text-center">
|
||||
<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}
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Wrench, Plus, Pencil, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Wrench, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -45,6 +45,13 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(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: () => (
|
||||
<Button onClick={handleCreate} className="gap-2">
|
||||
<Plus className="h-4 w-4" />
|
||||
공정 등록
|
||||
</Button>
|
||||
),
|
||||
// 등록 버튼 (공통 컴포넌트에서 오른쪽에 렌더링)
|
||||
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 (
|
||||
<>
|
||||
<UniversalListPage config={config} initialData={allProcesses} />
|
||||
<UniversalListPage config={config} initialData={allProcesses} onSearchChange={setSearchQuery} />
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<DeleteConfirmDialog
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { ReactNode, Fragment, useState, useEffect, useRef, useCallback } from "react";
|
||||
import { LucideIcon, Trash2, Plus, Loader2, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
|
||||
import { LucideIcon, Trash2, Plus, Loader2, ArrowUpDown, ArrowUp, ArrowDown, Search } from "lucide-react";
|
||||
import { DateRangeSelector } from "@/components/molecules/DateRangeSelector";
|
||||
import { Card, CardContent } from "@/components/ui/card";
|
||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||
@@ -17,6 +17,7 @@ import {
|
||||
SelectValue,
|
||||
} from "@/components/ui/select";
|
||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||
import { Input } from "@/components/ui/input";
|
||||
import { PageLayout } from "@/components/organisms/PageLayout";
|
||||
import { PageHeader } from "@/components/organisms/PageHeader";
|
||||
import { StatCards } from "@/components/organisms/StatCards";
|
||||
@@ -106,10 +107,14 @@ export interface IntegratedListTemplateV2Props<T = any> {
|
||||
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<T = any>({
|
||||
onSearchChange,
|
||||
searchPlaceholder = "검색...",
|
||||
extraFilters,
|
||||
hideSearch = false,
|
||||
hideSearch = true, // 기본값: 타이틀 아래에 검색창 표시 (Card 안 SearchFilter 숨김)
|
||||
tabs,
|
||||
activeTab,
|
||||
onTabChange,
|
||||
@@ -536,32 +541,71 @@ export function IntegratedListTemplateV2<T = any>({
|
||||
/>
|
||||
|
||||
{/* 헤더 액션 (달력, 버튼 등) - 타이틀 아래 배치 */}
|
||||
{/* 레이아웃: [달력 (왼쪽)] -------------- [등록 버튼 (오른쪽 끝)] */}
|
||||
{(dateRangeSelector?.enabled || createButton || headerActions) && (
|
||||
{/* 레이아웃: [달력] [프리셋버튼] [검색창] -------------- [추가버튼들] [등록버튼] (오른쪽 끝) */}
|
||||
{(dateRangeSelector?.enabled || createButton || headerActions || (hideSearch && onSearchChange)) && (
|
||||
isLoading ? renderHeaderActionSkeleton() : (
|
||||
<div className="flex items-center gap-2 flex-wrap w-full">
|
||||
{/* 날짜 범위 선택기 (왼쪽) */}
|
||||
{dateRangeSelector?.enabled && (
|
||||
<div className="flex flex-col xl:flex-row xl:items-center gap-2 w-full">
|
||||
{/* 날짜 범위 선택기 + 검색창 (왼쪽) */}
|
||||
{dateRangeSelector?.enabled ? (
|
||||
<DateRangeSelector
|
||||
startDate={dateRangeSelector.startDate || ''}
|
||||
endDate={dateRangeSelector.endDate || ''}
|
||||
onStartDateChange={dateRangeSelector.onStartDateChange}
|
||||
onEndDateChange={dateRangeSelector.onEndDateChange}
|
||||
hidePresets={dateRangeSelector.showPresets === false}
|
||||
hideDateInputs={dateRangeSelector.hideDateInputs}
|
||||
extraActions={
|
||||
<>
|
||||
{/* hideSearch=true면 검색창 자동 추가 (extraActions 앞에) */}
|
||||
{hideSearch && onSearchChange && (
|
||||
<div className="relative w-full xl:w-[300px]">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue || ''}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-9 w-full bg-gray-50 border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{/* 기존 extraActions (추가 버튼 등) */}
|
||||
{dateRangeSelector.extraActions}
|
||||
</>
|
||||
}
|
||||
/>
|
||||
) : (
|
||||
/* dateRangeSelector 없어도 hideSearch=true면 검색창 표시 */
|
||||
hideSearch && onSearchChange && (
|
||||
<div className="relative w-full xl:w-[300px]">
|
||||
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
type="text"
|
||||
placeholder={searchPlaceholder}
|
||||
value={searchValue || ''}
|
||||
onChange={(e) => onSearchChange(e.target.value)}
|
||||
className="pl-9 w-full bg-gray-50 border-gray-200"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
)}
|
||||
{/* 레거시 헤더 액션 (기존 호환성 유지) */}
|
||||
{headerActions}
|
||||
{/* 등록 버튼 (오른쪽 끝) */}
|
||||
{createButton && (
|
||||
<Button className="ml-auto" onClick={createButton.onClick}>
|
||||
{createButton.icon ? (
|
||||
<createButton.icon className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
{/* 버튼 영역 (오른쪽 끝으로 통합) */}
|
||||
{(headerActions || createButton) && (
|
||||
<div className="flex items-center gap-2 ml-auto shrink-0">
|
||||
{/* 헤더 액션 (엑셀 다운로드 등 추가 버튼들) */}
|
||||
{headerActions}
|
||||
{/* 등록 버튼 */}
|
||||
{createButton && (
|
||||
<Button onClick={createButton.onClick}>
|
||||
{createButton.icon ? (
|
||||
<createButton.icon className="h-4 w-4 mr-2" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
{createButton.label}
|
||||
</Button>
|
||||
)}
|
||||
{createButton.label}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
|
||||
@@ -578,7 +578,7 @@ export function UniversalListPage<T>({
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2<T>
|
||||
<IntegratedListTemplateV2
|
||||
// 페이지 헤더
|
||||
title={config.title}
|
||||
description={config.description}
|
||||
|
||||
@@ -232,10 +232,16 @@ export interface UniversalListConfig<T> {
|
||||
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;
|
||||
};
|
||||
/**
|
||||
* 등록 버튼 (오른쪽 끝 배치)
|
||||
|
||||
520
src/lib/utils/excel-download.ts
Normal file
520
src/lib/utils/excel-download.ts
Normal file
@@ -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<T = Record<string, unknown>> {
|
||||
/** 엑셀 헤더에 표시될 이름 */
|
||||
header: string;
|
||||
/** 데이터 객체에서 가져올 키 */
|
||||
key: keyof T | string;
|
||||
/** 값 변환 함수 (선택) */
|
||||
transform?: (value: unknown, row: T) => string | number | boolean | null;
|
||||
/** 컬럼 너비 (문자 수 기준, 기본값: 15) */
|
||||
width?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 다운로드 옵션
|
||||
*/
|
||||
export interface ExcelDownloadOptions<T = Record<string, unknown>> {
|
||||
/** 다운로드할 데이터 배열 */
|
||||
data: T[];
|
||||
/** 컬럼 정의 */
|
||||
columns: ExcelColumn<T>[];
|
||||
/** 파일명 (확장자 제외, 기본값: 'export') */
|
||||
filename?: string;
|
||||
/** 시트명 (기본값: 'Sheet1') */
|
||||
sheetName?: string;
|
||||
/** 파일명에 날짜 추가 여부 (기본값: true) */
|
||||
appendDate?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 중첩 객체에서 값 추출 (예: 'vendor.name' → vendor 객체의 name 값)
|
||||
*/
|
||||
function getNestedValue(obj: Record<string, unknown>, path: string): unknown {
|
||||
return path.split('.').reduce((current: unknown, key: string) => {
|
||||
if (current && typeof current === 'object' && key in current) {
|
||||
return (current as Record<string, unknown>)[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<T extends Record<string, unknown>>({
|
||||
data,
|
||||
columns,
|
||||
filename = 'export',
|
||||
sheetName = 'Sheet1',
|
||||
appendDate = true,
|
||||
}: ExcelDownloadOptions<T>): 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<string, unknown>, 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<T extends Record<string, unknown>>({
|
||||
data,
|
||||
selectedIds,
|
||||
idField = 'id',
|
||||
...options
|
||||
}: ExcelDownloadOptions<T> & {
|
||||
selectedIds: string[];
|
||||
idField?: keyof T | string;
|
||||
}): void {
|
||||
const selectedData = data.filter((item) => {
|
||||
const id = getNestedValue(item as Record<string, unknown>, 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<T = Record<string, unknown>> {
|
||||
/** 파싱 성공 여부 */
|
||||
success: boolean;
|
||||
/** 파싱된 데이터 */
|
||||
data: T[];
|
||||
/** 에러 목록 (행별) */
|
||||
errors: Array<{
|
||||
row: number;
|
||||
column?: string;
|
||||
message: string;
|
||||
}>;
|
||||
/** 전체 행 수 */
|
||||
totalRows: number;
|
||||
/** 유효한 행 수 */
|
||||
validRows: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 엑셀 파일을 파싱하여 데이터 배열로 변환
|
||||
*
|
||||
* 사용 예시:
|
||||
* ```tsx
|
||||
* const result = await parseExcelFile<ItemMaster>(file, {
|
||||
* columns: [
|
||||
* { header: '품목코드', key: 'itemCode', required: true },
|
||||
* { header: '품목명', key: 'itemName', required: true },
|
||||
* ],
|
||||
* skipRows: 2, // 헤더 + 안내 행 스킵
|
||||
* });
|
||||
* ```
|
||||
*/
|
||||
export async function parseExcelFile<T = Record<string, unknown>>(
|
||||
file: File,
|
||||
options: {
|
||||
columns: TemplateColumn[];
|
||||
/** 스킵할 행 수 (헤더, 안내 행 등) */
|
||||
skipRows?: number;
|
||||
/** 시트 인덱스 (기본값: 0) */
|
||||
sheetIndex?: number;
|
||||
}
|
||||
): Promise<ExcelParseResult<T>> {
|
||||
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<Record<string, unknown>>(worksheet, {
|
||||
header: 1, // 배열로 반환
|
||||
defval: '', // 빈 셀 기본값
|
||||
}) as unknown[][];
|
||||
|
||||
// 헤더 행에서 컬럼 인덱스 매핑
|
||||
const headerRow = jsonData[0] as string[];
|
||||
const columnIndexMap = new Map<string, number>();
|
||||
|
||||
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<string, unknown> = {};
|
||||
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);
|
||||
});
|
||||
}
|
||||
Reference in New Issue
Block a user