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:
@@ -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로 프리셋 이름 자동완성
|
||||
304
claudedocs/[IMPL-2026-02-05] detail-hooks-migration-plan.md
Normal file
304
claudedocs/[IMPL-2026-02-05] detail-hooks-migration-plan.md
Normal 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`
|
||||
276
claudedocs/guides/badge-commonization-guide.md
Normal file
276
claudedocs/guides/badge-commonization-guide.md
Normal 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 색상이 기존과 동일하게 표시되는지 확인
|
||||
@@ -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();
|
||||
|
||||
@@ -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" />;
|
||||
|
||||
@@ -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';
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
544
src/components/accounting/BillManagement/BillDetailV2.tsx
Normal file
544
src/components/accounting/BillManagement/BillDetailV2.tsx
Normal 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()}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
) : (
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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>;
|
||||
}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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";
|
||||
|
||||
/**
|
||||
* 상태 뱃지 컴포넌트
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
)}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
53
src/hooks/index.ts
Normal 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';
|
||||
292
src/hooks/useCRUDHandlers.ts
Normal file
292
src/hooks/useCRUDHandlers.ts
Normal 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
129
src/hooks/useDetailData.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
183
src/hooks/useDetailPageState.ts
Normal file
183
src/hooks/useDetailPageState.ts
Normal 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,
|
||||
};
|
||||
}
|
||||
114
src/hooks/useDetailPermissions.ts
Normal file
114
src/hooks/useDetailPermissions.ts
Normal 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;
|
||||
}
|
||||
286
src/lib/constants/filter-presets.ts
Normal file
286
src/lib/constants/filter-presets.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user