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:
유병철
2026-01-26 22:04:36 +09:00
parent ff93ab7fa2
commit 1f6b592b9f
65 changed files with 1974 additions and 503 deletions

3
.gitignore vendored
View File

@@ -116,3 +116,6 @@ tsconfig.tsbuildinfo
# ---> Dev Page Builder (프로토타입 - 로컬 전용)
src/app/**/dev/page-builder/
# ---> Dev Dashboard Prototypes (디자인 프로토타입 - 로컬 전용)
src/app/**/dev/dashboard/

View File

@@ -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 (다른 패턴 사용)

View File

@@ -0,0 +1,14 @@
import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect';
/**
* 회계관리 - 부모 메뉴 동적 리다이렉트
* 메뉴 구조에서 첫 번째 자식으로 자동 이동
*/
export default function AccountingPage() {
return (
<ParentMenuRedirect
parentPath="/accounting"
fallbackPath="/accounting/vendors"
/>
);
}

View File

@@ -0,0 +1,14 @@
import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect';
/**
* 결재관리 - 부모 메뉴 동적 리다이렉트
* 메뉴 구조에서 첫 번째 자식으로 자동 이동
*/
export default function ApprovalPage() {
return (
<ParentMenuRedirect
parentPath="/approval"
fallbackPath="/approval/inbox"
/>
);
}

View File

@@ -12,7 +12,6 @@ import { format } from 'date-fns';
import { TableCell, TableRow } from '@/components/ui/table';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import {
Select,
@@ -26,7 +25,6 @@ import {
type UniversalListConfig,
type TableColumn,
} from '@/components/templates/UniversalListPage';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { getDynamicBoardPosts } from '@/components/board/DynamicBoard/actions';
import { getBoardByCode } from '@/components/board/BoardManagement/actions';
import type { PostApiData } from '@/components/customer-center/shared/types';
@@ -340,20 +338,22 @@ export default function DynamicBoardListPage() {
},
columns: tableColumns,
headerActions: () => (
<>
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
<Button className="ml-auto" onClick={handleCreate}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</>
),
// 공통 패턴: dateRangeSelector + createButton + onSearchChange
dateRangeSelector: {
enabled: true,
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
createButton: {
label: '글쓰기',
icon: Plus,
onClick: handleCreate,
},
onSearchChange: setSearchValue,
tableHeaderActions: (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">

View File

@@ -0,0 +1,14 @@
import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect';
/**
* 건설관리 - 부모 메뉴 동적 리다이렉트
* 메뉴 구조에서 첫 번째 자식으로 자동 이동
*/
export default function ConstructionPage() {
return (
<ParentMenuRedirect
parentPath="/construction"
fallbackPath="/construction/project"
/>
);
}

View File

@@ -0,0 +1,14 @@
import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect';
/**
* 고객센터 - 부모 메뉴 동적 리다이렉트
* 메뉴 구조에서 첫 번째 자식으로 자동 이동
*/
export default function CustomerCenterPage() {
return (
<ParentMenuRedirect
parentPath="/customer-center"
fallbackPath="/customer-center/notices"
/>
);
}

View File

@@ -0,0 +1,14 @@
import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect';
/**
* 인사관리 - 부모 메뉴 동적 리다이렉트
* 메뉴 구조에서 첫 번째 자식으로 자동 이동
*/
export default function HrPage() {
return (
<ParentMenuRedirect
parentPath="/hr"
fallbackPath="/hr/attendance-management"
/>
);
}

View 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"
/>
);
}

View File

@@ -0,0 +1,14 @@
import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect';
/**
* 자재관리 - 부모 메뉴 동적 리다이렉트
* 메뉴 구조에서 첫 번째 자식으로 자동 이동
*/
export default function MaterialPage() {
return (
<ParentMenuRedirect
parentPath="/material"
fallbackPath="/material/stock-status"
/>
);
}

View File

@@ -0,0 +1,14 @@
import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect';
/**
* 출고관리 - 부모 메뉴 동적 리다이렉트
* 메뉴 구조에서 첫 번째 자식으로 자동 이동
*/
export default function OutboundPage() {
return (
<ParentMenuRedirect
parentPath="/outbound"
fallbackPath="/outbound/shipments"
/>
);
}

View File

@@ -0,0 +1,14 @@
import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect';
/**
* 생산관리 - 부모 메뉴 동적 리다이렉트
* 메뉴 구조에서 첫 번째 자식으로 자동 이동
*/
export default function ProductionPage() {
return (
<ParentMenuRedirect
parentPath="/production"
fallbackPath="/production/dashboard"
/>
);
}

View File

@@ -0,0 +1,14 @@
import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect';
/**
* 품질관리 - 부모 메뉴 동적 리다이렉트
* 메뉴 구조에서 첫 번째 자식으로 자동 이동
*/
export default function QualityPage() {
return (
<ParentMenuRedirect
parentPath="/quality"
fallbackPath="/quality/inspections"
/>
);
}

View 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"
/>
);
}

View File

@@ -0,0 +1,14 @@
import { ParentMenuRedirect } from '@/components/common/ParentMenuRedirect';
/**
* 설정 - 부모 메뉴 동적 리다이렉트
* 메뉴 구조에서 첫 번째 자식으로 자동 이동
*/
export default function SettingsPage() {
return (
<ParentMenuRedirect
parentPath="/settings"
fallbackPath="/settings/accounts"
/>
);
}

View File

@@ -242,6 +242,7 @@ export function BadDebtCollection({ initialData, initialSummary }: BadDebtCollec
// 커스텀 필터 함수
customFilterFn: (items) => {
if (!items || items.length === 0) return items;
let result = [...items];
// 거래처 필터

View File

@@ -48,7 +48,8 @@ interface PaginationMeta {
// ===== API → Frontend 변환 =====
function transformItem(item: BankTransactionApiItem): BankTransaction {
return {
id: String(item.id),
// 입금/출금 테이블이 별도이므로 type을 접두어로 붙여 고유 ID 생성
id: `${item.type}-${item.id}`,
bankName: item.bank_name,
accountName: item.account_name,
transactionDate: item.transaction_date,

View File

@@ -279,18 +279,16 @@ export function BankTransactionInquiry({
onEndDateChange: setEndDate,
},
// 테이블 상단 콘텐츠 (새로고침 버튼)
beforeTableContent: (
<div className="flex items-center justify-end w-full">
<Button variant="outline" onClick={handleRefresh} disabled={isLoading}>
{isLoading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
</Button>
</div>
// 헤더 액션: 새로고침 버튼
headerActions: () => (
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={isLoading}>
{isLoading ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<RefreshCw className="h-4 w-4 mr-1" />
)}
{isLoading ? '조회중...' : '새로고침'}
</Button>
),
// 테이블 헤더 액션 (3개 필터)

View File

@@ -429,6 +429,42 @@ export function BillManagementClient({
icon: Plus,
},
// 헤더 액션: 상태 선택 + 저장 + 수취/발행 라디오
headerActions: () => (
<div className="flex items-center gap-3">
<RadioGroup
value={billTypeFilter}
onValueChange={(value) => { setBillTypeFilter(value); loadData(1); }}
className="flex items-center gap-3"
>
<div className="flex items-center space-x-1">
<RadioGroupItem value="received" id="received" />
<Label htmlFor="received" className="cursor-pointer text-sm"></Label>
</div>
<div className="flex items-center space-x-1">
<RadioGroupItem value="issued" id="issued" />
<Label htmlFor="issued" className="cursor-pointer text-sm"></Label>
</div>
</RadioGroup>
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{BILL_STATUS_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={handleSave} size="sm" disabled={isLoading}>
<Save className="h-4 w-4 mr-1" />
</Button>
</div>
),
// 테이블 헤더 액션 (필터)
tableHeaderActions: (
<div className="flex items-center gap-2 flex-wrap">
@@ -473,44 +509,6 @@ export function BillManagementClient({
</div>
),
// beforeTableContent: 상태 선택 + 저장 + 수취/발행 라디오
beforeTableContent: (
<div className="flex items-center gap-4">
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[110px]">
<SelectValue placeholder="보관중" />
</SelectTrigger>
<SelectContent>
{BILL_STATUS_FILTER_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
<Save className="h-4 w-4 mr-2" />
</Button>
<RadioGroup
value={billTypeFilter}
onValueChange={(value) => { setBillTypeFilter(value); loadData(1); }}
className="flex items-center gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="received" id="received" />
<Label htmlFor="received" className="cursor-pointer"></Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="issued" id="issued" />
<Label htmlFor="issued" className="cursor-pointer"></Label>
</div>
</RadioGroup>
</div>
),
// 렌더링 함수
renderTableRow,
renderMobileCard,

View File

@@ -17,7 +17,7 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { format, startOfMonth, endOfMonth } from 'date-fns';
import { CreditCard, Plus, RefreshCw, Save, Loader2 } from 'lucide-react';
import { CreditCard, Plus, RefreshCw, Save, Loader2, Search } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Checkbox } from '@/components/ui/checkbox';
import { Input } from '@/components/ui/input';
@@ -380,18 +380,66 @@ export function CardTransactionInquiry({
},
filterTitle: '카드 필터',
// 헤더 액션 (등록 버튼)
// 헤더 액션: 계정과목명 Select + 저장 + 새로고침
headerActions: () => (
<Button className="ml-auto" onClick={() => router.push('/ko/accounting/card-transactions?mode=new')}>
<Plus className="w-4 h-4 mr-2" />
</Button>
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700 whitespace-nowrap"></span>
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_SUBJECT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={handleSaveAccountSubject} size="sm">
<Save className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={isLoading}>
{isLoading ? (
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
) : (
<RefreshCw className="h-4 w-4 mr-1" />
)}
{isLoading ? '조회중...' : '새로고침'}
</Button>
</div>
),
// 등록 버튼
createButton: {
label: '카드내역 등록',
icon: Plus,
onClick: () => router.push('/ko/accounting/card-transactions?mode=new'),
},
// 커스텀 필터 함수
customFilterFn: (items) => {
if (cardFilter === 'all') return items;
return items.filter((item) => item.cardName === cardFilter);
if (!items || items.length === 0) return items;
let result = [...items];
// 검색어 필터
if (searchQuery) {
const search = searchQuery.toLowerCase();
result = result.filter((item) =>
item.card.toLowerCase().includes(search) ||
item.cardName.toLowerCase().includes(search) ||
item.user.toLowerCase().includes(search) ||
item.merchantName.toLowerCase().includes(search)
);
}
// 카드명 필터
if (cardFilter !== 'all') {
result = result.filter((item) => item.cardName === cardFilter);
}
return result;
},
// 커스텀 정렬 함수
@@ -417,8 +465,15 @@ export function CardTransactionInquiry({
},
// 날짜 선택기 (헤더 액션)
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
searchPlaceholder: '카드, 카드명, 사용자, 가맹점명 검색...',
dateRangeSelector: {
enabled: true,
showPresets: true,
startDate,
endDate,
onStartDateChange: setStartDate,
@@ -428,42 +483,6 @@ export function CardTransactionInquiry({
// 선택 항목 변경 콜백
onSelectionChange: setSelectedItems,
// 테이블 상단 콘텐츠 (계정과목명 + 저장 + 새로고침)
beforeTableContent: (
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700"></span>
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="계정과목명 선택" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_SUBJECT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
onClick={handleSaveAccountSubject}
className="bg-blue-500 hover:bg-blue-600"
>
<Save className="h-4 w-4 mr-2" />
</Button>
</div>
<Button variant="outline" onClick={handleRefresh} disabled={isLoading}>
{isLoading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<RefreshCw className="h-4 w-4 mr-2" />
)}
</Button>
</div>
),
// 테이블 헤더 액션 (2개 필터)
tableHeaderActions: () => (
<div className="flex items-center gap-2 flex-wrap">

View File

@@ -22,8 +22,10 @@ import {
Save,
Trash2,
RefreshCw,
Search,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import {
@@ -103,6 +105,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
const [endDate, setEndDate] = useState('2025-09-03');
const [depositData, setDepositData] = useState<DepositRecord[]>(initialData);
const [isRefreshing, setIsRefreshing] = useState(false);
const [searchQuery, setSearchQuery] = useState('');
// 인라인 필터 상태 (tableHeaderActions에서 사용)
const [vendorFilter, setVendorFilter] = useState<string>('all');
@@ -262,7 +265,19 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
// 커스텀 필터 함수 (인라인 필터 사용)
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
// 검색어 필터
if (searchQuery) {
const search = searchQuery.toLowerCase();
const matchesSearch =
item.depositorName.toLowerCase().includes(search) ||
item.accountName.toLowerCase().includes(search) ||
(item.note?.toLowerCase().includes(search) || false) ||
(item.vendorName?.toLowerCase().includes(search) || false);
if (!matchesSearch) return false;
}
// 거래처 필터
if (vendorFilter !== 'all' && item.vendorName !== vendorFilter) {
return false;
@@ -295,9 +310,16 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
return sorted;
},
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
searchPlaceholder: '입금자명, 계좌명, 적요, 거래처 검색...',
// 공통 헤더 옵션
dateRangeSelector: {
enabled: true,
showPresets: true,
startDate,
endDate,
onStartDateChange: setStartDate,
@@ -325,14 +347,45 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
},
filterTitle: '입금 필터',
// 헤더 액션 (등록 버튼)
headerActions: () => (
<Button className="ml-auto" onClick={() => router.push('/ko/accounting/deposits?mode=new')}>
<Plus className="w-4 h-4 mr-2" />
</Button>
// 헤더 액션: 계정과목명 Select + 저장 + 새로고침
headerActions: ({ selectedItems }) => (
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700 whitespace-nowrap"></span>
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_SUBJECT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => handleSaveAccountSubject(selectedItems)} size="sm">
<Save className="h-4 w-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
>
<RefreshCw className={`h-4 w-4 mr-1 ${isRefreshing ? 'animate-spin' : ''}`} />
{isRefreshing ? '조회중...' : '새로고침'}
</Button>
</div>
),
// 등록 버튼
createButton: {
label: '입금등록',
icon: Plus,
onClick: () => router.push('/ko/accounting/deposits?mode=new'),
},
// Stats 카드
computeStats: (): StatCard[] => [
{ label: '총 입금', value: `${stats.totalDeposit.toLocaleString()}`, icon: Banknote, iconColor: 'text-blue-500' },
@@ -341,44 +394,9 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
{ label: '입금유형 미설정', value: `${stats.depositTypeUnsetCount}`, icon: Banknote, iconColor: 'text-red-500' },
],
// beforeTableContent: 계정과목명 Select + 저장 버튼 + 새로고침
beforeTableContent: (
<div className="flex items-center justify-between w-full">
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700"></span>
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="계정과목명 선택" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_SUBJECT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
<Button
variant="outline"
onClick={handleRefresh}
disabled={isRefreshing}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
{isRefreshing ? '조회중...' : '새로고침'}
</Button>
</div>
),
// tableHeaderActions: 3개 인라인 필터
tableHeaderActions: ({ selectedItems }) => (
tableHeaderActions: (
<div className="flex items-center gap-2 flex-wrap">
{/* 저장 버튼 */}
<Button onClick={() => handleSaveAccountSubject(selectedItems)} className="bg-blue-500 hover:bg-blue-600">
<Save className="h-4 w-4 mr-2" />
</Button>
{/* 거래처 필터 */}
<Select value={vendorFilter} onValueChange={setVendorFilter}>
<SelectTrigger className="w-[140px]">
@@ -556,6 +574,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
vendorOptions,
tableTotals,
isRefreshing,
searchQuery,
handleRowClick,
handleEdit,
handleRefresh,

View File

@@ -902,51 +902,9 @@ export function ExpectedExpenseManagement({
},
filterTitle: '예상비용 필터',
// 테이블 헤더 액션 (거래처/정렬 필터)
tableHeaderActions: () => (
// 헤더 액션: 선택 기반 액션 버튼들
headerActions: () => (
<div className="flex items-center gap-2">
{/* 거래처 필터 */}
<Select value={vendorFilter} onValueChange={setVendorFilter}>
<SelectTrigger className="w-[140px] h-8 text-sm">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
{vendorFilterOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 필터 (최신순/등록순) */}
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
<SelectTrigger className="w-[100px] h-8 text-sm">
<SelectValue placeholder="최신순" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
),
// 테이블 앞 컨텐츠 (액션 버튼)
beforeTableContent: (
<div className="flex items-center gap-2 flex-wrap">
{/* 등록 버튼 */}
<Button
size="sm"
onClick={handleOpenCreateDialog}
>
<Plus className="h-4 w-4 mr-1" />
</Button>
{/* 예상 지급일 변경 버튼 - 1개 이상 선택 시 활성화 */}
<Button
variant="outline"
@@ -983,6 +941,46 @@ export function ExpectedExpenseManagement({
</div>
),
// 등록 버튼
createButton: {
label: '등록',
icon: Plus,
onClick: handleOpenCreateDialog,
},
// 테이블 헤더 액션 (거래처/정렬 필터)
tableHeaderActions: () => (
<div className="flex items-center gap-2">
{/* 거래처 필터 */}
<Select value={vendorFilter} onValueChange={setVendorFilter}>
<SelectTrigger className="w-[140px] h-8 text-sm">
<SelectValue placeholder="전체" />
</SelectTrigger>
<SelectContent>
{vendorFilterOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 필터 (최신순/등록순) */}
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
<SelectTrigger className="w-[100px] h-8 text-sm">
<SelectValue placeholder="최신순" />
</SelectTrigger>
<SelectContent>
{SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
),
// Stats 카드
computeStats: (): StatCard[] => {
const totalExpense = filteredRawData.reduce((sum, d) => sum + d.amount, 0);
@@ -1011,7 +1009,7 @@ export function ExpectedExpenseManagement({
vendorFilter,
vendorFilterOptions,
sortOption,
selectedItems,
selectedItems.size,
handleOpenCreateDialog,
handleOpenDateChangeDialog,
handleElectronicApproval,

View File

@@ -22,8 +22,10 @@ import {
Pencil,
Save,
Trash2,
Search,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
@@ -85,6 +87,7 @@ export function PurchaseManagement() {
const [endDate, setEndDate] = useState('2025-12-31');
const [purchaseData, setPurchaseData] = useState<PurchaseRecord[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [searchQuery, setSearchQuery] = useState('');
// 통합 필터 상태 (filterConfig 기반)
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
@@ -295,7 +298,17 @@ export function PurchaseManagement() {
// 커스텀 필터 함수 (filterValues 파라미터 사용)
customFilterFn: (items, fv) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
// 검색어 필터
if (searchQuery) {
const search = searchQuery.toLowerCase();
const matchesSearch =
item.purchaseNo.toLowerCase().includes(search) ||
item.vendorName.toLowerCase().includes(search);
if (!matchesSearch) return false;
}
const vendorVal = fv.vendor as string;
const purchaseTypeVal = fv.purchaseType as string;
const issuanceVal = fv.issuance as string;
@@ -336,15 +349,45 @@ export function PurchaseManagement() {
return sorted;
},
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
searchPlaceholder: '매입번호, 거래처명 검색...',
// 공통 헤더 옵션
dateRangeSelector: {
enabled: true,
showPresets: true,
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
// 헤더 액션: 계정과목명 Select + 저장 버튼
headerActions: ({ selectedItems }) => (
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700 whitespace-nowrap"></span>
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_SUBJECT_SELECTOR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => handleSaveAccountSubject(selectedItems)} size="sm">
<Save className="h-4 w-4 mr-1" />
</Button>
</div>
),
// 통합 필터 시스템 (PC: 인라인, 모바일: 바텀시트 자동 분기)
filterConfig,
initialFilters: filterValues,
@@ -358,29 +401,6 @@ export function PurchaseManagement() {
{ label: '세금계산서 수취 미확인', value: `${stats.taxInvoicePendingCount}`, icon: Receipt, iconColor: 'text-red-500' },
],
// beforeTableContent: 계정과목명 Select + 저장 버튼 (테이블 밖에 위치)
beforeTableContent: ({ selectedItems }) => (
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700"></span>
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="계정과목명 선택" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_SUBJECT_SELECTOR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => handleSaveAccountSubject(selectedItems)} className="bg-blue-500 hover:bg-blue-600">
<Save className="h-4 w-4 mr-2" />
</Button>
</div>
),
// 테이블 하단 합계 행
tableFooter: (
<TableRow className="bg-muted/50 font-medium">
@@ -532,6 +552,7 @@ export function PurchaseManagement() {
filterValues,
selectedAccountSubject,
tableTotals,
searchQuery,
handleRowClick,
handleEdit,
handleTaxInvoiceToggle,

View File

@@ -22,8 +22,10 @@ import {
Pencil,
Save,
Trash2,
Search,
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { Switch } from '@/components/ui/switch';
@@ -97,6 +99,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
const [startDate, setStartDate] = useState('2025-01-01');
const [endDate, setEndDate] = useState('2025-12-31');
const [salesData, setSalesData] = useState<SalesRecord[]>(initialData);
const [searchQuery, setSearchQuery] = useState('');
// 통합 필터 상태 (filterConfig 사용)
const [filterValues, setFilterValues] = useState<Record<string, string | string[]>>({
@@ -297,7 +300,18 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
// 커스텀 필터 함수 (filterConfig 사용)
customFilterFn: (items, fv) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
// 검색어 필터
if (searchQuery) {
const search = searchQuery.toLowerCase();
const matchesSearch =
item.salesNo.toLowerCase().includes(search) ||
item.vendorName.toLowerCase().includes(search) ||
item.note.toLowerCase().includes(search);
if (!matchesSearch) return false;
}
const vendorVal = fv.vendor as string;
const salesTypeVal = fv.salesType as string;
const issuanceVal = fv.issuance as string;
@@ -342,14 +356,43 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
return sorted;
},
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
searchPlaceholder: '매출번호, 거래처명, 비고 검색...',
// 공통 헤더 옵션
dateRangeSelector: {
enabled: true,
showPresets: true,
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
// 헤더 액션 (계정과목명 Select + 저장 버튼)
headerActions: ({ selectedItems }) => (
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700 whitespace-nowrap"></span>
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_SUBJECT_SELECTOR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => handleSaveAccountSubject(selectedItems)} size="sm">
<Save className="h-4 w-4 mr-1" />
</Button>
</div>
),
createButton: {
label: '매출 등록',
onClick: handleCreate,
@@ -368,29 +411,6 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
initialFilters: filterValues,
filterTitle: '매출 필터',
// beforeTableContent: 계정과목명 Select + 저장 버튼 (테이블 밖에 위치)
beforeTableContent: ({ selectedItems }) => (
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700"></span>
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="계정과목명 선택" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_SUBJECT_SELECTOR_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button onClick={() => handleSaveAccountSubject(selectedItems)} className="bg-blue-500 hover:bg-blue-600">
<Save className="h-4 w-4 mr-2" />
</Button>
</div>
),
// 테이블 하단 합계 행
tableFooter: (
<TableRow className="bg-muted/50 font-medium">
@@ -534,6 +554,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
filterValues,
selectedAccountSubject,
tableTotals,
searchQuery,
handleRowClick,
handleEdit,
handleCreate,

View File

@@ -211,6 +211,7 @@ export function VendorManagement({ initialData, initialTotal }: VendorManagement
// 커스텀 필터 함수
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
// 구분 필터
const categoryFilter = filterValues.category as string;

View File

@@ -73,14 +73,13 @@ import { toast } from 'sonner';
// ===== 테이블 컬럼 정의 =====
const tableColumns = [
{ key: 'withdrawalDate', label: '출금일' },
{ key: 'accountName', label: '출금계좌' },
{ key: 'recipientName', label: '수취인명' },
{ key: 'withdrawalAmount', label: '출금금액', className: 'text-right' },
{ key: 'vendorName', label: '거래처' },
{ key: 'note', label: '적요' },
{ key: 'withdrawalType', label: '출금유형', className: 'text-center' },
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
{ key: 'withdrawalDate', label: '출금일', className: 'w-[100px]' },
{ key: 'accountName', label: '출금계좌', className: 'min-w-[120px]' },
{ key: 'recipientName', label: '수취인명', className: 'min-w-[100px]' },
{ key: 'withdrawalAmount', label: '출금금액', className: 'text-right w-[110px]' },
{ key: 'vendorName', label: '거래처', className: 'min-w-[100px]' },
{ key: 'note', label: '적요', className: 'min-w-[150px]' },
{ key: 'withdrawalType', label: '출금유형', className: 'text-center w-[90px]' },
];
// ===== 컴포넌트 Props =====
@@ -112,6 +111,9 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
// 상단 계정과목명 선택 (저장용)
const [selectedAccountSubject, setSelectedAccountSubject] = useState<string>('unset');
// 검색어 상태 (헤더에서 직접 관리)
const [searchQuery, setSearchQuery] = useState('');
// 로딩 상태
const [isRefreshing, setIsRefreshing] = useState(false);
@@ -297,17 +299,23 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
},
filterTitle: '출금 필터',
// 헤더 액션 (등록 버튼)
headerActions: () => (
<Button className="ml-auto" onClick={() => router.push('/ko/accounting/withdrawals?mode=new')}>
<Plus className="w-4 h-4 mr-2" />
</Button>
),
// 검색창 숨김 (dateRangeSelector extraActions로 렌더링)
hideSearch: true,
// 커스텀 필터 함수
// 커스텀 필터 함수 (검색 + 필터)
customFilterFn: (items) => {
return items.filter((item) => {
// 검색어 필터
if (searchQuery) {
const search = searchQuery.toLowerCase();
const matchesSearch =
item.recipientName.toLowerCase().includes(search) ||
item.accountName.toLowerCase().includes(search) ||
item.note.toLowerCase().includes(search) ||
item.vendorName.toLowerCase().includes(search);
if (!matchesSearch) return false;
}
// 거래처 필터
if (vendorFilter !== 'all' && item.vendorName !== vendorFilter) {
return false;
@@ -342,23 +350,30 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
return sorted;
},
// 날짜 범위 선택기
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
// 날짜 범위 선택기 (달력 | 프리셋버튼 | 검색창(자동) - 한 줄)
dateRangeSelector: {
enabled: true,
showPresets: true,
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
// beforeTableContent: 계정과목명 + 저장 + 새로고침
beforeTableContent: (
<div className="flex items-center justify-between w-full">
// 헤더 액션: 계정과목명 Select + 저장 + 새로고침
headerActions: ({ selectedItems }) => {
const selectedArray = withdrawalData.filter(item => selectedItems.has(item.id));
return (
<div className="flex items-center gap-2">
<span className="text-sm font-medium text-gray-700"></span>
<span className="text-sm font-medium text-gray-700 whitespace-nowrap"></span>
<Select value={selectedAccountSubject} onValueChange={setSelectedAccountSubject}>
<SelectTrigger className="w-[150px]">
<SelectValue placeholder="계정과목명 선택" />
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{ACCOUNT_SUBJECT_OPTIONS.map((option) => (
@@ -368,29 +383,33 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
))}
</SelectContent>
</Select>
<Button onClick={() => handleSaveAccountSubject(selectedArray)} size="sm">
<Save className="h-4 w-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleRefresh}
disabled={isRefreshing}
>
<RefreshCw className={`h-4 w-4 mr-1 ${isRefreshing ? 'animate-spin' : ''}`} />
{isRefreshing ? '조회중...' : '새로고침'}
</Button>
</div>
<Button
variant="outline"
onClick={handleRefresh}
disabled={isRefreshing}
>
<RefreshCw className={`h-4 w-4 mr-2 ${isRefreshing ? 'animate-spin' : ''}`} />
{isRefreshing ? '조회중...' : '새로고침'}
</Button>
</div>
),
);
},
// tableHeaderActions: 저장 버튼 + 인라인 필터들
tableHeaderActions: ({ selectedItems }) => (
// 등록 버튼
createButton: {
label: '출금등록',
icon: Plus,
onClick: () => router.push('/ko/accounting/withdrawals?mode=new'),
},
// tableHeaderActions: 필터만 (거래처, 출금유형, 정렬)
tableHeaderActions: () => (
<div className="flex items-center gap-2 flex-wrap">
<Button
onClick={() => handleSaveAccountSubject(selectedItems)}
className="bg-blue-500 hover:bg-blue-600"
>
<Save className="h-4 w-4 mr-2" />
</Button>
{/* 거래처 필터 */}
<Select value={vendorFilter} onValueChange={setVendorFilter}>
<SelectTrigger className="w-[140px]">
@@ -446,7 +465,6 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
<TableCell></TableCell>
</TableRow>
),
@@ -506,29 +524,6 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
{WITHDRAWAL_TYPE_LABELS[item.withdrawalType]}
</Badge>
</TableCell>
{/* 작업 */}
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
{handlers.isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-gray-600 hover:text-gray-700 hover:bg-gray-50"
onClick={() => handleEdit(item)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => handlers.onDelete?.(item)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
@@ -576,9 +571,11 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
}),
[
initialData,
withdrawalData,
stats,
startDate,
endDate,
searchQuery,
vendorFilter,
withdrawalTypeFilter,
sortOption,

View File

@@ -510,6 +510,11 @@ export function ApprovalBox() {
tabs: tabs,
defaultTab: activeTab,
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
dateRangeSelector: {
enabled: true,
showPresets: false,

View File

@@ -464,6 +464,11 @@ export function DraftBox() {
{ key: 'actions', label: '작업', className: 'text-center' },
],
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
dateRangeSelector: {
enabled: true,
showPresets: false,

View File

@@ -445,6 +445,11 @@ export function ReferenceBox() {
computeStats: () => statCards,
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
dateRangeSelector: {
enabled: true,
showPresets: false,

View File

@@ -14,8 +14,10 @@ export interface TabChipProps {
label: string;
/** 카운트 숫자 */
count?: number;
/** 활성 상태 */
/** 활성 상태 (active 또는 isActive 둘 다 지원) */
active?: boolean;
/** 활성 상태 (active의 별칭) */
isActive?: boolean;
/** 클릭 이벤트 */
onClick?: () => void;
/** 색상 테마 */
@@ -28,26 +30,30 @@ export function TabChip({
label,
count,
active = false,
isActive,
onClick,
color = "blue",
className = "",
}: TabChipProps) {
// isActive가 전달되면 isActive 사용, 아니면 active 사용
const isActiveState = isActive ?? active;
return (
<button
onClick={onClick}
className={`
flex items-center gap-2 px-4 py-2.5 rounded-full border transition-all
${
active
? "border-primary bg-primary/5"
: "border-gray-200 hover:border-gray-300 hover:bg-gray-50"
isActiveState
? "border-primary bg-primary text-white shadow-sm"
: "border-gray-200 bg-white hover:border-gray-300 hover:bg-gray-50"
}
${className}
`}
>
<span
className={`text-sm ${
active ? "text-primary font-medium" : "text-gray-600 font-normal"
isActiveState ? "text-white font-medium" : "text-gray-600 font-normal"
}`}
>
{label}
@@ -55,7 +61,7 @@ export function TabChip({
{count !== undefined && (
<span
className={`text-sm font-semibold ${
active ? "text-primary" : "text-gray-900"
isActiveState ? "text-white" : "text-gray-900"
}`}
>
{count}

View File

@@ -97,6 +97,8 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
// 날짜 범위
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
// 검색어
const [searchQuery, setSearchQuery] = useState('');
// Stats 데이터
const [stats, setStats] = useState<BiddingStats | null>(initialStats || null);
@@ -220,6 +222,7 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
// 커스텀 필터 함수 (activeStatTab + filterValues 기반)
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
// Stats 탭 필터
if (activeStatTab === 'waiting' && item.status !== 'waiting') return false;
@@ -291,6 +294,11 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
return sorted;
},
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
// 공통 헤더 옵션: 날짜 선택기
dateRangeSelector: {
enabled: true,
@@ -410,8 +418,8 @@ export default function BiddingListClient({ initialData = [], initialStats }: Bi
/>
),
}),
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit]
[startDate, endDate, searchQuery, activeStatTab, stats, handleRowClick, handleEdit]
);
return <UniversalListPage config={config} initialData={initialData} />;
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
}

View File

@@ -106,6 +106,7 @@ export default function ContractListClient({ initialData = [], initialStats }: C
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [stats, setStats] = useState<ContractStats | null>(initialStats || null);
// Stats 로드
@@ -235,6 +236,7 @@ export default function ContractListClient({ initialData = [], initialStats }: C
// 커스텀 필터 함수
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
// Stats 탭 필터
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
@@ -304,6 +306,11 @@ export default function ContractListClient({ initialData = [], initialStats }: C
return sorted;
},
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
// 공통 헤더 옵션
dateRangeSelector: {
enabled: true,
@@ -433,8 +440,8 @@ export default function ContractListClient({ initialData = [], initialStats }: C
/>
),
}),
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit]
[startDate, endDate, searchQuery, activeStatTab, stats, handleRowClick, handleEdit]
);
return <UniversalListPage config={config} initialData={initialData} />;
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
}

View File

@@ -68,6 +68,8 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
// 날짜 범위
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
// 검색어
const [searchQuery, setSearchQuery] = useState('');
// Stats 데이터
const [stats, setStats] = useState<EstimateStats | null>(initialStats || null);
// 필터 옵션 데이터
@@ -210,6 +212,7 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
// 커스텀 필터 함수 (activeStatTab + filterValues 기반)
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
// Stats 탭 필터
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
@@ -274,6 +277,11 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
return sorted;
},
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
// 공통 헤더 옵션: 날짜 선택기
dateRangeSelector: {
enabled: true,
@@ -389,8 +397,8 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
/>
),
}),
[startDate, endDate, activeStatTab, stats, partnerOptions, estimatorOptions, handleRowClick, handleEdit]
[startDate, endDate, searchQuery, activeStatTab, stats, partnerOptions, estimatorOptions, handleRowClick, handleEdit]
);
return <UniversalListPage config={config} initialData={initialData} />;
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
}

View File

@@ -113,6 +113,7 @@ export default function HandoverReportListClient({
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [searchQuery, setSearchQuery] = useState('');
const [stats, setStats] = useState<HandoverReportStats | null>(initialStats || null);
// Stats 로드
@@ -234,6 +235,7 @@ export default function HandoverReportListClient({
// 커스텀 필터 함수
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
// Stats 탭 필터
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
@@ -297,6 +299,11 @@ export default function HandoverReportListClient({
return sorted;
},
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
// 공통 헤더 옵션
dateRangeSelector: {
enabled: true,
@@ -410,8 +417,8 @@ export default function HandoverReportListClient({
/>
),
}),
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit]
[startDate, endDate, searchQuery, activeStatTab, stats, handleRowClick, handleEdit]
);
return <UniversalListPage config={config} initialData={initialData} />;
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
}

View File

@@ -89,6 +89,7 @@ export default function IssueManagementListClient({
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [stats, setStats] = useState<IssueStats | null>(initialStats || null);
const [searchQuery, setSearchQuery] = useState('');
const [withdrawDialogOpen, setWithdrawDialogOpen] = useState(false);
const [itemsToWithdraw, setItemsToWithdraw] = useState<Set<string>>(new Set());
const [clearSelectionFn, setClearSelectionFn] = useState<(() => void) | null>(null);
@@ -271,6 +272,7 @@ export default function IssueManagementListClient({
// 커스텀 필터 함수
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
// Stats 탭 필터
if (activeStatTab !== 'all' && item.status !== activeStatTab) return false;
@@ -346,6 +348,11 @@ export default function IssueManagementListClient({
return sorted;
},
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
// 공통 헤더 옵션
dateRangeSelector: {
enabled: true,
@@ -494,12 +501,12 @@ export default function IssueManagementListClient({
/>
),
}),
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit, handleCreate, handleWithdrawClick]
[startDate, endDate, searchQuery, activeStatTab, stats, handleRowClick, handleEdit, handleCreate, handleWithdrawClick]
);
return (
<>
<UniversalListPage config={config} initialData={initialData} />
<UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />
{/* 철회 확인 다이얼로그 */}
<ConfirmDialog

View File

@@ -555,6 +555,7 @@ export default function ItemManagementClient({
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
hideSearch: true,
// 등록 버튼
createButton: {

View File

@@ -59,6 +59,7 @@ export default function LaborManagementClient({
const [startDate, setStartDate] = useState(format(startOfYear(today), 'yyyy-MM-dd'));
const [endDate, setEndDate] = useState(format(endOfYear(today), 'yyyy-MM-dd'));
const [stats, setStats] = useState<LaborStats>(initialStats ?? { total: 0, active: 0 });
const [searchQuery, setSearchQuery] = useState('');
// Stats 로드
useEffect(() => {
@@ -211,6 +212,7 @@ export default function LaborManagementClient({
// 커스텀 필터 함수
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
// 구분 필터
const categoryFilter = filterValues.category as string;
@@ -242,6 +244,11 @@ export default function LaborManagementClient({
return sorted;
},
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
// 공통 헤더 옵션
dateRangeSelector: {
enabled: true,
@@ -361,6 +368,7 @@ export default function LaborManagementClient({
[
startDate,
endDate,
searchQuery,
stats,
handleRowClick,
handleEdit,
@@ -371,5 +379,5 @@ export default function LaborManagementClient({
]
);
return <UniversalListPage config={config} initialData={initialData} />;
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
}

View File

@@ -82,6 +82,7 @@ export default function ConstructionManagementListClient({
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [stats, setStats] = useState<ConstructionManagementStats | null>(initialStats || null);
const [searchQuery, setSearchQuery] = useState('');
// 달력 관련 상태
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | null>(null);
@@ -289,6 +290,7 @@ export default function ConstructionManagementListClient({
// 커스텀 필터 함수
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
// Stats 탭 필터
if (activeStatTab === 'in_progress' && item.status !== 'in_progress') return false;
@@ -379,6 +381,11 @@ export default function ConstructionManagementListClient({
return sorted;
},
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
// 공통 헤더 옵션
dateRangeSelector: {
enabled: true,
@@ -530,6 +537,7 @@ export default function ConstructionManagementListClient({
[
startDate,
endDate,
searchQuery,
activeStatTab,
stats,
selectedCalendarDate,
@@ -550,5 +558,5 @@ export default function ConstructionManagementListClient({
]
);
return <UniversalListPage config={config} initialData={initialData} />;
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
}

View File

@@ -104,12 +104,23 @@ export default function ProjectDetailClient({ projectId }: ProjectDetailClientPr
icon={FolderKanban}
/>
{/* 기간 선택 (달력 + 프리셋 버튼) */}
{/* 기간 선택 + 검색 영역 */}
<DateRangeSelector
startDate={filterStartDate}
endDate={filterEndDate}
onStartDateChange={setFilterStartDate}
onEndDateChange={setFilterEndDate}
extraActions={
<div className="relative w-full sm:w-[300px]">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="프로젝트 검색 (현장명, 거래처, 계약번호)"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
}
/>
{/* 상태 카드 */}
@@ -155,17 +166,6 @@ export default function ProjectDetailClient({ projectId }: ProjectDetailClientPr
</Card>
</div>
{/* 검색 영역 */}
<div className="relative max-w-md">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
placeholder="프로젝트 검색 (현장명, 거래처, 계약번호)"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="pl-10"
/>
</div>
{/* 칸반 보드 */}
<Card>
<CardContent className="p-4 min-h-[600px]">

View File

@@ -89,6 +89,7 @@ export default function OrderManagementListClient({
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [searchQuery, setSearchQuery] = useState('');
// 달력 관련 상태
const [selectedCalendarDate, setSelectedCalendarDate] = useState<Date | null>(null);
@@ -304,6 +305,7 @@ export default function OrderManagementListClient({
// 커스텀 필터 함수
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
// 거래처 필터 (다중선택)
const partnerFilters = filterValues.partners as string[];
@@ -423,6 +425,11 @@ export default function OrderManagementListClient({
return sorted;
},
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
// 공통 헤더 옵션
dateRangeSelector: {
enabled: true,
@@ -584,6 +591,7 @@ export default function OrderManagementListClient({
[
startDate,
endDate,
searchQuery,
selectedCalendarDate,
calendarEvents,
calendarBadges,
@@ -606,5 +614,5 @@ export default function OrderManagementListClient({
]
);
return <UniversalListPage config={config} initialData={initialData} />;
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
}

View File

@@ -188,6 +188,7 @@ export default function PartnerListClient({ initialData = [], initialStats }: Pa
// 커스텀 필터 함수
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
// 악성채권 필터
const badDebtFilter = filterValues.badDebt as string;

View File

@@ -75,6 +75,7 @@ export default function ProgressBillingManagementListClient({
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [stats, setStats] = useState<ProgressBillingStats | null>(initialStats || null);
const [searchQuery, setSearchQuery] = useState('');
// Stats 로드
useEffect(() => {
@@ -188,6 +189,7 @@ export default function ProgressBillingManagementListClient({
// 커스텀 필터 함수
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
// Stats 탭 필터
if (activeStatTab === 'contractWaiting' &&
@@ -239,6 +241,11 @@ export default function ProgressBillingManagementListClient({
return sorted;
},
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
// 공통 헤더 옵션
dateRangeSelector: {
enabled: true,
@@ -350,8 +357,8 @@ export default function ProgressBillingManagementListClient({
/>
),
}),
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit]
[startDate, endDate, searchQuery, activeStatTab, stats, handleRowClick, handleEdit]
);
return <UniversalListPage config={config} initialData={initialData} />;
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
}

View File

@@ -97,6 +97,7 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
// 날짜 범위
const [startDate, setStartDate] = useState('');
const [endDate, setEndDate] = useState('');
const [searchQuery, setSearchQuery] = useState('');
// ===== 핸들러 =====
const handleRowClick = useCallback(
@@ -187,6 +188,7 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
// 커스텀 필터 함수 (activeStatTab 필터링 포함)
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
// Stats 탭 필터
if (activeStatTab === 'scheduled' && item.attendanceStatus !== 'scheduled') return false;
@@ -216,6 +218,11 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
return sorted;
},
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
// 날짜 범위 선택기
dateRangeSelector: {
enabled: true,
@@ -350,8 +357,8 @@ export default function SiteBriefingListClient({ initialData = [] }: SiteBriefin
);
},
}),
[handleRowClick, handleEdit, handleCreate, activeStatTab, startDate, endDate]
[handleRowClick, handleEdit, handleCreate, activeStatTab, startDate, endDate, searchQuery]
);
return <UniversalListPage config={config} initialData={initialData} />;
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
}

View File

@@ -68,6 +68,7 @@ export default function SiteManagementListClient({
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [stats, setStats] = useState<SiteStats | null>(initialStats || null);
const [searchQuery, setSearchQuery] = useState('');
// Stats 로드
useEffect(() => {
@@ -183,6 +184,7 @@ export default function SiteManagementListClient({
// 커스텀 필터 함수
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
// Stats 탭 필터
if (activeStatTab === 'construction' && item.status !== 'active') return false;
@@ -228,6 +230,11 @@ export default function SiteManagementListClient({
return sorted;
},
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
// 공통 헤더 옵션
dateRangeSelector: {
enabled: true,
@@ -337,8 +344,8 @@ export default function SiteManagementListClient({
/>
),
}),
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit]
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit, searchQuery]
);
return <UniversalListPage config={config} initialData={initialData} />;
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
}

View File

@@ -82,6 +82,7 @@ export default function StructureReviewListClient({
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [stats, setStats] = useState<StructureReviewStats | null>(initialStats || null);
const [searchQuery, setSearchQuery] = useState('');
// Stats 로드
useEffect(() => {
@@ -201,6 +202,7 @@ export default function StructureReviewListClient({
// 커스텀 필터 함수
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
// Stats 탭 필터
if (activeStatTab === 'pending' && item.status !== 'pending') return false;
@@ -246,6 +248,11 @@ export default function StructureReviewListClient({
return sorted;
},
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
// 공통 헤더 옵션
dateRangeSelector: {
enabled: true,
@@ -376,8 +383,8 @@ export default function StructureReviewListClient({
/>
),
}),
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit, handleCreate]
[startDate, endDate, activeStatTab, stats, handleRowClick, handleEdit, handleCreate, searchQuery]
);
return <UniversalListPage config={config} initialData={initialData} />;
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
}

View File

@@ -84,6 +84,7 @@ export default function UtilityManagementListClient({
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [stats, setStats] = useState<UtilityStats | null>(initialStats || null);
const [searchQuery, setSearchQuery] = useState('');
// Stats 로드
useEffect(() => {
@@ -212,6 +213,7 @@ export default function UtilityManagementListClient({
// 커스텀 필터 함수
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
// Stats 탭 필터
if (activeStatTab === 'waiting' && item.status !== 'scheduled' && item.status !== 'issued') return false;
@@ -279,6 +281,11 @@ export default function UtilityManagementListClient({
return sorted;
},
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
// 공통 헤더 옵션
dateRangeSelector: {
enabled: true,
@@ -389,8 +396,8 @@ export default function UtilityManagementListClient({
/>
),
}),
[startDate, endDate, activeStatTab, stats]
[startDate, endDate, activeStatTab, stats, searchQuery]
);
return <UniversalListPage config={config} initialData={initialData} />;
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
}

View File

@@ -84,6 +84,7 @@ export default function WorkerStatusListClient({
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [stats, setStats] = useState<WorkerStatusStats | null>(initialStats || null);
const [searchQuery, setSearchQuery] = useState('');
// Stats 로드
useEffect(() => {
@@ -220,6 +221,7 @@ export default function WorkerStatusListClient({
// 커스텀 필터 함수
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
return items.filter((item) => {
// Stats 탭 필터 (계약상태)
if (activeStatTab !== 'all' && item.contractStatus !== activeStatTab) return false;
@@ -300,6 +302,11 @@ export default function WorkerStatusListClient({
return sorted;
},
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
// 공통 헤더 옵션
dateRangeSelector: {
enabled: true,
@@ -415,8 +422,8 @@ export default function WorkerStatusListClient({
/>
),
}),
[startDate, endDate, activeStatTab, stats, handleRowClick, handleViewDetail]
[startDate, endDate, activeStatTab, stats, handleRowClick, handleViewDetail, searchQuery]
);
return <UniversalListPage config={config} initialData={initialData} />;
return <UniversalListPage config={config} initialData={initialData} onSearchChange={setSearchQuery} />;
}

View 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>
);
}

View File

@@ -130,6 +130,7 @@ export function EventList() {
// 커스텀 필터 (날짜)
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
if (!startDate || !endDate) return items;
return items.filter((item) => {
// 이벤트 기간이 선택한 기간과 겹치는지 확인

View File

@@ -124,6 +124,7 @@ export function InquiryList() {
// 커스텀 필터 (날짜 + 카테고리 + 상태)
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
let result = [...items];
// 날짜 필터

View File

@@ -97,6 +97,7 @@ export function NoticeList() {
// 커스텀 필터 (날짜)
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
// 날짜 필터는 외부 상태 사용
if (!startDate || !endDate) return items;
return items.filter((item) => {

View File

@@ -11,9 +11,11 @@ import {
Plus,
FileText,
Edit,
Search,
} from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { TableRow, TableCell } from '@/components/ui/table';
import { format } from 'date-fns';
@@ -434,6 +436,11 @@ export function AttendanceManagement() {
computeStats: () => statCards,
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchValue,
onSearchChange: setSearchValue,
dateRangeSelector: {
enabled: true,
showPresets: true,
@@ -482,6 +489,7 @@ export function AttendanceManagement() {
},
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
let filtered = items;
const filterOption = filterValues.filter as string;
if (filterOption && filterOption !== 'all') {

View File

@@ -2,9 +2,10 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { CreditCard, Edit, Trash2, Plus } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { CreditCard, Edit, Trash2, Plus, Search, RefreshCw } from 'lucide-react';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { TableRow, TableCell } from '@/components/ui/table';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
@@ -58,7 +59,7 @@ export function CardManagement({ initialData }: CardManagementProps) {
};
// 검색 및 필터 상태
const [searchValue, setSearchValue] = useState('');
const [searchQuery, setSearchQuery] = useState('');
const [activeTab, setActiveTab] = useState<string>('all');
const [currentPage, setCurrentPage] = useState(1);
const itemsPerPage = 20;
@@ -78,8 +79,8 @@ export function CardManagement({ initialData }: CardManagementProps) {
}
// 검색 필터
if (searchValue) {
const search = searchValue.toLowerCase();
if (searchQuery) {
const search = searchQuery.toLowerCase();
filtered = filtered.filter(c =>
c.cardName.toLowerCase().includes(search) ||
c.cardNumber.includes(search) ||
@@ -89,7 +90,7 @@ export function CardManagement({ initialData }: CardManagementProps) {
}
return filtered;
}, [cards, activeTab, searchValue]);
}, [cards, activeTab, searchQuery]);
// 페이지네이션된 데이터
const paginatedData = useMemo(() => {

View File

@@ -2,10 +2,11 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Users, Edit, Trash2, UserCheck, UserX, Clock, Calendar, Mail, Plus, Upload, Loader2 } from 'lucide-react';
import { Users, Edit, Trash2, UserCheck, UserX, Clock, Calendar, Mail, Plus, Upload, Loader2, Search } from 'lucide-react';
import { getEmployees, deleteEmployee, deleteEmployees, getEmployeeStats } from './actions';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { TableRow, TableCell } from '@/components/ui/table';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
@@ -437,6 +438,11 @@ export function EmployeeManagement() {
computeStats: () => statCards,
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchValue,
onSearchChange: setSearchValue,
dateRangeSelector: {
enabled: true,
showPresets: true,
@@ -489,6 +495,7 @@ export function EmployeeManagement() {
},
customFilterFn: (items, filterValues) => {
if (!items || items.length === 0) return items;
let filtered = items;
const filterOption = filterValues.filter as FilterOption;
if (filterOption && filterOption !== 'all') {

View File

@@ -13,9 +13,11 @@ import {
Gift,
MinusCircle,
Loader2,
Search,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { TableRow, TableCell } from '@/components/ui/table';
@@ -374,6 +376,11 @@ export function SalaryManagement() {
itemsPerPage: itemsPerPage,
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
// 날짜 범위 선택 (DateRangeSelector 사용)
dateRangeSelector: {
enabled: true,

View File

@@ -38,7 +38,6 @@ import {
type FilterFieldConfig,
type FilterValues,
} from '@/components/templates/UniversalListPage';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { VacationGrantDialog } from './VacationGrantDialog';
import { VacationRequestDialog } from './VacationRequestDialog';
@@ -587,48 +586,40 @@ export function VacationManagement() {
}
}, [mainTab, handleApproveClick, handleRejectClick]);
// ===== 헤더 액션 (DateRangeSelector + 버튼들) =====
// ===== 헤더 액션 (탭별 버튼들만 - DateRangeSelector와 검색창은 공통 옵션 사용) =====
const headerActions = useCallback(({ selectedItems: selected }: { selectedItems: Set<string>; onClearSelection?: () => void; onRefresh?: () => void }) => (
<>
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
<div className="ml-auto flex gap-2">
{/* 탭별 액션 버튼 */}
{mainTab === 'grant' && (
<Button onClick={() => setGrantDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
)}
<div className="flex items-center gap-2">
{/* 탭별 액션 버튼 */}
{mainTab === 'grant' && (
<Button onClick={() => setGrantDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
)}
{mainTab === 'request' && (
<>
{/* 버튼 순서: 승인 → 거절 → 휴가신청 (휴가신청 버튼 위치 고정) */}
{selected.size > 0 && (
<>
<Button variant="default" onClick={() => handleApproveClick(selected)}>
<Check className="h-4 w-4 mr-2" />
</Button>
<Button variant="destructive" onClick={() => handleRejectClick(selected)}>
<X className="h-4 w-4 mr-2" />
</Button>
</>
)}
<Button onClick={() => setRequestDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</>
)}
</div>
</>
), [startDate, endDate, mainTab, handleApproveClick, handleRejectClick]);
{mainTab === 'request' && (
<>
{/* 버튼 순서: 승인 → 거절 → 휴가신청 (휴가신청 버튼 위치 고정) */}
{selected.size > 0 && (
<>
<Button variant="default" onClick={() => handleApproveClick(selected)}>
<Check className="h-4 w-4 mr-2" />
</Button>
<Button variant="destructive" onClick={() => handleRejectClick(selected)}>
<X className="h-4 w-4 mr-2" />
</Button>
</>
)}
<Button onClick={() => setRequestDialogOpen(true)}>
<Plus className="h-4 w-4 mr-2" />
</Button>
</>
)}
</div>
), [mainTab, handleApproveClick, handleRejectClick]);
// ===== filterConfig 기반 통합 필터 시스템 =====
const filterConfig: FilterFieldConfig[] = useMemo(() => [
@@ -693,6 +684,15 @@ export function VacationManagement() {
columns: tableColumns,
// 공통 패턴: dateRangeSelector
dateRangeSelector: {
enabled: true,
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
tabs: tabs,
defaultTab: mainTab,

View File

@@ -17,7 +17,8 @@ import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
import { TableRow, TableCell } from '@/components/ui/table';
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { Search, Plus, Edit, Trash2, Package } from 'lucide-react';
import { Search, Plus, Edit, Trash2, Package, Download, FileDown, Upload } from 'lucide-react';
import { downloadExcel, downloadSelectedExcel, downloadExcelTemplate, parseExcelFile, type ExcelColumn, type TemplateColumn } from '@/lib/utils/excel-download';
import { useItemList } from '@/hooks/useItemList';
import { handleApiError } from '@/lib/api/error-handler';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
@@ -237,6 +238,120 @@ export default function ItemListClient() {
}
};
// 엑셀 다운로드용 컬럼 정의
const excelColumns: ExcelColumn<ItemMaster>[] = [
{ header: '품목코드', key: 'itemCode', width: 15 },
{ header: '품목유형', key: 'itemType', width: 10, transform: (v) => ITEM_TYPE_LABELS[v as keyof typeof ITEM_TYPE_LABELS] || String(v) },
{ header: '품목명', key: 'itemName', width: 30 },
{ header: '규격', key: 'specification', width: 20 },
{ header: '단위', key: 'unit', width: 8 },
{ header: '대분류', key: 'category1', width: 12 },
{ header: '중분류', key: 'category2', width: 12 },
{ header: '소분류', key: 'category3', width: 12 },
{ header: '구매단가', key: 'purchasePrice', width: 12 },
{ header: '판매단가', key: 'salesPrice', width: 12 },
{ header: '활성상태', key: 'isActive', width: 10, transform: (v) => v ? '활성' : '비활성' },
];
// 전체 엑셀 다운로드
const handleExcelDownload = () => {
if (items.length === 0) {
alert('다운로드할 데이터가 없습니다.');
return;
}
downloadExcel({
data: items,
columns: excelColumns,
filename: '품목목록',
sheetName: '품목',
});
};
// 선택 항목 엑셀 다운로드
const handleSelectedExcelDownload = (selectedIds: string[]) => {
if (selectedIds.length === 0) {
alert('선택된 항목이 없습니다.');
return;
}
downloadSelectedExcel({
data: items,
columns: excelColumns,
selectedIds,
idField: 'id',
filename: '품목목록_선택',
sheetName: '품목',
});
};
// 업로드용 템플릿 컬럼 정의
const templateColumns: TemplateColumn[] = [
{ header: '품목코드', key: 'itemCode', required: true, type: 'text', sampleValue: 'KD-FG-001', description: '고유 코드', width: 15 },
{ header: '품목유형', key: 'itemType', required: true, type: 'select', options: ['FG', 'PT', 'SM', 'RM', 'CS'], sampleValue: 'FG', description: 'FG:제품/PT:부품/SM:부자재/RM:원자재/CS:소모품', width: 12 },
{ header: '품목명', key: 'itemName', required: true, type: 'text', sampleValue: '스크린도어 본체', width: 25 },
{ header: '규격', key: 'specification', type: 'text', sampleValue: '1800x2100', width: 15 },
{ header: '단위', key: 'unit', required: true, type: 'select', options: ['EA', 'SET', 'KG', 'M', 'M2', 'BOX'], sampleValue: 'EA', width: 10 },
{ header: '대분류', key: 'category1', type: 'text', sampleValue: '스크린도어', width: 12 },
{ header: '중분류', key: 'category2', type: 'text', sampleValue: '본체류', width: 12 },
{ header: '소분류', key: 'category3', type: 'text', sampleValue: '프레임', width: 12 },
{ header: '구매단가', key: 'purchasePrice', type: 'number', sampleValue: 150000, width: 12 },
{ header: '판매단가', key: 'salesPrice', type: 'number', sampleValue: 200000, width: 12 },
{ header: '활성상태', key: 'isActive', type: 'boolean', sampleValue: 'Y', description: 'Y:활성/N:비활성', width: 10 },
];
// 양식 다운로드
const handleTemplateDownload = () => {
downloadExcelTemplate({
columns: templateColumns,
filename: '품목등록_양식',
sheetName: '품목등록',
includeSampleRow: true,
includeGuideRow: true,
});
};
// 파일 업로드 input ref
const fileInputRef = useRef<HTMLInputElement>(null);
// 양식 업로드 핸들러
const handleFileUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
try {
const result = await parseExcelFile(file, {
columns: templateColumns,
skipRows: 2, // 헤더 + 안내 행 스킵
});
if (!result.success || result.errors.length > 0) {
const errorMessages = result.errors.slice(0, 5).map(
(err) => `${err.row}행: ${err.message}`
).join('\n');
alert(`업로드 오류:\n${errorMessages}${result.errors.length > 5 ? `\n... 외 ${result.errors.length - 5}` : ''}`);
return;
}
if (result.data.length === 0) {
alert('업로드할 데이터가 없습니다.');
return;
}
// TODO: 실제 API 호출로 데이터 저장
// 지금은 파싱 결과만 확인
console.log('[Excel Upload] 파싱 결과:', result.data);
alert(`${result.data.length}건의 데이터가 파싱되었습니다.\n(실제 등록 기능은 추후 구현 예정)`);
} catch (error) {
console.error('[Excel Upload] 오류:', error);
alert('파일 업로드에 실패했습니다.');
} finally {
// input 초기화 (같은 파일 재선택 가능하도록)
if (fileInputRef.current) {
fileInputRef.current.value = '';
}
}
};
// 탭 옵션 (품목 유형별)
const tabs: TabOption[] = [
{ value: 'all', label: '전체', count: totalStats.totalAll, color: 'gray' },
@@ -301,6 +416,56 @@ export default function ItemListClient() {
icon: Plus,
},
// 헤더 액션 (엑셀 다운로드)
headerActions: ({ selectedItems }) => (
<div className="flex items-center gap-2">
{/* 양식 다운로드 버튼 - 추후 활성화
<Button
variant="outline"
size="sm"
onClick={handleTemplateDownload}
className="gap-2"
>
<FileDown className="h-4 w-4" />
양식 다운로드
</Button>
*/}
{/* 양식 업로드 버튼 - 추후 활성화
<Button
variant="outline"
size="sm"
onClick={() => fileInputRef.current?.click()}
className="gap-2"
>
<Upload className="h-4 w-4" />
양식 업로드
</Button>
*/}
{/* 엑셀 데이터 다운로드 버튼 */}
{selectedItems.size > 0 ? (
<Button
variant="outline"
size="sm"
onClick={() => handleSelectedExcelDownload(Array.from(selectedItems))}
className="gap-2"
>
<Download className="h-4 w-4" />
({selectedItems.size})
</Button>
) : (
<Button
variant="outline"
size="sm"
onClick={handleExcelDownload}
className="gap-2"
>
<Download className="h-4 w-4" />
</Button>
)}
</div>
),
// API 액션 (일괄 삭제 포함)
actions: {
getList: async () => ({ success: true, data: items }),
@@ -488,6 +653,15 @@ export default function ItemListClient() {
externalIsLoading={isLoading}
/>
{/* 숨겨진 파일 업로드 input */}
<input
ref={fileInputRef}
type="file"
accept=".xlsx,.xls"
onChange={handleFileUpload}
className="hidden"
/>
{/* 개별 삭제 확인 다이얼로그 */}
<DeleteConfirmDialog
open={deleteDialogOpen}

View File

@@ -46,6 +46,8 @@ interface DateRangeSelectorProps {
hideDateInputs?: boolean;
/** 날짜 입력 너비 */
dateInputWidth?: string;
/** 프리셋 버튼 위치: 'inline' (날짜 옆), 'below' (별도 줄) */
presetsPosition?: 'inline' | 'below';
}
/**
@@ -79,6 +81,7 @@ export function DateRangeSelector({
hidePresets = false,
hideDateInputs = false,
dateInputWidth = 'w-[140px]',
presetsPosition = 'inline',
}: DateRangeSelectorProps) {
// 프리셋 클릭 핸들러
@@ -119,59 +122,94 @@ export function DateRangeSelector({
}
}, [onStartDateChange, onEndDateChange]);
return (
<div className="flex flex-col gap-2 w-full">
{/* 1줄: 날짜 + 프리셋 */}
{/* 태블릿/모바일(~1279px): 세로 배치 / PC(1280px+): 가로 한 줄 */}
<div className="flex flex-col xl:flex-row xl:items-center gap-2">
{/* 날짜 범위 선택 (Input type="date") */}
{!hideDateInputs && (
<div className="flex items-center gap-1 shrink-0">
<Input
type="date"
value={startDate}
onChange={(e) => onStartDateChange(e.target.value)}
className="w-[165px]"
/>
<span className="text-muted-foreground shrink-0">~</span>
<Input
type="date"
value={endDate}
onChange={(e) => onEndDateChange(e.target.value)}
className="w-[165px]"
/>
</div>
)}
{/* 기간 버튼들 - 모바일에서 가로 스크롤 */}
{!hidePresets && presets.length > 0 && (
<div
className="overflow-x-auto -mx-1 px-1 xl:overflow-visible xl:mx-0 xl:px-0"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
<div className="flex items-center gap-1 min-w-max [&::-webkit-scrollbar]:hidden">
{presets.map((preset) => (
<Button
key={preset}
variant="outline"
size="sm"
onClick={() => handlePresetClick(preset)}
className="shrink-0 text-xs sm:text-sm px-2 sm:px-3"
>
{PRESET_LABELS[preset]}
</Button>
))}
</div>
</div>
)}
// 프리셋 버튼 렌더링
const renderPresets = () => {
if (hidePresets || presets.length === 0) return null;
return (
<div
className="overflow-x-auto -mx-1 px-1 xl:overflow-visible xl:mx-0 xl:px-0"
style={{ scrollbarWidth: 'none', msOverflowStyle: 'none' }}
>
<div className="flex items-center gap-1 min-w-max [&::-webkit-scrollbar]:hidden">
{presets.map((preset) => (
<Button
key={preset}
variant="outline"
size="sm"
onClick={() => handlePresetClick(preset)}
className="shrink-0 text-xs sm:text-sm px-2 sm:px-3"
>
{PRESET_LABELS[preset]}
</Button>
))}
</div>
</div>
);
};
{/* 2줄: 추가 액션 버튼들 - 항상 별도 줄, 오른쪽 정렬 */}
{extraActions && (
<div className="flex items-center gap-2 justify-end">
// presetsPosition이 'below'일 때: 달력+extraActions 같은 줄, 프리셋은 아래 줄
if (presetsPosition === 'below') {
return (
<div className="flex flex-col gap-2 w-full">
{/* 1줄: 날짜 + extraActions */}
<div className="flex items-center gap-2">
{/* 날짜 범위 선택 */}
{!hideDateInputs && (
<div className="flex items-center gap-1 shrink-0">
<Input
type="date"
value={startDate}
onChange={(e) => onStartDateChange(e.target.value)}
className="w-[165px]"
/>
<span className="text-muted-foreground shrink-0">~</span>
<Input
type="date"
value={endDate}
onChange={(e) => onEndDateChange(e.target.value)}
className="w-[165px]"
/>
</div>
)}
{/* extraActions (검색창 등) */}
{extraActions}
</div>
{/* 2줄: 프리셋 버튼들 */}
{renderPresets()}
</div>
);
}
// presetsPosition이 'inline' (기본값)
// PC(1280px+): 달력 | 프리셋버튼 | 검색창 (한 줄)
// 태블릿: 달력 / 프리셋버튼 / 검색창 (세 줄)
return (
<div className="flex flex-col xl:flex-row xl:items-center gap-2 w-full">
{/* 날짜 범위 선택 */}
{!hideDateInputs && (
<div className="flex items-center gap-1 shrink-0">
<Input
type="date"
value={startDate}
onChange={(e) => onStartDateChange(e.target.value)}
className="w-[165px]"
/>
<span className="text-muted-foreground shrink-0">~</span>
<Input
type="date"
value={endDate}
onChange={(e) => onEndDateChange(e.target.value)}
className="w-[165px]"
/>
</div>
)}
{/* 기간 버튼들 - 달력 바로 옆 */}
{renderPresets()}
{/* extraActions (검색창 등) - 마지막에 배치 */}
{extraActions}
</div>
);
}

View File

@@ -22,7 +22,7 @@ interface StatCardsProps {
export function StatCards({ stats }: StatCardsProps) {
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 md:gap-4">
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-4 gap-2">
{stats.map((stat, index) => {
const Icon = stat.icon;
const isClickable = !!stat.onClick;
@@ -37,24 +37,24 @@ export function StatCards({ stats }: StatCardsProps) {
}`}
onClick={stat.onClick}
>
<CardContent className="p-3 md:p-4 lg:p-6">
<CardContent className="p-2 md:p-3">
<div className="flex items-center justify-between">
<div className="flex-1">
<p className="sm:text-xs md:text-sm text-muted-foreground mb-1 md:mb-2 uppercase tracking-wide text-[12px]">
<div className="flex-1 min-w-0">
<p className="text-[10px] md:text-xs text-muted-foreground mb-0.5 uppercase tracking-wide truncate">
{stat.label}
</p>
<p className="font-bold text-[24px]">
<p className="font-bold text-base md:text-lg truncate">
{stat.value}
</p>
{stat.trend && (
<p className={`text-[10px] sm:text-xs md:text-sm mt-1 md:mt-2 font-medium ${stat.trend.isPositive ? 'text-green-600' : 'text-red-600'}`}>
<p className={`text-[9px] md:text-[10px] mt-0.5 font-medium ${stat.trend.isPositive ? 'text-green-600' : 'text-red-600'}`}>
{stat.trend.value}
</p>
)}
</div>
{Icon && (
<Icon
className={`w-8 h-8 sm:w-9 sm:h-9 md:w-10 md:h-10 lg:w-12 lg:h-12 opacity-15 ${stat.iconColor || 'text-blue-600'}`}
className={`w-6 h-6 md:w-8 md:h-8 opacity-15 flex-shrink-0 ${stat.iconColor || 'text-blue-600'}`}
/>
)}
</div>

View File

@@ -223,9 +223,22 @@ export function PricingListClient({
) => {
const { isSelected, onToggle } = handlers;
// 행 클릭 핸들러: 등록되지 않은 항목은 등록, 등록된 항목은 수정
const handleRowClick = () => {
if (item.status === 'not_registered') {
handleRegister(item);
} else {
handleEdit(item);
}
};
return (
<TableRow key={item.id} className="hover:bg-muted/50">
<TableCell className="text-center">
<TableRow
key={item.id}
className="hover:bg-muted/50 cursor-pointer"
onClick={handleRowClick}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={onToggle}

View File

@@ -11,7 +11,7 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { Wrench, Plus, Pencil, Trash2, Loader2 } from 'lucide-react';
import { Wrench, Plus, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
@@ -45,6 +45,13 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
// 날짜 범위 상태
const [startDate, setStartDate] = useState('2025-01-01');
const [endDate, setEndDate] = useState('2025-12-31');
// 검색어 상태
const [searchQuery, setSearchQuery] = useState('');
// ===== 데이터 로드 =====
const loadData = useCallback(async () => {
setIsLoading(true);
@@ -249,36 +256,48 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
clientSideFiltering: true,
itemsPerPage: 20,
// 탭 필터 함수
tabFilter: (item: Process, activeTab: string) => {
if (activeTab === 'all') return true;
return item.status === activeTab;
// 검색창 (공통 컴포넌트에서 자동 생성)
hideSearch: true,
searchValue: searchQuery,
onSearchChange: setSearchQuery,
// 날짜 범위 선택기
dateRangeSelector: {
enabled: true,
showPresets: true,
startDate,
endDate,
onStartDateChange: setStartDate,
onEndDateChange: setEndDate,
},
// 검색 필터 함수
searchFilter: (item: Process, searchValue: string) => {
const search = searchValue.toLowerCase();
// 필터 (공통 컴포넌트에서 처리)
tabFilter: (item, tabValue) => {
if (tabValue === 'all') return true;
return item.status === tabValue;
},
// 검색 필터
searchFilter: (item, searchValue) => {
if (!searchValue || !searchValue.trim()) return true;
const search = searchValue.toLowerCase().trim();
return (
item.processCode.toLowerCase().includes(search) ||
item.processName.toLowerCase().includes(search) ||
item.department.toLowerCase().includes(search)
(item.processCode || '').toLowerCase().includes(search) ||
(item.processName || '').toLowerCase().includes(search) ||
(item.department || '').toLowerCase().includes(search)
);
},
// 탭 설정
// 탭 (공통 컴포넌트에서 Card 안에 렌더링)
tabs,
defaultTab: 'all',
// 검색
searchPlaceholder: '공정코드, 공정명, 담당부서 검색',
// 헤더 액션
headerActions: () => (
<Button onClick={handleCreate} className="gap-2">
<Plus className="h-4 w-4" />
</Button>
),
// 등록 버튼 (공통 컴포넌트에서 오른쪽에 렌더링)
createButton: {
label: '공정 등록',
onClick: handleCreate,
icon: Plus,
},
// 일괄 삭제 핸들러
onBulkDelete: handleBulkDelete,
@@ -448,12 +467,12 @@ export default function ProcessListClient({ initialData = [], initialStats }: Pr
);
},
}),
[tabs, handleCreate, handleRowClick, handleEdit, handleDeleteClick, handleToggleStatus, handleBulkDelete]
[tabs, handleCreate, handleRowClick, handleEdit, handleDeleteClick, handleToggleStatus, handleBulkDelete, startDate, endDate, searchQuery]
);
return (
<>
<UniversalListPage config={config} initialData={allProcesses} />
<UniversalListPage config={config} initialData={allProcesses} onSearchChange={setSearchQuery} />
{/* 삭제 확인 다이얼로그 */}
<DeleteConfirmDialog

View File

@@ -1,7 +1,7 @@
"use client";
import { ReactNode, Fragment, useState, useEffect, useRef, useCallback } from "react";
import { LucideIcon, Trash2, Plus, Loader2, ArrowUpDown, ArrowUp, ArrowDown } from "lucide-react";
import { LucideIcon, Trash2, Plus, Loader2, ArrowUpDown, ArrowUp, ArrowDown, Search } from "lucide-react";
import { DateRangeSelector } from "@/components/molecules/DateRangeSelector";
import { Card, CardContent } from "@/components/ui/card";
import { Tabs, TabsContent } from "@/components/ui/tabs";
@@ -17,6 +17,7 @@ import {
SelectValue,
} from "@/components/ui/select";
import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog';
import { Input } from "@/components/ui/input";
import { PageLayout } from "@/components/organisms/PageLayout";
import { PageHeader } from "@/components/organisms/PageHeader";
import { StatCards } from "@/components/organisms/StatCards";
@@ -106,10 +107,14 @@ export interface IntegratedListTemplateV2Props<T = any> {
dateRangeSelector?: {
enabled: boolean;
showPresets?: boolean;
/** 날짜 입력 숨김 (검색창만 표시하고 싶을 때) */
hideDateInputs?: boolean;
startDate?: string;
endDate?: string;
onStartDateChange?: (date: string) => void;
onEndDateChange?: (date: string) => void;
/** 추가 액션 (검색창 등) - 프리셋 버튼 옆에 배치 */
extraActions?: ReactNode;
};
/**
* 등록 버튼 (오른쪽 끝 배치)
@@ -237,7 +242,7 @@ export function IntegratedListTemplateV2<T = any>({
onSearchChange,
searchPlaceholder = "검색...",
extraFilters,
hideSearch = false,
hideSearch = true, // 기본값: 타이틀 아래에 검색창 표시 (Card 안 SearchFilter 숨김)
tabs,
activeTab,
onTabChange,
@@ -536,32 +541,71 @@ export function IntegratedListTemplateV2<T = any>({
/>
{/* 헤더 액션 (달력, 버튼 등) - 타이틀 아래 배치 */}
{/* 레이아웃: [달력 (왼쪽)] -------------- [등록 버튼 (오른쪽 끝)] */}
{(dateRangeSelector?.enabled || createButton || headerActions) && (
{/* 레이아웃: [달력] [프리셋버튼] [검색창] -------------- [추가버튼들] [등록버튼] (오른쪽 끝) */}
{(dateRangeSelector?.enabled || createButton || headerActions || (hideSearch && onSearchChange)) && (
isLoading ? renderHeaderActionSkeleton() : (
<div className="flex items-center gap-2 flex-wrap w-full">
{/* 날짜 범위 선택기 (왼쪽) */}
{dateRangeSelector?.enabled && (
<div className="flex flex-col xl:flex-row xl:items-center gap-2 w-full">
{/* 날짜 범위 선택기 + 검색창 (왼쪽) */}
{dateRangeSelector?.enabled ? (
<DateRangeSelector
startDate={dateRangeSelector.startDate || ''}
endDate={dateRangeSelector.endDate || ''}
onStartDateChange={dateRangeSelector.onStartDateChange}
onEndDateChange={dateRangeSelector.onEndDateChange}
hidePresets={dateRangeSelector.showPresets === false}
hideDateInputs={dateRangeSelector.hideDateInputs}
extraActions={
<>
{/* hideSearch=true면 검색창 자동 추가 (extraActions 앞에) */}
{hideSearch && onSearchChange && (
<div className="relative w-full xl:w-[300px]">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={searchPlaceholder}
value={searchValue || ''}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9 w-full bg-gray-50 border-gray-200"
/>
</div>
)}
{/* 기존 extraActions (추가 버튼 등) */}
{dateRangeSelector.extraActions}
</>
}
/>
) : (
/* dateRangeSelector 없어도 hideSearch=true면 검색창 표시 */
hideSearch && onSearchChange && (
<div className="relative w-full xl:w-[300px]">
<Search className="absolute left-3 top-1/2 transform -translate-y-1/2 h-4 w-4 text-muted-foreground" />
<Input
type="text"
placeholder={searchPlaceholder}
value={searchValue || ''}
onChange={(e) => onSearchChange(e.target.value)}
className="pl-9 w-full bg-gray-50 border-gray-200"
/>
</div>
)
)}
{/* 레거시 헤더 액션 (기존 호환성 유지) */}
{headerActions}
{/* 등록 버튼 (오른쪽 끝) */}
{createButton && (
<Button className="ml-auto" onClick={createButton.onClick}>
{createButton.icon ? (
<createButton.icon className="h-4 w-4 mr-2" />
) : (
<Plus className="h-4 w-4 mr-2" />
{/* 버튼 영역 (오른쪽 끝으로 통합) */}
{(headerActions || createButton) && (
<div className="flex items-center gap-2 ml-auto shrink-0">
{/* 헤더 액션 (엑셀 다운로드 등 추가 버튼들) */}
{headerActions}
{/* 등록 버튼 */}
{createButton && (
<Button onClick={createButton.onClick}>
{createButton.icon ? (
<createButton.icon className="h-4 w-4 mr-2" />
) : (
<Plus className="h-4 w-4 mr-2" />
)}
{createButton.label}
</Button>
)}
{createButton.label}
</Button>
</div>
)}
</div>
)

View File

@@ -578,7 +578,7 @@ export function UniversalListPage<T>({
return (
<>
<IntegratedListTemplateV2<T>
<IntegratedListTemplateV2
// 페이지 헤더
title={config.title}
description={config.description}

View File

@@ -232,10 +232,16 @@ export interface UniversalListConfig<T> {
dateRangeSelector?: {
enabled: boolean;
showPresets?: boolean;
/** 날짜 입력 숨김 (검색창만 표시하고 싶을 때) */
hideDateInputs?: boolean;
/** 프리셋 버튼 위치: 'inline' (날짜 옆), 'below' (별도 줄) */
presetsPosition?: 'inline' | 'below';
startDate?: string;
endDate?: string;
onStartDateChange?: (date: string) => void;
onEndDateChange?: (date: string) => void;
/** 추가 액션 (검색창 등) - presetsPosition이 'below'일 때 달력 옆에 배치됨 */
extraActions?: ReactNode;
};
/**
* 등록 버튼 (오른쪽 끝 배치)

View 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);
});
}