feat(WEB): 상태 뱃지 공통화 및 상세 페이지 훅 시스템 추가

공통화:
- status-config.ts 신규 추가 (상태 설정 중앙 관리)
- StatusBadge 컴포넌트 개선
- 뱃지 공통화 가이드 문서 추가

상세 페이지 훅 시스템:
- useDetailData, useDetailPageState, useDetailPermissions, useCRUDHandlers 훅 신규 추가
- hooks/index.ts 진입점 추가
- BillDetailV2 신규 컴포넌트 추가

리팩토링:
- 회계(매입/어음/거래처), 품목, 단가, 견적, 주문 상태 뱃지 공통화 적용
- 생산(작업지시서/대시보드/작업자화면), 품질(검사관리) 상태 뱃지 적용
- 공사관리(칸반/프로젝트카드) 상태 뱃지 적용
- 고객센터(문의관리), 인사(직원CSV업로드) 개선
- 리스트 페이지 공통화 현황 분석 문서 추가

상수 정리:
- lib/constants/ 디렉토리 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-05 15:57:49 +09:00
parent 07dd52aa7b
commit 2639724f9f
43 changed files with 2944 additions and 103 deletions

View File

@@ -0,0 +1,505 @@
# 리스트 페이지 공통화 현황 분석
> 작성일: 2026-02-05
> 목적: 리스트 페이지 반복 패턴 식별 및 공통화 가능성 분석
---
## 📊 전체 현황
| 구분 | 수량 |
|------|------|
| 총 리스트 페이지 | 37개 |
| UniversalListPage 사용 | 15개+ |
| IntegratedListTemplateV2 직접 사용 | 5개+ |
| 레거시 패턴 | 10개+ |
---
## 🏗️ 템플릿 계층 구조
```
UniversalListPage (최상위 - config 기반)
└── IntegratedListTemplateV2 (하위 - props 기반)
└── 공통 UI 컴포넌트
├── PageLayout
├── PageHeader
├── StatCards
├── DateRangeSelector
├── MobileFilter
├── ListMobileCard
└── Table, Pagination 등
```
---
## 📁 리스트 페이지 목록 및 사용 템플릿
### UniversalListPage 사용 (최신 패턴)
| 파일 | 도메인 | 특징 |
|------|--------|------|
| `items/ItemListClient.tsx` | 품목관리 | 외부 훅(useItemList) 사용, 엑셀 업로드/다운로드 |
| `pricing/PricingListClient.tsx` | 가격관리 | 외부 훅 사용 |
| `production/WorkOrders/WorkOrderList.tsx` | 생산 | 공정 기반 탭, 외부 통계 API |
| `outbound/ShipmentManagement/ShipmentList.tsx` | 출고 | 캘린더 통합, 날짜범위 필터 |
| `outbound/VehicleDispatchManagement/VehicleDispatchList.tsx` | 배차 | - |
| `material/ReceivingManagement/ReceivingList.tsx` | 입고 | - |
| `material/StockStatus/StockStatusList.tsx` | 재고 | - |
| `customer-center/NoticeManagement/NoticeList.tsx` | 공지사항 | 클라이언트 사이드 필터링 |
| `customer-center/EventManagement/EventList.tsx` | 이벤트 | 클라이언트 사이드 필터링 |
| `customer-center/InquiryManagement/InquiryList.tsx` | 문의 | - |
| `customer-center/FAQManagement/FAQList.tsx` | FAQ | - |
| `quality/InspectionManagement/InspectionList.tsx` | 품질검사 | - |
| `process-management/ProcessListClient.tsx` | 공정관리 | - |
| `pricing-table-management/PricingTableListClient.tsx` | 단가표 | - |
| `pricing-distribution/PriceDistributionList.tsx` | 가격배포 | - |
### 건설 도메인 (UniversalListPage 사용)
| 파일 | 기능 |
|------|------|
| `construction/management/ProjectListClient.tsx` | 프로젝트 목록 |
| `construction/management/ConstructionManagementListClient.tsx` | 공사관리 목록 |
| `construction/contract/ContractListClient.tsx` | 계약 목록 |
| `construction/estimates/EstimateListClient.tsx` | 견적 목록 |
| `construction/bidding/BiddingListClient.tsx` | 입찰 목록 |
| `construction/pricing-management/PricingListClient.tsx` | 단가관리 목록 |
| `construction/partners/PartnerListClient.tsx` | 협력사 목록 |
| `construction/order-management/OrderManagementListClient.tsx` | 발주관리 목록 |
| `construction/site-management/SiteManagementListClient.tsx` | 현장관리 목록 |
| `construction/site-briefings/SiteBriefingListClient.tsx` | 현장브리핑 목록 |
| `construction/handover-report/HandoverReportListClient.tsx` | 인수인계 목록 |
| `construction/issue-management/IssueManagementListClient.tsx` | 이슈관리 목록 |
| `construction/structure-review/StructureReviewListClient.tsx` | 구조검토 목록 |
| `construction/utility-management/UtilityManagementListClient.tsx` | 유틸리티 목록 |
| `construction/worker-status/WorkerStatusListClient.tsx` | 작업자 현황 |
| `construction/progress-billing/ProgressBillingManagementListClient.tsx` | 기성관리 목록 |
### 기타/레거시
| 파일 | 비고 |
|------|------|
| `settings/PopupManagement/PopupList.tsx` | 팝업관리 |
| `production/WorkResults/WorkResultList.tsx` | 작업실적 |
| `quality/PerformanceReportManagement/PerformanceReportList.tsx` | 성과보고서 |
| `board/BoardList/BoardListUnified.tsx` | 통합 게시판 |
---
## 🔄 반복 패턴 분석
### 1. Badge 색상 매핑 (매우 반복적)
각 페이지마다 개별 정의되어 있는 패턴:
```typescript
// ItemListClient.tsx
const badges: Record<string, { variant: string; className: string }> = {
FG: { variant: 'default', className: 'bg-purple-100 text-purple-700 border-purple-200' },
PT: { variant: 'default', className: 'bg-orange-100 text-orange-700 border-orange-200' },
// ...
};
// WorkOrderList.tsx
const PRIORITY_COLORS: Record<string, string> = {
'긴급': 'bg-red-100 text-red-700',
'우선': 'bg-orange-100 text-orange-700',
'일반': 'bg-gray-100 text-gray-700',
};
// ShipmentList.tsx - types.ts에서 import
export const SHIPMENT_STATUS_STYLES: Record<ShipmentStatus, string> = { ... };
```
**현황**:
- `src/lib/utils/status-config.ts``createStatusConfig` 유틸 존재
- 일부 페이지만 사용 중 (대부분 개별 정의)
### 2. 상태 라벨 정의 (반복적)
```typescript
// WorkOrderList.tsx - types.ts에서 import
export const WORK_ORDER_STATUS_LABELS: Record<WorkOrderStatus, string> = {
pending: '대기',
in_progress: '진행중',
completed: '완료',
};
// ShipmentList.tsx - types.ts에서 import
export const SHIPMENT_STATUS_LABELS: Record<ShipmentStatus, string> = { ... };
```
**현황**: 각 도메인 types.ts에서 개별 정의
### 3. 필터 설정 (filterConfig)
```typescript
// WorkOrderList.tsx
const filterConfig: FilterFieldConfig[] = [
{
key: 'status',
label: '상태',
type: 'single',
options: [
{ value: 'waiting', label: '작업대기' },
{ value: 'in_progress', label: '진행중' },
{ value: 'completed', label: '작업완료' },
],
},
{
key: 'priority',
label: '우선순위',
type: 'single',
options: [
{ value: 'urgent', label: '긴급' },
{ value: 'priority', label: '우선' },
{ value: 'normal', label: '일반' },
],
},
];
```
**공통 필터 패턴**:
- 상태 필터 (대기/진행/완료)
- 우선순위 필터 (긴급/우선/일반)
- 유형 필터 (전체/유형1/유형2...)
### 4. 행 클릭 핸들러 패턴
```typescript
// 모든 페이지에서 동일한 패턴
const handleRowClick = useCallback(
(item: SomeType) => {
router.push(`/ko/${basePath}/${item.id}?mode=view`);
},
[router]
);
```
### 5. 테이블 행 렌더링 (renderTableRow)
```typescript
// 공통 구조
<TableRow onClick={() => handleRowClick(item)}>
<TableCell onClick={(e) => e.stopPropagation()}>
<Checkbox checked={isSelected} onCheckedChange={onToggle} />
</TableCell>
<TableCell className="text-center">{globalIndex}</TableCell>
{/* 데이터 컬럼들 */}
<TableCell>
<Badge className={getStatusStyle(item.status)}>
{getStatusLabel(item.status)}
</Badge>
</TableCell>
</TableRow>
```
---
## ✅ 이미 공통화된 것
| 유틸/컴포넌트 | 위치 | 사용률 |
|--------------|------|--------|
| `UniversalListPage` | templates/ | 높음 (15개+) |
| `IntegratedListTemplateV2` | templates/ | 높음 |
| `ListMobileCard`, `InfoField` | organisms/ | 높음 |
| `MobileFilter` | molecules/ | 높음 |
| `DateRangeSelector` | molecules/ | 높음 |
| `StatCards` | organisms/ | 높음 |
| `createStatusConfig` | lib/utils/ | **낮음** (일부만 사용) |
---
## ❌ 공통화 필요한 것
### 높은 우선순위 (ROI 높음)
| 패턴 | 현황 | 공통화 방안 |
|------|------|-------------|
| **Badge 색상 매핑** | 각 페이지 개별 정의 | `src/lib/utils/badge-styles.ts` 생성 |
| **공통 필터 프리셋** | 각 페이지 개별 정의 | `src/lib/constants/filter-presets.ts` 생성 |
| **우선순위 색상** | 각 페이지 개별 정의 | 공통 상수로 추출 |
### 중간 우선순위
| 패턴 | 현황 | 공통화 방안 |
|------|------|-------------|
| 상태 라벨 | 도메인별 types.ts | 도메인별 유지 (비즈니스 로직) |
| 행 클릭 핸들러 | 각 페이지 개별 | UniversalListPage에서 처리 중 |
---
## 📋 공통화 대상 상세
### 1. Badge 스타일 공통화
**현재 분산된 위치**:
- `items/ItemListClient.tsx` - getItemTypeBadge()
- `production/WorkOrders/types.ts` - WORK_ORDER_STATUS_COLORS
- `outbound/ShipmentManagement/types.ts` - SHIPMENT_STATUS_STYLES
- 기타 각 도메인별 개별 정의
**이미 존재하는 공통 유틸** (`src/lib/utils/status-config.ts`):
```typescript
export const BADGE_STYLE_PRESETS: Record<StatusStylePreset, string> = {
default: 'bg-gray-100 text-gray-800',
success: 'bg-green-100 text-green-800',
warning: 'bg-yellow-100 text-yellow-800',
destructive: 'bg-red-100 text-red-800',
info: 'bg-blue-100 text-blue-800',
muted: 'bg-gray-100 text-gray-500',
orange: 'bg-orange-100 text-orange-800',
purple: 'bg-purple-100 text-purple-800',
};
```
**문제**: 존재하지만 대부분의 페이지에서 사용하지 않음
### 2. 공통 필터 프리셋
**추출 가능한 공통 필터**:
```typescript
// 상태 필터 (거의 모든 페이지)
export const COMMON_STATUS_FILTER: FilterFieldConfig = {
key: 'status',
label: '상태',
type: 'single',
options: [
{ value: 'pending', label: '대기' },
{ value: 'in_progress', label: '진행중' },
{ value: 'completed', label: '완료' },
],
};
// 우선순위 필터 (생산, 출고 등)
export const COMMON_PRIORITY_FILTER: FilterFieldConfig = {
key: 'priority',
label: '우선순위',
type: 'single',
options: [
{ value: 'urgent', label: '긴급' },
{ value: 'priority', label: '우선' },
{ value: 'normal', label: '일반' },
],
};
```
### 3. 우선순위 색상 통합
**현재 상태**: 여러 파일에서 동일한 색상 반복
```typescript
// 긴급: bg-red-100 text-red-700
// 우선: bg-orange-100 text-orange-700
// 일반: bg-gray-100 text-gray-700
```
---
## 🎯 권장 액션
### Phase 1: 즉시 실행 가능
1. **`createStatusConfig` 사용률 높이기**
- 기존 유틸 활용도 확인
- 새 페이지 작성 시 필수 사용 권장
2. **공통 필터 프리셋 파일 생성**
- 위치: `src/lib/constants/filter-presets.ts`
- 상태/우선순위/유형 필터 템플릿
3. **우선순위 색상 상수 통합**
- 위치: `src/lib/utils/status-config.ts`에 추가
### Phase 2: 점진적 적용
1. 신규 페이지는 공통 유틸 필수 사용
2. 기존 페이지는 수정 시 점진적 마이그레이션
3. 기능 변경 없이 import만 변경
---
## 📊 공통화 효과 예측
| 항목 | Before | After |
|------|--------|-------|
| Badge 정의 위치 | 37개 파일에 분산 | 1개 파일 (+ import) |
| 필터 프리셋 | 각 페이지 개별 | 공통 상수 재사용 |
| 색상 변경 시 수정 범위 | 37개 파일 | 1개 파일 |
| 신규 페이지 개발 시간 | 기존 페이지 참고 필요 | 공통 유틸 import만 |
---
## 📝 결론
1. **UniversalListPage는 이미 잘 구축됨** - 대부분 리스트가 사용 중
2. **Badge/필터 공통화가 주요 개선점** - 반복 코드 제거 가능
3. **기존 유틸(`createStatusConfig`) 활용도 낮음** - 홍보/가이드 필요
4. **기능 변경 없이 공통화 가능** - 리팩토링 리스크 낮음
---
## ✅ 공통화 작업 완료 현황 (2026-02-05)
### 생성된 파일
| 파일 | 설명 |
|------|------|
| `src/lib/constants/filter-presets.ts` | 공통 필터 프리셋 (상태/우선순위/품목유형 등) |
| `claudedocs/guides/badge-commonization-guide.md` | Badge 공통화 사용 가이드 |
### 수정된 파일
| 파일 | 변경 내용 |
|------|----------|
| `src/lib/utils/status-config.ts` | 우선순위/품목유형 설정 추가, 한글 라벨 지원 |
| `src/components/production/WorkOrders/WorkOrderList.tsx` | 공통 유틸 적용 (샘플 마이그레이션) |
### 추가된 공통 유틸
**filter-presets.ts**:
- `COMMON_STATUS_FILTER` - 대기/진행/완료
- `WORK_STATUS_FILTER` - 작업대기/진행중/작업완료
- `COMMON_PRIORITY_FILTER` - 긴급/우선/일반
- `ITEM_TYPE_FILTER` - 품목유형
- `createSingleFilter()`, `createMultiFilter()` - 커스텀 필터 생성
**status-config.ts**:
- `getPriorityLabel()`, `getPriorityStyle()` - 우선순위 (한글/영문 모두 지원)
- `getItemTypeLabel()`, `getItemTypeStyle()` - 품목유형
- `COMMON_STATUS_CONFIG`, `WORK_STATUS_CONFIG` 등 - 미리 정의된 상태 설정
### 샘플 마이그레이션 결과 (WorkOrderList.tsx)
**Before**:
```tsx
// 개별 정의
const PRIORITY_COLORS: Record<string, string> = {
'긴급': 'bg-red-100 text-red-700',
'우선': 'bg-orange-100 text-orange-700',
'일반': 'bg-gray-100 text-gray-700',
};
const filterConfig: FilterFieldConfig[] = [
{ key: 'status', label: '상태', type: 'single', options: [...] },
{ key: 'priority', label: '우선순위', type: 'single', options: [...] },
];
<Badge className={`${PRIORITY_COLORS[item.priorityLabel]} border-0`}>
```
**After**:
```tsx
// 공통 유틸 사용
import { WORK_STATUS_FILTER, COMMON_PRIORITY_FILTER } from '@/lib/constants/filter-presets';
import { getPriorityStyle } from '@/lib/utils/status-config';
const filterConfig = [WORK_STATUS_FILTER, COMMON_PRIORITY_FILTER];
<Badge className={`${getPriorityStyle(item.priorityLabel)} border-0`}>
```
**효과**:
- 코드 라인 20줄 → 3줄
- 필터 옵션 중복 정의 제거
- 색상 일관성 보장
---
## 🔄 추가 마이그레이션 (2026-02-05 업데이트)
### 완료된 마이그레이션
| 파일 | 적용 내용 | 효과 |
|------|----------|------|
| `WorkOrderList.tsx` | WORK_STATUS_FILTER + COMMON_PRIORITY_FILTER + getPriorityStyle | 20줄 → 3줄 |
| `ItemListClient.tsx` | getItemTypeStyle (품목유형 Badge) | 17줄 → 4줄 |
| `ItemDetailClient.tsx` | getItemTypeStyle (품목유형 Badge) | 17줄 → 4줄 |
### 마이그레이션 제외 대상 (도메인 특화 설정)
| 파일 | 제외 사유 |
|------|----------|
| `PricingListClient.tsx` | 다른 색상 체계 (SM=cyan, BENDING 추가 타입) |
| `StockStatus/types.ts` | 레거시 타입 지원 (raw_material, bent_part 등) |
| `ShipmentManagement/types.ts` | 다른 우선순위 라벨 (보통/낮음) |
| `issue-management/types.ts` | 2단계 우선순위 (긴급/일반만) |
| `WipProductionModal.tsx` | 버튼 스타일 우선순위 (Badge 아님) |
| `ReceivingList.tsx` | 도메인 특화 상태 (입고대기/입고완료/검사완료) |
| HR 페이지들 | 도메인 특화 상태 설정 |
| 건설 도메인 페이지들 | 도메인 특화 상태 설정 |
### 분석 결과 요약
1. **공통 유틸 적용 완료 페이지**: 3개 (WorkOrderList, ItemListClient, ItemDetailClient)
2. **도메인 특화 설정 페이지**: 34개 (개별 유지가 적절)
3. **결론**: 대부분의 페이지는 도메인별 특화된 상태/라벨/색상을 사용하며, 이는 비즈니스 로직을 명확히 반영하기 위해 의도된 설계
### 공통 유틸 권장 사용 시나리오
1. **신규 리스트 페이지 생성 시**: 표준 패턴(대기/진행/완료, 긴급/우선/일반) 사용
2. **품목유형 Badge**: 일관된 색상 적용 필요 시 `getItemTypeStyle` 사용
3. **우선순위 Badge**: 표준 3단계(긴급/우선/일반) 사용 시 `getPriorityStyle` 사용
---
## 🎨 getPresetStyle 마이그레이션 완료 (2026-02-05 최종)
### 마이그레이션 완료 파일 (22개)
| 파일 | 적용 내용 |
|------|----------|
| `orders/OrderRegistration.tsx` | success, info preset |
| `pricing-distribution/PriceDistributionDetail.tsx` | success preset |
| `pricing/PricingFormClient.tsx` | purple, info, success preset |
| `quality/InspectionManagement/InspectionList.tsx` | success, destructive preset |
| `quality/InspectionManagement/InspectionCreate.tsx` | success, destructive preset |
| `quality/InspectionManagement/InspectionDetail.tsx` | success, destructive preset |
| `accounting/PurchaseManagement/index.tsx` | info preset |
| `accounting/PurchaseManagement/PurchaseDetail.tsx` | orange preset (기존) |
| `accounting/PurchaseManagement/PurchaseDetailModal.tsx` | orange preset (기존) |
| `accounting/VendorManagement/CreditAnalysisModal/CreditAnalysisDocument.tsx` | info preset |
| `quotes/QuoteRegistration.tsx` | success preset |
| `pricing/PricingHistoryDialog.tsx` | info preset |
| `business/construction/management/KanbanColumn.tsx` | info preset |
| `business/construction/management/DetailCard.tsx` | warning preset |
| `business/construction/management/StageCard.tsx` | warning preset |
| `business/construction/management/ProjectCard.tsx` | info preset |
| `production/WorkerScreen/WorkCard.tsx` | success, destructive preset |
| `production/WorkerScreen/ProcessDetailSection.tsx` | warning preset |
| `production/ProductionDashboard/index.tsx` | orange, success preset (기존) |
| `items/ItemForm/BOMSection.tsx` | info preset (기존) |
| `items/DynamicItemForm/sections/DynamicBOMSection.tsx` | info preset (기존) |
| `items/ItemMasterDataManagement/tabs/MasterFieldTab/index.tsx` | info preset |
| `customer-center/InquiryManagement/InquiryList.tsx` | warning, success preset (기존) |
| `hr/EmployeeManagement/CSVUploadDialog.tsx` | success, destructive preset (기존) |
### 마이그레이션 제외 파일 (유지)
| 파일 | 제외 사유 |
|------|----------|
| `business/MainDashboard.tsx` | CEO 대시보드 - 다양한 데이터 시각화용 고유 색상 (achievement %, overdue days 등) |
| `pricing/PricingListClient.tsx` | 도메인 특화 색상 체계 (SM=cyan, BENDING type 등) |
| `business/CEODashboard/sections/TodayIssueSection.tsx` | 알림 유형별 고유 색상+아이콘 (notification_type 기반) |
| `dev/DevToolbar.tsx` | 개발 도구 (운영 무관) |
| `ui/status-badge.tsx` | 이미 status-config.ts 사용 중 |
| `items/ItemDetailClient.tsx` | getItemTypeStyle 사용 (도메인 특화) |
| `items/ItemListClient.tsx` | getItemTypeStyle 사용 (도메인 특화) |
### 사용된 Preset 유형 통계
| Preset | 사용 횟수 | 용도 |
|--------|----------|------|
| `success` | 15+ | 완료, 일치, 활성, 긍정적 상태 |
| `info` | 10+ | 정보성 라벨, 진행 상태, 문서 타입 |
| `warning` | 6+ | 진행중, 주의 필요, 선행 생산 |
| `destructive` | 5+ | 오류, 불일치, 긴급 |
| `orange` | 3+ | 품의서/지출결의서, 지연 |
| `purple` | 2+ | 최종 확정, 특수 상태 |
### 마이그레이션 효과
1. **코드 일관성**: 22개 파일에서 동일한 유틸리티 함수 사용
2. **유지보수성**: 색상 변경 시 `status-config.ts` 한 곳만 수정
3. **가독성 향상**: `getPresetStyle('success')` vs `bg-green-100 text-green-700 border-green-200`
4. **타입 안전성**: TypeScript로 프리셋 이름 자동완성

View File

@@ -0,0 +1,304 @@
# 상세 페이지 훅 마이그레이션 계획서
> 작성일: 2026-02-05
> 상태: 계획 수립
---
## 1. 개요
### 1.1 목적
- 상세/등록/수정 페이지의 반복 코드를 공통 훅으로 통합
- 코드 일관성 확보 및 유지보수성 향상
- 서비스 런칭 전 기술 부채 최소화
### 1.2 생성된 공통 훅
| 훅 | 위치 | 역할 |
|----|------|------|
| `useDetailPageState` | `src/hooks/useDetailPageState.ts` | 페이지 상태 관리 (mode, id, navigation) |
| `useDetailData` | `src/hooks/useDetailData.ts` | 데이터 로딩 + 로딩/에러 상태 |
| `useCRUDHandlers` | `src/hooks/useCRUDHandlers.ts` | 등록/수정/삭제 + toast/redirect |
| `useDetailPermissions` | `src/hooks/useDetailPermissions.ts` | 권한 체크 |
### 1.3 테스트 완료
- [x] `BillDetail.tsx``BillDetailV2.tsx` 마이그레이션 성공
- [x] 조회/수정/등록 모드 정상 작동 확인
- [x] 유효성 검사 정상 작동 확인
---
## 2. 마이그레이션 대상
### 2.1 전체 현황
| 구분 | 개수 | 비고 |
|------|------|------|
| IntegratedDetailTemplate 사용 | 47개 | 훅 마이그레이션 대상 |
| 레거시/커스텀 패턴 | 36개 | 별도 검토 (이번 범위 외) |
| **총계** | 83개 | |
### 2.2 복잡도별 분류
| 복잡도 | 기준 | 개수 |
|--------|------|------|
| 단순 | < 200줄, useState 3~4개 | 12개 |
| 보통 | 200~500줄, useState 5~7개 | 18개 |
| 복잡 | > 500줄, useState 8~11개 | 17개 |
---
## 3. 도메인별 대상 목록
### 3.1 회계관리 (10개)
| # | 파일 | 라인 | 복잡도 | 상태 |
|---|------|------|--------|------|
| 1 | `accounting/BadDebtCollection/BadDebtDetail.tsx` | 966 | 복잡 | ⬜ |
| 2 | `accounting/BillManagement/BillDetail.tsx` | 474 | 보통 | ✅ 완료 |
| 3 | `accounting/CardTransactionInquiry/CardTransactionDetailClient.tsx` | 138 | 단순 | ⬜ |
| 4 | `accounting/DepositManagement/DepositDetailClientV2.tsx` | 143 | 단순 | ⬜ |
| 5 | `accounting/PurchaseManagement/PurchaseDetail.tsx` | 698 | 복잡 | ⬜ |
| 6 | `accounting/SalesManagement/SalesDetail.tsx` | 581 | 복잡 | ⬜ |
| 7 | `accounting/VendorLedger/VendorLedgerDetail.tsx` | 385 | 보통 | ⬜ |
| 8 | `accounting/VendorManagement/VendorDetail.tsx` | 683 | 복잡 | ⬜ |
| 9 | `accounting/VendorManagement/VendorDetailClient.tsx` | 585 | 복잡 | ⬜ |
| 10 | `accounting/WithdrawalManagement/WithdrawalDetail.tsx` | 327 | 보통 | ⬜ |
### 3.2 건설관리 (13개)
| # | 파일 | 라인 | 복잡도 | 상태 |
|---|------|------|--------|------|
| 1 | `construction/bidding/BiddingDetailForm.tsx` | 544 | 복잡 | ⬜ |
| 2 | `construction/contract/ContractDetailForm.tsx` | 546 | 복잡 | ⬜ |
| 3 | `construction/estimates/EstimateDetailForm.tsx` | 763 | 복잡 | ⬜ |
| 4 | `construction/handover-report/HandoverReportDetailForm.tsx` | 699 | 복잡 | ⬜ |
| 5 | `construction/issue-management/IssueDetailForm.tsx` | 627 | 복잡 | ⬜ |
| 6 | `construction/item-management/ItemDetailClient.tsx` | 486 | 보통 | ⬜ |
| 7 | `construction/labor-management/LaborDetailClientV2.tsx` | 120 | 단순 | ⬜ |
| 8 | `construction/management/ConstructionDetailClient.tsx` | 739 | 복잡 | ⬜ |
| 9 | `construction/order-management/OrderDetailForm.tsx` | 275 | 보통 | ⬜ |
| 10 | `construction/pricing-management/PricingDetailClientV2.tsx` | 134 | 단순 | ⬜ |
| 11 | `construction/progress-billing/ProgressBillingDetailForm.tsx` | 193 | 단순 | ⬜ |
| 12 | `construction/site-management/SiteDetailForm.tsx` | 385 | 보통 | ⬜ |
| 13 | `construction/structure-review/StructureReviewDetailForm.tsx` | 392 | 보통 | ⬜ |
### 3.3 기타 도메인 (24개)
#### 고객센터 (3개)
| # | 파일 | 라인 | 복잡도 | 상태 |
|---|------|------|--------|------|
| 1 | `customer-center/EventManagement/EventDetail.tsx` | 101 | 단순 | ⬜ |
| 2 | `customer-center/InquiryManagement/InquiryDetail.tsx` | 357 | 보통 | ⬜ |
| 3 | `customer-center/NoticeManagement/NoticeDetail.tsx` | 101 | 단순 | ⬜ |
#### 인사관리 (1개)
| # | 파일 | 라인 | 복잡도 | 상태 |
|---|------|------|--------|------|
| 1 | `hr/EmployeeManagement/EmployeeDetail.tsx` | 221 | 단순 | ⬜ |
#### 자재관리 (2개)
| # | 파일 | 라인 | 복잡도 | 상태 |
|---|------|------|--------|------|
| 1 | `material/ReceivingManagement/ReceivingDetail.tsx` | ~350 | 보통 | ⬜ |
| 2 | `material/StockStatus/StockStatusDetail.tsx` | ~300 | 보통 | ⬜ |
#### 주문관리 (2개)
| # | 파일 | 라인 | 복잡도 | 상태 |
|---|------|------|--------|------|
| 1 | `orders/OrderSalesDetailEdit.tsx` | 735 | 복잡 | ⬜ |
| 2 | `orders/OrderSalesDetailView.tsx` | 668 | 복잡 | ⬜ |
#### 출고관리 (2개)
| # | 파일 | 라인 | 복잡도 | 상태 |
|---|------|------|--------|------|
| 1 | `outbound/ShipmentManagement/ShipmentDetail.tsx` | 670 | 복잡 | ⬜ |
| 2 | `outbound/VehicleDispatchManagement/VehicleDispatchDetail.tsx` | 180 | 단순 | ⬜ |
#### 생산관리 (1개)
| # | 파일 | 라인 | 복잡도 | 상태 |
|---|------|------|--------|------|
| 1 | `production/WorkOrders/WorkOrderDetail.tsx` | 531 | 복잡 | ⬜ |
#### 품질관리 (1개)
| # | 파일 | 라인 | 복잡도 | 상태 |
|---|------|------|--------|------|
| 1 | `quality/InspectionManagement/InspectionDetail.tsx` | 949 | 복잡 | ⬜ |
#### 설정 (2개)
| # | 파일 | 라인 | 복잡도 | 상태 |
|---|------|------|--------|------|
| 1 | `settings/PermissionManagement/PermissionDetail.tsx` | 455 | 보통 | ⬜ |
| 2 | `settings/PopupManagement/PopupDetailClientV2.tsx` | 198 | 단순 | ⬜ |
#### 거래처 (1개)
| # | 파일 | 라인 | 복잡도 | 상태 |
|---|------|------|--------|------|
| 1 | `clients/ClientDetailClientV2.tsx` | 252 | 단순 | ⬜ |
#### 기타 (9개)
| # | 파일 | 라인 | 복잡도 | 상태 |
|---|------|------|--------|------|
| 1 | `board/BoardManagement/BoardDetail.tsx` | 119 | 단순 | ⬜ |
| 2 | `process-management/ProcessDetail.tsx` | 346 | 보통 | ⬜ |
| 3 | `process-management/StepDetail.tsx` | 143 | 단순 | ⬜ |
| 4 | `settings/AccountManagement/AccountDetail.tsx` | 355 | 보통 | ⬜ |
| 5 | `accounting/DepositManagement/DepositDetail.tsx` | 327 | 보통 | ⬜ |
| 6 | `clients/ClientDetail.tsx` | 253 | 보통 | ⬜ |
| 7 | `construction/labor-management/LaborDetailClient.tsx` | 471 | 보통 | ⬜ |
| 8 | `construction/pricing-management/PricingDetailClient.tsx` | 464 | 보통 | ⬜ |
| 9 | `quotes/LocationDetailPanel.tsx` | 826 | 복잡 | ⬜ |
---
## 4. 작업 방식
### 4.1 Git 브랜치 전략
```
main
└── feature/detail-hooks-migration
├── 회계관리 커밋
├── 건설관리 커밋
└── 기타 도메인 커밋
```
### 4.2 파일별 작업 순서
1. 파일 읽기 및 현재 패턴 파악
2. `useDetailData` 적용 (데이터 로딩 부분)
3. `useCRUDHandlers` 적용 (CRUD 핸들러 부분)
4. 개별 useState → 통합 formData 객체로 변환 (선택)
5. 기능 테스트
6. 커밋
### 4.3 적용할 변경 패턴
#### Before (기존)
```tsx
const [data, setData] = useState(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
fetchData(id).then(result => {
if (result.success) setData(result.data);
else setError(result.error);
}).finally(() => setIsLoading(false));
}, [id]);
const handleSubmit = async () => {
const result = await updateData(id, formData);
if (result.success) {
toast.success('저장되었습니다.');
router.push('/list');
} else {
toast.error(result.error);
}
};
```
#### After (신규)
```tsx
const { data, isLoading, error } = useDetailData(id, fetchData);
const { handleUpdate, isSubmitting } = useCRUDHandlers({
onUpdate: updateData,
successRedirect: '/list',
successMessages: { update: '저장되었습니다.' },
});
```
---
## 5. 일정 계획
| Phase | 대상 | 파일 수 | 예상 기간 |
|-------|------|---------|----------|
| Phase 1 | 회계관리 | 10개 | 1일 |
| Phase 2 | 건설관리 | 13개 | 1.5일 |
| Phase 3 | 기타 도메인 | 24개 | 2일 |
| Phase 4 | 통합 테스트 | - | 1일 |
| **총계** | | **47개** | **약 5~6일** |
---
## 6. 체크리스트
### 6.1 사전 준비
- [x] 공통 훅 4개 생성 완료
- [x] 테스트 마이그레이션 (BillDetail) 완료
- [x] 계획서 작성
- [ ] 브랜치 생성
### 6.2 Phase 1: 회계관리 (0/10)
- [ ] BadDebtDetail.tsx
- [x] BillDetail.tsx ✅
- [ ] CardTransactionDetailClient.tsx
- [ ] DepositDetailClientV2.tsx
- [ ] PurchaseDetail.tsx
- [ ] SalesDetail.tsx
- [ ] VendorLedgerDetail.tsx
- [ ] VendorDetail.tsx
- [ ] VendorDetailClient.tsx
- [ ] WithdrawalDetail.tsx
### 6.3 Phase 2: 건설관리 (0/13)
- [ ] BiddingDetailForm.tsx
- [ ] ContractDetailForm.tsx
- [ ] EstimateDetailForm.tsx
- [ ] HandoverReportDetailForm.tsx
- [ ] IssueDetailForm.tsx
- [ ] ItemDetailClient.tsx
- [ ] LaborDetailClientV2.tsx
- [ ] ConstructionDetailClient.tsx
- [ ] OrderDetailForm.tsx
- [ ] PricingDetailClientV2.tsx
- [ ] ProgressBillingDetailForm.tsx
- [ ] SiteDetailForm.tsx
- [ ] StructureReviewDetailForm.tsx
### 6.4 Phase 3: 기타 도메인 (0/24)
- [ ] EventDetail.tsx
- [ ] InquiryDetail.tsx
- [ ] NoticeDetail.tsx
- [ ] EmployeeDetail.tsx
- [ ] ReceivingDetail.tsx
- [ ] StockStatusDetail.tsx
- [ ] OrderSalesDetailEdit.tsx
- [ ] OrderSalesDetailView.tsx
- [ ] ShipmentDetail.tsx
- [ ] VehicleDispatchDetail.tsx
- [ ] WorkOrderDetail.tsx
- [ ] InspectionDetail.tsx
- [ ] PermissionDetail.tsx
- [ ] PopupDetailClientV2.tsx
- [ ] ClientDetailClientV2.tsx
- [ ] BoardDetail.tsx
- [ ] ProcessDetail.tsx
- [ ] StepDetail.tsx
- [ ] AccountDetail.tsx
- [ ] DepositDetail.tsx
- [ ] ClientDetail.tsx
- [ ] LaborDetailClient.tsx
- [ ] PricingDetailClient.tsx
- [ ] LocationDetailPanel.tsx
### 6.5 완료 후
- [ ] 전체 기능 테스트
- [ ] 코드 리뷰
- [ ] PR 머지
- [ ] BillDetailV2.tsx 정리 (원본으로 교체)
---
## 7. 위험 요소 및 대응
| 위험 | 가능성 | 대응 |
|------|--------|------|
| 기존 기능 손상 | 중 | 파일별 테스트, Git 롤백 준비 |
| 예상보다 복잡한 파일 | 중 | 복잡한 파일은 부분 적용 허용 |
| 타입 에러 | 높 | 래퍼 함수로 타입 호환성 확보 |
---
## 8. 참고 자료
- 공통 훅 소스: `src/hooks/index.ts`
- 테스트 케이스: `BillDetailV2.tsx`
- 기존 템플릿: `IntegratedDetailTemplate.tsx`

View File

@@ -0,0 +1,276 @@
# Badge 공통화 가이드
> 작성일: 2026-02-05
> 목적: 리스트 페이지에서 Badge 스타일을 공통 유틸로 통일하는 방법 안내
---
## 📦 공통 유틸 위치
```
src/lib/utils/status-config.ts ← Badge 스타일 및 상태 설정
src/lib/constants/filter-presets.ts ← 필터 프리셋
```
---
## 🎨 1. 프리셋 스타일 사용하기
### 사용 가능한 프리셋
| 프리셋 | 스타일 | 용도 |
|--------|--------|------|
| `default` | `bg-gray-100 text-gray-800` | 기본/일반 |
| `success` | `bg-green-100 text-green-800` | 완료/성공/활성 |
| `warning` | `bg-yellow-100 text-yellow-800` | 대기/주의 |
| `destructive` | `bg-red-100 text-red-800` | 오류/반려/긴급 |
| `info` | `bg-blue-100 text-blue-800` | 진행중/정보 |
| `muted` | `bg-gray-100 text-gray-500` | 비활성/무효 |
| `orange` | `bg-orange-100 text-orange-800` | 우선/경고 |
| `purple` | `bg-purple-100 text-purple-800` | 제품/특수 |
### 기본 사용법
```tsx
import { getPresetStyle } from '@/lib/utils/status-config';
// 프리셋 스타일 가져오기
const style = getPresetStyle('success'); // "bg-green-100 text-green-800"
// Badge에 적용
<Badge className={getPresetStyle('warning')}>대기</Badge>
```
---
## 🏷️ 2. 우선순위 Badge
### 공통 유틸 사용
```tsx
import { getPriorityLabel, getPriorityStyle } from '@/lib/utils/status-config';
// 우선순위 Badge 렌더링
function PriorityBadge({ priority }: { priority: string }) {
return (
<Badge variant="outline" className={getPriorityStyle(priority)}>
{getPriorityLabel(priority)}
</Badge>
);
}
// 사용
<PriorityBadge priority="urgent" /> // 긴급 (빨간색)
<PriorityBadge priority="priority" /> // 우선 (주황색)
<PriorityBadge priority="normal" /> // 일반 (회색)
```
### Before (개별 정의) ❌
```tsx
// 각 페이지마다 반복되던 코드
const PRIORITY_COLORS: Record<string, string> = {
'긴급': 'bg-red-100 text-red-700',
'우선': 'bg-orange-100 text-orange-700',
'일반': 'bg-gray-100 text-gray-700',
};
```
### After (공통 유틸) ✅
```tsx
import { getPriorityLabel, getPriorityStyle } from '@/lib/utils/status-config';
<Badge className={getPriorityStyle(item.priority)}>
{getPriorityLabel(item.priority)}
</Badge>
```
---
## 📦 3. 품목 유형 Badge
### 공통 유틸 사용
```tsx
import { getItemTypeLabel, getItemTypeStyle } from '@/lib/utils/status-config';
// 품목 유형 Badge 렌더링
function ItemTypeBadge({ itemType }: { itemType: string }) {
return (
<Badge variant="outline" className={getItemTypeStyle(itemType)}>
{getItemTypeLabel(itemType)}
</Badge>
);
}
// 사용
<ItemTypeBadge itemType="FG" /> // 제품 (보라색)
<ItemTypeBadge itemType="PT" /> // 부품 (주황색)
<ItemTypeBadge itemType="SM" /> // 부자재 (녹색)
<ItemTypeBadge itemType="RM" /> // 원자재 (파란색)
<ItemTypeBadge itemType="CS" /> // 소모품 (회색)
```
---
## 🔧 4. 커스텀 상태 설정 만들기
### createStatusConfig 사용법
```tsx
import { createStatusConfig } from '@/lib/utils/status-config';
// 도메인별 커스텀 상태 정의
const {
STATUS_OPTIONS, // Select 옵션용
STATUS_LABELS, // 라벨 맵
STATUS_STYLES, // 스타일 맵
getStatusLabel, // 라벨 헬퍼
getStatusStyle, // 스타일 헬퍼
} = createStatusConfig({
draft: { label: '임시저장', style: 'muted' },
pending: { label: '승인대기', style: 'warning' },
approved: { label: '승인완료', style: 'success' },
rejected: { label: '반려', style: 'destructive' },
}, { includeAll: true, allLabel: '전체' });
// 사용
<Badge className={getStatusStyle(item.status)}>
{getStatusLabel(item.status)}
</Badge>
// Select 옵션으로도 사용 가능
<Select>
{STATUS_OPTIONS.map(opt => (
<SelectItem key={opt.value} value={opt.value}>{opt.label}</SelectItem>
))}
</Select>
```
---
## ✅ 5. 미리 정의된 공통 설정 사용하기
### 바로 사용 가능한 설정들
```tsx
import {
COMMON_STATUS_CONFIG, // 대기/진행/완료
WORK_STATUS_CONFIG, // 작업대기/진행중/작업완료
APPROVAL_STATUS_CONFIG, // 승인대기/승인완료/반려
ACTIVE_STATUS_CONFIG, // 활성/비활성
SHIPMENT_STATUS_CONFIG, // 출고예정/대기/중/완료
RECEIVING_STATUS_CONFIG, // 입고예정/대기/검사중/완료/반품
} from '@/lib/utils/status-config';
// 사용 예시
<Badge className={COMMON_STATUS_CONFIG.getStatusStyle(item.status)}>
{COMMON_STATUS_CONFIG.getStatusLabel(item.status)}
</Badge>
// 필터 옵션으로 사용
filterConfig: [
{
key: 'status',
label: '상태',
type: 'single',
options: WORK_STATUS_CONFIG.STATUS_OPTIONS.filter(opt => opt.value !== 'all'),
},
],
```
---
## 🔄 6. 마이그레이션 체크리스트
### 기존 코드에서 공통 유틸로 전환하기
1. **우선순위 색상 정의 찾기**
```bash
# 검색
grep -r "PRIORITY_COLORS" src/components/
grep -r "'긴급'" src/components/
```
2. **import 추가**
```tsx
import { getPriorityLabel, getPriorityStyle } from '@/lib/utils/status-config';
```
3. **기존 코드 교체**
```tsx
// Before
const color = PRIORITY_COLORS[item.priority] || '';
<Badge className={color}>{item.priority}</Badge>
// After
<Badge className={getPriorityStyle(item.priority)}>
{getPriorityLabel(item.priority)}
</Badge>
```
4. **기존 상수 정의 삭제**
```tsx
// 삭제
const PRIORITY_COLORS: Record<string, string> = { ... };
```
---
## 📋 7. 필터 프리셋 함께 사용하기
### filter-presets.ts와 연계
```tsx
import { COMMON_PRIORITY_FILTER, WORK_STATUS_FILTER } from '@/lib/constants/filter-presets';
import { getPriorityStyle, WORK_STATUS_CONFIG } from '@/lib/utils/status-config';
// UniversalListPage config
const config: UniversalListConfig<MyItem> = {
// ...
// 필터 설정 (공통 프리셋 사용)
filterConfig: [
WORK_STATUS_FILTER,
COMMON_PRIORITY_FILTER,
],
// 테이블 행에서 Badge 사용 (공통 스타일 사용)
renderTableRow: (item, index, globalIndex, handlers) => (
<TableRow>
{/* ... */}
<TableCell>
<Badge className={WORK_STATUS_CONFIG.getStatusStyle(item.status)}>
{WORK_STATUS_CONFIG.getStatusLabel(item.status)}
</Badge>
</TableCell>
<TableCell>
<Badge className={getPriorityStyle(item.priority)}>
{getPriorityLabel(item.priority)}
</Badge>
</TableCell>
</TableRow>
),
};
```
---
## 📊 변경 효과
| 항목 | Before | After |
|------|--------|-------|
| 우선순위 색상 정의 | 각 페이지 개별 | 1곳 (status-config.ts) |
| 품목유형 색상 정의 | 각 페이지 개별 | 1곳 (status-config.ts) |
| 색상 변경 시 | 모든 파일 수정 | 1개 파일만 수정 |
| 일관성 | 파일마다 다를 수 있음 | 항상 동일 |
| 신규 개발 시 | 기존 코드 복사 필요 | import만 하면 됨 |
---
## 🚨 주의사항
1. **기존 기능 유지**: 마이그레이션 시 동작이 동일한지 확인
2. **점진적 적용**: 한 번에 모든 파일 변경하지 말고, 수정하는 파일만 적용
3. **테스트**: Badge 색상이 기존과 동일하게 표시되는지 확인

View File

@@ -1,7 +1,8 @@
'use client';
import { useParams, useSearchParams } from 'next/navigation';
import { BillDetail } from '@/components/accounting/BillManagement/BillDetail';
// V2 테스트: 새 훅 적용 버전
import { BillDetailV2 as BillDetail } from '@/components/accounting/BillManagement/BillDetailV2';
export default function BillDetailPage() {
const params = useParams();

View File

@@ -1,6 +1,7 @@
'use client';
import { BillDetail } from '@/components/accounting/BillManagement/BillDetail';
// V2: 새 훅 적용 버전
import { BillDetailV2 as BillDetail } from '@/components/accounting/BillManagement/BillDetailV2';
export default function BillNewPage() {
return <BillDetail billId="new" mode="new" />;

View File

@@ -3,7 +3,8 @@
import { useEffect, useState } from 'react';
import { useSearchParams } from 'next/navigation';
import { BillManagementClient } from '@/components/accounting/BillManagement/BillManagementClient';
import { BillDetail } from '@/components/accounting/BillManagement/BillDetail';
// V2 테스트: 새 훅 적용 버전
import { BillDetailV2 as BillDetail } from '@/components/accounting/BillManagement/BillDetailV2';
import { getBills } from '@/components/accounting/BillManagement/actions';
import type { BillRecord } from '@/components/accounting/BillManagement/types';

View File

@@ -122,6 +122,14 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
toast.error('금액을 입력해주세요.');
return { success: false, error: '금액을 입력해주세요.' };
}
if (!issueDate) {
toast.error('발행일을 입력해주세요.');
return { success: false, error: '발행일을 입력해주세요.' };
}
if (!maturityDate) {
toast.error('만기일을 입력해주세요.');
return { success: false, error: '만기일을 입력해주세요.' };
}
// 차수 유효성 검사
for (let i = 0; i < installments.length; i++) {
@@ -290,7 +298,9 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
{/* 발행일 */}
<div className="space-y-2">
<Label htmlFor="issueDate"></Label>
<Label htmlFor="issueDate">
<span className="text-red-500">*</span>
</Label>
<Input
id="issueDate"
type="date"
@@ -302,7 +312,9 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
{/* 만기일 */}
<div className="space-y-2">
<Label htmlFor="maturityDate"></Label>
<Label htmlFor="maturityDate">
<span className="text-red-500">*</span>
</Label>
<Input
id="maturityDate"
type="date"

View File

@@ -0,0 +1,544 @@
'use client';
import { useState, useCallback, useEffect, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import { Plus, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { CurrencyInput } from '@/components/ui/currency-input';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { toast } from 'sonner';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import { billConfig } from './billConfig';
import type { BillRecord, BillType, BillStatus, InstallmentRecord } from './types';
import {
BILL_TYPE_OPTIONS,
getBillStatusOptions,
} from './types';
import { getBill, createBill, updateBill, deleteBill, getClients } from './actions';
// ===== 새 훅 import =====
import { useDetailData, useCRUDHandlers } from '@/hooks';
// ===== Props =====
interface BillDetailProps {
billId: string;
mode: 'view' | 'edit' | 'new';
}
// ===== 거래처 타입 =====
interface ClientOption {
id: string;
name: string;
}
// ===== 폼 데이터 타입 (개별 useState 대신 통합) =====
interface BillFormData {
billNumber: string;
billType: BillType;
vendorId: string;
amount: number;
issueDate: string;
maturityDate: string;
status: BillStatus;
note: string;
installments: InstallmentRecord[];
}
const INITIAL_FORM_DATA: BillFormData = {
billNumber: '',
billType: 'received',
vendorId: '',
amount: 0,
issueDate: '',
maturityDate: '',
status: 'stored',
note: '',
installments: [],
};
export function BillDetailV2({ billId, mode }: BillDetailProps) {
const router = useRouter();
const isViewMode = mode === 'view';
const isNewMode = mode === 'new';
// ===== 거래처 목록 =====
const [clients, setClients] = useState<ClientOption[]>([]);
// ===== 폼 상태 (통합된 단일 state) =====
const [formData, setFormData] = useState<BillFormData>(INITIAL_FORM_DATA);
// ===== 폼 필드 업데이트 헬퍼 =====
const updateField = useCallback(<K extends keyof BillFormData>(
field: K,
value: BillFormData[K]
) => {
setFormData(prev => ({ ...prev, [field]: value }));
}, []);
// ===== 거래처 목록 로드 =====
useEffect(() => {
async function loadClients() {
const result = await getClients();
if (result.success && result.data) {
setClients(result.data.map(c => ({ id: String(c.id), name: c.name })));
}
}
loadClients();
}, []);
// ===== 새 훅: useDetailData로 데이터 로딩 =====
// 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음
const fetchBillWrapper = useCallback(
(id: string | number) => getBill(String(id)),
[]
);
const {
data: billData,
isLoading,
error: loadError,
} = useDetailData<BillRecord>(
billId !== 'new' ? billId : null,
fetchBillWrapper,
{ skip: isNewMode }
);
// ===== 데이터 로드 시 폼에 반영 =====
useEffect(() => {
if (billData) {
setFormData({
billNumber: billData.billNumber,
billType: billData.billType,
vendorId: billData.vendorId,
amount: billData.amount,
issueDate: billData.issueDate,
maturityDate: billData.maturityDate,
status: billData.status,
note: billData.note,
installments: billData.installments,
});
}
}, [billData]);
// ===== 로드 에러 처리 =====
useEffect(() => {
if (loadError) {
toast.error(loadError);
router.push('/ko/accounting/bills');
}
}, [loadError, router]);
// ===== 유효성 검사 함수 =====
const validateForm = useCallback((): { valid: boolean; error?: string } => {
if (!formData.billNumber.trim()) {
return { valid: false, error: '어음번호를 입력해주세요.' };
}
if (!formData.vendorId) {
return { valid: false, error: '거래처를 선택해주세요.' };
}
if (formData.amount <= 0) {
return { valid: false, error: '금액을 입력해주세요.' };
}
if (!formData.issueDate) {
return { valid: false, error: '발행일을 입력해주세요.' };
}
if (!formData.maturityDate) {
return { valid: false, error: '만기일을 입력해주세요.' };
}
// 차수 유효성 검사
for (let i = 0; i < formData.installments.length; i++) {
const inst = formData.installments[i];
if (!inst.date) {
return { valid: false, error: `차수 ${i + 1}번의 일자를 입력해주세요.` };
}
if (inst.amount <= 0) {
return { valid: false, error: `차수 ${i + 1}번의 금액을 입력해주세요.` };
}
}
return { valid: true };
}, [formData]);
// ===== 타입 래퍼: 훅은 string | number를 받지만 actions는 string만 받음 =====
const updateBillWrapper = useCallback(
(id: string | number, data: Partial<BillRecord>) => updateBill(String(id), data),
[]
);
const deleteBillWrapper = useCallback(
(id: string | number) => deleteBill(String(id)),
[]
);
// ===== 새 훅: useCRUDHandlers로 CRUD 처리 =====
const {
handleCreate,
handleUpdate,
handleDelete: crudDelete,
isSubmitting,
isDeleting,
} = useCRUDHandlers<Partial<BillRecord>, Partial<BillRecord>>({
onCreate: createBill,
onUpdate: updateBillWrapper,
onDelete: deleteBillWrapper,
successRedirect: '/ko/accounting/bills',
successMessages: {
create: '어음이 등록되었습니다.',
update: '어음이 수정되었습니다.',
delete: '어음이 삭제되었습니다.',
},
// 수정 성공 시 view 모드로 이동
disableRedirect: !isNewMode,
onSuccess: (action) => {
if (action === 'update') {
router.push(`/ko/accounting/bills/${billId}?mode=view`);
}
},
});
// ===== 저장 핸들러 (유효성 검사 + CRUD 훅 사용) =====
const handleSubmit = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
// 유효성 검사
const validation = validateForm();
if (!validation.valid) {
toast.error(validation.error!);
return { success: false, error: validation.error };
}
const billData: Partial<BillRecord> = {
...formData,
vendorName: clients.find(c => c.id === formData.vendorId)?.name || '',
};
if (isNewMode) {
return handleCreate(billData);
} else {
return handleUpdate(billId, billData);
}
}, [formData, clients, isNewMode, billId, handleCreate, handleUpdate, validateForm]);
// ===== 삭제 핸들러 =====
const handleDelete = useCallback(async (): Promise<{ success: boolean; error?: string }> => {
return crudDelete(billId);
}, [billId, crudDelete]);
// ===== 차수 관리 핸들러 =====
const handleAddInstallment = useCallback(() => {
const newInstallment: InstallmentRecord = {
id: `inst-${Date.now()}`,
date: '',
amount: 0,
note: '',
};
setFormData(prev => ({
...prev,
installments: [...prev.installments, newInstallment],
}));
}, []);
const handleRemoveInstallment = useCallback((id: string) => {
setFormData(prev => ({
...prev,
installments: prev.installments.filter(inst => inst.id !== id),
}));
}, []);
const handleUpdateInstallment = useCallback((
id: string,
field: keyof InstallmentRecord,
value: string | number
) => {
setFormData(prev => ({
...prev,
installments: prev.installments.map(inst =>
inst.id === id ? { ...inst, [field]: value } : inst
),
}));
}, []);
// ===== 상태 옵션 (구분에 따라 변경) =====
const statusOptions = useMemo(
() => getBillStatusOptions(formData.billType),
[formData.billType]
);
// ===== 폼 콘텐츠 렌더링 =====
const renderFormContent = () => (
<>
{/* 기본 정보 섹션 */}
<Card className="mb-6">
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
{/* 어음번호 */}
<div className="space-y-2">
<Label htmlFor="billNumber">
<span className="text-red-500">*</span>
</Label>
<Input
id="billNumber"
value={formData.billNumber}
onChange={(e) => updateField('billNumber', e.target.value)}
placeholder="어음번호를 입력해주세요"
disabled={isViewMode}
/>
</div>
{/* 구분 */}
<div className="space-y-2">
<Label htmlFor="billType">
<span className="text-red-500">*</span>
</Label>
<Select
value={formData.billType}
onValueChange={(v) => updateField('billType', v as BillType)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{BILL_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 거래처 */}
<div className="space-y-2">
<Label htmlFor="vendorId">
<span className="text-red-500">*</span>
</Label>
<Select
value={formData.vendorId}
onValueChange={(v) => updateField('vendorId', v)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{clients.map((client) => (
<SelectItem key={client.id} value={client.id}>
{client.name}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 금액 */}
<div className="space-y-2">
<Label htmlFor="amount">
<span className="text-red-500">*</span>
</Label>
<CurrencyInput
id="amount"
value={formData.amount}
onChange={(value) => updateField('amount', value ?? 0)}
placeholder="금액을 입력해주세요"
disabled={isViewMode}
/>
</div>
{/* 발행일 */}
<div className="space-y-2">
<Label htmlFor="issueDate">
<span className="text-red-500">*</span>
</Label>
<Input
id="issueDate"
type="date"
value={formData.issueDate}
onChange={(e) => updateField('issueDate', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 만기일 */}
<div className="space-y-2">
<Label htmlFor="maturityDate">
<span className="text-red-500">*</span>
</Label>
<Input
id="maturityDate"
type="date"
value={formData.maturityDate}
onChange={(e) => updateField('maturityDate', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 상태 */}
<div className="space-y-2">
<Label htmlFor="status">
<span className="text-red-500">*</span>
</Label>
<Select
value={formData.status}
onValueChange={(v) => updateField('status', v as BillStatus)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{statusOptions.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 비고 */}
<div className="space-y-2">
<Label htmlFor="note"></Label>
<Input
id="note"
value={formData.note}
onChange={(e) => updateField('note', e.target.value)}
placeholder="비고를 입력해주세요"
disabled={isViewMode}
/>
</div>
</div>
</CardContent>
</Card>
{/* 차수 관리 섹션 */}
<Card className="mb-6">
<CardHeader className="flex flex-row items-center justify-between">
<CardTitle className="text-lg flex items-center gap-2">
<span className="text-red-500">*</span>
</CardTitle>
{!isViewMode && (
<Button
variant="outline"
size="sm"
onClick={handleAddInstallment}
className="text-orange-500 border-orange-300 hover:bg-orange-50"
>
<Plus className="h-4 w-4 mr-1" />
</Button>
)}
</CardHeader>
<CardContent>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-[50px]">No</TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
<TableHead></TableHead>
{!isViewMode && <TableHead className="w-[60px]"></TableHead>}
</TableRow>
</TableHeader>
<TableBody>
{formData.installments.length === 0 ? (
<TableRow>
<TableCell colSpan={isViewMode ? 4 : 5} className="text-center text-gray-500 py-8">
</TableCell>
</TableRow>
) : (
formData.installments.map((inst, index) => (
<TableRow key={inst.id}>
<TableCell>{index + 1}</TableCell>
<TableCell>
<Input
type="date"
value={inst.date}
onChange={(e) => handleUpdateInstallment(inst.id, 'date', e.target.value)}
disabled={isViewMode}
className="w-full"
/>
</TableCell>
<TableCell>
<CurrencyInput
value={inst.amount}
onChange={(value) => handleUpdateInstallment(inst.id, 'amount', value ?? 0)}
disabled={isViewMode}
className="w-full"
/>
</TableCell>
<TableCell>
<Input
value={inst.note}
onChange={(e) => handleUpdateInstallment(inst.id, 'note', e.target.value)}
disabled={isViewMode}
className="w-full"
/>
</TableCell>
{!isViewMode && (
<TableCell>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => handleRemoveInstallment(inst.id)}
>
<X className="h-4 w-4" />
</Button>
</TableCell>
)}
</TableRow>
))
)}
</TableBody>
</Table>
</CardContent>
</Card>
</>
);
// ===== 템플릿 모드 및 동적 설정 =====
const templateMode = isNewMode ? 'create' : mode;
const dynamicConfig = {
...billConfig,
title: isViewMode ? '어음 상세' : '어음',
actions: {
...billConfig.actions,
submitLabel: isNewMode ? '등록' : '저장',
},
};
return (
<IntegratedDetailTemplate
config={dynamicConfig}
mode={templateMode}
initialData={{}}
itemId={billId}
isLoading={isLoading || isSubmitting || isDeleting}
onSubmit={handleSubmit}
onDelete={billId && billId !== 'new' ? handleDelete : undefined}
renderView={() => renderFormContent()}
renderForm={() => renderFormContent()}
/>
);
}

View File

@@ -7,6 +7,7 @@ import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
import { QuantityInput } from '@/components/ui/quantity-input';
import { CurrencyInput } from '@/components/ui/currency-input';
import {
@@ -269,7 +270,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
<>
{/* 문서 타입 및 열람 버튼 */}
<div className="flex items-center gap-4 p-3 bg-orange-50 border border-orange-200 rounded-lg">
<Badge variant="outline" className="bg-orange-100 text-orange-800 border-orange-300">
<Badge variant="outline" className={getPresetStyle('orange')}>
{sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'}
</Badge>
<span className="text-sm text-muted-foreground"> </span>

View File

@@ -14,6 +14,7 @@ import { QuantityInput } from '@/components/ui/quantity-input';
import { CurrencyInput } from '@/components/ui/currency-input';
import { Switch } from '@/components/ui/switch';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
import {
Select,
SelectContent,
@@ -180,7 +181,7 @@ export function PurchaseDetailModal({
{/* 근거 문서 */}
{data.sourceDocument && (
<div className="flex items-center gap-4 p-3 bg-muted rounded-lg">
<Badge variant="outline" className="bg-orange-100 text-orange-800">
<Badge variant="outline" className={getPresetStyle('orange')}>
{data.sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'}
</Badge>
<span className="font-medium">{data.sourceDocument.documentNo}</span>

View File

@@ -26,6 +26,7 @@ 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 { getPresetStyle } from '@/lib/utils/status-config';
import { Switch } from '@/components/ui/switch';
import {
Dialog,
@@ -443,7 +444,7 @@ export function PurchaseManagement() {
<TableCell>{item.vendorName}</TableCell>
<TableCell className="text-center">
{item.sourceDocument ? (
<Badge variant="outline" className="text-xs border-blue-300 text-blue-600 bg-blue-50">
<Badge variant="outline" className={`text-xs ${getPresetStyle('info')}`}>
{item.sourceDocument.type === 'proposal' ? '품의서' : '지출결의서'}
</Badge>
) : (

View File

@@ -4,6 +4,7 @@ import { useState } from 'react';
import { AlertTriangle, CheckCircle } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { cn } from '@/lib/utils';
import { CreditSignal } from './CreditSignal';
@@ -55,7 +56,7 @@ export function CreditAnalysisDocument({
<div className="space-y-6">
{/* 기업 정보 */}
<div className="text-center">
<Badge variant="outline" className="mb-2 bg-blue-50 text-blue-600 border-blue-200">
<Badge variant="outline" className={`mb-2 ${getPresetStyle('info')}`}>
</Badge>
<h2 className="text-xl font-bold text-gray-800 mt-2">

View File

@@ -1,6 +1,7 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
import { cn } from '@/lib/utils';
import type { StageDetail, StageCardStatus } from './types';
import { DETAIL_CONFIG } from './types';
@@ -20,7 +21,7 @@ export default function DetailCard({ detail, onClick }: DetailCardProps) {
case 'completed':
return <Badge variant="secondary" className="text-xs"></Badge>;
case 'in_progress':
return <Badge className="text-xs bg-yellow-500"></Badge>;
return <Badge className={`text-xs ${getPresetStyle('warning')}`}></Badge>;
case 'waiting':
return <Badge variant="outline" className="text-xs"></Badge>;
default:

View File

@@ -3,6 +3,7 @@
import { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
interface KanbanColumnProps {
title: string;
@@ -30,7 +31,7 @@ export default function KanbanColumn({
<div className="flex items-center gap-2">
<h3 className="text-sm font-semibold text-foreground">{title}</h3>
{count !== undefined && (
<Badge className="text-xs bg-blue-500 hover:bg-blue-600">{count}</Badge>
<Badge className={`text-xs ${getPresetStyle('info')}`}>{count}</Badge>
)}
</div>
{headerAction}

View File

@@ -1,6 +1,7 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
import { cn } from '@/lib/utils';
import type { ProjectDetail, ProjectStatus } from './types';
@@ -20,7 +21,7 @@ export default function ProjectCard({ project, isSelected, onClick }: ProjectCar
case 'completed':
return <Badge variant="secondary" className="text-xs"></Badge>;
case 'in_progress':
return <Badge className="text-xs bg-blue-500"></Badge>;
return <Badge className={`text-xs ${getPresetStyle('info')}`}></Badge>;
default:
return <Badge variant="outline" className="text-xs">{status}</Badge>;
}

View File

@@ -1,6 +1,7 @@
'use client';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
import { cn } from '@/lib/utils';
import type { Stage, StageCardStatus } from './types';
import { STAGE_LABELS, STAGE_CARD_STATUS_LABELS } from './types';
@@ -18,7 +19,7 @@ export default function StageCard({ stage, isSelected, onClick }: StageCardProps
case 'completed':
return <Badge variant="secondary" className="text-xs"></Badge>;
case 'in_progress':
return <Badge className="text-xs bg-yellow-500"></Badge>;
return <Badge className={`text-xs ${getPresetStyle('warning')}`}></Badge>;
case 'waiting':
return <Badge variant="outline" className="text-xs"></Badge>;
default:

View File

@@ -16,6 +16,7 @@ import { TableCell, TableRow } from '@/components/ui/table';
import { Card, CardContent } from '@/components/ui/card';
import { Checkbox } from '@/components/ui/checkbox';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
import {
UniversalListPage,
type UniversalListConfig,
@@ -55,17 +56,11 @@ export function InquiryList() {
router.push('/ko/customer-center/qna?mode=new');
}, [router]);
// ===== 상태 Badge 색상 =====
// ===== 상태 Badge 색상 (공통 유틸 사용) =====
const getStatusBadge = useCallback((status: InquiryStatus) => {
if (status === 'waiting') {
return (
<Badge variant="secondary" className="bg-yellow-100 text-yellow-700">
{INQUIRY_STATUS_LABELS[status]}
</Badge>
);
}
const style = status === 'waiting' ? getPresetStyle('warning') : getPresetStyle('success');
return (
<Badge variant="secondary" className="bg-green-100 text-green-700">
<Badge variant="secondary" className={style}>
{INQUIRY_STATUS_LABELS[status]}
</Badge>
);

View File

@@ -19,6 +19,7 @@ import {
TableRow,
} from '@/components/ui/table';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
import { FileSpreadsheet, AlertCircle, CheckCircle } from 'lucide-react';
import { FileDropzone } from '@/components/ui/file-dropzone';
import type { Employee, CSVEmployeeRow, CSVValidationResult } from './types';
@@ -211,9 +212,9 @@ export function CSVUploadDialog({
<TableCell className="font-medium">{result.row}</TableCell>
<TableCell>
{result.isValid ? (
<Badge className="bg-green-100 text-green-800"></Badge>
<Badge className={getPresetStyle('success')}></Badge>
) : (
<Badge className="bg-red-100 text-red-800"></Badge>
<Badge className={getPresetStyle('destructive')}></Badge>
)}
</TableCell>
<TableCell>{result.data.name || '-'}</TableCell>

View File

@@ -13,6 +13,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
import { NumberInput } from '@/components/ui/number-input';
import {
Table,
@@ -489,7 +490,7 @@ function BOMLineRow({
<TableCell colSpan={8} className="bg-blue-50 p-4">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-blue-100 text-blue-700">
<Badge variant="outline" className={getPresetStyle('info')}>
</Badge>
</div>

View File

@@ -9,6 +9,7 @@
import { useRouter } from 'next/navigation';
import type { ItemMaster } from '@/types/item';
import { ITEM_TYPE_LABELS, PART_TYPE_LABELS, PART_USAGE_LABELS, PRODUCT_CATEGORY_LABELS } from '@/types/item';
import { getItemTypeStyle } from '@/lib/utils/status-config';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Label } from '@/components/ui/label';
@@ -38,20 +39,11 @@ interface ItemDetailClientProps {
/**
* 품목 유형별 Badge 반환
* - 공통 유틸 getItemTypeStyle 사용
*/
function getItemTypeBadge(itemType: string) {
const badges: Record<string, { className: string }> = {
FG: { className: 'bg-purple-50 text-purple-700 border-purple-200' },
PT: { className: 'bg-orange-50 text-orange-700 border-orange-200' },
SM: { className: 'bg-green-50 text-green-700 border-green-200' },
RM: { className: 'bg-blue-50 text-blue-700 border-blue-200' },
CS: { className: 'bg-gray-50 text-gray-700 border-gray-200' },
};
const config = badges[itemType] || { className: '' };
return (
<Badge variant="outline" className={config.className}>
<Badge variant="outline" className={getItemTypeStyle(itemType)}>
{ITEM_TYPE_LABELS[itemType as keyof typeof ITEM_TYPE_LABELS]}
</Badge>
);

View File

@@ -8,6 +8,7 @@ import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
import { NumberInput } from '@/components/ui/number-input';
import { CurrencyInput } from '@/components/ui/currency-input';
import {
@@ -332,7 +333,7 @@ export default function BOMSection({
<TableCell colSpan={9} className="bg-blue-50 p-4">
<div className="space-y-3">
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-blue-100 text-blue-700">
<Badge variant="outline" className={getPresetStyle('info')}>
</Badge>
</div>

View File

@@ -12,6 +12,7 @@ import { useState, useEffect, useRef, useMemo } from 'react';
import { useRouter } from 'next/navigation';
import type { ItemMaster } from '@/types/item';
import { ITEM_TYPE_LABELS } from '@/types/item';
import { getItemTypeStyle } from '@/lib/utils/status-config';
import { useCommonCodes } from '@/hooks/useCommonCodes';
import { Badge } from '@/components/ui/badge';
import { Checkbox } from '@/components/ui/checkbox';
@@ -46,20 +47,11 @@ function useDebounce<T>(value: T, delay: number): T {
/**
* 품목 유형별 Badge 색상 반환
* - 공통 유틸 getItemTypeStyle 사용
*/
function getItemTypeBadge(itemType: string) {
const badges: Record<string, { variant: 'default' | 'secondary' | 'outline' | 'destructive'; className: string }> = {
FG: { variant: 'default', className: 'bg-purple-100 text-purple-700 border-purple-200' },
PT: { variant: 'default', className: 'bg-orange-100 text-orange-700 border-orange-200' },
SM: { variant: 'default', className: 'bg-green-100 text-green-700 border-green-200' },
RM: { variant: 'default', className: 'bg-blue-100 text-blue-700 border-blue-200' },
CS: { variant: 'default', className: 'bg-gray-100 text-gray-700 border-gray-200' },
};
const config = badges[itemType] || { variant: 'outline' as const, className: '' };
return (
<Badge variant="outline" className={config.className}>
<Badge variant="outline" className={getItemTypeStyle(itemType)}>
{ITEM_TYPE_LABELS[itemType as keyof typeof ITEM_TYPE_LABELS]}
</Badge>
);

View File

@@ -10,6 +10,7 @@ import type { ItemMasterField } from '@/contexts/ItemMasterContext';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
import { Plus, Edit, Trash2 } from 'lucide-react';
// 입력방식 옵션 (ItemMasterDataManagement에서 사용하는 상수)
@@ -95,7 +96,7 @@ export function MasterFieldTab({
<Badge variant="secondary" className="text-xs">{field.category}</Badge>
)}
{field.properties?.attributeType && field.properties.attributeType !== 'custom' && (
<Badge variant="default" className="text-xs bg-blue-500">
<Badge variant="default" className={`text-xs ${getPresetStyle('info')}`}>
{field.properties.attributeType === 'unit' ? '단위 연동' :
field.properties.attributeType === 'material' ? '재질 연동' : '표면처리 연동'}
</Badge>

View File

@@ -3,6 +3,7 @@
import { Badge } from "@/components/ui/badge";
import { BadgeSm } from "@/components/atoms/BadgeSm";
import { LucideIcon } from "lucide-react";
import { BADGE_STYLE_PRESETS, type StatusStylePreset } from "@/lib/utils/status-config";
/**
* 상태 뱃지 컴포넌트

View File

@@ -23,6 +23,7 @@ import { PhoneInput } from "@/components/ui/phone-input";
import { Checkbox } from "@/components/ui/checkbox";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { getPresetStyle } from "@/lib/utils/status-config";
import { Alert, AlertDescription } from "@/components/ui/alert";
import {
Select,
@@ -553,7 +554,7 @@ export function OrderRegistration({
<code className="text-sm font-mono bg-gray-100 px-2 py-0.5 rounded">
{form.selectedQuotation.quoteNumber}
</code>
<Badge variant="outline" className="bg-green-100 text-green-700 border-green-200">
<Badge variant="outline" className={getPresetStyle('success')}>
{form.selectedQuotation.grade} ()
</Badge>
</div>
@@ -907,7 +908,7 @@ export function OrderRegistration({
<div key={group.key} className={cn("border rounded-lg overflow-hidden", fieldErrors.items && "border-red-500")}>
<div className="bg-blue-50 px-4 py-2 border-b flex items-center justify-between">
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-blue-100 text-blue-700 border-blue-300">
<Badge variant="outline" className={getPresetStyle('info')}>
{group.label}
</Badge>
<span className="text-sm text-muted-foreground">

View File

@@ -17,6 +17,7 @@ import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
@@ -429,7 +430,7 @@ export function PriceDistributionDetail({ id, mode: propMode }: Props) {
<TableCell className="text-right font-mono">{item.marginRate}%</TableCell>
<TableCell className="text-right font-mono font-semibold">{formatPrice(item.salesPrice)}</TableCell>
<TableCell>
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
<Badge variant="outline" className={getPresetStyle('success')}>
{item.status}
</Badge>
</TableCell>

View File

@@ -34,6 +34,7 @@ import { Textarea } from '@/components/ui/textarea';
import { CurrencyInput } from '@/components/ui/currency-input';
import { NumberInput } from '@/components/ui/number-input';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
import { Separator } from '@/components/ui/separator';
import {
Select,
@@ -352,19 +353,19 @@ export function PricingFormClient({
{isEditMode && initialData && (
<div className="mb-4 flex gap-2 justify-end">
{initialData.isFinal && (
<Badge className="bg-purple-600">
<Badge className={getPresetStyle('purple')}>
<Lock className="h-3 w-3 mr-1" />
</Badge>
)}
{initialData.currentRevision > 0 && (
<Badge variant="outline" className="bg-blue-50 text-blue-700">
<Badge variant="outline" className={getPresetStyle('info')}>
<History className="h-3 w-3 mr-1" />
{initialData.currentRevision}
</Badge>
)}
{initialData.status === 'active' && !initialData.isFinal && (
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-200">
<Badge variant="outline" className={getPresetStyle('success')}>
</Badge>
)}

View File

@@ -12,6 +12,7 @@ import {
DialogTitle,
} from '@/components/ui/dialog';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
import { Separator } from '@/components/ui/separator';
import { History } from 'lucide-react';
import type { PricingData } from './types';
@@ -50,7 +51,7 @@ export function PricingHistoryDialog({
<div className="border-2 border-blue-200 rounded-lg p-4 bg-blue-50">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Badge className="bg-blue-600"> </Badge>
<Badge className={getPresetStyle('info')}> </Badge>
<span className="font-semibold">
{pricingData.currentRevision}
</span>

View File

@@ -18,6 +18,7 @@ import { StatCardGridSkeleton } from '@/components/ui/skeleton';
import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
import { Button } from '@/components/ui/button';
import { PageLayout } from '@/components/organisms/PageLayout';
import { toast } from 'sonner';
@@ -263,7 +264,7 @@ export default function ProductionDashboard() {
<CardTitle className="flex items-center gap-2 text-base">
<Timer className="h-4 w-4 text-orange-500" />
<Badge className="ml-auto bg-orange-100 text-orange-800 hover:bg-orange-100">
<Badge className={`ml-auto ${getPresetStyle('orange')} hover:bg-orange-100`}>
{stats.delayed}
</Badge>
</CardTitle>
@@ -292,7 +293,7 @@ export default function ProductionDashboard() {
<CardTitle className="flex items-center gap-2 text-base">
<CheckCircle2 className="h-4 w-4 text-green-500" />
<Badge className="ml-auto bg-green-100 text-green-800 hover:bg-green-100">
<Badge className={`ml-auto ${getPresetStyle('success')} hover:bg-green-100`}>
{stats.completed}
</Badge>
</CardTitle>

View File

@@ -27,7 +27,6 @@ import {
type StatCard,
type ListParams,
} from '@/components/templates/UniversalListPage';
import type { FilterFieldConfig } from '@/components/molecules/MobileFilter';
import { ListMobileCard, InfoField } from '@/components/organisms/MobileCard';
import { getWorkOrders, getWorkOrderStats, getProcessOptions } from './actions';
import type { ProcessOption } from './actions';
@@ -40,6 +39,9 @@ import {
} from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { WipProductionModal } from './WipProductionModal';
// 공통 유틸 import
import { WORK_STATUS_FILTER, COMMON_PRIORITY_FILTER } from '@/lib/constants/filter-presets';
import { getPriorityStyle } from '@/lib/utils/status-config';
// 페이지당 항목 수
const ITEMS_PER_PAGE = 20;
@@ -61,36 +63,8 @@ const PROCESS_CODE_TO_TAB: Record<string, string> = {
'BENDING': 'bending',
};
// 우선순위 뱃지 색상
const PRIORITY_COLORS: Record<string, string> = {
'긴급': 'bg-red-100 text-red-700',
'우선': 'bg-orange-100 text-orange-700',
'일반': 'bg-gray-100 text-gray-700',
};
// 필터 설정: 상태 + 우선순위
const filterConfig: FilterFieldConfig[] = [
{
key: 'status',
label: '상태',
type: 'single',
options: [
{ value: 'waiting', label: '작업대기' },
{ value: 'in_progress', label: '진행중' },
{ value: 'completed', label: '작업완료' },
],
},
{
key: 'priority',
label: '우선순위',
type: 'single',
options: [
{ value: 'urgent', label: '긴급' },
{ value: 'priority', label: '우선' },
{ value: 'normal', label: '일반' },
],
},
];
// 필터 설정: 공통 프리셋 사용 (상태 + 우선순위)
const filterConfig = [WORK_STATUS_FILTER, COMMON_PRIORITY_FILTER];
export function WorkOrderList() {
const router = useRouter();
@@ -408,7 +382,7 @@ export function WorkOrderList() {
</Badge>
</TableCell>
<TableCell>
<Badge className={`${PRIORITY_COLORS[item.priorityLabel] || 'bg-gray-100 text-gray-700'} border-0`}>
<Badge className={`${getPriorityStyle(item.priorityLabel)} border-0`}>
{item.priorityLabel}
</Badge>
</TableCell>

View File

@@ -16,6 +16,7 @@ import { useState, useEffect, useCallback } from 'react';
import { ChevronDown, Loader2 } from 'lucide-react';
import { ContentSkeleton } from '@/components/ui/skeleton';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
@@ -366,7 +367,7 @@ function ProcessStepItemCard({ item, index, isCompleted }: ProcessStepItemCardPr
{item.location}
</Badge>
{item.isPriority && (
<Badge className="text-xs bg-yellow-400 hover:bg-yellow-400 text-yellow-900 px-2 py-0.5">
<Badge className={`text-xs ${getPresetStyle('warning')} px-2 py-0.5`}>
</Badge>
)}

View File

@@ -18,6 +18,7 @@ import { useState } from 'react';
import { ChevronDown } from 'lucide-react';
import { Card, CardContent } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
import { Button } from '@/components/ui/button';
import type { WorkOrder } from '../ProductionDashboard/types';
import { STATUS_LABELS } from '../ProductionDashboard/types';
@@ -72,13 +73,13 @@ export function WorkCard({
<div className="flex items-center gap-2">
{/* 순위 뱃지 */}
{order.priority <= 3 && (
<Badge className="bg-emerald-500 hover:bg-emerald-500 text-white text-xs font-medium px-2.5 py-1 rounded">
<Badge className={`${getPresetStyle('success')} text-xs font-medium px-2.5 py-1 rounded`}>
{order.priority}
</Badge>
)}
{/* 긴급 뱃지 */}
{order.isUrgent && (
<Badge className="bg-red-500 hover:bg-red-500 text-white text-xs font-medium px-2.5 py-1 rounded">
<Badge className={`${getPresetStyle('destructive')} text-xs font-medium px-2.5 py-1 rounded`}>
</Badge>
)}

View File

@@ -15,6 +15,7 @@ import { useRouter } from 'next/navigation';
import { Plus, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -185,9 +186,9 @@ export function InspectionCreate() {
<TableCell className="text-center">{item.constructionHeight}</TableCell>
<TableCell className="text-center">
{isSame ? (
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-200"></Badge>
<Badge variant="outline" className={`text-xs ${getPresetStyle('success')}`}></Badge>
) : (
<Badge variant="outline" className="text-xs bg-red-50 text-red-700 border-red-200"></Badge>
<Badge variant="outline" className={`text-xs ${getPresetStyle('destructive')}`}></Badge>
)}
</TableCell>
<TableCell>{item.changeReason || '-'}</TableCell>

View File

@@ -22,6 +22,7 @@ import {
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
@@ -301,9 +302,9 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
<TableCell className="text-center">{item.constructionHeight}</TableCell>
<TableCell className="text-center">
{isSame ? (
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-200"></Badge>
<Badge variant="outline" className={`text-xs ${getPresetStyle('success')}`}></Badge>
) : (
<Badge variant="outline" className="text-xs bg-red-50 text-red-700 border-red-200"></Badge>
<Badge variant="outline" className={`text-xs ${getPresetStyle('destructive')}`}></Badge>
)}
</TableCell>
<TableCell>{item.changeReason || '-'}</TableCell>
@@ -354,9 +355,9 @@ export function InspectionDetail({ id }: InspectionDetailProps) {
<TableCell className="text-center">{item.constructionHeight}</TableCell>
<TableCell className="text-center">
{isSame ? (
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-200"></Badge>
<Badge variant="outline" className={`text-xs ${getPresetStyle('success')}`}></Badge>
) : (
<Badge variant="outline" className="text-xs bg-red-50 text-red-700 border-red-200"></Badge>
<Badge variant="outline" className={`text-xs ${getPresetStyle('destructive')}`}></Badge>
)}
</TableCell>
<TableCell>{item.changeReason || '-'}</TableCell>

View File

@@ -21,6 +21,7 @@ import {
} from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { getPresetStyle } from '@/lib/utils/status-config';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import {
@@ -323,9 +324,9 @@ export function InspectionList() {
<TableCell className="text-center">{item.locationCount}</TableCell>
<TableCell className="text-center">
{item.requiredInfo === '완료' ? (
<Badge variant="outline" className="text-xs bg-green-50 text-green-700 border-green-200"></Badge>
<Badge variant="outline" className={`text-xs ${getPresetStyle('success')}`}></Badge>
) : (
<Badge variant="outline" className="text-xs bg-red-50 text-red-700 border-red-200">{item.requiredInfo}</Badge>
<Badge variant="outline" className={`text-xs ${getPresetStyle('destructive')}`}>{item.requiredInfo}</Badge>
)}
</TableCell>
<TableCell>{item.inspectionPeriod}</TableCell>

View File

@@ -24,6 +24,7 @@ import {
import { Button } from "../ui/button";
import { Card, CardContent, CardHeader, CardTitle } from "../ui/card";
import { Badge } from "../ui/badge";
import { getPresetStyle } from "@/lib/utils/status-config";
import { Alert, AlertDescription } from "../ui/alert";
import {
FileText,
@@ -1110,7 +1111,7 @@ export function QuoteRegistration({
<Calculator className="h-5 w-5 text-green-600" />
</CardTitle>
<Badge variant="default" className="bg-green-600">
<Badge variant="default" className={getPresetStyle('success')}>
{calculatedGrandTotal.toLocaleString()}
</Badge>
</div>
@@ -1125,7 +1126,7 @@ export function QuoteRegistration({
<div key={idx} className="border border-green-200 rounded-lg p-4 bg-white">
<div className="flex items-center justify-between mb-3">
<div className="flex items-center gap-2">
<Badge variant="outline" className="bg-green-100">
<Badge variant="outline" className={getPresetStyle('success')}>
{itemResult.index + 1}
</Badge>
<span className="font-medium">

53
src/hooks/index.ts Normal file
View File

@@ -0,0 +1,53 @@
/**
* SAM ERP 공통 훅 모음
*
* @example
* import { useDetailPageState, useDetailData, useCRUDHandlers } from '@/hooks';
*/
// ===== 상세 페이지 관련 =====
export { useDetailPageState } from './useDetailPageState';
export type {
DetailMode,
UseDetailPageStateOptions,
UseDetailPageStateReturn,
} from './useDetailPageState';
export { useDetailData } from './useDetailData';
export type {
ApiResponse,
FetchFunction,
UseDetailDataOptions,
UseDetailDataReturn,
} from './useDetailData';
export { useCRUDHandlers } from './useCRUDHandlers';
export type {
CRUDResult,
SuccessMessages,
ErrorMessages,
UseCRUDHandlersOptions,
UseCRUDHandlersReturn,
} from './useCRUDHandlers';
export { useDetailPermissions } from './useDetailPermissions';
export type {
DetailPermissionConfig,
UseDetailPermissionsOptions,
UseDetailPermissionsReturn,
} from './useDetailPermissions';
// ===== 기존 훅 =====
export { usePermission } from './usePermission';
export { useAuthGuard } from './useAuthGuard';
export { useUserRole } from './useUserRole';
export { useCommonCodes } from './useCommonCodes';
export { useClientList } from './useClientList';
export { useClientGroupList } from './useClientGroupList';
export { useItemList } from './useItemList';
export { useDaumPostcode } from './useDaumPostcode';
export { useCurrentTime } from './useCurrentTime';
export { useFCM } from './useFCM';
export { useMenuPolling } from './useMenuPolling';
export { useCEODashboard } from './useCEODashboard';
export { useCardManagementModals } from './useCardManagementModals';

View File

@@ -0,0 +1,292 @@
'use client';
import { useCallback, useState } from 'react';
import { useRouter } from 'next/navigation';
import { toast } from 'sonner';
/**
* API 응답 타입 (Server Action 표준 형식)
*/
export interface ApiResponse<T = unknown> {
success: boolean;
data?: T;
error?: string;
}
/**
* CRUD 핸들러 반환 타입
*/
export interface CRUDResult {
success: boolean;
error?: string;
}
/**
* 성공 메시지 설정
*/
export interface SuccessMessages {
create?: string;
update?: string;
delete?: string;
}
/**
* 에러 메시지 설정
*/
export interface ErrorMessages {
create?: string;
update?: string;
delete?: string;
}
export interface UseCRUDHandlersOptions<TCreate = unknown, TUpdate = unknown> {
/** 등록 API 함수 */
onCreate?: (data: TCreate) => Promise<ApiResponse>;
/** 수정 API 함수 */
onUpdate?: (id: string | number, data: TUpdate) => Promise<ApiResponse>;
/** 삭제 API 함수 */
onDelete?: (id: string | number) => Promise<ApiResponse>;
/** 성공 후 이동할 경로 (절대 경로 권장) */
successRedirect?: string;
/** 성공 메시지 (기본값 제공) */
successMessages?: SuccessMessages;
/** 에러 메시지 (기본값 제공) */
errorMessages?: ErrorMessages;
/** 성공 후 콜백 (리다이렉트 전) */
onSuccess?: (action: 'create' | 'update' | 'delete') => void | Promise<void>;
/** 에러 발생 시 콜백 */
onError?: (action: 'create' | 'update' | 'delete', error: string) => void;
/** 리다이렉트 비활성화 (커스텀 처리 시) */
disableRedirect?: boolean;
}
export interface UseCRUDHandlersReturn<TCreate = unknown, TUpdate = unknown> {
/** 등록 핸들러 */
handleCreate: (data: TCreate) => Promise<CRUDResult>;
/** 수정 핸들러 */
handleUpdate: (id: string | number, data: TUpdate) => Promise<CRUDResult>;
/** 삭제 핸들러 */
handleDelete: (id: string | number) => Promise<CRUDResult>;
/** 등록/수정 통합 핸들러 (isCreateMode에 따라 분기) */
handleSubmit: (
data: TCreate | TUpdate,
options: { isCreateMode: boolean; id?: string | number }
) => Promise<CRUDResult>;
/** 처리 중 상태 */
isSubmitting: boolean;
/** 삭제 중 상태 */
isDeleting: boolean;
}
const DEFAULT_SUCCESS_MESSAGES: SuccessMessages = {
create: '등록되었습니다.',
update: '저장되었습니다.',
delete: '삭제되었습니다.',
};
const DEFAULT_ERROR_MESSAGES: ErrorMessages = {
create: '등록에 실패했습니다.',
update: '저장에 실패했습니다.',
delete: '삭제에 실패했습니다.',
};
/**
* CRUD 핸들러 통합 훅
*
* 등록/수정/삭제 로직을 표준화하고 toast 알림, 리다이렉트를 자동 처리합니다.
*
* @example
* ```tsx
* const { handleSubmit, handleDelete, isSubmitting } = useCRUDHandlers({
* onCreate: createBankAccount,
* onUpdate: updateBankAccount,
* onDelete: deleteBankAccount,
* successRedirect: '/ko/settings/accounts',
* successMessages: {
* create: '계좌가 등록되었습니다.',
* update: '계좌가 수정되었습니다.',
* delete: '계좌가 삭제되었습니다.',
* },
* });
*
* // 폼 제출
* const onSubmit = async () => {
* const result = await handleSubmit(formData, { isCreateMode, id });
* // result.success로 추가 처리 가능
* };
*
* // 삭제
* const onDelete = async () => {
* await handleDelete(id);
* };
* ```
*/
export function useCRUDHandlers<TCreate = unknown, TUpdate = unknown>(
options: UseCRUDHandlersOptions<TCreate, TUpdate> = {}
): UseCRUDHandlersReturn<TCreate, TUpdate> {
const {
onCreate,
onUpdate,
onDelete,
successRedirect,
successMessages = {},
errorMessages = {},
onSuccess,
onError,
disableRedirect = false,
} = options;
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [isDeleting, setIsDeleting] = useState(false);
// 메시지 병합
const messages = {
success: { ...DEFAULT_SUCCESS_MESSAGES, ...successMessages },
error: { ...DEFAULT_ERROR_MESSAGES, ...errorMessages },
};
// 리다이렉트 처리
const handleRedirect = useCallback(() => {
if (!disableRedirect && successRedirect) {
router.push(successRedirect);
}
}, [router, successRedirect, disableRedirect]);
// 등록 핸들러
const handleCreate = useCallback(
async (data: TCreate): Promise<CRUDResult> => {
if (!onCreate) {
console.warn('[useCRUDHandlers] onCreate is not defined');
return { success: false, error: 'onCreate is not defined' };
}
setIsSubmitting(true);
try {
const result = await onCreate(data);
if (result.success) {
toast.success(messages.success.create);
await onSuccess?.('create');
handleRedirect();
return { success: true };
} else {
const errorMsg = result.error || messages.error.create!;
toast.error(errorMsg);
onError?.('create', errorMsg);
return { success: false, error: errorMsg };
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : messages.error.create!;
console.error('[useCRUDHandlers] Create error:', err);
toast.error(errorMsg);
onError?.('create', errorMsg);
return { success: false, error: errorMsg };
} finally {
setIsSubmitting(false);
}
},
[onCreate, messages, onSuccess, onError, handleRedirect]
);
// 수정 핸들러
const handleUpdate = useCallback(
async (id: string | number, data: TUpdate): Promise<CRUDResult> => {
if (!onUpdate) {
console.warn('[useCRUDHandlers] onUpdate is not defined');
return { success: false, error: 'onUpdate is not defined' };
}
setIsSubmitting(true);
try {
const result = await onUpdate(id, data);
if (result.success) {
toast.success(messages.success.update);
await onSuccess?.('update');
handleRedirect();
return { success: true };
} else {
const errorMsg = result.error || messages.error.update!;
toast.error(errorMsg);
onError?.('update', errorMsg);
return { success: false, error: errorMsg };
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : messages.error.update!;
console.error('[useCRUDHandlers] Update error:', err);
toast.error(errorMsg);
onError?.('update', errorMsg);
return { success: false, error: errorMsg };
} finally {
setIsSubmitting(false);
}
},
[onUpdate, messages, onSuccess, onError, handleRedirect]
);
// 삭제 핸들러
const handleDelete = useCallback(
async (id: string | number): Promise<CRUDResult> => {
if (!onDelete) {
console.warn('[useCRUDHandlers] onDelete is not defined');
return { success: false, error: 'onDelete is not defined' };
}
setIsDeleting(true);
try {
const result = await onDelete(id);
if (result.success) {
toast.success(messages.success.delete);
await onSuccess?.('delete');
handleRedirect();
return { success: true };
} else {
const errorMsg = result.error || messages.error.delete!;
toast.error(errorMsg);
onError?.('delete', errorMsg);
return { success: false, error: errorMsg };
}
} catch (err) {
const errorMsg = err instanceof Error ? err.message : messages.error.delete!;
console.error('[useCRUDHandlers] Delete error:', err);
toast.error(errorMsg);
onError?.('delete', errorMsg);
return { success: false, error: errorMsg };
} finally {
setIsDeleting(false);
}
},
[onDelete, messages, onSuccess, onError, handleRedirect]
);
// 등록/수정 통합 핸들러
const handleSubmit = useCallback(
async (
data: TCreate | TUpdate,
{ isCreateMode, id }: { isCreateMode: boolean; id?: string | number }
): Promise<CRUDResult> => {
if (isCreateMode) {
return handleCreate(data as TCreate);
} else {
if (!id) {
const errorMsg = 'ID is required for update';
toast.error(errorMsg);
return { success: false, error: errorMsg };
}
return handleUpdate(id, data as TUpdate);
}
},
[handleCreate, handleUpdate]
);
return {
handleCreate,
handleUpdate,
handleDelete,
handleSubmit,
isSubmitting,
isDeleting,
};
}

129
src/hooks/useDetailData.ts Normal file
View File

@@ -0,0 +1,129 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
/**
* API 응답 타입 (Server Action 표준 형식)
*/
export interface ApiResponse<T> {
success: boolean;
data?: T;
error?: string;
}
/**
* 데이터 fetch 함수 타입
*/
export type FetchFunction<T> = (id: string | number) => Promise<ApiResponse<T>>;
export interface UseDetailDataOptions {
/** fetch 건너뛰기 (등록 모드 등) */
skip?: boolean;
/** ID가 없어도 fetch (선택적) */
allowNullId?: boolean;
}
export interface UseDetailDataReturn<T> {
/** 로드된 데이터 */
data: T | null;
/** 데이터 직접 설정 (폼 리셋 등) */
setData: (data: T | null) => void;
/** 로딩 상태 */
isLoading: boolean;
/** 에러 메시지 */
error: string | null;
/** 에러 설정 */
setError: (error: string | null) => void;
/** 데이터 새로고침 */
refetch: () => Promise<void>;
/** 로드 성공 여부 */
isSuccess: boolean;
/** 로드 실패 여부 */
isError: boolean;
}
/**
* 상세 데이터 로딩 훅
*
* ID 기반으로 데이터를 fetch하고 로딩/에러 상태를 관리합니다.
*
* @example
* ```tsx
* // 기본 사용
* const { data, isLoading, error } = useDetailData(id, getBankAccount);
*
* // 등록 모드에서 skip
* const { data, isLoading } = useDetailData(id, getBankAccount, { skip: isCreateMode });
*
* // 데이터 새로고침
* const { data, refetch } = useDetailData(id, getBankAccount);
* await refetch();
* ```
*/
export function useDetailData<T>(
id: string | number | null | undefined,
fetchFn: FetchFunction<T>,
options: UseDetailDataOptions = {}
): UseDetailDataReturn<T> {
const { skip = false, allowNullId = false } = options;
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState(!skip && (!!id || allowNullId));
const [error, setError] = useState<string | null>(null);
// fetch 함수
const fetchData = useCallback(async () => {
// skip 조건 체크
if (skip) {
setIsLoading(false);
return;
}
// ID 체크 (allowNullId가 false면 ID 필수)
if (!id && !allowNullId) {
setIsLoading(false);
return;
}
setIsLoading(true);
setError(null);
try {
const result = await fetchFn(id as string | number);
if (result.success && result.data) {
setData(result.data);
} else {
setError(result.error || '데이터를 불러오는데 실패했습니다.');
setData(null);
}
} catch (err) {
console.error('[useDetailData] Fetch error:', err);
setError(err instanceof Error ? err.message : '알 수 없는 오류가 발생했습니다.');
setData(null);
} finally {
setIsLoading(false);
}
}, [id, fetchFn, skip, allowNullId]);
// ID 변경 시 자동 fetch
useEffect(() => {
fetchData();
}, [fetchData]);
// refetch 함수 (외부에서 호출 가능)
const refetch = useCallback(async () => {
await fetchData();
}, [fetchData]);
return {
data,
setData,
isLoading,
error,
setError,
refetch,
isSuccess: !isLoading && !error && data !== null,
isError: !isLoading && error !== null,
};
}

View File

@@ -0,0 +1,183 @@
'use client';
import { useState, useCallback, useMemo } from 'react';
import { useRouter, useParams, useSearchParams } from 'next/navigation';
/**
* 상세 페이지 모드 타입
* - view: 조회 모드
* - edit: 수정 모드
* - create: 등록 모드
*/
export type DetailMode = 'view' | 'edit' | 'create';
export interface UseDetailPageStateOptions {
/** 기본 모드 (기본값: 'view') */
defaultMode?: DetailMode;
/** 목록 페이지 경로 (뒤로가기용) */
listPath?: string;
}
export interface UseDetailPageStateReturn<T> {
// ===== 라우터 정보 =====
/** 현재 아이템 ID (params.id) */
id: string | null;
/** 현재 locale */
locale: string;
/** Next.js router */
router: ReturnType<typeof useRouter>;
// ===== 모드 관리 =====
/** 현재 모드 */
mode: DetailMode;
/** 모드 변경 */
setMode: (mode: DetailMode) => void;
/** 조회 모드 여부 */
isViewMode: boolean;
/** 수정 모드 여부 */
isEditMode: boolean;
/** 등록 모드 여부 */
isCreateMode: boolean;
// ===== 데이터 상태 =====
/** 데이터 */
data: T | null;
/** 데이터 설정 */
setData: (data: T | null) => void;
/** 로딩 상태 */
isLoading: boolean;
/** 로딩 상태 설정 */
setIsLoading: (loading: boolean) => void;
/** 에러 메시지 */
error: string | null;
/** 에러 설정 */
setError: (error: string | null) => void;
// ===== 네비게이션 =====
/** 목록으로 이동 */
goToList: () => void;
/** 수정 모드로 전환 (URL도 변경) */
goToEdit: () => void;
/** 조회 모드로 전환 */
goToView: () => void;
}
/**
* 상세 페이지 공통 상태 관리 훅
*
* 등록/수정/상세 페이지에서 반복되는 상태 및 라우터 로직을 통합합니다.
*
* @example
* ```tsx
* // 상세/수정 페이지
* const {
* id, mode, isViewMode, isEditMode,
* data, setData, isLoading, setIsLoading,
* goToList, goToEdit
* } = useDetailPageState<Account>({ listPath: '/settings/accounts' });
*
* // 등록 페이지
* const { mode, isCreateMode, goToList } = useDetailPageState<Account>({
* defaultMode: 'create',
* listPath: '/settings/accounts'
* });
* ```
*/
export function useDetailPageState<T = unknown>(
options: UseDetailPageStateOptions = {}
): UseDetailPageStateReturn<T> {
const { defaultMode = 'view', listPath } = options;
// ===== 라우터 =====
const router = useRouter();
const params = useParams();
const searchParams = useSearchParams();
// ID 추출 (params.id가 string | string[] | undefined일 수 있음)
const id = useMemo(() => {
const rawId = params?.id;
if (Array.isArray(rawId)) return rawId[0] || null;
return rawId || null;
}, [params?.id]);
// locale 추출
const locale = useMemo(() => {
const rawLocale = params?.locale;
if (Array.isArray(rawLocale)) return rawLocale[0] || 'ko';
return rawLocale || 'ko';
}, [params?.locale]);
// ===== 모드 관리 =====
// URL의 ?mode=edit 파라미터 확인
const urlMode = searchParams?.get('mode');
const initialMode = urlMode === 'edit' ? 'edit' : defaultMode;
const [mode, setModeState] = useState<DetailMode>(initialMode);
const setMode = useCallback((newMode: DetailMode) => {
setModeState(newMode);
}, []);
const isViewMode = mode === 'view';
const isEditMode = mode === 'edit';
const isCreateMode = mode === 'create';
// ===== 데이터 상태 =====
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState(!isCreateMode); // 등록 모드는 로딩 불필요
const [error, setError] = useState<string | null>(null);
// ===== 네비게이션 =====
const goToList = useCallback(() => {
if (listPath) {
router.push(`/${locale}${listPath}`);
} else {
router.back();
}
}, [router, locale, listPath]);
const goToEdit = useCallback(() => {
setModeState('edit');
if (id) {
// URL에 mode=edit 추가 (현재 경로 유지)
const currentPath = window.location.pathname;
router.push(`${currentPath}?mode=edit`);
}
}, [router, id]);
const goToView = useCallback(() => {
setModeState('view');
if (id) {
// URL에서 mode 파라미터 제거
const currentPath = window.location.pathname;
router.push(currentPath);
}
}, [router, id]);
return {
// 라우터 정보
id,
locale,
router,
// 모드 관리
mode,
setMode,
isViewMode,
isEditMode,
isCreateMode,
// 데이터 상태
data,
setData,
isLoading,
setIsLoading,
error,
setError,
// 네비게이션
goToList,
goToEdit,
goToView,
};
}

View File

@@ -0,0 +1,114 @@
'use client';
import { useMemo } from 'react';
import { usePermission } from './usePermission';
/**
* 상세 페이지 권한 설정 타입
*/
export interface DetailPermissionConfig {
/** 수정 권한 오버라이드 (boolean 또는 함수) */
canEdit?: boolean | (() => boolean);
/** 삭제 권한 오버라이드 */
canDelete?: boolean | (() => boolean);
/** 등록 권한 오버라이드 */
canCreate?: boolean | (() => boolean);
/** 조회 권한 오버라이드 */
canView?: boolean | (() => boolean);
/** 승인 권한 오버라이드 */
canApprove?: boolean | (() => boolean);
/** 내보내기 권한 오버라이드 */
canExport?: boolean | (() => boolean);
}
export interface UseDetailPermissionsOptions {
/** 권한 설정 (config에서 전달) */
config?: DetailPermissionConfig;
/** 권한 체크할 URL 직접 지정 (다른 메뉴 권한 체크 시) */
overrideUrl?: string;
}
export interface UseDetailPermissionsReturn {
/** 조회 가능 여부 */
canView: boolean;
/** 등록 가능 여부 */
canCreate: boolean;
/** 수정 가능 여부 */
canEdit: boolean;
/** 삭제 가능 여부 */
canDelete: boolean;
/** 승인 가능 여부 */
canApprove: boolean;
/** 내보내기 가능 여부 */
canExport: boolean;
/** 권한 로딩 중 */
isLoading: boolean;
}
/**
* 권한 값 해석 헬퍼
* - boolean: 그대로 반환
* - function: 실행 결과 반환
* - undefined: 기본값 반환
*/
function resolvePermission(
configValue: boolean | (() => boolean) | undefined,
defaultValue: boolean
): boolean {
if (configValue === undefined) {
return defaultValue;
}
if (typeof configValue === 'function') {
return configValue();
}
return configValue;
}
/**
* 상세 페이지 권한 통합 훅
*
* usePermission 훅을 래핑하여 config 기반 권한 오버라이드를 지원합니다.
* IntegratedDetailTemplate과 함께 사용하거나 단독으로 사용 가능합니다.
*
* @example
* ```tsx
* // 기본 사용 (URL 기반 자동 권한)
* const { canEdit, canDelete } = useDetailPermissions();
*
* // config 오버라이드
* const { canEdit, canDelete } = useDetailPermissions({
* config: {
* canEdit: isOwner, // boolean
* canDelete: () => isAdmin, // function
* }
* });
*
* // 다른 메뉴 권한 체크
* const { canApprove } = useDetailPermissions({
* overrideUrl: '/approval/inbox'
* });
* ```
*/
export function useDetailPermissions(
options: UseDetailPermissionsOptions = {}
): UseDetailPermissionsReturn {
const { config, overrideUrl } = options;
// 기본 권한 조회 (URL 기반)
const basePermissions = usePermission(overrideUrl);
// config 오버라이드 적용
const permissions = useMemo(() => {
return {
canView: resolvePermission(config?.canView, basePermissions.canView),
canCreate: resolvePermission(config?.canCreate, basePermissions.canCreate),
canEdit: resolvePermission(config?.canEdit, basePermissions.canUpdate),
canDelete: resolvePermission(config?.canDelete, basePermissions.canDelete),
canApprove: resolvePermission(config?.canApprove, basePermissions.canApprove),
canExport: resolvePermission(config?.canExport, basePermissions.canExport),
isLoading: basePermissions.isLoading,
};
}, [config, basePermissions]);
return permissions;
}

View File

@@ -0,0 +1,286 @@
/**
* 공통 필터 프리셋
*
* 리스트 페이지에서 반복적으로 사용되는 필터 설정을 공통화
* MobileFilter 및 UniversalListPage의 filterConfig에서 사용
*
* @example
* import { COMMON_STATUS_FILTER, COMMON_PRIORITY_FILTER, createStatusFilter } from '@/lib/constants/filter-presets';
*
* const filterConfig = [
* COMMON_STATUS_FILTER,
* COMMON_PRIORITY_FILTER,
* createStatusFilter('approval', '승인상태', APPROVAL_STATUS_OPTIONS),
* ];
*/
import type { FilterFieldConfig, FilterOption } from '@/components/molecules/MobileFilter';
// ============================================================
// 공통 필터 옵션
// ============================================================
/**
* 상태 필터 옵션 (대기/진행/완료)
*/
export const STATUS_OPTIONS: readonly FilterOption[] = [
{ value: 'pending', label: '대기' },
{ value: 'in_progress', label: '진행중' },
{ value: 'completed', label: '완료' },
] as const;
/**
* 작업 상태 필터 옵션 (작업대기/진행중/작업완료)
*/
export const WORK_STATUS_OPTIONS: readonly FilterOption[] = [
{ value: 'waiting', label: '작업대기' },
{ value: 'in_progress', label: '진행중' },
{ value: 'completed', label: '작업완료' },
] as const;
/**
* 우선순위 필터 옵션
*/
export const PRIORITY_OPTIONS: readonly FilterOption[] = [
{ value: 'urgent', label: '긴급' },
{ value: 'priority', label: '우선' },
{ value: 'normal', label: '일반' },
] as const;
/**
* 활성/비활성 상태 옵션
*/
export const ACTIVE_STATUS_OPTIONS: readonly FilterOption[] = [
{ value: 'active', label: '활성' },
{ value: 'inactive', label: '비활성' },
] as const;
/**
* 승인 상태 옵션
*/
export const APPROVAL_STATUS_OPTIONS: readonly FilterOption[] = [
{ value: 'pending', label: '승인대기' },
{ value: 'approved', label: '승인완료' },
{ value: 'rejected', label: '반려' },
] as const;
/**
* 정렬 옵션 (게시판 등)
*/
export const SORT_OPTIONS: readonly FilterOption[] = [
{ value: 'latest', label: '최신순' },
{ value: 'oldest', label: '오래된순' },
{ value: 'views', label: '조회순' },
] as const;
/**
* 품목 유형 옵션
*/
export const ITEM_TYPE_OPTIONS: readonly FilterOption[] = [
{ value: 'FG', label: '제품' },
{ value: 'PT', label: '부품' },
{ value: 'SM', label: '부자재' },
{ value: 'RM', label: '원자재' },
{ value: 'CS', label: '소모품' },
] as const;
/**
* 배송 방식 옵션
*/
export const DELIVERY_METHOD_OPTIONS: readonly FilterOption[] = [
{ value: 'delivery', label: '배송' },
{ value: 'pickup', label: '픽업' },
{ value: 'direct', label: '직접배송' },
] as const;
// ============================================================
// 공통 필터 설정 (FilterFieldConfig)
// ============================================================
/**
* 공통 상태 필터 (대기/진행/완료)
*/
export const COMMON_STATUS_FILTER: FilterFieldConfig = {
key: 'status',
label: '상태',
type: 'single',
options: STATUS_OPTIONS,
allOptionLabel: '전체',
};
/**
* 작업 상태 필터 (작업대기/진행중/작업완료)
*/
export const WORK_STATUS_FILTER: FilterFieldConfig = {
key: 'status',
label: '상태',
type: 'single',
options: WORK_STATUS_OPTIONS,
allOptionLabel: '전체',
};
/**
* 우선순위 필터
*/
export const COMMON_PRIORITY_FILTER: FilterFieldConfig = {
key: 'priority',
label: '우선순위',
type: 'single',
options: PRIORITY_OPTIONS,
allOptionLabel: '전체',
};
/**
* 활성/비활성 필터
*/
export const ACTIVE_STATUS_FILTER: FilterFieldConfig = {
key: 'isActive',
label: '상태',
type: 'single',
options: ACTIVE_STATUS_OPTIONS,
allOptionLabel: '전체',
};
/**
* 승인 상태 필터
*/
export const APPROVAL_STATUS_FILTER: FilterFieldConfig = {
key: 'approvalStatus',
label: '승인상태',
type: 'single',
options: APPROVAL_STATUS_OPTIONS,
allOptionLabel: '전체',
};
/**
* 정렬 필터 (게시판 등)
*/
export const SORT_FILTER: FilterFieldConfig = {
key: 'sort',
label: '정렬',
type: 'single',
options: SORT_OPTIONS,
};
/**
* 품목 유형 필터
*/
export const ITEM_TYPE_FILTER: FilterFieldConfig = {
key: 'itemType',
label: '품목유형',
type: 'single',
options: ITEM_TYPE_OPTIONS,
allOptionLabel: '전체',
};
/**
* 품목 유형 다중선택 필터
*/
export const ITEM_TYPE_MULTI_FILTER: FilterFieldConfig = {
key: 'itemTypes',
label: '품목유형',
type: 'multi',
options: ITEM_TYPE_OPTIONS,
};
/**
* 배송 방식 필터
*/
export const DELIVERY_METHOD_FILTER: FilterFieldConfig = {
key: 'deliveryMethod',
label: '배송방식',
type: 'single',
options: DELIVERY_METHOD_OPTIONS,
allOptionLabel: '전체',
};
// ============================================================
// 필터 생성 유틸리티 함수
// ============================================================
/**
* 커스텀 단일선택 필터 생성
*
* @param key - 필터 키
* @param label - 필터 라벨
* @param options - 옵션 배열
* @param allOptionLabel - '전체' 옵션 라벨 (기본: '전체')
*/
export function createSingleFilter(
key: string,
label: string,
options: readonly FilterOption[],
allOptionLabel = '전체'
): FilterFieldConfig {
return {
key,
label,
type: 'single',
options,
allOptionLabel,
};
}
/**
* 커스텀 다중선택 필터 생성
*
* @param key - 필터 키
* @param label - 필터 라벨
* @param options - 옵션 배열
*/
export function createMultiFilter(
key: string,
label: string,
options: readonly FilterOption[]
): FilterFieldConfig {
return {
key,
label,
type: 'multi',
options,
};
}
/**
* API 응답에서 필터 옵션 생성
*
* @param items - API 응답 아이템 배열
* @param valueKey - value로 사용할 키
* @param labelKey - label로 사용할 키
*/
export function createOptionsFromApi<T extends Record<string, unknown>>(
items: T[],
valueKey: keyof T,
labelKey: keyof T
): FilterOption[] {
return items.map((item) => ({
value: String(item[valueKey]),
label: String(item[labelKey]),
}));
}
// ============================================================
// 초기 필터 값 헬퍼
// ============================================================
/**
* 기본 필터 초기값 생성
*
* @param filters - 필터 설정 배열
* @returns 초기값 객체 (single: 'all', multi: [])
*/
export function createInitialFilterValues(
filters: FilterFieldConfig[]
): Record<string, string | string[]> {
const values: Record<string, string | string[]> = {};
for (const filter of filters) {
if (filter.type === 'single') {
values[filter.key] = 'all';
} else {
values[filter.key] = [];
}
}
return values;
}

View File

@@ -151,4 +151,167 @@ export function getPresetStyle(
return presets[preset] || presets.default;
}
// ============================================================
// 공통 우선순위 설정
// ============================================================
/**
* 우선순위 타입
*/
export type PriorityType = 'urgent' | 'priority' | 'normal';
/**
* 우선순위 라벨
*/
export const PRIORITY_LABELS: Record<PriorityType, string> = {
urgent: '긴급',
priority: '우선',
normal: '일반',
};
/**
* 우선순위 Badge 스타일
*/
export const PRIORITY_BADGE_STYLES: Record<PriorityType, string> = {
urgent: 'bg-red-100 text-red-700 border-red-200',
priority: 'bg-orange-100 text-orange-700 border-orange-200',
normal: 'bg-gray-100 text-gray-700 border-gray-200',
};
/**
* 한글 우선순위 라벨 → 영문 키 매핑
*/
const PRIORITY_LABEL_TO_KEY: Record<string, PriorityType> = {
'긴급': 'urgent',
'우선': 'priority',
'일반': 'normal',
};
/**
* 우선순위 라벨 가져오기
* @param priority - 영문 키('urgent') 또는 한글 라벨('긴급')
*/
export function getPriorityLabel(priority: string): string {
// 이미 한글이면 그대로 반환
if (PRIORITY_LABEL_TO_KEY[priority]) {
return priority;
}
return PRIORITY_LABELS[priority as PriorityType] || priority;
}
/**
* 우선순위 스타일 가져오기
* @param priority - 영문 키('urgent') 또는 한글 라벨('긴급')
*/
export function getPriorityStyle(priority: string): string {
// 한글 라벨이면 영문 키로 변환
const key = PRIORITY_LABEL_TO_KEY[priority] || (priority as PriorityType);
return PRIORITY_BADGE_STYLES[key] || PRIORITY_BADGE_STYLES.normal;
}
// ============================================================
// 공통 품목 유형 설정
// ============================================================
/**
* 품목 유형 타입
*/
export type ItemType = 'FG' | 'PT' | 'SM' | 'RM' | 'CS';
/**
* 품목 유형 라벨
*/
export const ITEM_TYPE_LABELS: Record<ItemType, string> = {
FG: '제품',
PT: '부품',
SM: '부자재',
RM: '원자재',
CS: '소모품',
};
/**
* 품목 유형 Badge 스타일
*/
export const ITEM_TYPE_BADGE_STYLES: Record<ItemType, string> = {
FG: 'bg-purple-100 text-purple-700 border-purple-200',
PT: 'bg-orange-100 text-orange-700 border-orange-200',
SM: 'bg-green-100 text-green-700 border-green-200',
RM: 'bg-blue-100 text-blue-700 border-blue-200',
CS: 'bg-gray-100 text-gray-700 border-gray-200',
};
/**
* 품목 유형 라벨 가져오기
*/
export function getItemTypeLabel(itemType: string): string {
return ITEM_TYPE_LABELS[itemType as ItemType] || itemType;
}
/**
* 품목 유형 스타일 가져오기
*/
export function getItemTypeStyle(itemType: string): string {
return ITEM_TYPE_BADGE_STYLES[itemType as ItemType] || ITEM_TYPE_BADGE_STYLES.CS;
}
// ============================================================
// 공통 상태 설정 (미리 생성된 설정)
// ============================================================
/**
* 기본 상태 설정 (대기/진행/완료)
*/
export const COMMON_STATUS_CONFIG = createStatusConfig({
pending: { label: '대기', style: 'warning' },
in_progress: { label: '진행중', style: 'info' },
completed: { label: '완료', style: 'success' },
}, { includeAll: true });
/**
* 작업 상태 설정 (작업대기/진행중/작업완료)
*/
export const WORK_STATUS_CONFIG = createStatusConfig({
waiting: { label: '작업대기', style: 'warning' },
in_progress: { label: '진행중', style: 'info' },
completed: { label: '작업완료', style: 'success' },
}, { includeAll: true });
/**
* 승인 상태 설정
*/
export const APPROVAL_STATUS_CONFIG = createStatusConfig({
pending: { label: '승인대기', style: 'warning' },
approved: { label: '승인완료', style: 'success' },
rejected: { label: '반려', style: 'destructive' },
}, { includeAll: true });
/**
* 활성/비활성 상태 설정
*/
export const ACTIVE_STATUS_CONFIG = createStatusConfig({
active: { label: '활성', style: 'success' },
inactive: { label: '비활성', style: 'muted' },
}, { includeAll: true });
/**
* 출고 상태 설정
*/
export const SHIPMENT_STATUS_CONFIG = createStatusConfig({
scheduled: { label: '출고예정', style: 'default' },
ready: { label: '출고대기', style: 'warning' },
shipping: { label: '출고중', style: 'info' },
completed: { label: '출고완료', style: 'success' },
}, { includeAll: true });
/**
* 입고 상태 설정
*/
export const RECEIVING_STATUS_CONFIG = createStatusConfig({
scheduled: { label: '입고예정', style: 'default' },
pending: { label: '입고대기', style: 'warning' },
inspecting: { label: '검사중', style: 'info' },
completed: { label: '입고완료', style: 'success' },
rejected: { label: '반품', style: 'destructive' },
}, { includeAll: true });
export default createStatusConfig;