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 (프로토타입 - 로컬 전용)
|
# ---> Dev Page Builder (프로토타입 - 로컬 전용)
|
||||||
src/app/**/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 { TableCell, TableRow } from '@/components/ui/table';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Button } from '@/components/ui/button';
|
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
Select,
|
Select,
|
||||||
@@ -26,7 +25,6 @@ import {
|
|||||||
type UniversalListConfig,
|
type UniversalListConfig,
|
||||||
type TableColumn,
|
type TableColumn,
|
||||||
} from '@/components/templates/UniversalListPage';
|
} from '@/components/templates/UniversalListPage';
|
||||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
|
||||||
import { getDynamicBoardPosts } from '@/components/board/DynamicBoard/actions';
|
import { getDynamicBoardPosts } from '@/components/board/DynamicBoard/actions';
|
||||||
import { getBoardByCode } from '@/components/board/BoardManagement/actions';
|
import { getBoardByCode } from '@/components/board/BoardManagement/actions';
|
||||||
import type { PostApiData } from '@/components/customer-center/shared/types';
|
import type { PostApiData } from '@/components/customer-center/shared/types';
|
||||||
@@ -340,20 +338,22 @@ export default function DynamicBoardListPage() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
columns: tableColumns,
|
columns: tableColumns,
|
||||||
headerActions: () => (
|
|
||||||
<>
|
// 공통 패턴: dateRangeSelector + createButton + onSearchChange
|
||||||
<DateRangeSelector
|
dateRangeSelector: {
|
||||||
startDate={startDate}
|
enabled: true,
|
||||||
endDate={endDate}
|
startDate,
|
||||||
onStartDateChange={setStartDate}
|
endDate,
|
||||||
onEndDateChange={setEndDate}
|
onStartDateChange: setStartDate,
|
||||||
/>
|
onEndDateChange: setEndDate,
|
||||||
<Button className="ml-auto" onClick={handleCreate}>
|
},
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
createButton: {
|
||||||
글쓰기
|
label: '글쓰기',
|
||||||
</Button>
|
icon: Plus,
|
||||||
</>
|
onClick: handleCreate,
|
||||||
),
|
},
|
||||||
|
onSearchChange: setSearchValue,
|
||||||
|
|
||||||
tableHeaderActions: (
|
tableHeaderActions: (
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
<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) => {
|
customFilterFn: (items) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
let result = [...items];
|
let result = [...items];
|
||||||
|
|
||||||
// 거래처 필터
|
// 거래처 필터
|
||||||
|
|||||||
@@ -48,7 +48,8 @@ interface PaginationMeta {
|
|||||||
// ===== API → Frontend 변환 =====
|
// ===== API → Frontend 변환 =====
|
||||||
function transformItem(item: BankTransactionApiItem): BankTransaction {
|
function transformItem(item: BankTransactionApiItem): BankTransaction {
|
||||||
return {
|
return {
|
||||||
id: String(item.id),
|
// 입금/출금 테이블이 별도이므로 type을 접두어로 붙여 고유 ID 생성
|
||||||
|
id: `${item.type}-${item.id}`,
|
||||||
bankName: item.bank_name,
|
bankName: item.bank_name,
|
||||||
accountName: item.account_name,
|
accountName: item.account_name,
|
||||||
transactionDate: item.transaction_date,
|
transactionDate: item.transaction_date,
|
||||||
|
|||||||
@@ -279,18 +279,16 @@ export function BankTransactionInquiry({
|
|||||||
onEndDateChange: setEndDate,
|
onEndDateChange: setEndDate,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 테이블 상단 콘텐츠 (새로고침 버튼)
|
// 헤더 액션: 새로고침 버튼
|
||||||
beforeTableContent: (
|
headerActions: () => (
|
||||||
<div className="flex items-center justify-end w-full">
|
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={isLoading}>
|
||||||
<Button variant="outline" onClick={handleRefresh} disabled={isLoading}>
|
{isLoading ? (
|
||||||
{isLoading ? (
|
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
) : (
|
||||||
) : (
|
<RefreshCw className="h-4 w-4 mr-1" />
|
||||||
<RefreshCw className="h-4 w-4 mr-2" />
|
)}
|
||||||
)}
|
{isLoading ? '조회중...' : '새로고침'}
|
||||||
새로고침
|
</Button>
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// 테이블 헤더 액션 (3개 필터)
|
// 테이블 헤더 액션 (3개 필터)
|
||||||
|
|||||||
@@ -429,6 +429,42 @@ export function BillManagementClient({
|
|||||||
icon: Plus,
|
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: (
|
tableHeaderActions: (
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
@@ -473,44 +509,6 @@ export function BillManagementClient({
|
|||||||
</div>
|
</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,
|
renderTableRow,
|
||||||
renderMobileCard,
|
renderMobileCard,
|
||||||
|
|||||||
@@ -17,7 +17,7 @@
|
|||||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -380,18 +380,66 @@ export function CardTransactionInquiry({
|
|||||||
},
|
},
|
||||||
filterTitle: '카드 필터',
|
filterTitle: '카드 필터',
|
||||||
|
|
||||||
// 헤더 액션 (등록 버튼)
|
// 헤더 액션: 계정과목명 Select + 저장 + 새로고침
|
||||||
headerActions: () => (
|
headerActions: () => (
|
||||||
<Button className="ml-auto" onClick={() => router.push('/ko/accounting/card-transactions?mode=new')}>
|
<div className="flex items-center gap-2">
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<span className="text-sm font-medium text-gray-700 whitespace-nowrap">계정과목명</span>
|
||||||
카드내역 등록
|
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||||
</Button>
|
<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) => {
|
customFilterFn: (items) => {
|
||||||
if (cardFilter === 'all') return items;
|
if (!items || items.length === 0) return items;
|
||||||
return items.filter((item) => item.cardName === cardFilter);
|
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: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
showPresets: true,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
onStartDateChange: setStartDate,
|
onStartDateChange: setStartDate,
|
||||||
@@ -428,42 +483,6 @@ export function CardTransactionInquiry({
|
|||||||
// 선택 항목 변경 콜백
|
// 선택 항목 변경 콜백
|
||||||
onSelectionChange: setSelectedItems,
|
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개 필터)
|
// 테이블 헤더 액션 (2개 필터)
|
||||||
tableHeaderActions: () => (
|
tableHeaderActions: () => (
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<div className="flex items-center gap-2 flex-wrap">
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ import {
|
|||||||
Save,
|
Save,
|
||||||
Trash2,
|
Trash2,
|
||||||
RefreshCw,
|
RefreshCw,
|
||||||
|
Search,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import {
|
import {
|
||||||
@@ -103,6 +105,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
|
|||||||
const [endDate, setEndDate] = useState('2025-09-03');
|
const [endDate, setEndDate] = useState('2025-09-03');
|
||||||
const [depositData, setDepositData] = useState<DepositRecord[]>(initialData);
|
const [depositData, setDepositData] = useState<DepositRecord[]>(initialData);
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
// 인라인 필터 상태 (tableHeaderActions에서 사용)
|
// 인라인 필터 상태 (tableHeaderActions에서 사용)
|
||||||
const [vendorFilter, setVendorFilter] = useState<string>('all');
|
const [vendorFilter, setVendorFilter] = useState<string>('all');
|
||||||
@@ -262,7 +265,19 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
|
|||||||
|
|
||||||
// 커스텀 필터 함수 (인라인 필터 사용)
|
// 커스텀 필터 함수 (인라인 필터 사용)
|
||||||
customFilterFn: (items, filterValues) => {
|
customFilterFn: (items, filterValues) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
return items.filter((item) => {
|
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) {
|
if (vendorFilter !== 'all' && item.vendorName !== vendorFilter) {
|
||||||
return false;
|
return false;
|
||||||
@@ -295,9 +310,16 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
|
|||||||
return sorted;
|
return sorted;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchQuery,
|
||||||
|
onSearchChange: setSearchQuery,
|
||||||
|
searchPlaceholder: '입금자명, 계좌명, 적요, 거래처 검색...',
|
||||||
|
|
||||||
// 공통 헤더 옵션
|
// 공통 헤더 옵션
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
showPresets: true,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
onStartDateChange: setStartDate,
|
onStartDateChange: setStartDate,
|
||||||
@@ -325,14 +347,45 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
|
|||||||
},
|
},
|
||||||
filterTitle: '입금 필터',
|
filterTitle: '입금 필터',
|
||||||
|
|
||||||
// 헤더 액션 (등록 버튼)
|
// 헤더 액션: 계정과목명 Select + 저장 + 새로고침
|
||||||
headerActions: () => (
|
headerActions: ({ selectedItems }) => (
|
||||||
<Button className="ml-auto" onClick={() => router.push('/ko/accounting/deposits?mode=new')}>
|
<div className="flex items-center gap-2">
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
<span className="text-sm font-medium text-gray-700 whitespace-nowrap">계정과목명</span>
|
||||||
입금등록
|
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||||
</Button>
|
<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 카드
|
// Stats 카드
|
||||||
computeStats: (): StatCard[] => [
|
computeStats: (): StatCard[] => [
|
||||||
{ label: '총 입금', value: `${stats.totalDeposit.toLocaleString()}원`, icon: Banknote, iconColor: 'text-blue-500' },
|
{ 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' },
|
{ 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: 3개 인라인 필터
|
||||||
tableHeaderActions: ({ selectedItems }) => (
|
tableHeaderActions: (
|
||||||
<div className="flex items-center gap-2 flex-wrap">
|
<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}>
|
<Select value={vendorFilter} onValueChange={setVendorFilter}>
|
||||||
<SelectTrigger className="w-[140px]">
|
<SelectTrigger className="w-[140px]">
|
||||||
@@ -556,6 +574,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
|
|||||||
vendorOptions,
|
vendorOptions,
|
||||||
tableTotals,
|
tableTotals,
|
||||||
isRefreshing,
|
isRefreshing,
|
||||||
|
searchQuery,
|
||||||
handleRowClick,
|
handleRowClick,
|
||||||
handleEdit,
|
handleEdit,
|
||||||
handleRefresh,
|
handleRefresh,
|
||||||
|
|||||||
@@ -902,51 +902,9 @@ export function ExpectedExpenseManagement({
|
|||||||
},
|
},
|
||||||
filterTitle: '예상비용 필터',
|
filterTitle: '예상비용 필터',
|
||||||
|
|
||||||
// 테이블 헤더 액션 (거래처/정렬 필터)
|
// 헤더 액션: 선택 기반 액션 버튼들
|
||||||
tableHeaderActions: () => (
|
headerActions: () => (
|
||||||
<div className="flex items-center gap-2">
|
<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개 이상 선택 시 활성화 */}
|
{/* 예상 지급일 변경 버튼 - 1개 이상 선택 시 활성화 */}
|
||||||
<Button
|
<Button
|
||||||
variant="outline"
|
variant="outline"
|
||||||
@@ -983,6 +941,46 @@ export function ExpectedExpenseManagement({
|
|||||||
</div>
|
</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 카드
|
// Stats 카드
|
||||||
computeStats: (): StatCard[] => {
|
computeStats: (): StatCard[] => {
|
||||||
const totalExpense = filteredRawData.reduce((sum, d) => sum + d.amount, 0);
|
const totalExpense = filteredRawData.reduce((sum, d) => sum + d.amount, 0);
|
||||||
@@ -1011,7 +1009,7 @@ export function ExpectedExpenseManagement({
|
|||||||
vendorFilter,
|
vendorFilter,
|
||||||
vendorFilterOptions,
|
vendorFilterOptions,
|
||||||
sortOption,
|
sortOption,
|
||||||
selectedItems,
|
selectedItems.size,
|
||||||
handleOpenCreateDialog,
|
handleOpenCreateDialog,
|
||||||
handleOpenDateChangeDialog,
|
handleOpenDateChangeDialog,
|
||||||
handleElectronicApproval,
|
handleElectronicApproval,
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Save,
|
Save,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Search,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
@@ -85,6 +87,7 @@ export function PurchaseManagement() {
|
|||||||
const [endDate, setEndDate] = useState('2025-12-31');
|
const [endDate, setEndDate] = useState('2025-12-31');
|
||||||
const [purchaseData, setPurchaseData] = useState<PurchaseRecord[]>([]);
|
const [purchaseData, setPurchaseData] = useState<PurchaseRecord[]>([]);
|
||||||
const [isLoading, setIsLoading] = useState(true);
|
const [isLoading, setIsLoading] = useState(true);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
// 통합 필터 상태 (filterConfig 기반)
|
// 통합 필터 상태 (filterConfig 기반)
|
||||||
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
|
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
|
||||||
@@ -295,7 +298,17 @@ export function PurchaseManagement() {
|
|||||||
|
|
||||||
// 커스텀 필터 함수 (filterValues 파라미터 사용)
|
// 커스텀 필터 함수 (filterValues 파라미터 사용)
|
||||||
customFilterFn: (items, fv) => {
|
customFilterFn: (items, fv) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
return items.filter((item) => {
|
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 vendorVal = fv.vendor as string;
|
||||||
const purchaseTypeVal = fv.purchaseType as string;
|
const purchaseTypeVal = fv.purchaseType as string;
|
||||||
const issuanceVal = fv.issuance as string;
|
const issuanceVal = fv.issuance as string;
|
||||||
@@ -336,15 +349,45 @@ export function PurchaseManagement() {
|
|||||||
return sorted;
|
return sorted;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchQuery,
|
||||||
|
onSearchChange: setSearchQuery,
|
||||||
|
searchPlaceholder: '매입번호, 거래처명 검색...',
|
||||||
|
|
||||||
// 공통 헤더 옵션
|
// 공통 헤더 옵션
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
showPresets: true,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
onStartDateChange: setStartDate,
|
onStartDateChange: setStartDate,
|
||||||
onEndDateChange: setEndDate,
|
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: 인라인, 모바일: 바텀시트 자동 분기)
|
// 통합 필터 시스템 (PC: 인라인, 모바일: 바텀시트 자동 분기)
|
||||||
filterConfig,
|
filterConfig,
|
||||||
initialFilters: filterValues,
|
initialFilters: filterValues,
|
||||||
@@ -358,29 +401,6 @@ export function PurchaseManagement() {
|
|||||||
{ label: '세금계산서 수취 미확인', value: `${stats.taxInvoicePendingCount}건`, icon: Receipt, iconColor: 'text-red-500' },
|
{ 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: (
|
tableFooter: (
|
||||||
<TableRow className="bg-muted/50 font-medium">
|
<TableRow className="bg-muted/50 font-medium">
|
||||||
@@ -532,6 +552,7 @@ export function PurchaseManagement() {
|
|||||||
filterValues,
|
filterValues,
|
||||||
selectedAccountSubject,
|
selectedAccountSubject,
|
||||||
tableTotals,
|
tableTotals,
|
||||||
|
searchQuery,
|
||||||
handleRowClick,
|
handleRowClick,
|
||||||
handleEdit,
|
handleEdit,
|
||||||
handleTaxInvoiceToggle,
|
handleTaxInvoiceToggle,
|
||||||
|
|||||||
@@ -22,8 +22,10 @@ import {
|
|||||||
Pencil,
|
Pencil,
|
||||||
Save,
|
Save,
|
||||||
Trash2,
|
Trash2,
|
||||||
|
Search,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Switch } from '@/components/ui/switch';
|
import { Switch } from '@/components/ui/switch';
|
||||||
@@ -97,6 +99,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
|||||||
const [startDate, setStartDate] = useState('2025-01-01');
|
const [startDate, setStartDate] = useState('2025-01-01');
|
||||||
const [endDate, setEndDate] = useState('2025-12-31');
|
const [endDate, setEndDate] = useState('2025-12-31');
|
||||||
const [salesData, setSalesData] = useState<SalesRecord[]>(initialData);
|
const [salesData, setSalesData] = useState<SalesRecord[]>(initialData);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
// 통합 필터 상태 (filterConfig 사용)
|
// 통합 필터 상태 (filterConfig 사용)
|
||||||
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
|
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
|
||||||
@@ -297,7 +300,18 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
|||||||
|
|
||||||
// 커스텀 필터 함수 (filterConfig 사용)
|
// 커스텀 필터 함수 (filterConfig 사용)
|
||||||
customFilterFn: (items, fv) => {
|
customFilterFn: (items, fv) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
return items.filter((item) => {
|
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 vendorVal = fv.vendor as string;
|
||||||
const salesTypeVal = fv.salesType as string;
|
const salesTypeVal = fv.salesType as string;
|
||||||
const issuanceVal = fv.issuance as string;
|
const issuanceVal = fv.issuance as string;
|
||||||
@@ -342,14 +356,43 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
|||||||
return sorted;
|
return sorted;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchQuery,
|
||||||
|
onSearchChange: setSearchQuery,
|
||||||
|
searchPlaceholder: '매출번호, 거래처명, 비고 검색...',
|
||||||
|
|
||||||
// 공통 헤더 옵션
|
// 공통 헤더 옵션
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
showPresets: true,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
onStartDateChange: setStartDate,
|
onStartDateChange: setStartDate,
|
||||||
onEndDateChange: setEndDate,
|
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: {
|
createButton: {
|
||||||
label: '매출 등록',
|
label: '매출 등록',
|
||||||
onClick: handleCreate,
|
onClick: handleCreate,
|
||||||
@@ -368,29 +411,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
|||||||
initialFilters: filterValues,
|
initialFilters: filterValues,
|
||||||
filterTitle: '매출 필터',
|
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: (
|
tableFooter: (
|
||||||
<TableRow className="bg-muted/50 font-medium">
|
<TableRow className="bg-muted/50 font-medium">
|
||||||
@@ -534,6 +554,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
|||||||
filterValues,
|
filterValues,
|
||||||
selectedAccountSubject,
|
selectedAccountSubject,
|
||||||
tableTotals,
|
tableTotals,
|
||||||
|
searchQuery,
|
||||||
handleRowClick,
|
handleRowClick,
|
||||||
handleEdit,
|
handleEdit,
|
||||||
handleCreate,
|
handleCreate,
|
||||||
|
|||||||
@@ -211,6 +211,7 @@ export function VendorManagement({ initialData, initialTotal }: VendorManagement
|
|||||||
|
|
||||||
// 커스텀 필터 함수
|
// 커스텀 필터 함수
|
||||||
customFilterFn: (items, filterValues) => {
|
customFilterFn: (items, filterValues) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
// 구분 필터
|
// 구분 필터
|
||||||
const categoryFilter = filterValues.category as string;
|
const categoryFilter = filterValues.category as string;
|
||||||
|
|||||||
@@ -73,14 +73,13 @@ import { toast } from 'sonner';
|
|||||||
|
|
||||||
// ===== 테이블 컬럼 정의 =====
|
// ===== 테이블 컬럼 정의 =====
|
||||||
const tableColumns = [
|
const tableColumns = [
|
||||||
{ key: 'withdrawalDate', label: '출금일' },
|
{ key: 'withdrawalDate', label: '출금일', className: 'w-[100px]' },
|
||||||
{ key: 'accountName', label: '출금계좌' },
|
{ key: 'accountName', label: '출금계좌', className: 'min-w-[120px]' },
|
||||||
{ key: 'recipientName', label: '수취인명' },
|
{ key: 'recipientName', label: '수취인명', className: 'min-w-[100px]' },
|
||||||
{ key: 'withdrawalAmount', label: '출금금액', className: 'text-right' },
|
{ key: 'withdrawalAmount', label: '출금금액', className: 'text-right w-[110px]' },
|
||||||
{ key: 'vendorName', label: '거래처' },
|
{ key: 'vendorName', label: '거래처', className: 'min-w-[100px]' },
|
||||||
{ key: 'note', label: '적요' },
|
{ key: 'note', label: '적요', className: 'min-w-[150px]' },
|
||||||
{ key: 'withdrawalType', label: '출금유형', className: 'text-center' },
|
{ key: 'withdrawalType', label: '출금유형', className: 'text-center w-[90px]' },
|
||||||
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
|
|
||||||
];
|
];
|
||||||
|
|
||||||
// ===== 컴포넌트 Props =====
|
// ===== 컴포넌트 Props =====
|
||||||
@@ -112,6 +111,9 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
|||||||
// 상단 계정과목명 선택 (저장용)
|
// 상단 계정과목명 선택 (저장용)
|
||||||
const [selectedAccountSubject, setSelectedAccountSubject] = useState<string>('unset');
|
const [selectedAccountSubject, setSelectedAccountSubject] = useState<string>('unset');
|
||||||
|
|
||||||
|
// 검색어 상태 (헤더에서 직접 관리)
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
// 로딩 상태
|
// 로딩 상태
|
||||||
const [isRefreshing, setIsRefreshing] = useState(false);
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
||||||
|
|
||||||
@@ -297,17 +299,23 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
|||||||
},
|
},
|
||||||
filterTitle: '출금 필터',
|
filterTitle: '출금 필터',
|
||||||
|
|
||||||
// 헤더 액션 (등록 버튼)
|
// 검색창 숨김 (dateRangeSelector extraActions로 렌더링)
|
||||||
headerActions: () => (
|
hideSearch: true,
|
||||||
<Button className="ml-auto" onClick={() => router.push('/ko/accounting/withdrawals?mode=new')}>
|
|
||||||
<Plus className="w-4 h-4 mr-2" />
|
|
||||||
출금등록
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
|
|
||||||
// 커스텀 필터 함수
|
// 커스텀 필터 함수 (검색 + 필터)
|
||||||
customFilterFn: (items) => {
|
customFilterFn: (items) => {
|
||||||
return items.filter((item) => {
|
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) {
|
if (vendorFilter !== 'all' && item.vendorName !== vendorFilter) {
|
||||||
return false;
|
return false;
|
||||||
@@ -342,23 +350,30 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
|||||||
return sorted;
|
return sorted;
|
||||||
},
|
},
|
||||||
|
|
||||||
// 날짜 범위 선택기
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchQuery,
|
||||||
|
onSearchChange: setSearchQuery,
|
||||||
|
|
||||||
|
// 날짜 범위 선택기 (달력 | 프리셋버튼 | 검색창(자동) - 한 줄)
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
showPresets: true,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
onStartDateChange: setStartDate,
|
onStartDateChange: setStartDate,
|
||||||
onEndDateChange: setEndDate,
|
onEndDateChange: setEndDate,
|
||||||
},
|
},
|
||||||
|
|
||||||
// beforeTableContent: 계정과목명 + 저장 + 새로고침
|
// 헤더 액션: 계정과목명 Select + 저장 + 새로고침
|
||||||
beforeTableContent: (
|
headerActions: ({ selectedItems }) => {
|
||||||
<div className="flex items-center justify-between w-full">
|
const selectedArray = withdrawalData.filter(item => selectedItems.has(item.id));
|
||||||
|
return (
|
||||||
<div className="flex items-center gap-2">
|
<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}>
|
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
|
||||||
<SelectTrigger className="w-[150px]">
|
<SelectTrigger className="w-[120px]">
|
||||||
<SelectValue placeholder="계정과목명 선택" />
|
<SelectValue placeholder="선택" />
|
||||||
</SelectTrigger>
|
</SelectTrigger>
|
||||||
<SelectContent>
|
<SelectContent>
|
||||||
{ACCOUNT_SUBJECT_OPTIONS.map((option) => (
|
{ACCOUNT_SUBJECT_OPTIONS.map((option) => (
|
||||||
@@ -368,29 +383,33 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
|||||||
))}
|
))}
|
||||||
</SelectContent>
|
</SelectContent>
|
||||||
</Select>
|
</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>
|
</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">
|
<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}>
|
<Select value={vendorFilter} onValueChange={setVendorFilter}>
|
||||||
<SelectTrigger className="w-[140px]">
|
<SelectTrigger className="w-[140px]">
|
||||||
@@ -446,7 +465,6 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
|||||||
<TableCell></TableCell>
|
<TableCell></TableCell>
|
||||||
<TableCell></TableCell>
|
<TableCell></TableCell>
|
||||||
<TableCell></TableCell>
|
<TableCell></TableCell>
|
||||||
<TableCell></TableCell>
|
|
||||||
</TableRow>
|
</TableRow>
|
||||||
),
|
),
|
||||||
|
|
||||||
@@ -506,29 +524,6 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
|||||||
{WITHDRAWAL_TYPE_LABELS[item.withdrawalType]}
|
{WITHDRAWAL_TYPE_LABELS[item.withdrawalType]}
|
||||||
</Badge>
|
</Badge>
|
||||||
</TableCell>
|
</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>
|
</TableRow>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@@ -576,9 +571,11 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
|||||||
}),
|
}),
|
||||||
[
|
[
|
||||||
initialData,
|
initialData,
|
||||||
|
withdrawalData,
|
||||||
stats,
|
stats,
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
|
searchQuery,
|
||||||
vendorFilter,
|
vendorFilter,
|
||||||
withdrawalTypeFilter,
|
withdrawalTypeFilter,
|
||||||
sortOption,
|
sortOption,
|
||||||
|
|||||||
@@ -510,6 +510,11 @@ export function ApprovalBox() {
|
|||||||
tabs: tabs,
|
tabs: tabs,
|
||||||
defaultTab: activeTab,
|
defaultTab: activeTab,
|
||||||
|
|
||||||
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchQuery,
|
||||||
|
onSearchChange: setSearchQuery,
|
||||||
|
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
showPresets: false,
|
showPresets: false,
|
||||||
|
|||||||
@@ -464,6 +464,11 @@ export function DraftBox() {
|
|||||||
{ key: 'actions', label: '작업', className: 'text-center' },
|
{ key: 'actions', label: '작업', className: 'text-center' },
|
||||||
],
|
],
|
||||||
|
|
||||||
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchQuery,
|
||||||
|
onSearchChange: setSearchQuery,
|
||||||
|
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
showPresets: false,
|
showPresets: false,
|
||||||
|
|||||||
@@ -445,6 +445,11 @@ export function ReferenceBox() {
|
|||||||
|
|
||||||
computeStats: () => statCards,
|
computeStats: () => statCards,
|
||||||
|
|
||||||
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchQuery,
|
||||||
|
onSearchChange: setSearchQuery,
|
||||||
|
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
showPresets: false,
|
showPresets: false,
|
||||||
|
|||||||
@@ -14,8 +14,10 @@ export interface TabChipProps {
|
|||||||
label: string;
|
label: string;
|
||||||
/** 카운트 숫자 */
|
/** 카운트 숫자 */
|
||||||
count?: number;
|
count?: number;
|
||||||
/** 활성 상태 */
|
/** 활성 상태 (active 또는 isActive 둘 다 지원) */
|
||||||
active?: boolean;
|
active?: boolean;
|
||||||
|
/** 활성 상태 (active의 별칭) */
|
||||||
|
isActive?: boolean;
|
||||||
/** 클릭 이벤트 */
|
/** 클릭 이벤트 */
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
/** 색상 테마 */
|
/** 색상 테마 */
|
||||||
@@ -28,26 +30,30 @@ export function TabChip({
|
|||||||
label,
|
label,
|
||||||
count,
|
count,
|
||||||
active = false,
|
active = false,
|
||||||
|
isActive,
|
||||||
onClick,
|
onClick,
|
||||||
color = "blue",
|
color = "blue",
|
||||||
className = "",
|
className = "",
|
||||||
}: TabChipProps) {
|
}: TabChipProps) {
|
||||||
|
// isActive가 전달되면 isActive 사용, 아니면 active 사용
|
||||||
|
const isActiveState = isActive ?? active;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<button
|
<button
|
||||||
onClick={onClick}
|
onClick={onClick}
|
||||||
className={`
|
className={`
|
||||||
flex items-center gap-2 px-4 py-2.5 rounded-full border transition-all
|
flex items-center gap-2 px-4 py-2.5 rounded-full border transition-all
|
||||||
${
|
${
|
||||||
active
|
isActiveState
|
||||||
? "border-primary bg-primary/5"
|
? "border-primary bg-primary text-white shadow-sm"
|
||||||
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
|
: "border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50"
|
||||||
}
|
}
|
||||||
${className}
|
${className}
|
||||||
`}
|
`}
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
className={`text-sm ${
|
className={`text-sm ${
|
||||||
active ? "text-primary font-medium" : "text-gray-600 font-normal"
|
isActiveState ? "text-white font-medium" : "text-gray-600 font-normal"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{label}
|
{label}
|
||||||
@@ -55,7 +61,7 @@ export function TabChip({
|
|||||||
{count !== undefined && (
|
{count !== undefined && (
|
||||||
<span
|
<span
|
||||||
className={`text-sm font-semibold ${
|
className={`text-sm font-semibold ${
|
||||||
active ? "text-primary" : "text-gray-900"
|
isActiveState ? "text-white" : "text-gray-900"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{count}
|
{count}
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
|
|||||||
// 날짜 범위
|
// 날짜 범위
|
||||||
const [startDate, setStartDate] = useState('');
|
const [startDate, setStartDate] = useState('');
|
||||||
const [endDate, setEndDate] = useState('');
|
const [endDate, setEndDate] = useState('');
|
||||||
|
// 검색어
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
// Stats 데이터
|
// Stats 데이터
|
||||||
const [stats, setStats] = useState<BiddingStats | null>(initialStats || null);
|
const [stats, setStats] = useState<BiddingStats | null>(initialStats || null);
|
||||||
|
|
||||||
@@ -220,6 +222,7 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
|
|||||||
|
|
||||||
// 커스텀 필터 함수 (activeStatTab + filterValues 기반)
|
// 커스텀 필터 함수 (activeStatTab + filterValues 기반)
|
||||||
customFilterFn: (items, filterValues) => {
|
customFilterFn: (items, filterValues) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
// Stats 탭 필터
|
// Stats 탭 필터
|
||||||
if (activeStatTab === 'waiting' && item.status !== 'waiting') return false;
|
if (activeStatTab === 'waiting' && item.status !== 'waiting') return false;
|
||||||
@@ -291,6 +294,11 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
|
|||||||
return sorted;
|
return sorted;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchQuery,
|
||||||
|
onSearchChange: setSearchQuery,
|
||||||
|
|
||||||
// 공통 헤더 옵션: 날짜 선택기
|
// 공통 헤더 옵션: 날짜 선택기
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
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 [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
|
||||||
const [startDate, setStartDate] = useState('');
|
const [startDate, setStartDate] = useState('');
|
||||||
const [endDate, setEndDate] = useState('');
|
const [endDate, setEndDate] = useState('');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [stats, setStats] = useState<ContractStats | null>(initialStats || null);
|
const [stats, setStats] = useState<ContractStats | null>(initialStats || null);
|
||||||
|
|
||||||
// Stats 로드
|
// Stats 로드
|
||||||
@@ -235,6 +236,7 @@ export default function ContractListClient({ initialData = [], initialStats }: C
|
|||||||
|
|
||||||
// 커스텀 필터 함수
|
// 커스텀 필터 함수
|
||||||
customFilterFn: (items, filterValues) => {
|
customFilterFn: (items, filterValues) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
// Stats 탭 필터
|
// Stats 탭 필터
|
||||||
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
|
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
|
||||||
@@ -304,6 +306,11 @@ export default function ContractListClient({ initialData = [], initialStats }: C
|
|||||||
return sorted;
|
return sorted;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchQuery,
|
||||||
|
onSearchChange: setSearchQuery,
|
||||||
|
|
||||||
// 공통 헤더 옵션
|
// 공통 헤더 옵션
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
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 [startDate, setStartDate] = useState('');
|
||||||
const [endDate, setEndDate] = useState('');
|
const [endDate, setEndDate] = useState('');
|
||||||
|
// 검색어
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
// Stats 데이터
|
// Stats 데이터
|
||||||
const [stats, setStats] = useState<EstimateStats | null>(initialStats || null);
|
const [stats, setStats] = useState<EstimateStats | null>(initialStats || null);
|
||||||
// 필터 옵션 데이터
|
// 필터 옵션 데이터
|
||||||
@@ -210,6 +212,7 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
|||||||
|
|
||||||
// 커스텀 필터 함수 (activeStatTab + filterValues 기반)
|
// 커스텀 필터 함수 (activeStatTab + filterValues 기반)
|
||||||
customFilterFn: (items, filterValues) => {
|
customFilterFn: (items, filterValues) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
// Stats 탭 필터
|
// Stats 탭 필터
|
||||||
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
|
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
|
||||||
@@ -274,6 +277,11 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
|||||||
return sorted;
|
return sorted;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchQuery,
|
||||||
|
onSearchChange: setSearchQuery,
|
||||||
|
|
||||||
// 공통 헤더 옵션: 날짜 선택기
|
// 공통 헤더 옵션: 날짜 선택기
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
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 [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
|
||||||
const [startDate, setStartDate] = useState<string>('');
|
const [startDate, setStartDate] = useState<string>('');
|
||||||
const [endDate, setEndDate] = useState<string>('');
|
const [endDate, setEndDate] = useState<string>('');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [stats, setStats] = useState<HandoverReportStats | null>(initialStats || null);
|
const [stats, setStats] = useState<HandoverReportStats | null>(initialStats || null);
|
||||||
|
|
||||||
// Stats 로드
|
// Stats 로드
|
||||||
@@ -234,6 +235,7 @@ export default function HandoverReportListClient({
|
|||||||
|
|
||||||
// 커스텀 필터 함수
|
// 커스텀 필터 함수
|
||||||
customFilterFn: (items, filterValues) => {
|
customFilterFn: (items, filterValues) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
// Stats 탭 필터
|
// Stats 탭 필터
|
||||||
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
|
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
|
||||||
@@ -297,6 +299,11 @@ export default function HandoverReportListClient({
|
|||||||
return sorted;
|
return sorted;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchQuery,
|
||||||
|
onSearchChange: setSearchQuery,
|
||||||
|
|
||||||
// 공통 헤더 옵션
|
// 공통 헤더 옵션
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
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 [startDate, setStartDate] = useState<string>('');
|
||||||
const [endDate, setEndDate] = useState<string>('');
|
const [endDate, setEndDate] = useState<string>('');
|
||||||
const [stats, setStats] = useState<IssueStats | null>(initialStats || null);
|
const [stats, setStats] = useState<IssueStats | null>(initialStats || null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
const [withdrawDialogOpen, setWithdrawDialogOpen] = useState(false);
|
const [withdrawDialogOpen, setWithdrawDialogOpen] = useState(false);
|
||||||
const [itemsToWithdraw, setItemsToWithdraw] = useState<Set<string>>(new Set());
|
const [itemsToWithdraw, setItemsToWithdraw] = useState<Set<string>>(new Set());
|
||||||
const [clearSelectionFn, setClearSelectionFn] = useState<(() => void) | null>(null);
|
const [clearSelectionFn, setClearSelectionFn] = useState<(() => void) | null>(null);
|
||||||
@@ -271,6 +272,7 @@ export default function IssueManagementListClient({
|
|||||||
|
|
||||||
// 커스텀 필터 함수
|
// 커스텀 필터 함수
|
||||||
customFilterFn: (items, filterValues) => {
|
customFilterFn: (items, filterValues) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
// Stats 탭 필터
|
// Stats 탭 필터
|
||||||
if (activeStatTab !== 'all' && item.status !== activeStatTab) return false;
|
if (activeStatTab !== 'all' && item.status !== activeStatTab) return false;
|
||||||
@@ -346,6 +348,11 @@ export default function IssueManagementListClient({
|
|||||||
return sorted;
|
return sorted;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchQuery,
|
||||||
|
onSearchChange: setSearchQuery,
|
||||||
|
|
||||||
// 공통 헤더 옵션
|
// 공통 헤더 옵션
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<UniversalListPage config={config} initialData={initialData} />
|
<UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />
|
||||||
|
|
||||||
{/* 철회 확인 다이얼로그 */}
|
{/* 철회 확인 다이얼로그 */}
|
||||||
<ConfirmDialog
|
<ConfirmDialog
|
||||||
|
|||||||
@@ -555,6 +555,7 @@ export default function ItemManagementClient({
|
|||||||
onStartDateChange: setStartDate,
|
onStartDateChange: setStartDate,
|
||||||
onEndDateChange: setEndDate,
|
onEndDateChange: setEndDate,
|
||||||
},
|
},
|
||||||
|
hideSearch: true,
|
||||||
|
|
||||||
// 등록 버튼
|
// 등록 버튼
|
||||||
createButton: {
|
createButton: {
|
||||||
|
|||||||
@@ -59,6 +59,7 @@ export default function LaborManagementClient({
|
|||||||
const [startDate, setStartDate] = useState(format(startOfYear(today), 'yyyy-MM-dd'));
|
const [startDate, setStartDate] = useState(format(startOfYear(today), 'yyyy-MM-dd'));
|
||||||
const [endDate, setEndDate] = useState(format(endOfYear(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 [stats, setStats] = useState<LaborStats>(initialStats ?? { total: 0, active: 0 });
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
// Stats 로드
|
// Stats 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -211,6 +212,7 @@ export default function LaborManagementClient({
|
|||||||
|
|
||||||
// 커스텀 필터 함수
|
// 커스텀 필터 함수
|
||||||
customFilterFn: (items, filterValues) => {
|
customFilterFn: (items, filterValues) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
// 구분 필터
|
// 구분 필터
|
||||||
const categoryFilter = filterValues.category as string;
|
const categoryFilter = filterValues.category as string;
|
||||||
@@ -242,6 +244,11 @@ export default function LaborManagementClient({
|
|||||||
return sorted;
|
return sorted;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchQuery,
|
||||||
|
onSearchChange: setSearchQuery,
|
||||||
|
|
||||||
// 공통 헤더 옵션
|
// 공통 헤더 옵션
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -361,6 +368,7 @@ export default function LaborManagementClient({
|
|||||||
[
|
[
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
|
searchQuery,
|
||||||
stats,
|
stats,
|
||||||
handleRowClick,
|
handleRowClick,
|
||||||
handleEdit,
|
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 [startDate, setStartDate] = useState<string>('');
|
||||||
const [endDate, setEndDate] = useState<string>('');
|
const [endDate, setEndDate] = useState<string>('');
|
||||||
const [stats, setStats] = useState<ConstructionManagementStats | null>(initialStats || null);
|
const [stats, setStats] = useState<ConstructionManagementStats | null>(initialStats || null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
// 달력 관련 상태
|
// 달력 관련 상태
|
||||||
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | null>(null);
|
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | null>(null);
|
||||||
@@ -289,6 +290,7 @@ export default function ConstructionManagementListClient({
|
|||||||
|
|
||||||
// 커스텀 필터 함수
|
// 커스텀 필터 함수
|
||||||
customFilterFn: (items, filterValues) => {
|
customFilterFn: (items, filterValues) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
// Stats 탭 필터
|
// Stats 탭 필터
|
||||||
if (activeStatTab === 'in_progress' && item.status !== 'in_progress') return false;
|
if (activeStatTab === 'in_progress' && item.status !== 'in_progress') return false;
|
||||||
@@ -379,6 +381,11 @@ export default function ConstructionManagementListClient({
|
|||||||
return sorted;
|
return sorted;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchQuery,
|
||||||
|
onSearchChange: setSearchQuery,
|
||||||
|
|
||||||
// 공통 헤더 옵션
|
// 공통 헤더 옵션
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -530,6 +537,7 @@ export default function ConstructionManagementListClient({
|
|||||||
[
|
[
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
|
searchQuery,
|
||||||
activeStatTab,
|
activeStatTab,
|
||||||
stats,
|
stats,
|
||||||
selectedCalendarDate,
|
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}
|
icon={FolderKanban}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 기간 선택 (달력 + 프리셋 버튼) */}
|
{/* 기간 선택 + 검색 영역 */}
|
||||||
<DateRangeSelector
|
<DateRangeSelector
|
||||||
startDate={filterStartDate}
|
startDate={filterStartDate}
|
||||||
endDate={filterEndDate}
|
endDate={filterEndDate}
|
||||||
onStartDateChange={setFilterStartDate}
|
onStartDateChange={setFilterStartDate}
|
||||||
onEndDateChange={setFilterEndDate}
|
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>
|
</Card>
|
||||||
</div>
|
</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>
|
<Card>
|
||||||
<CardContent className="p-4 min-h-[600px]">
|
<CardContent className="p-4 min-h-[600px]">
|
||||||
|
|||||||
@@ -89,6 +89,7 @@ export default function OrderManagementListClient({
|
|||||||
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
|
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
|
||||||
const [startDate, setStartDate] = useState<string>('');
|
const [startDate, setStartDate] = useState<string>('');
|
||||||
const [endDate, setEndDate] = useState<string>('');
|
const [endDate, setEndDate] = useState<string>('');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
// 달력 관련 상태
|
// 달력 관련 상태
|
||||||
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | null>(null);
|
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | null>(null);
|
||||||
@@ -304,6 +305,7 @@ export default function OrderManagementListClient({
|
|||||||
|
|
||||||
// 커스텀 필터 함수
|
// 커스텀 필터 함수
|
||||||
customFilterFn: (items, filterValues) => {
|
customFilterFn: (items, filterValues) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
// 거래처 필터 (다중선택)
|
// 거래처 필터 (다중선택)
|
||||||
const partnerFilters = filterValues.partners as string[];
|
const partnerFilters = filterValues.partners as string[];
|
||||||
@@ -423,6 +425,11 @@ export default function OrderManagementListClient({
|
|||||||
return sorted;
|
return sorted;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchQuery,
|
||||||
|
onSearchChange: setSearchQuery,
|
||||||
|
|
||||||
// 공통 헤더 옵션
|
// 공통 헤더 옵션
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
@@ -584,6 +591,7 @@ export default function OrderManagementListClient({
|
|||||||
[
|
[
|
||||||
startDate,
|
startDate,
|
||||||
endDate,
|
endDate,
|
||||||
|
searchQuery,
|
||||||
selectedCalendarDate,
|
selectedCalendarDate,
|
||||||
calendarEvents,
|
calendarEvents,
|
||||||
calendarBadges,
|
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) => {
|
customFilterFn: (items, filterValues) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
// 악성채권 필터
|
// 악성채권 필터
|
||||||
const badDebtFilter = filterValues.badDebt as string;
|
const badDebtFilter = filterValues.badDebt as string;
|
||||||
|
|||||||
@@ -75,6 +75,7 @@ export default function ProgressBillingManagementListClient({
|
|||||||
const [startDate, setStartDate] = useState<string>('');
|
const [startDate, setStartDate] = useState<string>('');
|
||||||
const [endDate, setEndDate] = useState<string>('');
|
const [endDate, setEndDate] = useState<string>('');
|
||||||
const [stats, setStats] = useState<ProgressBillingStats | null>(initialStats || null);
|
const [stats, setStats] = useState<ProgressBillingStats | null>(initialStats || null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
// Stats 로드
|
// Stats 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -188,6 +189,7 @@ export default function ProgressBillingManagementListClient({
|
|||||||
|
|
||||||
// 커스텀 필터 함수
|
// 커스텀 필터 함수
|
||||||
customFilterFn: (items, filterValues) => {
|
customFilterFn: (items, filterValues) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
// Stats 탭 필터
|
// Stats 탭 필터
|
||||||
if (activeStatTab === 'contractWaiting' &&
|
if (activeStatTab === 'contractWaiting' &&
|
||||||
@@ -239,6 +241,11 @@ export default function ProgressBillingManagementListClient({
|
|||||||
return sorted;
|
return sorted;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchQuery,
|
||||||
|
onSearchChange: setSearchQuery,
|
||||||
|
|
||||||
// 공통 헤더 옵션
|
// 공통 헤더 옵션
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
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 [startDate, setStartDate] = useState('');
|
||||||
const [endDate, setEndDate] = useState('');
|
const [endDate, setEndDate] = useState('');
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
// ===== 핸들러 =====
|
// ===== 핸들러 =====
|
||||||
const handleRowClick = useCallback(
|
const handleRowClick = useCallback(
|
||||||
@@ -187,6 +188,7 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
|
|||||||
|
|
||||||
// 커스텀 필터 함수 (activeStatTab 필터링 포함)
|
// 커스텀 필터 함수 (activeStatTab 필터링 포함)
|
||||||
customFilterFn: (items, filterValues) => {
|
customFilterFn: (items, filterValues) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
// Stats 탭 필터
|
// Stats 탭 필터
|
||||||
if (activeStatTab === 'scheduled' && item.attendanceStatus !== 'scheduled') return false;
|
if (activeStatTab === 'scheduled' && item.attendanceStatus !== 'scheduled') return false;
|
||||||
@@ -216,6 +218,11 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
|
|||||||
return sorted;
|
return sorted;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchQuery,
|
||||||
|
onSearchChange: setSearchQuery,
|
||||||
|
|
||||||
// 날짜 범위 선택기
|
// 날짜 범위 선택기
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
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 [startDate, setStartDate] = useState<string>('');
|
||||||
const [endDate, setEndDate] = useState<string>('');
|
const [endDate, setEndDate] = useState<string>('');
|
||||||
const [stats, setStats] = useState<SiteStats | null>(initialStats || null);
|
const [stats, setStats] = useState<SiteStats | null>(initialStats || null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
// Stats 로드
|
// Stats 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -183,6 +184,7 @@ export default function SiteManagementListClient({
|
|||||||
|
|
||||||
// 커스텀 필터 함수
|
// 커스텀 필터 함수
|
||||||
customFilterFn: (items, filterValues) => {
|
customFilterFn: (items, filterValues) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
// Stats 탭 필터
|
// Stats 탭 필터
|
||||||
if (activeStatTab === 'construction' && item.status !== 'active') return false;
|
if (activeStatTab === 'construction' && item.status !== 'active') return false;
|
||||||
@@ -228,6 +230,11 @@ export default function SiteManagementListClient({
|
|||||||
return sorted;
|
return sorted;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchQuery,
|
||||||
|
onSearchChange: setSearchQuery,
|
||||||
|
|
||||||
// 공통 헤더 옵션
|
// 공통 헤더 옵션
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
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 [startDate, setStartDate] = useState<string>('');
|
||||||
const [endDate, setEndDate] = useState<string>('');
|
const [endDate, setEndDate] = useState<string>('');
|
||||||
const [stats, setStats] = useState<StructureReviewStats | null>(initialStats || null);
|
const [stats, setStats] = useState<StructureReviewStats | null>(initialStats || null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
// Stats 로드
|
// Stats 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -201,6 +202,7 @@ export default function StructureReviewListClient({
|
|||||||
|
|
||||||
// 커스텀 필터 함수
|
// 커스텀 필터 함수
|
||||||
customFilterFn: (items, filterValues) => {
|
customFilterFn: (items, filterValues) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
// Stats 탭 필터
|
// Stats 탭 필터
|
||||||
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
|
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
|
||||||
@@ -246,6 +248,11 @@ export default function StructureReviewListClient({
|
|||||||
return sorted;
|
return sorted;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchQuery,
|
||||||
|
onSearchChange: setSearchQuery,
|
||||||
|
|
||||||
// 공통 헤더 옵션
|
// 공통 헤더 옵션
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
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 [startDate, setStartDate] = useState<string>('');
|
||||||
const [endDate, setEndDate] = useState<string>('');
|
const [endDate, setEndDate] = useState<string>('');
|
||||||
const [stats, setStats] = useState<UtilityStats | null>(initialStats || null);
|
const [stats, setStats] = useState<UtilityStats | null>(initialStats || null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
// Stats 로드
|
// Stats 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -212,6 +213,7 @@ export default function UtilityManagementListClient({
|
|||||||
|
|
||||||
// 커스텀 필터 함수
|
// 커스텀 필터 함수
|
||||||
customFilterFn: (items, filterValues) => {
|
customFilterFn: (items, filterValues) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
// Stats 탭 필터
|
// Stats 탭 필터
|
||||||
if (activeStatTab === 'waiting' && item.status !== 'scheduled' && item.status !== 'issued') return false;
|
if (activeStatTab === 'waiting' && item.status !== 'scheduled' && item.status !== 'issued') return false;
|
||||||
@@ -279,6 +281,11 @@ export default function UtilityManagementListClient({
|
|||||||
return sorted;
|
return sorted;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchQuery,
|
||||||
|
onSearchChange: setSearchQuery,
|
||||||
|
|
||||||
// 공통 헤더 옵션
|
// 공통 헤더 옵션
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
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 [startDate, setStartDate] = useState<string>('');
|
||||||
const [endDate, setEndDate] = useState<string>('');
|
const [endDate, setEndDate] = useState<string>('');
|
||||||
const [stats, setStats] = useState<WorkerStatusStats | null>(initialStats || null);
|
const [stats, setStats] = useState<WorkerStatusStats | null>(initialStats || null);
|
||||||
|
const [searchQuery, setSearchQuery] = useState('');
|
||||||
|
|
||||||
// Stats 로드
|
// Stats 로드
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -220,6 +221,7 @@ export default function WorkerStatusListClient({
|
|||||||
|
|
||||||
// 커스텀 필터 함수
|
// 커스텀 필터 함수
|
||||||
customFilterFn: (items, filterValues) => {
|
customFilterFn: (items, filterValues) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
// Stats 탭 필터 (계약상태)
|
// Stats 탭 필터 (계약상태)
|
||||||
if (activeStatTab !== 'all' && item.contractStatus !== activeStatTab) return false;
|
if (activeStatTab !== 'all' && item.contractStatus !== activeStatTab) return false;
|
||||||
@@ -300,6 +302,11 @@ export default function WorkerStatusListClient({
|
|||||||
return sorted;
|
return sorted;
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchQuery,
|
||||||
|
onSearchChange: setSearchQuery,
|
||||||
|
|
||||||
// 공통 헤더 옵션
|
// 공통 헤더 옵션
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
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) => {
|
customFilterFn: (items, filterValues) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
if (!startDate || !endDate) return items;
|
if (!startDate || !endDate) return items;
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
// 이벤트 기간이 선택한 기간과 겹치는지 확인
|
// 이벤트 기간이 선택한 기간과 겹치는지 확인
|
||||||
|
|||||||
@@ -124,6 +124,7 @@ export function InquiryList() {
|
|||||||
|
|
||||||
// 커스텀 필터 (날짜 + 카테고리 + 상태)
|
// 커스텀 필터 (날짜 + 카테고리 + 상태)
|
||||||
customFilterFn: (items, filterValues) => {
|
customFilterFn: (items, filterValues) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
let result = [...items];
|
let result = [...items];
|
||||||
|
|
||||||
// 날짜 필터
|
// 날짜 필터
|
||||||
|
|||||||
@@ -97,6 +97,7 @@ export function NoticeList() {
|
|||||||
|
|
||||||
// 커스텀 필터 (날짜)
|
// 커스텀 필터 (날짜)
|
||||||
customFilterFn: (items, filterValues) => {
|
customFilterFn: (items, filterValues) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
// 날짜 필터는 외부 상태 사용
|
// 날짜 필터는 외부 상태 사용
|
||||||
if (!startDate || !endDate) return items;
|
if (!startDate || !endDate) return items;
|
||||||
return items.filter((item) => {
|
return items.filter((item) => {
|
||||||
|
|||||||
@@ -11,9 +11,11 @@ import {
|
|||||||
Plus,
|
Plus,
|
||||||
FileText,
|
FileText,
|
||||||
Edit,
|
Edit,
|
||||||
|
Search,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { TableRow, TableCell } from '@/components/ui/table';
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||||||
import { format } from 'date-fns';
|
import { format } from 'date-fns';
|
||||||
@@ -434,6 +436,11 @@ export function AttendanceManagement() {
|
|||||||
|
|
||||||
computeStats: () => statCards,
|
computeStats: () => statCards,
|
||||||
|
|
||||||
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchValue,
|
||||||
|
onSearchChange: setSearchValue,
|
||||||
|
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
showPresets: true,
|
showPresets: true,
|
||||||
@@ -482,6 +489,7 @@ export function AttendanceManagement() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
customFilterFn: (items, filterValues) => {
|
customFilterFn: (items, filterValues) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
let filtered = items;
|
let filtered = items;
|
||||||
const filterOption = filterValues.filter as string;
|
const filterOption = filterValues.filter as string;
|
||||||
if (filterOption && filterOption !== 'all') {
|
if (filterOption && filterOption !== 'all') {
|
||||||
|
|||||||
@@ -2,9 +2,10 @@
|
|||||||
|
|
||||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import { CreditCard, Edit, Trash2, Plus } from 'lucide-react';
|
import { CreditCard, Edit, Trash2, Plus, Search, RefreshCw } from 'lucide-react';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Input } from '@/components/ui/input';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { TableRow, TableCell } from '@/components/ui/table';
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
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 [activeTab, setActiveTab] = useState<string>('all');
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
const itemsPerPage = 20;
|
const itemsPerPage = 20;
|
||||||
@@ -78,8 +79,8 @@ export function CardManagement({ initialData }: CardManagementProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// 검색 필터
|
// 검색 필터
|
||||||
if (searchValue) {
|
if (searchQuery) {
|
||||||
const search = searchValue.toLowerCase();
|
const search = searchQuery.toLowerCase();
|
||||||
filtered = filtered.filter(c =>
|
filtered = filtered.filter(c =>
|
||||||
c.cardName.toLowerCase().includes(search) ||
|
c.cardName.toLowerCase().includes(search) ||
|
||||||
c.cardNumber.includes(search) ||
|
c.cardNumber.includes(search) ||
|
||||||
@@ -89,7 +90,7 @@ export function CardManagement({ initialData }: CardManagementProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return filtered;
|
return filtered;
|
||||||
}, [cards, activeTab, searchValue]);
|
}, [cards, activeTab, searchQuery]);
|
||||||
|
|
||||||
// 페이지네이션된 데이터
|
// 페이지네이션된 데이터
|
||||||
const paginatedData = useMemo(() => {
|
const paginatedData = useMemo(() => {
|
||||||
|
|||||||
@@ -2,10 +2,11 @@
|
|||||||
|
|
||||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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 { getEmployees, deleteEmployee, deleteEmployees, getEmployeeStats } from './actions';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { TableRow, TableCell } from '@/components/ui/table';
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
@@ -437,6 +438,11 @@ export function EmployeeManagement() {
|
|||||||
|
|
||||||
computeStats: () => statCards,
|
computeStats: () => statCards,
|
||||||
|
|
||||||
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchValue,
|
||||||
|
onSearchChange: setSearchValue,
|
||||||
|
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
showPresets: true,
|
showPresets: true,
|
||||||
@@ -489,6 +495,7 @@ export function EmployeeManagement() {
|
|||||||
},
|
},
|
||||||
|
|
||||||
customFilterFn: (items, filterValues) => {
|
customFilterFn: (items, filterValues) => {
|
||||||
|
if (!items || items.length === 0) return items;
|
||||||
let filtered = items;
|
let filtered = items;
|
||||||
const filterOption = filterValues.filter as FilterOption;
|
const filterOption = filterValues.filter as FilterOption;
|
||||||
if (filterOption && filterOption !== 'all') {
|
if (filterOption && filterOption !== 'all') {
|
||||||
|
|||||||
@@ -13,9 +13,11 @@ import {
|
|||||||
Gift,
|
Gift,
|
||||||
MinusCircle,
|
MinusCircle,
|
||||||
Loader2,
|
Loader2,
|
||||||
|
Search,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { Input } from '@/components/ui/input';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { TableRow, TableCell } from '@/components/ui/table';
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||||||
@@ -374,6 +376,11 @@ export function SalaryManagement() {
|
|||||||
|
|
||||||
itemsPerPage: itemsPerPage,
|
itemsPerPage: itemsPerPage,
|
||||||
|
|
||||||
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
|
hideSearch: true,
|
||||||
|
searchValue: searchQuery,
|
||||||
|
onSearchChange: setSearchQuery,
|
||||||
|
|
||||||
// 날짜 범위 선택 (DateRangeSelector 사용)
|
// 날짜 범위 선택 (DateRangeSelector 사용)
|
||||||
dateRangeSelector: {
|
dateRangeSelector: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
|||||||
@@ -38,7 +38,6 @@ import {
|
|||||||
type FilterFieldConfig,
|
type FilterFieldConfig,
|
||||||
type FilterValues,
|
type FilterValues,
|
||||||
} from '@/components/templates/UniversalListPage';
|
} from '@/components/templates/UniversalListPage';
|
||||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
|
||||||
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
|
||||||
import { VacationGrantDialog } from './VacationGrantDialog';
|
import { VacationGrantDialog } from './VacationGrantDialog';
|
||||||
import { VacationRequestDialog } from './VacationRequestDialog';
|
import { VacationRequestDialog } from './VacationRequestDialog';
|
||||||
@@ -587,48 +586,40 @@ export function VacationManagement() {
|
|||||||
}
|
}
|
||||||
}, [mainTab, handleApproveClick, handleRejectClick]);
|
}, [mainTab, handleApproveClick, handleRejectClick]);
|
||||||
|
|
||||||
// ===== 헤더 액션 (DateRangeSelector + 버튼들) =====
|
// ===== 헤더 액션 (탭별 버튼들만 - DateRangeSelector와 검색창은 공통 옵션 사용) =====
|
||||||
const headerActions = useCallback(({ selectedItems: selected }: { selectedItems: Set<string>; onClearSelection?: () => void; onRefresh?: () => void }) => (
|
const headerActions = useCallback(({ selectedItems: selected }: { selectedItems: Set<string>; onClearSelection?: () => void; onRefresh?: () => void }) => (
|
||||||
<>
|
<div className="flex items-center gap-2">
|
||||||
<DateRangeSelector
|
{/* 탭별 액션 버튼 */}
|
||||||
startDate={startDate}
|
{mainTab === 'grant' && (
|
||||||
endDate={endDate}
|
<Button onClick={() => setGrantDialogOpen(true)}>
|
||||||
onStartDateChange={setStartDate}
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
onEndDateChange={setEndDate}
|
부여등록
|
||||||
/>
|
</Button>
|
||||||
<div className="ml-auto flex gap-2">
|
)}
|
||||||
{/* 탭별 액션 버튼 */}
|
|
||||||
{mainTab === 'grant' && (
|
|
||||||
<Button onClick={() => setGrantDialogOpen(true)}>
|
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
|
||||||
부여등록
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{mainTab === 'request' && (
|
{mainTab === 'request' && (
|
||||||
<>
|
<>
|
||||||
{/* 버튼 순서: 승인 → 거절 → 휴가신청 (휴가신청 버튼 위치 고정) */}
|
{/* 버튼 순서: 승인 → 거절 → 휴가신청 (휴가신청 버튼 위치 고정) */}
|
||||||
{selected.size > 0 && (
|
{selected.size > 0 && (
|
||||||
<>
|
<>
|
||||||
<Button variant="default" onClick={() => handleApproveClick(selected)}>
|
<Button variant="default" onClick={() => handleApproveClick(selected)}>
|
||||||
<Check className="h-4 w-4 mr-2" />
|
<Check className="h-4 w-4 mr-2" />
|
||||||
승인
|
승인
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="destructive" onClick={() => handleRejectClick(selected)}>
|
<Button variant="destructive" onClick={() => handleRejectClick(selected)}>
|
||||||
<X className="h-4 w-4 mr-2" />
|
<X className="h-4 w-4 mr-2" />
|
||||||
거절
|
거절
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
<Button onClick={() => setRequestDialogOpen(true)}>
|
<Button onClick={() => setRequestDialogOpen(true)}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
휴가신청
|
휴가신청
|
||||||
</Button>
|
</Button>
|
||||||
</>
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</>
|
), [mainTab, handleApproveClick, handleRejectClick]);
|
||||||
), [startDate, endDate, mainTab, handleApproveClick, handleRejectClick]);
|
|
||||||
|
|
||||||
// ===== filterConfig 기반 통합 필터 시스템 =====
|
// ===== filterConfig 기반 통합 필터 시스템 =====
|
||||||
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
const filterConfig: FilterFieldConfig[] = useMemo(() => [
|
||||||
@@ -693,6 +684,15 @@ export function VacationManagement() {
|
|||||||
|
|
||||||
columns: tableColumns,
|
columns: tableColumns,
|
||||||
|
|
||||||
|
// 공통 패턴: dateRangeSelector
|
||||||
|
dateRangeSelector: {
|
||||||
|
enabled: true,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
onStartDateChange: setStartDate,
|
||||||
|
onEndDateChange: setEndDate,
|
||||||
|
},
|
||||||
|
|
||||||
tabs: tabs,
|
tabs: tabs,
|
||||||
defaultTab: mainTab,
|
defaultTab: mainTab,
|
||||||
|
|
||||||
|
|||||||
@@ -17,7 +17,8 @@ import { Badge } from '@/components/ui/badge';
|
|||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
import { TableRow, TableCell } from '@/components/ui/table';
|
import { TableRow, TableCell } from '@/components/ui/table';
|
||||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
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 { useItemList } from '@/hooks/useItemList';
|
||||||
import { handleApiError } from '@/lib/api/error-handler';
|
import { handleApiError } from '@/lib/api/error-handler';
|
||||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
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[] = [
|
const tabs: TabOption[] = [
|
||||||
{ value: 'all', label: '전체', count: totalStats.totalAll, color: 'gray' },
|
{ value: 'all', label: '전체', count: totalStats.totalAll, color: 'gray' },
|
||||||
@@ -301,6 +416,56 @@ export default function ItemListClient() {
|
|||||||
icon: Plus,
|
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 액션 (일괄 삭제 포함)
|
// API 액션 (일괄 삭제 포함)
|
||||||
actions: {
|
actions: {
|
||||||
getList: async () => ({ success: true, data: items }),
|
getList: async () => ({ success: true, data: items }),
|
||||||
@@ -488,6 +653,15 @@ export default function ItemListClient() {
|
|||||||
externalIsLoading={isLoading}
|
externalIsLoading={isLoading}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{/* 숨겨진 파일 업로드 input */}
|
||||||
|
<input
|
||||||
|
ref={fileInputRef}
|
||||||
|
type="file"
|
||||||
|
accept=".xlsx,.xls"
|
||||||
|
onChange={handleFileUpload}
|
||||||
|
className="hidden"
|
||||||
|
/>
|
||||||
|
|
||||||
{/* 개별 삭제 확인 다이얼로그 */}
|
{/* 개별 삭제 확인 다이얼로그 */}
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
open={deleteDialogOpen}
|
open={deleteDialogOpen}
|
||||||
|
|||||||
@@ -46,6 +46,8 @@ interface DateRangeSelectorProps {
|
|||||||
hideDateInputs?: boolean;
|
hideDateInputs?: boolean;
|
||||||
/** 날짜 입력 너비 */
|
/** 날짜 입력 너비 */
|
||||||
dateInputWidth?: string;
|
dateInputWidth?: string;
|
||||||
|
/** 프리셋 버튼 위치: 'inline' (날짜 옆), 'below' (별도 줄) */
|
||||||
|
presetsPosition?: 'inline' | 'below';
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -79,6 +81,7 @@ export function DateRangeSelector({
|
|||||||
hidePresets = false,
|
hidePresets = false,
|
||||||
hideDateInputs = false,
|
hideDateInputs = false,
|
||||||
dateInputWidth = 'w-[140px]',
|
dateInputWidth = 'w-[140px]',
|
||||||
|
presetsPosition = 'inline',
|
||||||
}: DateRangeSelectorProps) {
|
}: DateRangeSelectorProps) {
|
||||||
|
|
||||||
// 프리셋 클릭 핸들러
|
// 프리셋 클릭 핸들러
|
||||||
@@ -119,59 +122,94 @@ export function DateRangeSelector({
|
|||||||
}
|
}
|
||||||
}, [onStartDateChange, onEndDateChange]);
|
}, [onStartDateChange, onEndDateChange]);
|
||||||
|
|
||||||
return (
|
// 프리셋 버튼 렌더링
|
||||||
<div className="flex flex-col gap-2 w-full">
|
const renderPresets = () => {
|
||||||
{/* 1줄: 날짜 + 프리셋 */}
|
if (hidePresets || presets.length === 0) return null;
|
||||||
{/* 태블릿/모바일(~1279px): 세로 배치 / PC(1280px+): 가로 한 줄 */}
|
return (
|
||||||
<div className="flex flex-col xl:flex-row xl:items-center gap-2">
|
<div
|
||||||
{/* 날짜 범위 선택 (Input type="date") */}
|
className="overflow-x-auto -mx-1 px-1 xl:overflow-visible xl:mx-0 xl:px-0"
|
||||||
{!hideDateInputs && (
|
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
|
||||||
<div className="flex items-center gap-1 shrink-0">
|
>
|
||||||
<Input
|
<div className="flex items-center gap-1 min-w-max [&::-webkit-scrollbar]:hidden">
|
||||||
type="date"
|
{presets.map((preset) => (
|
||||||
value={startDate}
|
<Button
|
||||||
onChange={(e) => onStartDateChange(e.target.value)}
|
key={preset}
|
||||||
className="w-[165px]"
|
variant="outline"
|
||||||
/>
|
size="sm"
|
||||||
<span className="text-muted-foreground shrink-0">~</span>
|
onClick={() => handlePresetClick(preset)}
|
||||||
<Input
|
className="shrink-0 text-xs sm:text-sm px-2 sm:px-3"
|
||||||
type="date"
|
>
|
||||||
value={endDate}
|
{PRESET_LABELS[preset]}
|
||||||
onChange={(e) => onEndDateChange(e.target.value)}
|
</Button>
|
||||||
className="w-[165px]"
|
))}
|
||||||
/>
|
</div>
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
{/* 2줄: 추가 액션 버튼들 - 항상 별도 줄, 오른쪽 정렬 */}
|
// presetsPosition이 'below'일 때: 달력+extraActions 같은 줄, 프리셋은 아래 줄
|
||||||
{extraActions && (
|
if (presetsPosition === 'below') {
|
||||||
<div className="flex items-center gap-2 justify-end">
|
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}
|
{extraActions}
|
||||||
</div>
|
</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>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -22,7 +22,7 @@ interface StatCardsProps {
|
|||||||
|
|
||||||
export function StatCards({ stats }: StatCardsProps) {
|
export function StatCards({ stats }: StatCardsProps) {
|
||||||
return (
|
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) => {
|
{stats.map((stat, index) => {
|
||||||
const Icon = stat.icon;
|
const Icon = stat.icon;
|
||||||
const isClickable = !!stat.onClick;
|
const isClickable = !!stat.onClick;
|
||||||
@@ -37,24 +37,24 @@ export function StatCards({ stats }: StatCardsProps) {
|
|||||||
}`}
|
}`}
|
||||||
onClick={stat.onClick}
|
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 items-center justify-between">
|
||||||
<div className="flex-1">
|
<div className="flex-1 min-w-0">
|
||||||
<p className="sm:text-xs md:text-sm text-muted-foreground mb-1 md:mb-2 uppercase tracking-wide text-[12px]">
|
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5 uppercase tracking-wide truncate">
|
||||||
{stat.label}
|
{stat.label}
|
||||||
</p>
|
</p>
|
||||||
<p className="font-bold text-[24px]">
|
<p className="font-bold text-base md:text-lg truncate">
|
||||||
{stat.value}
|
{stat.value}
|
||||||
</p>
|
</p>
|
||||||
{stat.trend && (
|
{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}
|
{stat.trend.value}
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
{Icon && (
|
{Icon && (
|
||||||
<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>
|
</div>
|
||||||
|
|||||||
@@ -223,9 +223,22 @@ export function PricingListClient({
|
|||||||
) => {
|
) => {
|
||||||
const { isSelected, onToggle } = handlers;
|
const { isSelected, onToggle } = handlers;
|
||||||
|
|
||||||
|
// 행 클릭 핸들러: 등록되지 않은 항목은 등록, 등록된 항목은 수정
|
||||||
|
const handleRowClick = () => {
|
||||||
|
if (item.status === 'not_registered') {
|
||||||
|
handleRegister(item);
|
||||||
|
} else {
|
||||||
|
handleEdit(item);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
<TableRow
|
||||||
<TableCell className="text-center">
|
key={item.id}
|
||||||
|
className="hover:bg-muted/50 cursor-pointer"
|
||||||
|
onClick={handleRowClick}
|
||||||
|
>
|
||||||
|
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||||
<Checkbox
|
<Checkbox
|
||||||
checked={isSelected}
|
checked={isSelected}
|
||||||
onCheckedChange={onToggle}
|
onCheckedChange={onToggle}
|
||||||
|
|||||||
@@ -11,7 +11,7 @@
|
|||||||
|
|
||||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { TableCell, TableRow } from '@/components/ui/table';
|
import { TableCell, TableRow } from '@/components/ui/table';
|
||||||
import { Checkbox } from '@/components/ui/checkbox';
|
import { Checkbox } from '@/components/ui/checkbox';
|
||||||
@@ -45,6 +45,13 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
|
|||||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
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 () => {
|
const loadData = useCallback(async () => {
|
||||||
setIsLoading(true);
|
setIsLoading(true);
|
||||||
@@ -249,36 +256,48 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
|
|||||||
clientSideFiltering: true,
|
clientSideFiltering: true,
|
||||||
itemsPerPage: 20,
|
itemsPerPage: 20,
|
||||||
|
|
||||||
// 탭 필터 함수
|
// 검색창 (공통 컴포넌트에서 자동 생성)
|
||||||
tabFilter: (item: Process, activeTab: string) => {
|
hideSearch: true,
|
||||||
if (activeTab === 'all') return true;
|
searchValue: searchQuery,
|
||||||
return item.status === activeTab;
|
onSearchChange: setSearchQuery,
|
||||||
|
|
||||||
|
// 날짜 범위 선택기
|
||||||
|
dateRangeSelector: {
|
||||||
|
enabled: true,
|
||||||
|
showPresets: true,
|
||||||
|
startDate,
|
||||||
|
endDate,
|
||||||
|
onStartDateChange: setStartDate,
|
||||||
|
onEndDateChange: setEndDate,
|
||||||
},
|
},
|
||||||
|
|
||||||
// 검색 필터 함수
|
// 탭 필터 (공통 컴포넌트에서 처리)
|
||||||
searchFilter: (item: Process, searchValue: string) => {
|
tabFilter: (item, tabValue) => {
|
||||||
const search = searchValue.toLowerCase();
|
if (tabValue === 'all') return true;
|
||||||
|
return item.status === tabValue;
|
||||||
|
},
|
||||||
|
|
||||||
|
// 검색 필터
|
||||||
|
searchFilter: (item, searchValue) => {
|
||||||
|
if (!searchValue || !searchValue.trim()) return true;
|
||||||
|
const search = searchValue.toLowerCase().trim();
|
||||||
return (
|
return (
|
||||||
item.processCode.toLowerCase().includes(search) ||
|
(item.processCode || '').toLowerCase().includes(search) ||
|
||||||
item.processName.toLowerCase().includes(search) ||
|
(item.processName || '').toLowerCase().includes(search) ||
|
||||||
item.department.toLowerCase().includes(search)
|
(item.department || '').toLowerCase().includes(search)
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
||||||
// 탭 설정
|
// 탭 (공통 컴포넌트에서 Card 안에 렌더링)
|
||||||
tabs,
|
tabs,
|
||||||
defaultTab: 'all',
|
defaultTab: 'all',
|
||||||
|
|
||||||
// 검색
|
// 등록 버튼 (공통 컴포넌트에서 오른쪽에 렌더링)
|
||||||
searchPlaceholder: '공정코드, 공정명, 담당부서 검색',
|
createButton: {
|
||||||
|
label: '공정 등록',
|
||||||
// 헤더 액션
|
onClick: handleCreate,
|
||||||
headerActions: () => (
|
icon: Plus,
|
||||||
<Button onClick={handleCreate} className="gap-2">
|
},
|
||||||
<Plus className="h-4 w-4" />
|
|
||||||
공정 등록
|
|
||||||
</Button>
|
|
||||||
),
|
|
||||||
|
|
||||||
// 일괄 삭제 핸들러
|
// 일괄 삭제 핸들러
|
||||||
onBulkDelete: handleBulkDelete,
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<UniversalListPage config={config} initialData={allProcesses} />
|
<UniversalListPage config={config} initialData={allProcesses} onSearchChange={setSearchQuery} />
|
||||||
|
|
||||||
{/* 삭제 확인 다이얼로그 */}
|
{/* 삭제 확인 다이얼로그 */}
|
||||||
<DeleteConfirmDialog
|
<DeleteConfirmDialog
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { ReactNode, Fragment, useState, useEffect, useRef, useCallback } from "react";
|
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 { DateRangeSelector } from "@/components/molecules/DateRangeSelector";
|
||||||
import { Card, CardContent } from "@/components/ui/card";
|
import { Card, CardContent } from "@/components/ui/card";
|
||||||
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
import { Tabs, TabsContent } from "@/components/ui/tabs";
|
||||||
@@ -17,6 +17,7 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from "@/components/ui/select";
|
} from "@/components/ui/select";
|
||||||
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
|
||||||
|
import { Input } from "@/components/ui/input";
|
||||||
import { PageLayout } from "@/components/organisms/PageLayout";
|
import { PageLayout } from "@/components/organisms/PageLayout";
|
||||||
import { PageHeader } from "@/components/organisms/PageHeader";
|
import { PageHeader } from "@/components/organisms/PageHeader";
|
||||||
import { StatCards } from "@/components/organisms/StatCards";
|
import { StatCards } from "@/components/organisms/StatCards";
|
||||||
@@ -106,10 +107,14 @@ export interface IntegratedListTemplateV2Props<T = any> {
|
|||||||
dateRangeSelector?: {
|
dateRangeSelector?: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
showPresets?: boolean;
|
showPresets?: boolean;
|
||||||
|
/** 날짜 입력 숨김 (검색창만 표시하고 싶을 때) */
|
||||||
|
hideDateInputs?: boolean;
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
onStartDateChange?: (date: string) => void;
|
onStartDateChange?: (date: string) => void;
|
||||||
onEndDateChange?: (date: string) => void;
|
onEndDateChange?: (date: string) => void;
|
||||||
|
/** 추가 액션 (검색창 등) - 프리셋 버튼 옆에 배치 */
|
||||||
|
extraActions?: ReactNode;
|
||||||
};
|
};
|
||||||
/**
|
/**
|
||||||
* 등록 버튼 (오른쪽 끝 배치)
|
* 등록 버튼 (오른쪽 끝 배치)
|
||||||
@@ -237,7 +242,7 @@ export function IntegratedListTemplateV2<T = any>({
|
|||||||
onSearchChange,
|
onSearchChange,
|
||||||
searchPlaceholder = "검색...",
|
searchPlaceholder = "검색...",
|
||||||
extraFilters,
|
extraFilters,
|
||||||
hideSearch = false,
|
hideSearch = true, // 기본값: 타이틀 아래에 검색창 표시 (Card 안 SearchFilter 숨김)
|
||||||
tabs,
|
tabs,
|
||||||
activeTab,
|
activeTab,
|
||||||
onTabChange,
|
onTabChange,
|
||||||
@@ -536,32 +541,71 @@ export function IntegratedListTemplateV2<T = any>({
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* 헤더 액션 (달력, 버튼 등) - 타이틀 아래 배치 */}
|
{/* 헤더 액션 (달력, 버튼 등) - 타이틀 아래 배치 */}
|
||||||
{/* 레이아웃: [달력 (왼쪽)] -------------- [등록 버튼 (오른쪽 끝)] */}
|
{/* 레이아웃: [달력] [프리셋버튼] [검색창] -------------- [추가버튼들] [등록버튼] (오른쪽 끝) */}
|
||||||
{(dateRangeSelector?.enabled || createButton || headerActions) && (
|
{(dateRangeSelector?.enabled || createButton || headerActions || (hideSearch && onSearchChange)) && (
|
||||||
isLoading ? renderHeaderActionSkeleton() : (
|
isLoading ? renderHeaderActionSkeleton() : (
|
||||||
<div className="flex items-center gap-2 flex-wrap w-full">
|
<div className="flex flex-col xl:flex-row xl:items-center gap-2 w-full">
|
||||||
{/* 날짜 범위 선택기 (왼쪽) */}
|
{/* 날짜 범위 선택기 + 검색창 (왼쪽) */}
|
||||||
{dateRangeSelector?.enabled && (
|
{dateRangeSelector?.enabled ? (
|
||||||
<DateRangeSelector
|
<DateRangeSelector
|
||||||
startDate={dateRangeSelector.startDate || ''}
|
startDate={dateRangeSelector.startDate || ''}
|
||||||
endDate={dateRangeSelector.endDate || ''}
|
endDate={dateRangeSelector.endDate || ''}
|
||||||
onStartDateChange={dateRangeSelector.onStartDateChange}
|
onStartDateChange={dateRangeSelector.onStartDateChange}
|
||||||
onEndDateChange={dateRangeSelector.onEndDateChange}
|
onEndDateChange={dateRangeSelector.onEndDateChange}
|
||||||
hidePresets={dateRangeSelector.showPresets === false}
|
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}
|
{(headerActions || createButton) && (
|
||||||
{/* 등록 버튼 (오른쪽 끝) */}
|
<div className="flex items-center gap-2 ml-auto shrink-0">
|
||||||
{createButton && (
|
{/* 헤더 액션 (엑셀 다운로드 등 추가 버튼들) */}
|
||||||
<Button className="ml-auto" onClick={createButton.onClick}>
|
{headerActions}
|
||||||
{createButton.icon ? (
|
{/* 등록 버튼 */}
|
||||||
<createButton.icon className="h-4 w-4 mr-2" />
|
{createButton && (
|
||||||
) : (
|
<Button onClick={createButton.onClick}>
|
||||||
<Plus className="h-4 w-4 mr-2" />
|
{createButton.icon ? (
|
||||||
|
<createButton.icon className="h-4 w-4 mr-2" />
|
||||||
|
) : (
|
||||||
|
<Plus className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
{createButton.label}
|
||||||
|
</Button>
|
||||||
)}
|
)}
|
||||||
{createButton.label}
|
</div>
|
||||||
</Button>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -578,7 +578,7 @@ export function UniversalListPage<T>({
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
<IntegratedListTemplateV2<T>
|
<IntegratedListTemplateV2
|
||||||
// 페이지 헤더
|
// 페이지 헤더
|
||||||
title={config.title}
|
title={config.title}
|
||||||
description={config.description}
|
description={config.description}
|
||||||
|
|||||||
@@ -232,10 +232,16 @@ export interface UniversalListConfig<T> {
|
|||||||
dateRangeSelector?: {
|
dateRangeSelector?: {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
showPresets?: boolean;
|
showPresets?: boolean;
|
||||||
|
/** 날짜 입력 숨김 (검색창만 표시하고 싶을 때) */
|
||||||
|
hideDateInputs?: boolean;
|
||||||
|
/** 프리셋 버튼 위치: 'inline' (날짜 옆), 'below' (별도 줄) */
|
||||||
|
presetsPosition?: 'inline' | 'below';
|
||||||
startDate?: string;
|
startDate?: string;
|
||||||
endDate?: string;
|
endDate?: string;
|
||||||
onStartDateChange?: (date: string) => void;
|
onStartDateChange?: (date: string) => void;
|
||||||
onEndDateChange?: (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