Merge remote-tracking branch 'origin/master'
# Conflicts: # src/components/accounting/ReceivablesStatus/index.tsx
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# Juil Enterprise Test URLs
|
||||
Last Updated: 2025-12-30
|
||||
Last Updated: 2026-01-05
|
||||
|
||||
### 대시보드
|
||||
| 페이지 | URL | 상태 |
|
||||
@@ -17,7 +17,29 @@ Last Updated: 2025-12-30
|
||||
|---|---|---|
|
||||
| **거래처 관리** | `/ko/juil/project/bidding/partners` | ✅ 완료 |
|
||||
| **현장설명회관리** | `/ko/juil/project/bidding/site-briefings` | ✅ 완료 |
|
||||
| **견적관리** | `/ko/juil/project/bidding/estimates` | 🆕 NEW |
|
||||
| **견적관리** | `/ko/juil/project/bidding/estimates` | ✅ 완료 |
|
||||
| **입찰관리** | `/ko/juil/project/bidding` | ✅ 완료 |
|
||||
|
||||
### 계약관리 (Contract)
|
||||
| 페이지 | URL | 상태 |
|
||||
|---|---|---|
|
||||
| **계약관리** | `/ko/juil/project/contract` | 🆕 NEW |
|
||||
| **인수인계보고서관리** | `/ko/juil/project/contract/handover-report` | 🆕 NEW |
|
||||
|
||||
### 발주관리 (Order)
|
||||
| 페이지 | URL | 상태 |
|
||||
|---|---|---|
|
||||
| **현장관리** | `/ko/juil/order/site-management` | 🆕 NEW |
|
||||
| **구조검토관리** | `/ko/juil/order/structure-review` | 🆕 NEW |
|
||||
| **발주관리** | `/ko/juil/order/order-management` | 🆕 NEW |
|
||||
|
||||
### 기준정보 (Base Info) - 발주관리 하위
|
||||
| 페이지 | URL | 상태 |
|
||||
|---|---|---|
|
||||
| **카테고리관리** | `/ko/juil/order/base-info/categories` | 🆕 NEW |
|
||||
| **품목관리** | `/ko/juil/order/base-info/items` | 🆕 NEW |
|
||||
| **단가관리** | `/ko/juil/order/base-info/pricing` | 🆕 NEW |
|
||||
| **노임관리** | `/ko/juil/order/base-info/labor` | 🆕 NEW |
|
||||
|
||||
## 공사 관리 (Construction)
|
||||
### 인수인계 / 실측 / 발주 / 시공
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# claudedocs 문서 맵
|
||||
|
||||
> 프로젝트 기술 문서 인덱스 (Last Updated: 2025-12-30)
|
||||
> 프로젝트 기술 문서 인덱스 (Last Updated: 2026-01-02)
|
||||
|
||||
## ⭐ 빠른 참조
|
||||
|
||||
@@ -154,7 +154,9 @@ claudedocs/
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `[GUIDE-2025-12-29] vercel-deployment.md` | 🔴 **NEW** - Vercel 배포 가이드 (환경변수, CORS, 테스트 체크리스트) |
|
||||
| `[DESIGN-2026-01-02] document-modal-common-component.md` | 🔴 **NEW** - 문서 모달 공통 컴포넌트 설계 요구사항 (6개 모달 분석, 헤더/결재라인/테이블 조합형) |
|
||||
| `[GUIDE] print-area-utility.md` | 인쇄 모달 printArea 유틸리티 가이드 (8개 모달 적용, print-utils.ts) |
|
||||
| `[GUIDE-2025-12-29] vercel-deployment.md` | Vercel 배포 가이드 (환경변수, CORS, 테스트 체크리스트) |
|
||||
| `[PLAN-2025-12-23] common-component-extraction-plan.md` | 공통 컴포넌트 추출 계획서 (Phase 1-4, 체크리스트 포함, ~1,900줄 절감) |
|
||||
| `[ANALYSIS-2025-12-23] common-component-extraction-candidates.md` | 📋 공통 컴포넌트 추출 후보 분석 (다이얼로그 102개 중복, ~2,370줄 절감 예상) |
|
||||
| `[PLAN-2025-12-19] project-health-improvement.md` | ✅ **Phase 1 완료** - 프로젝트 헬스 개선 계획서 (타입에러 0개, API키 보안, SSR 수정) |
|
||||
@@ -214,6 +216,9 @@ claudedocs/
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `[IMPL-2026-01-05] item-management-checklist.md` | 🔴 **NEW** - 품목관리 구현 체크리스트 (발주관리 > 기준정보 > 품목관리) |
|
||||
| `[IMPL-2026-01-05] category-management-checklist.md` | 🔴 **NEW** - 카테고리관리 구현 체크리스트 (발주관리 > 기준정보) |
|
||||
| `[PLAN-2026-01-05] order-management-implementation.md` | 발주관리 페이지 구현 계획서 (달력+리스트, ScheduleCalendar 공통 컴포넌트) |
|
||||
| `[NEXT-2025-12-30] partner-management-session-context.md` | ⭐ **세션 체크포인트** - 거래처 관리 리스트 완료, 등록/상세/수정 예정 |
|
||||
| `[REF] juil-project-structure.md` | 주일 프로젝트 구조 가이드 (경로, 컴포넌트, 테스트 URL) |
|
||||
|
||||
|
||||
@@ -0,0 +1,538 @@
|
||||
# 품목기준관리 Zustand 리팩토링 설계서
|
||||
|
||||
> **핵심 목표**: 모든 기능을 100% 동일하게 유지하면서, 수정 절차를 간단화
|
||||
|
||||
## 📌 핵심 원칙
|
||||
|
||||
```
|
||||
⚠️ 중요: 모든 품목기준관리 기능을 그대로 가져와야 함
|
||||
⚠️ 중요: 수정 절차 간단화가 핵심 (3방향 동기화 → 1곳 수정)
|
||||
⚠️ 중요: 모든 기능이 정확히 동일하게 작동해야 함
|
||||
```
|
||||
|
||||
## 🔴 최종 검증 기준 (가장 중요!)
|
||||
|
||||
### 페이지 관계도
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ [DB / API] │
|
||||
│ (단일 진실 공급원) │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
↑ ↑ ↓
|
||||
│ │ │
|
||||
┌───────┴───────┐ ┌────────┴────────┐ ┌────────┴────────┐
|
||||
│ 품목기준관리 │ │ 품목기준관리 │ │ 품목관리 │
|
||||
│ 테스트 페이지 │ │ 페이지 (기존) │ │ 페이지 │
|
||||
│ (Zustand) │ │ (Context) │ │ (동적 폼 렌더링) │
|
||||
└───────────────┘ └──────────────────┘ └──────────────────┘
|
||||
[신규] [기존] [최종 사용처]
|
||||
```
|
||||
|
||||
### 검증 시나리오
|
||||
|
||||
```
|
||||
1. 테스트 페이지에서 섹션/필드 수정
|
||||
↓
|
||||
2. API 호출 → DB 저장
|
||||
↓
|
||||
3. 품목기준관리 페이지 (기존)에서 동일하게 표시되어야 함
|
||||
↓
|
||||
4. 품목관리 페이지에서 동적 폼이 변경된 구조로 렌더링되어야 함
|
||||
```
|
||||
|
||||
### 필수 검증 항목
|
||||
|
||||
| # | 검증 항목 | 설명 |
|
||||
|---|----------|------|
|
||||
| 1 | **API 동일성** | 테스트 페이지가 기존 페이지와 동일한 API 엔드포인트 사용 |
|
||||
| 2 | **데이터 동일성** | API 응답/요청 데이터 형식 100% 동일 |
|
||||
| 3 | **기존 페이지 반영** | 테스트 페이지에서 수정 → 기존 품목기준관리 페이지에 반영 |
|
||||
| 4 | **품목관리 반영** | 테스트 페이지에서 수정 → 품목관리 동적 폼에 반영 |
|
||||
|
||||
### 왜 이게 중요한가?
|
||||
|
||||
```
|
||||
테스트 페이지 (Zustand) ──┐
|
||||
├──→ 같은 API ──→ 같은 DB ──→ 품목관리 페이지
|
||||
기존 페이지 (Context) ────┘
|
||||
|
||||
→ 상태 관리 방식만 다르고, API/DB는 공유
|
||||
→ 테스트 페이지에서 수정한 내용이 품목관리 페이지에 그대로 적용되어야 함
|
||||
→ 이것이 성공하면 Zustand 리팩토링이 완전히 검증된 것
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 1. 현재 문제점 분석
|
||||
|
||||
### 1.1 중복 상태 관리 (3방향 동기화)
|
||||
|
||||
현재 `ItemMasterContext.tsx`에서 섹션 수정 시:
|
||||
|
||||
```typescript
|
||||
// updateSection() 함수 내부 (Line 1464-1486)
|
||||
setItemPages(...) // 1. 계층구조 탭
|
||||
setSectionTemplates(...) // 2. 섹션 탭
|
||||
setIndependentSections(...) // 3. 독립 섹션
|
||||
```
|
||||
|
||||
**문제점**:
|
||||
- 같은 데이터를 3곳에서 중복 관리
|
||||
- 한 곳 업데이트 누락 시 데이터 불일치
|
||||
- 모든 CRUD 함수에 동일 패턴 반복
|
||||
- 새 기능 추가 시 3곳 모두 수정 필요
|
||||
|
||||
### 1.2 현재 상태 변수 목록 (16개)
|
||||
|
||||
| # | 상태 변수 | 설명 | 중복 여부 |
|
||||
|---|----------|------|----------|
|
||||
| 1 | `itemMasters` | 품목 마스터 | - |
|
||||
| 2 | `specificationMasters` | 규격 마스터 | - |
|
||||
| 3 | `materialItemNames` | 자재 품목명 | - |
|
||||
| 4 | `itemCategories` | 품목 분류 | - |
|
||||
| 5 | `itemUnits` | 단위 | - |
|
||||
| 6 | `itemMaterials` | 재질 | - |
|
||||
| 7 | `surfaceTreatments` | 표면처리 | - |
|
||||
| 8 | `partTypeOptions` | 부품유형 옵션 | - |
|
||||
| 9 | `partUsageOptions` | 부품용도 옵션 | - |
|
||||
| 10 | `guideRailOptions` | 가이드레일 옵션 | - |
|
||||
| 11 | `sectionTemplates` | 섹션 템플릿 | ⚠️ 중복 |
|
||||
| 12 | `itemMasterFields` | 필드 마스터 | ⚠️ 중복 |
|
||||
| 13 | `itemPages` | 페이지 (섹션/필드 포함) | ⚠️ 중복 |
|
||||
| 14 | `independentSections` | 독립 섹션 | ⚠️ 중복 |
|
||||
| 15 | `independentFields` | 독립 필드 | ⚠️ 중복 |
|
||||
| 16 | `independentBomItems` | 독립 BOM | ⚠️ 중복 |
|
||||
|
||||
**중복 문제가 있는 엔티티**:
|
||||
- **섹션**: `sectionTemplates`, `itemPages.sections`, `independentSections`
|
||||
- **필드**: `itemMasterFields`, `itemPages.sections.fields`, `independentFields`
|
||||
- **BOM**: `itemPages.sections.bom_items`, `independentBomItems`
|
||||
|
||||
---
|
||||
|
||||
## 2. 리팩토링 설계
|
||||
|
||||
### 2.1 정규화된 상태 구조 (Normalized State)
|
||||
|
||||
```typescript
|
||||
// stores/useItemMasterStore.ts
|
||||
interface ItemMasterState {
|
||||
// ===== 정규화된 엔티티 (ID 기반 딕셔너리) =====
|
||||
entities: {
|
||||
pages: Record<number, PageEntity>;
|
||||
sections: Record<number, SectionEntity>;
|
||||
fields: Record<number, FieldEntity>;
|
||||
bomItems: Record<number, BOMItemEntity>;
|
||||
};
|
||||
|
||||
// ===== ID 목록 (순서 관리) =====
|
||||
ids: {
|
||||
pages: number[];
|
||||
independentSections: number[]; // page_id가 null인 섹션
|
||||
independentFields: number[]; // section_id가 null인 필드
|
||||
independentBomItems: number[]; // section_id가 null인 BOM
|
||||
};
|
||||
|
||||
// ===== 참조 데이터 (중복 없음) =====
|
||||
references: {
|
||||
itemMasters: ItemMaster[];
|
||||
specificationMasters: SpecificationMaster[];
|
||||
materialItemNames: MaterialItemName[];
|
||||
itemCategories: ItemCategory[];
|
||||
itemUnits: ItemUnit[];
|
||||
itemMaterials: ItemMaterial[];
|
||||
surfaceTreatments: SurfaceTreatment[];
|
||||
partTypeOptions: PartTypeOption[];
|
||||
partUsageOptions: PartUsageOption[];
|
||||
guideRailOptions: GuideRailOption[];
|
||||
};
|
||||
|
||||
// ===== UI 상태 =====
|
||||
ui: {
|
||||
isLoading: boolean;
|
||||
error: string | null;
|
||||
selectedPageId: number | null;
|
||||
selectedSectionId: number | null;
|
||||
};
|
||||
}
|
||||
```
|
||||
|
||||
### 2.2 엔티티 구조
|
||||
|
||||
```typescript
|
||||
// 페이지 엔티티 (섹션 ID만 참조)
|
||||
interface PageEntity {
|
||||
id: number;
|
||||
page_name: string;
|
||||
item_type: string;
|
||||
description?: string;
|
||||
order_no: number;
|
||||
is_active: boolean;
|
||||
sectionIds: number[]; // 섹션 객체 대신 ID만 저장
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// 섹션 엔티티 (필드/BOM ID만 참조)
|
||||
interface SectionEntity {
|
||||
id: number;
|
||||
title: string;
|
||||
page_id: number | null; // null이면 독립 섹션
|
||||
order_no: number;
|
||||
is_collapsible: boolean;
|
||||
default_open: boolean;
|
||||
fieldIds: number[]; // 필드 ID 목록
|
||||
bomItemIds: number[]; // BOM ID 목록
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// 필드 엔티티
|
||||
interface FieldEntity {
|
||||
id: number;
|
||||
field_key: string;
|
||||
field_name: string;
|
||||
field_type: string;
|
||||
section_id: number | null; // null이면 독립 필드
|
||||
order_no: number;
|
||||
is_required: boolean;
|
||||
options?: any;
|
||||
default_value?: any;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
// BOM 엔티티
|
||||
interface BOMItemEntity {
|
||||
id: number;
|
||||
section_id: number | null; // null이면 독립 BOM
|
||||
child_item_code: string;
|
||||
child_item_name: string;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
order_no: number;
|
||||
created_at?: string;
|
||||
updated_at?: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 2.3 수정 절차 비교
|
||||
|
||||
#### Before (현재): 3방향 동기화
|
||||
|
||||
```typescript
|
||||
const updateSection = async (sectionId, updates) => {
|
||||
const response = await api.update(sectionId, updates);
|
||||
|
||||
// 1. itemPages 업데이트
|
||||
setItemPages(prev => prev.map(page => ({
|
||||
...page,
|
||||
sections: page.sections.map(s => s.id === sectionId ? {...s, ...updates} : s)
|
||||
})));
|
||||
|
||||
// 2. sectionTemplates 업데이트
|
||||
setSectionTemplates(prev => prev.map(t =>
|
||||
t.id === sectionId ? {...t, ...updates} : t
|
||||
));
|
||||
|
||||
// 3. independentSections 업데이트
|
||||
setIndependentSections(prev => prev.map(s =>
|
||||
s.id === sectionId ? {...s, ...updates} : s
|
||||
));
|
||||
};
|
||||
```
|
||||
|
||||
#### After (Zustand): 1곳만 수정
|
||||
|
||||
```typescript
|
||||
const updateSection = async (sectionId, updates) => {
|
||||
const response = await api.update(sectionId, updates);
|
||||
|
||||
// 딱 1곳만 수정하면 끝!
|
||||
set((state) => ({
|
||||
entities: {
|
||||
...state.entities,
|
||||
sections: {
|
||||
...state.entities.sections,
|
||||
[sectionId]: { ...state.entities.sections[sectionId], ...updates }
|
||||
}
|
||||
}
|
||||
}));
|
||||
};
|
||||
```
|
||||
|
||||
### 2.4 파생 상태 (Selectors)
|
||||
|
||||
```typescript
|
||||
// 계층구조 탭용: 페이지 + 섹션 + 필드 조합
|
||||
const usePageWithDetails = (pageId: number) => {
|
||||
return useItemMasterStore((state) => {
|
||||
const page = state.entities.pages[pageId];
|
||||
if (!page) return null;
|
||||
|
||||
return {
|
||||
...page,
|
||||
sections: page.sectionIds.map(sId => {
|
||||
const section = state.entities.sections[sId];
|
||||
return {
|
||||
...section,
|
||||
fields: section.fieldIds.map(fId => state.entities.fields[fId]),
|
||||
bom_items: section.bomItemIds.map(bId => state.entities.bomItems[bId]),
|
||||
};
|
||||
}),
|
||||
};
|
||||
});
|
||||
};
|
||||
|
||||
// 섹션 탭용: 모든 섹션 (페이지 연결 여부 무관)
|
||||
const useAllSections = () => {
|
||||
return useItemMasterStore((state) =>
|
||||
Object.values(state.entities.sections)
|
||||
);
|
||||
};
|
||||
|
||||
// 독립 섹션만
|
||||
const useIndependentSections = () => {
|
||||
return useItemMasterStore((state) =>
|
||||
state.ids.independentSections.map(id => state.entities.sections[id])
|
||||
);
|
||||
};
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 기능 매핑 체크리스트
|
||||
|
||||
### 3.1 페이지 관리
|
||||
|
||||
| 기존 함수 | 새 함수 | 상태 |
|
||||
|----------|--------|------|
|
||||
| `loadItemPages` | `loadPages` | ⬜ |
|
||||
| `addItemPage` | `createPage` | ⬜ |
|
||||
| `updateItemPage` | `updatePage` | ⬜ |
|
||||
| `deleteItemPage` | `deletePage` | ⬜ |
|
||||
|
||||
### 3.2 섹션 관리
|
||||
|
||||
| 기존 함수 | 새 함수 | 상태 |
|
||||
|----------|--------|------|
|
||||
| `loadSectionTemplates` | `loadSections` | ⬜ |
|
||||
| `loadIndependentSections` | (loadSections에 통합) | ⬜ |
|
||||
| `addSectionTemplate` | `createSection` | ⬜ |
|
||||
| `addSectionToPage` | `createSectionInPage` | ⬜ |
|
||||
| `createIndependentSection` | `createSection` (page_id: null) | ⬜ |
|
||||
| `updateSectionTemplate` | `updateSection` | ⬜ |
|
||||
| `updateSection` | `updateSection` | ⬜ |
|
||||
| `deleteSectionTemplate` | `deleteSection` | ⬜ |
|
||||
| `deleteSection` | `deleteSection` | ⬜ |
|
||||
| `linkSectionToPage` | `linkSectionToPage` | ⬜ |
|
||||
| `unlinkSectionFromPage` | `unlinkSectionFromPage` | ⬜ |
|
||||
| `getSectionUsage` | `getSectionUsage` | ⬜ |
|
||||
|
||||
### 3.3 필드 관리
|
||||
|
||||
| 기존 함수 | 새 함수 | 상태 |
|
||||
|----------|--------|------|
|
||||
| `loadItemMasterFields` | `loadFields` | ⬜ |
|
||||
| `loadIndependentFields` | (loadFields에 통합) | ⬜ |
|
||||
| `addItemMasterField` | `createField` | ⬜ |
|
||||
| `addFieldToSection` | `createFieldInSection` | ⬜ |
|
||||
| `createIndependentField` | `createField` (section_id: null) | ⬜ |
|
||||
| `updateItemMasterField` | `updateField` | ⬜ |
|
||||
| `updateField` | `updateField` | ⬜ |
|
||||
| `deleteItemMasterField` | `deleteField` | ⬜ |
|
||||
| `deleteField` | `deleteField` | ⬜ |
|
||||
| `linkFieldToSection` | `linkFieldToSection` | ⬜ |
|
||||
| `unlinkFieldFromSection` | `unlinkFieldFromSection` | ⬜ |
|
||||
| `getFieldUsage` | `getFieldUsage` | ⬜ |
|
||||
|
||||
### 3.4 BOM 관리
|
||||
|
||||
| 기존 함수 | 새 함수 | 상태 |
|
||||
|----------|--------|------|
|
||||
| `loadIndependentBomItems` | `loadBomItems` | ⬜ |
|
||||
| `addBOMItem` | `createBomItem` | ⬜ |
|
||||
| `createIndependentBomItem` | `createBomItem` (section_id: null) | ⬜ |
|
||||
| `updateBOMItem` | `updateBomItem` | ⬜ |
|
||||
| `deleteBOMItem` | `deleteBomItem` | ⬜ |
|
||||
|
||||
### 3.5 참조 데이터 관리
|
||||
|
||||
| 기존 함수 | 새 함수 | 상태 |
|
||||
|----------|--------|------|
|
||||
| `addItemMaster` / `updateItemMaster` / `deleteItemMaster` | `itemMasterActions` | ⬜ |
|
||||
| `addSpecificationMaster` / `updateSpecificationMaster` / `deleteSpecificationMaster` | `specificationActions` | ⬜ |
|
||||
| `addMaterialItemName` / `updateMaterialItemName` / `deleteMaterialItemName` | `materialItemNameActions` | ⬜ |
|
||||
| `addItemCategory` / `updateItemCategory` / `deleteItemCategory` | `categoryActions` | ⬜ |
|
||||
| `addItemUnit` / `updateItemUnit` / `deleteItemUnit` | `unitActions` | ⬜ |
|
||||
| `addItemMaterial` / `updateItemMaterial` / `deleteItemMaterial` | `materialActions` | ⬜ |
|
||||
| `addSurfaceTreatment` / `updateSurfaceTreatment` / `deleteSurfaceTreatment` | `surfaceTreatmentActions` | ⬜ |
|
||||
| `addPartTypeOption` / `updatePartTypeOption` / `deletePartTypeOption` | `partTypeActions` | ⬜ |
|
||||
| `addPartUsageOption` / `updatePartUsageOption` / `deletePartUsageOption` | `partUsageActions` | ⬜ |
|
||||
| `addGuideRailOption` / `updateGuideRailOption` / `deleteGuideRailOption` | `guideRailActions` | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
## 4. 구현 계획
|
||||
|
||||
### Phase 1: 기반 구축 ✅ 완료 (2025-12-20)
|
||||
|
||||
- [x] Zustand, Immer 설치
|
||||
- [x] 테스트 페이지 라우트 생성 (`/items-management-test`)
|
||||
- [x] 기본 스토어 구조 생성 (`useItemMasterStore.ts`)
|
||||
- [x] 타입 정의 (`types.ts`)
|
||||
|
||||
### Phase 2: API 연동 ✅ 완료 (2025-12-20)
|
||||
|
||||
- [x] 기존 API 구조 분석 (`item-master.ts`)
|
||||
- [x] API 응답 → 정규화 상태 변환 함수 (`normalizers.ts`)
|
||||
- [x] 스토어에 `initFromApi()` 함수 구현
|
||||
- [x] 테스트 페이지에서 실제 API 데이터 로드 기능 추가
|
||||
|
||||
**생성된 파일**:
|
||||
- `src/stores/item-master/normalizers.ts` - API 응답 정규화 함수
|
||||
|
||||
**테스트 페이지 기능**:
|
||||
- "실제 API 로드" 버튼 - 백엔드 API에서 실제 데이터 로드
|
||||
- "테스트 데이터 로드" 버튼 - 하드코딩된 테스트 데이터 로드
|
||||
- 데이터 소스 표시 (API/테스트/없음)
|
||||
|
||||
### Phase 3: 핵심 엔티티 구현
|
||||
|
||||
- [x] 페이지 CRUD 구현 (로컬 상태)
|
||||
- [x] 섹션 CRUD 구현 (로컬 상태)
|
||||
- [x] 필드 CRUD 구현 (로컬 상태)
|
||||
- [x] BOM CRUD 구현 (로컬 상태)
|
||||
- [x] link/unlink 기능 구현 (로컬 상태)
|
||||
- [ ] API 연동 CRUD (DB 저장) - **다음 단계**
|
||||
|
||||
### Phase 3: 참조 데이터 구현
|
||||
|
||||
- [ ] 품목 마스터 관리
|
||||
- [ ] 규격 마스터 관리
|
||||
- [ ] 분류/단위/재질 등 옵션 관리
|
||||
|
||||
### Phase 4: 파생 상태 & 셀렉터
|
||||
|
||||
- [ ] 계층구조 뷰용 셀렉터
|
||||
- [ ] 섹션 탭용 셀렉터
|
||||
- [ ] 필드 탭용 셀렉터
|
||||
- [ ] 독립 항목 셀렉터
|
||||
|
||||
### Phase 5: UI 연동
|
||||
|
||||
- [ ] 테스트 페이지 컴포넌트 생성
|
||||
- [ ] 기존 컴포넌트 재사용 (스토어만 교체)
|
||||
- [ ] 동작 검증
|
||||
|
||||
### Phase 6: 검증 & 마이그레이션
|
||||
|
||||
- [ ] 기존 페이지와 1:1 동작 비교
|
||||
- [ ] 엣지 케이스 테스트
|
||||
- [ ] 성능 비교
|
||||
- [ ] 기존 페이지 마이그레이션 결정
|
||||
|
||||
---
|
||||
|
||||
## 5. 파일 구조
|
||||
|
||||
```
|
||||
src/
|
||||
├── stores/
|
||||
│ └── item-master/
|
||||
│ ├── useItemMasterStore.ts # 메인 스토어
|
||||
│ ├── slices/
|
||||
│ │ ├── pageSlice.ts # 페이지 액션
|
||||
│ │ ├── sectionSlice.ts # 섹션 액션
|
||||
│ │ ├── fieldSlice.ts # 필드 액션
|
||||
│ │ ├── bomSlice.ts # BOM 액션
|
||||
│ │ └── referenceSlice.ts # 참조 데이터 액션
|
||||
│ ├── selectors/
|
||||
│ │ ├── pageSelectors.ts # 페이지 파생 상태
|
||||
│ │ ├── sectionSelectors.ts # 섹션 파생 상태
|
||||
│ │ └── fieldSelectors.ts # 필드 파생 상태
|
||||
│ └── types.ts # 타입 정의
|
||||
│
|
||||
├── app/[locale]/(protected)/
|
||||
│ └── items-management-test/
|
||||
│ └── page.tsx # 테스트 페이지
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 6. 테스트 시나리오
|
||||
|
||||
### 6.1 섹션 수정 동기화 테스트
|
||||
|
||||
```
|
||||
시나리오: 섹션 이름 수정
|
||||
1. 계층구조 탭에서 섹션 선택
|
||||
2. 섹션 이름 "기본정보" → "기본 정보" 수정
|
||||
3. 검증:
|
||||
- [ ] 계층구조 탭에 반영
|
||||
- [ ] 섹션 탭에 반영
|
||||
- [ ] 독립 섹션(연결 해제 시) 반영
|
||||
- [ ] API 호출 1회만 발생
|
||||
```
|
||||
|
||||
### 6.2 필드 이동 테스트
|
||||
|
||||
```
|
||||
시나리오: 필드를 다른 섹션으로 이동
|
||||
1. 섹션 A에서 필드 선택
|
||||
2. 섹션 B로 이동 (unlink → link)
|
||||
3. 검증:
|
||||
- [ ] 섹션 A에서 필드 제거
|
||||
- [ ] 섹션 B에 필드 추가
|
||||
- [ ] 계층구조 탭 반영
|
||||
- [ ] 필드 탭에서 section_id 변경
|
||||
```
|
||||
|
||||
### 6.3 독립 → 연결 테스트
|
||||
|
||||
```
|
||||
시나리오: 독립 섹션을 페이지에 연결
|
||||
1. 독립 섹션 선택
|
||||
2. 페이지에 연결 (linkSectionToPage)
|
||||
3. 검증:
|
||||
- [ ] 독립 섹션 목록에서 제거
|
||||
- [ ] 페이지의 섹션 목록에 추가
|
||||
- [ ] 섹션 탭에서 page_id 변경
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 롤백 계획
|
||||
|
||||
문제 발생 시:
|
||||
1. 테스트 페이지 라우트 제거
|
||||
2. 스토어 코드 삭제
|
||||
3. 기존 `ItemMasterContext` 그대로 사용
|
||||
|
||||
**리스크 최소화**:
|
||||
- 기존 코드 수정 없음
|
||||
- 새 코드만 추가
|
||||
- 언제든 롤백 가능
|
||||
|
||||
---
|
||||
|
||||
## 8. 성공 기준
|
||||
|
||||
| 항목 | 기준 |
|
||||
|-----|------|
|
||||
| **기능 동등성** | 기존 모든 기능 100% 동작 |
|
||||
| **동기화** | 1곳 수정으로 모든 뷰 업데이트 |
|
||||
| **코드량** | CRUD 함수 코드 50% 이상 감소 |
|
||||
| **버그** | 데이터 불일치 버그 0건 |
|
||||
| **성능** | 기존 대비 동등 또는 향상 |
|
||||
|
||||
---
|
||||
|
||||
## 변경 이력
|
||||
|
||||
| 날짜 | 작성자 | 내용 |
|
||||
|-----|--------|------|
|
||||
| 2025-12-20 | Claude | 초안 작성 |
|
||||
| 2025-12-20 | Claude | Phase 1 완료 - 기반 구축 |
|
||||
| 2025-12-20 | Claude | Phase 2 완료 - API 연동 (normalizers.ts, initFromApi) |
|
||||
@@ -0,0 +1,344 @@
|
||||
# 품목기준관리 Zustand 리팩토링 - 세션 컨텍스트
|
||||
|
||||
> 다음 세션에서 이 문서를 먼저 읽고 작업 이어가기
|
||||
|
||||
## 🎯 프로젝트 목표
|
||||
|
||||
**핵심 목표:**
|
||||
1. 품목기준관리 100% 동일 기능 구현
|
||||
2. **더 유연한 데이터 관리** (Zustand 정규화 구조)
|
||||
3. **개선된 UX** (Context 3방향 동기화 → Zustand 1곳 수정)
|
||||
|
||||
**접근 방식:**
|
||||
- 기존 컴포넌트 재사용 ❌
|
||||
- 테스트 페이지에서 완전히 새로 구현 ✅
|
||||
- 분리된 상태 유지 → 복구 시나리오 보장
|
||||
|
||||
---
|
||||
|
||||
## 세션 요약 (2025-12-22 - 11차 세션)
|
||||
|
||||
### ✅ 오늘 완료된 작업
|
||||
|
||||
1. **기존 품목기준관리와 상세 기능 비교**
|
||||
- 구현 완료율: 약 72%
|
||||
- 핵심 CRUD 기능 모두 구현 확인
|
||||
|
||||
2. **누락된 핵심 기능 식별**
|
||||
- 🔴 절대경로(absolute_path) 수정 - PathEditDialog
|
||||
- 🔴 페이지 복제 - handleDuplicatePage
|
||||
- 🔴 필드 조건부 표시 - ConditionalDisplayUI
|
||||
- 🟡 칼럼 관리 - ColumnManageDialog
|
||||
- 🟡 섹션/필드 사용 현황 표시
|
||||
|
||||
3. **브랜치 분리 완료**
|
||||
- `feature/item-master-zustand` 브랜치 생성
|
||||
- 29개 파일, 8,248줄 커밋
|
||||
- master와 분리 관리 가능
|
||||
|
||||
---
|
||||
|
||||
## 세션 요약 (2025-12-21 - 10차 세션)
|
||||
|
||||
### ✅ 오늘 완료된 작업
|
||||
|
||||
1. **기존 품목기준관리와 기능 비교 분석**
|
||||
- 기존 페이지의 모든 핵심 기능 구현 확인
|
||||
- 커스텀 탭 관리는 기존 페이지에서도 비활성화(주석 처리)됨
|
||||
- 탭 관리 기능은 로컬 상태만 사용 (백엔드 미연동, 새로고침 시 초기화)
|
||||
|
||||
2. **Phase D-2 (커스텀 탭 관리) 분석 결과**
|
||||
- 기존 페이지의 "탭 관리" 버튼: 주석 처리됨 (미사용)
|
||||
- 속성 하위 탭 관리: 로컬 상태로만 동작 (영속성 없음)
|
||||
- **결론**: 선택적 기능으로 분류, 핵심 기능 구현 완료
|
||||
|
||||
---
|
||||
|
||||
## 세션 요약 (2025-12-21 - 9차 세션)
|
||||
|
||||
### ✅ 완료된 작업
|
||||
|
||||
1. **속성 CRUD API 연동 완료**
|
||||
- `types.ts`: PropertyActions 인터페이스 추가
|
||||
- `useItemMasterStore.ts`: addUnit, updateUnit, deleteUnit, addMaterial, updateMaterial, deleteMaterial, addTreatment, updateTreatment, deleteTreatment 구현
|
||||
- `item-master-api.ts`: UnitOptionRequest/Response 타입 수정 (unit_code, unit_name 사용)
|
||||
|
||||
2. **Import 기능 구현 완료**
|
||||
- `ImportSectionDialog.tsx`: 독립 섹션 목록에서 선택하여 페이지에 연결
|
||||
- `ImportFieldDialog.tsx`: 독립 필드 목록에서 선택하여 섹션에 연결
|
||||
- `dialogs/index.ts`: Import 다이얼로그 export 추가
|
||||
- `HierarchyTab.tsx`: 불러오기 버튼에 Import 다이얼로그 연결
|
||||
|
||||
3. **섹션 복제 API 연동 완료**
|
||||
- `SectionsTab.tsx`: handleCloneSection 함수 구현 (API 연동 + toast 알림)
|
||||
|
||||
4. **타입 수정**
|
||||
- `transformers.ts`: transformUnitOptionResponse 수정 (unit_name, unit_code 사용)
|
||||
- `useFormStructure.ts`: 단위 옵션 매핑 수정 (unit_name, unit_code 사용)
|
||||
|
||||
---
|
||||
|
||||
### ✅ 완료된 Phase
|
||||
|
||||
| Phase | 내용 | 상태 |
|
||||
|-------|------|------|
|
||||
| Phase 1 | Zustand 스토어 기본 구조 | ✅ |
|
||||
| Phase 2 | API 연동 (initFromApi) | ✅ |
|
||||
| Phase 3 | API CRUD 연동 (update 함수들) | ✅ |
|
||||
| Phase A-1 | 계층구조 기본 표시 | ✅ |
|
||||
| Phase A-2 | 드래그앤드롭 순서 변경 | ✅ |
|
||||
| Phase A-3 | 인라인 편집 (페이지/섹션/경로) | ✅ |
|
||||
| Phase B-1 | 페이지 CRUD 다이얼로그 | ✅ |
|
||||
| Phase B-2 | 섹션 CRUD 다이얼로그 | ✅ |
|
||||
| Phase B-3 | 필드 CRUD 다이얼로그 | ✅ |
|
||||
| Phase B-4 | BOM 관리 UI | ✅ |
|
||||
| Phase C-1 | 섹션 탭 구현 (SectionsTab.tsx) | ✅ |
|
||||
| Phase C-2 | 항목 탭 구현 (FieldsTab.tsx) | ✅ |
|
||||
| Phase D-1 | 속성 탭 기본 구조 (PropertiesTab.tsx) | ✅ |
|
||||
| Phase E | Import 기능 (섹션/필드 불러오기) | ✅ |
|
||||
|
||||
### ✅ 현재 상태: 핵심 기능 구현 완료
|
||||
|
||||
**Phase D-2 (커스텀 탭 관리)**: 선택적 기능으로 분류됨
|
||||
- 기존 페이지에서도 "탭 관리" 버튼은 주석 처리 (미사용)
|
||||
- 속성 하위 탭 관리도 로컬 상태로만 동작 (백엔드 미연동)
|
||||
- 필요 시 추후 구현 가능
|
||||
|
||||
---
|
||||
|
||||
## 📋 기능 비교 결과
|
||||
|
||||
### ✅ 구현 완료된 핵심 기능
|
||||
|
||||
| 기능 | 테스트 페이지 | 기존 페이지 |
|
||||
|------|-------------|------------|
|
||||
| 계층구조 관리 | ✅ | ✅ |
|
||||
| 페이지 CRUD | ✅ | ✅ |
|
||||
| 섹션 CRUD | ✅ | ✅ |
|
||||
| 필드 CRUD | ✅ | ✅ |
|
||||
| BOM 관리 | ✅ | ✅ |
|
||||
| 드래그앤드롭 순서 변경 | ✅ | ✅ |
|
||||
| 인라인 편집 | ✅ | ✅ |
|
||||
| Import (섹션/필드) | ✅ | ✅ |
|
||||
| 섹션 복제 | ✅ | ✅ |
|
||||
| 단위/재질/표면처리 CRUD | ✅ | ✅ |
|
||||
| 검색/필터 | ✅ | ✅ |
|
||||
|
||||
### ⚠️ 선택적 기능 (기존 페이지에서도 제한적 사용)
|
||||
|
||||
| 기능 | 상태 | 비고 |
|
||||
|------|------|------|
|
||||
| 커스텀 메인 탭 관리 | 미구현 | 기존 페이지에서 주석 처리됨 |
|
||||
| 속성 하위 탭 관리 | 미구현 | 로컬 상태만 (영속성 없음) |
|
||||
| 칼럼 관리 | 미구현 | 로컬 상태만 (영속성 없음) |
|
||||
|
||||
---
|
||||
|
||||
## 📋 전체 기능 체크리스트
|
||||
|
||||
### Phase A: 기본 UI 구조 (계층구조 탭 완성) ✅
|
||||
|
||||
#### A-1. 계층구조 기본 표시 ✅ 완료
|
||||
- [x] 페이지 목록 표시 (좌측 패널)
|
||||
- [x] 페이지 선택 시 섹션 목록 표시 (우측 패널)
|
||||
- [x] 섹션 내부 필드 목록 표시
|
||||
- [x] 필드 타입별 뱃지 표시
|
||||
- [x] BOM 타입 섹션 구분 표시
|
||||
|
||||
#### A-2. 드래그앤드롭 순서 변경 ✅ 완료
|
||||
- [x] 섹션 드래그앤드롭 순서 변경
|
||||
- [x] 필드 드래그앤드롭 순서 변경
|
||||
- [x] 스토어 reorderSections 함수 구현
|
||||
- [x] 스토어 reorderFields 함수 구현
|
||||
- [x] DraggableSection 컴포넌트 생성
|
||||
- [x] DraggableField 컴포넌트 생성
|
||||
|
||||
#### A-3. 인라인 편집 ✅ 완료
|
||||
- [x] InlineEdit 재사용 컴포넌트 생성
|
||||
- [x] 페이지 이름 더블클릭 인라인 수정
|
||||
- [x] 섹션 제목 더블클릭 인라인 수정
|
||||
- [x] 절대경로 인라인 수정
|
||||
|
||||
---
|
||||
|
||||
### Phase B: CRUD 다이얼로그 ✅
|
||||
|
||||
#### B-1. 페이지 관리 ✅ 완료
|
||||
- [x] PageDialog 컴포넌트 (페이지 추가/수정)
|
||||
- [x] DeleteConfirmDialog (재사용 가능한 삭제 확인)
|
||||
- [x] 페이지 추가 버튼 연결
|
||||
- [x] 페이지 삭제 버튼 연결
|
||||
|
||||
#### B-2. 섹션 관리 ✅ 완료
|
||||
- [x] SectionDialog 컴포넌트 (섹션 추가/수정)
|
||||
- [x] 섹션 삭제 다이얼로그
|
||||
- [x] 섹션 연결해제 다이얼로그
|
||||
- [x] 섹션 추가 버튼 연결
|
||||
- [x] ImportSectionDialog (섹션 불러오기) ✅
|
||||
|
||||
#### B-3. 필드 관리 ✅ 완료
|
||||
- [x] FieldDialog 컴포넌트 (필드 추가/수정)
|
||||
- [x] 드롭다운 옵션 동적 관리
|
||||
- [x] 필드 삭제 다이얼로그
|
||||
- [x] 필드 연결해제 다이얼로그
|
||||
- [x] 필드 추가 버튼 연결
|
||||
- [x] ImportFieldDialog (필드 불러오기) ✅
|
||||
|
||||
#### B-4. BOM 관리 ✅ 완료
|
||||
- [x] BOMDialog 컴포넌트 (BOM 추가/수정)
|
||||
- [x] BOM 항목 삭제 다이얼로그
|
||||
- [x] BOM 추가 버튼 연결
|
||||
- [x] BOM 수정 버튼 연결
|
||||
|
||||
---
|
||||
|
||||
### Phase C: 섹션 탭 + 항목 탭 ✅
|
||||
|
||||
#### C-1. 섹션 탭 ✅ 완료
|
||||
- [x] 모든 섹션 목록 표시 (연결된 + 독립)
|
||||
- [x] 섹션 상세 정보 표시
|
||||
- [x] 섹션 내부 필드 표시 (확장/축소)
|
||||
- [x] 일반 섹션 / BOM 섹션 탭 분리
|
||||
- [x] 페이지 연결 상태 표시
|
||||
- [x] 섹션 추가/수정/삭제 다이얼로그 연동
|
||||
- [x] 섹션 복제 기능 (API 연동 완료) ✅
|
||||
|
||||
#### C-2. 항목 탭 (마스터 필드) ✅ 완료
|
||||
- [x] 모든 필드 목록 표시
|
||||
- [x] 필드 상세 정보 표시
|
||||
- [x] 검색 기능 (필드명, 필드키, 타입)
|
||||
- [x] 필터 기능 (전체/독립/연결된 필드)
|
||||
- [x] 필드 추가/수정/삭제 다이얼로그 연동
|
||||
- [x] 독립 필드 → 섹션 연결 기능
|
||||
|
||||
---
|
||||
|
||||
### Phase D: 속성 탭 (진행 중)
|
||||
|
||||
#### D-1. 속성 관리 ✅ 완료
|
||||
- [x] PropertiesTab.tsx 기본 구조
|
||||
- [x] 단위 관리 (CRUD) - API 연동 완료
|
||||
- [x] 재질 관리 (CRUD) - API 연동 완료
|
||||
- [x] 표면처리 관리 (CRUD) - API 연동 완료
|
||||
- [x] PropertyDialog (속성 옵션 추가)
|
||||
|
||||
#### D-2. 탭 관리 (예정)
|
||||
- [ ] 커스텀 탭 추가/수정/삭제
|
||||
- [ ] 속성 하위 탭 추가/수정/삭제
|
||||
- [ ] 탭 순서 변경
|
||||
|
||||
---
|
||||
|
||||
### Phase E: Import 기능 ✅
|
||||
|
||||
- [x] ImportSectionDialog (섹션 불러오기)
|
||||
- [x] ImportFieldDialog (필드 불러오기)
|
||||
- [x] HierarchyTab 불러오기 버튼 연결
|
||||
|
||||
---
|
||||
|
||||
## 📁 파일 구조
|
||||
|
||||
```
|
||||
src/stores/item-master/
|
||||
├── types.ts # 정규화된 엔티티 타입 + PropertyActions
|
||||
├── useItemMasterStore.ts # Zustand 스토어
|
||||
├── normalizers.ts # API 응답 정규화
|
||||
|
||||
src/app/[locale]/(protected)/items-management-test/
|
||||
├── page.tsx # 테스트 페이지 메인
|
||||
├── components/ # 테스트 페이지 전용 컴포넌트
|
||||
│ ├── HierarchyTab.tsx # 계층구조 탭 ✅
|
||||
│ ├── DraggableSection.tsx # 드래그 섹션 ✅
|
||||
│ ├── DraggableField.tsx # 드래그 필드 ✅
|
||||
│ ├── InlineEdit.tsx # 인라인 편집 컴포넌트 ✅
|
||||
│ ├── SectionsTab.tsx # 섹션 탭 ✅ (복제 기능 추가)
|
||||
│ ├── FieldsTab.tsx # 항목 탭 ✅
|
||||
│ ├── PropertiesTab.tsx # 속성 탭 ✅
|
||||
│ └── dialogs/ # 다이얼로그 컴포넌트 ✅
|
||||
│ ├── index.ts # 인덱스 ✅
|
||||
│ ├── DeleteConfirmDialog.tsx # 삭제 확인 ✅
|
||||
│ ├── PageDialog.tsx # 페이지 다이얼로그 ✅
|
||||
│ ├── SectionDialog.tsx # 섹션 다이얼로그 ✅
|
||||
│ ├── FieldDialog.tsx # 필드 다이얼로그 ✅
|
||||
│ ├── BOMDialog.tsx # BOM 다이얼로그 ✅
|
||||
│ ├── PropertyDialog.tsx # 속성 다이얼로그 ✅
|
||||
│ ├── ImportSectionDialog.tsx # 섹션 불러오기 ✅
|
||||
│ └── ImportFieldDialog.tsx # 필드 불러오기 ✅
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 핵심 파일 위치
|
||||
|
||||
| 파일 | 용도 |
|
||||
|-----|------|
|
||||
| `claudedocs/architecture/[DESIGN-2025-12-20] item-master-zustand-refactoring.md` | 📋 설계 문서 |
|
||||
| `src/stores/item-master/useItemMasterStore.ts` | 🏪 Zustand 스토어 |
|
||||
| `src/stores/item-master/types.ts` | 📝 타입 정의 |
|
||||
| `src/stores/item-master/normalizers.ts` | 🔄 API 응답 정규화 |
|
||||
| `src/app/[locale]/(protected)/items-management-test/page.tsx` | 🧪 테스트 페이지 |
|
||||
| `src/components/items/ItemMasterDataManagement.tsx` | 📚 기존 페이지 (참조용) |
|
||||
|
||||
---
|
||||
|
||||
## 테스트 페이지 접속
|
||||
|
||||
```
|
||||
http://localhost:3000/ko/items-management-test
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 브랜치 정보
|
||||
|
||||
| 항목 | 값 |
|
||||
|------|-----|
|
||||
| 작업 브랜치 | `feature/item-master-zustand` |
|
||||
| 기본 브랜치 | `master` (테스트 페이지 없음) |
|
||||
|
||||
### 브랜치 작업 명령어
|
||||
|
||||
```bash
|
||||
# 테스트 페이지 작업 시
|
||||
git checkout feature/item-master-zustand
|
||||
|
||||
# master 최신 내용 반영
|
||||
git merge master
|
||||
|
||||
# 테스트 완료 후 master에 합치기
|
||||
git checkout master
|
||||
git merge feature/item-master-zustand
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 다음 세션 시작 명령
|
||||
|
||||
```
|
||||
누락된 기능 구현해줘 - 절대경로 수정부터
|
||||
```
|
||||
|
||||
또는
|
||||
|
||||
```
|
||||
테스트 페이지 실사용 테스트하고 버그 수정해줘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 남은 작업
|
||||
|
||||
### 🔴 누락된 핵심 기능 (100% 구현 위해 필요)
|
||||
1. **절대경로(absolute_path) 수정** - PathEditDialog
|
||||
2. **페이지 복제** - handleDuplicatePage
|
||||
3. **필드 조건부 표시** - ConditionalDisplayUI
|
||||
|
||||
### 🟡 추가 기능
|
||||
4. **칼럼 관리** - ColumnManageDialog
|
||||
5. **섹션/필드 사용 현황 표시**
|
||||
|
||||
### 🟢 마이그레이션
|
||||
6. **실사용 테스트**: 테스트 페이지에서 실제 데이터로 CRUD 테스트
|
||||
7. **버그 수정**: 발견되는 버그 즉시 수정
|
||||
8. **마이그레이션**: 테스트 완료 후 기존 페이지 대체
|
||||
@@ -0,0 +1,276 @@
|
||||
# 문서 모달 공통 컴포넌트 설계 요구사항
|
||||
|
||||
> Last Updated: 2026-01-06
|
||||
|
||||
## 현황 분석
|
||||
|
||||
### 전체 문서 모달 목록 (10개)
|
||||
|
||||
#### A. juil 비즈니스 모달 (프린트 중심)
|
||||
| 컴포넌트 | 용도 | 헤더 구성 | 결재라인 |
|
||||
|---------|------|----------|---------|
|
||||
| ProcessWorkLogPreviewModal | 공정 작업일지 | 로고 + 제목 + 결재 | 3열 (자체 구현) |
|
||||
| WorkLogModal | 생산 작업일지 | 로고 + 제목 + 결재 | 3열 (자체 구현) |
|
||||
| EstimateDocumentModal | 견적서 | 제목 + 결재 | 3열 (자체 구현) |
|
||||
| ContractDocumentModal | 계약서 | PDF iframe | 없음 |
|
||||
| HandoverReportDocumentModal | 인수인계보고서 | 결재 먼저 | 4열 (자체 구현) |
|
||||
| **OrderDocumentModal (juil)** | 🆕 발주서 | 제목만 | 없음 |
|
||||
|
||||
#### B. 수주 문서 모달
|
||||
| 컴포넌트 | 용도 | 헤더 구성 |
|
||||
|---------|------|----------|
|
||||
| OrderDocumentModal (orders) | 수주문서 3종 | 제목만 (분기) |
|
||||
|
||||
#### C. 전자결재 문서 (approval)
|
||||
| 컴포넌트 | 용도 | 결재라인 |
|
||||
|---------|------|---------|
|
||||
| ProposalDocument | 품의서 | ⭐ **ApprovalLineBox** 사용 |
|
||||
| ExpenseReportDocument | 지출결의서 | ⭐ **ApprovalLineBox** 사용 |
|
||||
| ExpenseEstimateDocument | 지출예상내역서 | ⭐ **ApprovalLineBox** 사용 |
|
||||
|
||||
---
|
||||
|
||||
## ⭐ 기존 공통 컴포넌트 발견
|
||||
|
||||
### ApprovalLineBox (이미 존재!)
|
||||
**위치**: `src/components/approval/DocumentDetail/ApprovalLineBox.tsx`
|
||||
|
||||
```tsx
|
||||
interface ApprovalLineBoxProps {
|
||||
drafter: Approver; // 작성자
|
||||
approvers: Approver[]; // 결재자 배열 (동적 열 개수)
|
||||
}
|
||||
|
||||
interface Approver {
|
||||
id: string;
|
||||
name: string;
|
||||
position: string;
|
||||
department: string;
|
||||
status: 'pending' | 'approved' | 'rejected' | 'none';
|
||||
approvedAt?: string;
|
||||
}
|
||||
```
|
||||
|
||||
**특징**:
|
||||
- ✅ 동적 열 개수 지원 (approvers 배열 길이에 따라)
|
||||
- ✅ 상태 아이콘 표시 (승인/반려/대기)
|
||||
- ✅ 구분/이름/부서 3행 구조
|
||||
- ⚠️ 현재 approval 문서에서만 사용 중
|
||||
|
||||
### 문제점
|
||||
- juil 문서들은 **자체 결재라인 구현** (코드 중복)
|
||||
- 각 문서마다 결재라인 구조가 미묘하게 다름
|
||||
- 작업일지: 작성/검토/승인 + 날짜행
|
||||
- 견적서: 작성/승인 (2열)
|
||||
- 인수인계: 작성/검토/승인/승인 (4열)
|
||||
|
||||
---
|
||||
|
||||
## 공통 패턴 분석
|
||||
|
||||
### ✅ 완전히 동일한 패턴
|
||||
```
|
||||
1. 모달 프레임: Radix UI Dialog
|
||||
2. 인쇄 처리: print-hidden + print-area 클래스
|
||||
3. 인쇄 유틸: printArea() 함수 (lib/print-utils.ts)
|
||||
4. 용지 크기: max-w-[210mm] (A4 기준)
|
||||
5. 레이아웃: 고정 헤더 + 버튼 영역 + 스크롤 문서 영역
|
||||
6. 모달 크기: max-w-[95vw] md:max-w-[800px] lg:max-w-[900px]
|
||||
```
|
||||
|
||||
### 🔄 변동이 심한 영역
|
||||
|
||||
#### 1. 문서 헤더 레이아웃
|
||||
| 유형 | 문서 | 구조 |
|
||||
|------|------|------|
|
||||
| 3열 | 작업일지 | `[로고] [제목+코드] [결재]` |
|
||||
| 2열 | 견적서, 품의서 | `[제목+번호] [결재]` |
|
||||
| 1열+우측 | 인수인계 | `[결재 먼저] + [기본정보]` |
|
||||
| 1열 중앙 | 발주서, 수주문서 | `[제목 중앙]` |
|
||||
|
||||
#### 2. 결재라인 구성
|
||||
| 문서 | 열 구조 | 행 구조 |
|
||||
|------|---------|---------|
|
||||
| 작업일지 | 작성/검토/승인 | 구분/이름/부서/날짜 |
|
||||
| 견적서 | 작성/승인 | 구분/이름/부서 |
|
||||
| 인수인계 | 작성/검토/승인/승인 | 구분/이름/부서 |
|
||||
| 전자결재 | **동적** (ApprovalLineBox) | 구분/이름/부서 |
|
||||
|
||||
#### 3. 버튼 영역
|
||||
| 문서 | 버튼 구성 |
|
||||
|------|----------|
|
||||
| 견적서 | 수정, 상신, 인쇄 |
|
||||
| 발주서 | 수정, 삭제, 인쇄 |
|
||||
| 전자결재 | 수정, 복사, 승인, 반려, 상신 |
|
||||
|
||||
---
|
||||
|
||||
## 공통 컴포넌트 제안 (수정)
|
||||
|
||||
### 1. PrintableDocumentModal (Base)
|
||||
모달 프레임 + 인쇄 기능만 담당 (변경 없음)
|
||||
|
||||
```tsx
|
||||
interface PrintableDocumentModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string;
|
||||
width?: 'sm' | 'md' | 'lg';
|
||||
actions?: ReactNode; // 버튼 영역
|
||||
children: ReactNode; // 문서 본문
|
||||
}
|
||||
```
|
||||
|
||||
### 2. ApprovalLine (확장)
|
||||
**기존 ApprovalLineBox 확장 또는 새로 통합**
|
||||
|
||||
```tsx
|
||||
interface ApprovalLineProps {
|
||||
// 방법 1: 단순 열 지정
|
||||
columns?: 2 | 3 | 4;
|
||||
approvers?: Array<{
|
||||
role: string; // '작성' | '검토' | '승인'
|
||||
name: string;
|
||||
department?: string;
|
||||
date?: string;
|
||||
status?: 'pending' | 'approved' | 'rejected';
|
||||
}>;
|
||||
|
||||
// 방법 2: 기존 ApprovalLineBox 호환
|
||||
drafter?: Approver;
|
||||
dynamicApprovers?: Approver[];
|
||||
|
||||
// 옵션
|
||||
showDateRow?: boolean; // 날짜행 표시 여부
|
||||
showStatusIcon?: boolean; // 상태 아이콘 표시 여부
|
||||
}
|
||||
```
|
||||
|
||||
### 3. DocumentHeaderLayout (프리셋)
|
||||
|
||||
```tsx
|
||||
type HeaderVariant =
|
||||
| 'three-column' // [로고] [제목] [결재]
|
||||
| 'two-column' // [제목+번호] [결재]
|
||||
| 'single-center' // [제목 중앙]
|
||||
| 'approval-first' // [결재] + [정보 테이블]
|
||||
|
||||
<DocumentHeaderLayout variant="three-column">
|
||||
<CompanyLogo type="KD" />
|
||||
<DocumentTitle title="작업일지" code="WL-001" />
|
||||
<ApprovalLine columns={3} approvers={...} />
|
||||
</DocumentHeaderLayout>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 컴포넌트 구조 제안 (수정)
|
||||
|
||||
```
|
||||
src/components/common/document/
|
||||
├── PrintableDocumentModal.tsx # 기본 모달 프레임
|
||||
├── DocumentHeader/
|
||||
│ ├── index.tsx # 헤더 레이아웃 프리셋
|
||||
│ ├── DocumentTitle.tsx # 문서 타이틀
|
||||
│ └── CompanyLogo.tsx # 회사 로고
|
||||
├── ApprovalLine/
|
||||
│ ├── index.tsx # 통합 결재라인 (★ 핵심)
|
||||
│ └── ApprovalLineBox.tsx # 기존 컴포넌트 이동/확장
|
||||
├── DocumentTable/
|
||||
│ ├── index.tsx # 기본 문서 테이블
|
||||
│ ├── InfoGrid.tsx # 정보 그리드 (2×4 등)
|
||||
│ └── SummaryRow.tsx # 합계행
|
||||
└── index.ts # 배럴 export
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 마이그레이션 전략
|
||||
|
||||
### Phase 1: ApprovalLine 통합 (우선)
|
||||
1. 기존 `ApprovalLineBox` → `common/document/ApprovalLine/`로 이동
|
||||
2. columns 기반 간단 모드 추가
|
||||
3. showDateRow, showStatusIcon 옵션 추가
|
||||
|
||||
### Phase 2: PrintableDocumentModal 생성
|
||||
1. 모달 프레임 공통화
|
||||
2. print-hidden/print-area 자동 적용
|
||||
3. 버튼 영역 슬롯 제공
|
||||
|
||||
### Phase 3: 기존 모달 리팩토링
|
||||
| 순서 | 모달 | 작업량 |
|
||||
|------|------|-------|
|
||||
| 1 | WorkLogModal 계열 | 구조 동일, 리팩토링 쉬움 |
|
||||
| 2 | EstimateDocumentModal | 결재라인 교체 |
|
||||
| 3 | 전자결재 문서들 | ApprovalLineBox 경로 변경만 |
|
||||
| 4 | OrderDocumentModal (juil) | 결재라인 없음, 프레임만 적용 |
|
||||
| 5 | HandoverReportDocumentModal | 4열 결재라인 |
|
||||
|
||||
---
|
||||
|
||||
## 결정 필요 사항
|
||||
|
||||
### Q1. ApprovalLine 통합 방식
|
||||
- **A) 확장**: 기존 ApprovalLineBox에 옵션 추가
|
||||
- **B) 새로 작성**: columns 기반 단순 버전 + 기존 호환 어댑터
|
||||
|
||||
### Q2. 위치 결정
|
||||
- **A) common/document/**: 문서 전용 공통 컴포넌트
|
||||
- **B) approval/에서 re-export**: 기존 위치 유지, 공용 export
|
||||
|
||||
### Q3. 날짜행 처리
|
||||
- **A) 옵션화**: `showDateRow={true}`
|
||||
- **B) 별도 컴포넌트**: `ApprovalLineWithDate`
|
||||
|
||||
---
|
||||
|
||||
## 예상 작업량 (수정)
|
||||
|
||||
| 단계 | 내용 | 파일 수 |
|
||||
|------|------|--------|
|
||||
| 1 | ApprovalLine 통합 | 3개 |
|
||||
| 2 | PrintableDocumentModal | 2개 |
|
||||
| 3 | DocumentHeader 컴포넌트 | 3개 |
|
||||
| 4 | 기존 모달 리팩토링 | 10개 |
|
||||
|
||||
**총 예상**: ~18개 파일 수정/생성
|
||||
|
||||
---
|
||||
|
||||
## 참고: 인쇄 유틸리티
|
||||
|
||||
```ts
|
||||
// src/lib/print-utils.ts
|
||||
printArea(options?: { title?: string; styles?: string })
|
||||
```
|
||||
|
||||
- `.print-area` 클래스 요소를 새 창에서 인쇄
|
||||
- A4 용지 설정 자동 적용
|
||||
- 기존 스타일시트 자동 로드
|
||||
|
||||
---
|
||||
|
||||
## 관련 파일 경로
|
||||
|
||||
```
|
||||
문서 모달 관련 파일들:
|
||||
|
||||
src/components/
|
||||
├── process-management/
|
||||
│ └── ProcessWorkLogPreviewModal.tsx
|
||||
├── production/WorkerScreen/
|
||||
│ └── WorkLogModal.tsx
|
||||
├── orders/documents/
|
||||
│ └── OrderDocumentModal.tsx (수주)
|
||||
├── approval/DocumentDetail/
|
||||
│ ├── ApprovalLineBox.tsx ⭐ 기존 공통
|
||||
│ ├── ProposalDocument.tsx
|
||||
│ ├── ExpenseReportDocument.tsx
|
||||
│ ├── ExpenseEstimateDocument.tsx
|
||||
│ └── types.ts
|
||||
└── business/juil/
|
||||
├── estimates/modals/EstimateDocumentModal.tsx
|
||||
├── contract/modals/ContractDocumentModal.tsx
|
||||
├── handover-report/modals/HandoverReportDocumentModal.tsx
|
||||
└── order-management/modals/OrderDocumentModal.tsx 🆕
|
||||
```
|
||||
194
claudedocs/guides/[GUIDE] print-area-utility.md
Normal file
194
claudedocs/guides/[GUIDE] print-area-utility.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# 인쇄 모달 printArea 유틸리티 적용 가이드
|
||||
|
||||
> 작성일: 2026-01-02
|
||||
> 적용 범위: 모든 인쇄 가능한 모달/다이얼로그
|
||||
|
||||
## 개요
|
||||
|
||||
기존 `window.print()` 방식은 Radix UI Dialog 포털 구조로 인해 CSS `@media print` 제어가 어렵고, 인쇄 시 모달 헤더/버튼이 함께 출력되거나 여러 페이지로 나뉘는 문제가 있었습니다.
|
||||
|
||||
이를 해결하기 위해 JavaScript 기반 `printArea()` 유틸리티를 도입하여 `.print-area` 영역만 새 창에서 인쇄하도록 통일했습니다.
|
||||
|
||||
## 공통 컴포넌트 변경
|
||||
|
||||
### 1. print-utils.ts (신규)
|
||||
|
||||
**파일 위치**: `/src/lib/print-utils.ts`
|
||||
|
||||
```typescript
|
||||
interface PrintOptions {
|
||||
title?: string; // 브라우저 인쇄 다이얼로그에 표시될 제목
|
||||
styles?: string; // 추가 CSS 스타일
|
||||
closeAfterPrint?: boolean; // 인쇄 후 창 닫기 (기본: true)
|
||||
}
|
||||
|
||||
// 특정 요소 인쇄
|
||||
export function printElement(
|
||||
elementOrSelector: HTMLElement | string,
|
||||
options?: PrintOptions
|
||||
): void;
|
||||
|
||||
// .print-area 클래스 요소 인쇄 (주로 사용)
|
||||
export function printArea(options?: PrintOptions): void;
|
||||
```
|
||||
|
||||
**동작 방식**:
|
||||
1. 새 창 열기
|
||||
2. 현재 페이지의 스타일시트 복사
|
||||
3. `.print-area` 요소 내용 복제
|
||||
4. `.print-hidden` 요소 제거
|
||||
5. A4 용지에 맞는 인쇄 스타일 적용
|
||||
6. 자동 인쇄 실행 후 창 닫기
|
||||
|
||||
### 2. globals.css 인쇄 스타일 (간소화)
|
||||
|
||||
**파일 위치**: `/src/app/globals.css`
|
||||
|
||||
```css
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 10mm;
|
||||
}
|
||||
|
||||
* {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
.print-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 적용된 모달 목록
|
||||
|
||||
| 컴포넌트 | 파일 경로 | 인쇄 제목 |
|
||||
|---------|----------|----------|
|
||||
| DocumentDetailModal | `src/components/approval/DocumentDetail/index.tsx` | 문서 타입별 (품의서, 기안서 등) |
|
||||
| ProcessWorkLogPreviewModal | `src/components/process-management/ProcessWorkLogPreviewModal.tsx` | 작업일지 템플릿명 |
|
||||
| ReceivingReceiptDialog | `src/components/material/ReceivingManagement/ReceivingReceiptDialog.tsx` | 입고증 인쇄 |
|
||||
| WorkLogModal | `src/components/production/WorkerScreen/WorkLogModal.tsx` | 작업일지 인쇄 |
|
||||
| OrderDocumentModal | `src/components/orders/documents/OrderDocumentModal.tsx` | 계약서/거래명세서/발주서 |
|
||||
| ShipmentDetail | `src/components/outbound/ShipmentManagement/ShipmentDetail.tsx` | 출고증/거래명세서/납품확인서 |
|
||||
| EstimateDocumentModal | `src/components/business/juil/estimates/modals/EstimateDocumentModal.tsx` | 견적서 인쇄 |
|
||||
| ContractDocumentModal | `src/components/business/juil/contract/modals/ContractDocumentModal.tsx` | 계약서 인쇄 |
|
||||
|
||||
## 사용 방법
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```tsx
|
||||
import { printArea } from '@/lib/print-utils';
|
||||
|
||||
// 인쇄 핸들러
|
||||
const handlePrint = () => {
|
||||
printArea({ title: '문서 인쇄' });
|
||||
};
|
||||
```
|
||||
|
||||
### 모달 구조 규칙
|
||||
|
||||
인쇄 가능한 모달은 다음 구조를 따라야 합니다:
|
||||
|
||||
```tsx
|
||||
<Dialog>
|
||||
<DialogContent>
|
||||
{/* 헤더 영역 - 인쇄에서 제외 */}
|
||||
<div className="print-hidden">
|
||||
<h2>문서 제목</h2>
|
||||
<Button onClick={handlePrint}>인쇄</Button>
|
||||
<Button onClick={onClose}>닫기</Button>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 - 인쇄에서 제외 */}
|
||||
<div className="print-hidden">
|
||||
<Button>수정</Button>
|
||||
<Button>인쇄</Button>
|
||||
</div>
|
||||
|
||||
{/* 문서 영역 - 이 영역만 인쇄됨 */}
|
||||
<div className="print-area">
|
||||
{/* 실제 문서 내용 */}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
### CSS 클래스 규칙
|
||||
|
||||
| 클래스 | 용도 |
|
||||
|--------|------|
|
||||
| `.print-area` | 인쇄될 영역 (필수) |
|
||||
| `.print-hidden` | 인쇄에서 제외할 영역 (헤더, 버튼 등) |
|
||||
|
||||
## 이전 방식 vs 새 방식
|
||||
|
||||
### 이전 방식 (문제점)
|
||||
|
||||
```tsx
|
||||
const handlePrint = () => {
|
||||
window.print(); // 전체 페이지 인쇄 시도
|
||||
};
|
||||
```
|
||||
|
||||
**문제점**:
|
||||
- Radix UI 포털 구조로 CSS `@media print` 제어 어려움
|
||||
- `visibility: hidden` 사용 시 빈 공간으로 인해 3-4페이지로 출력
|
||||
- `display: none` 사용 시 빈 페이지 출력
|
||||
- 모달 헤더/버튼이 함께 인쇄됨
|
||||
|
||||
### 새 방식 (해결)
|
||||
|
||||
```tsx
|
||||
const handlePrint = () => {
|
||||
printArea({ title: '문서 인쇄' });
|
||||
};
|
||||
```
|
||||
|
||||
**장점**:
|
||||
- 새 창에서 `.print-area` 내용만 추출하여 인쇄
|
||||
- Radix UI 포털 구조 영향 없음
|
||||
- 항상 1페이지로 깔끔하게 인쇄
|
||||
- 문서 내용만 인쇄 (헤더/버튼 제외)
|
||||
|
||||
## 새 인쇄 모달 추가 시
|
||||
|
||||
1. `printArea` import 추가
|
||||
2. `handlePrint` 함수에서 `printArea()` 호출
|
||||
3. 모달 구조에 `.print-hidden` / `.print-area` 클래스 적용
|
||||
|
||||
```tsx
|
||||
import { printArea } from '@/lib/print-utils';
|
||||
|
||||
export function NewDocumentModal() {
|
||||
const handlePrint = () => {
|
||||
printArea({ title: '새 문서 인쇄' });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogContent>
|
||||
<div className="print-hidden">
|
||||
{/* 헤더/버튼 */}
|
||||
</div>
|
||||
<div className="print-area">
|
||||
{/* 인쇄될 문서 내용 */}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **`.print-area` 클래스 필수**: 인쇄 영역에 반드시 `.print-area` 클래스 적용
|
||||
2. **중첩 `.print-area` 금지**: 하나의 모달에 `.print-area`는 하나만 존재해야 함
|
||||
3. **스타일 복제**: 인쇄 시 현재 페이지의 스타일시트가 자동으로 복사됨
|
||||
4. **팝업 차단 주의**: 브라우저 팝업 차단 시 인쇄 창이 열리지 않을 수 있음
|
||||
@@ -0,0 +1,60 @@
|
||||
# StatCards 컴포넌트 레이아웃 변경
|
||||
|
||||
## 변경일
|
||||
2026-01-05
|
||||
|
||||
## 변경 파일
|
||||
`/src/components/organisms/StatCards.tsx`
|
||||
|
||||
## 변경 내용
|
||||
|
||||
### Before (flex 기반)
|
||||
```tsx
|
||||
<div className="flex flex-col sm:flex-row gap-3 md:gap-4">
|
||||
```
|
||||
- 모바일: 세로 1열
|
||||
- SM 이상: 가로 한 줄로 모든 카드 표시 (`flex-1`)
|
||||
|
||||
### After (grid 기반)
|
||||
```tsx
|
||||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3 md:gap-4">
|
||||
```
|
||||
- 모바일: 2열 그리드
|
||||
- SM 이상: 3열 그리드
|
||||
|
||||
## 변경 사유
|
||||
|
||||
### 문제점
|
||||
- 급여관리 등 카드가 6개인 페이지에서 한 줄에 모든 카드가 들어가면 각 카드가 너무 좁아짐
|
||||
- PC 화면에서도 카드 내용이 빽빽하게 보여 가독성 저하
|
||||
|
||||
### 해결
|
||||
- grid 기반 레이아웃으로 변경하여 PC에서 3개씩 2줄로 표시
|
||||
- 각 카드가 충분한 너비를 확보하여 가독성 향상
|
||||
- 카드 개수에 따라 자연스럽게 줄바꿈
|
||||
|
||||
## 영향 범위
|
||||
`StatCards` 컴포넌트는 공통 컴포넌트로, 다음 템플릿에서 사용:
|
||||
- `IntegratedListTemplateV2`
|
||||
- `ListPageTemplate`
|
||||
|
||||
해당 템플릿을 사용하는 모든 페이지에 적용됨.
|
||||
|
||||
## 레이아웃 예시
|
||||
|
||||
### 카드 6개 (급여관리)
|
||||
```
|
||||
| 카드1 | 카드2 | 카드3 |
|
||||
| 카드4 | 카드5 | 카드6 |
|
||||
```
|
||||
|
||||
### 카드 4개
|
||||
```
|
||||
| 카드1 | 카드2 | 카드3 |
|
||||
| 카드4 | | |
|
||||
```
|
||||
|
||||
### 카드 3개
|
||||
```
|
||||
| 카드1 | 카드2 | 카드3 |
|
||||
```
|
||||
@@ -0,0 +1,132 @@
|
||||
# 품목기준관리 테스트 및 Zustand 도입 체크리스트
|
||||
|
||||
> **브랜치**: `feature/item-master-zustand`
|
||||
> **작성일**: 2025-12-24
|
||||
> **목표**: 훅 분리 완료 후 수동 테스트 → Zustand 도입
|
||||
|
||||
---
|
||||
|
||||
## 현재 상태
|
||||
|
||||
| 항목 | 상태 |
|
||||
|------|------|
|
||||
| 훅 분리 작업 | ✅ 완료 (2025-12-24) |
|
||||
| 메인 컴포넌트 줄 수 | 1,799줄 → 971줄 (46% 감소) |
|
||||
| 타입 에러 수정 | ✅ 완료 (55개 → 0개) |
|
||||
| 무한 로딩 버그 수정 | ✅ 완료 |
|
||||
| **Zustand 연동** | ✅ 완료 (2025-12-24) |
|
||||
| 빌드 | ✅ 성공 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 1: 훅 분리 작업 ✅
|
||||
|
||||
### 완료된 훅 (11개)
|
||||
|
||||
| # | 훅 이름 | 용도 | 상태 |
|
||||
|---|--------|------|------|
|
||||
| 1 | usePageManagement | 페이지 CRUD | ✅ 기존 |
|
||||
| 2 | useSectionManagement | 섹션 CRUD | ✅ 기존 |
|
||||
| 3 | useFieldManagement | 필드 CRUD | ✅ 기존 |
|
||||
| 4 | useMasterFieldManagement | 마스터 필드 | ✅ 기존 |
|
||||
| 5 | useTemplateManagement | 템플릿 관리 | ✅ 기존 |
|
||||
| 6 | useAttributeManagement | 속성/옵션 관리 | ✅ 기존 |
|
||||
| 7 | useTabManagement | 탭 관리 | ✅ 기존 |
|
||||
| 8 | useInitialDataLoading | 초기 데이터 로딩 | ✅ 신규 |
|
||||
| 9 | useImportManagement | 섹션/필드 불러오기 | ✅ 신규 |
|
||||
| 10 | useReorderManagement | 순서 변경 | ✅ 신규 |
|
||||
| 11 | useDeleteManagement | 삭제 관리 | ✅ 신규 |
|
||||
|
||||
### UI 컴포넌트 분리 (1개)
|
||||
|
||||
| # | 컴포넌트 | 용도 | 상태 |
|
||||
|---|---------|------|------|
|
||||
| 1 | AttributeTabContent | 속성 탭 (~500줄) | ✅ 완료 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 2: Zustand 연동 ✅
|
||||
|
||||
### 2.1 구조
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ useInitialDataLoading │
|
||||
│ ┌─────────────────────┐ ┌─────────────────────────────┐│
|
||||
│ │ Context 로드 │ AND │ Zustand Store 로드 ││
|
||||
│ │ (기존 호환성 유지) │ │ (정규화된 상태) ││
|
||||
│ └─────────────────────┘ └─────────────────────────────┘│
|
||||
│ ↓ ↓ │
|
||||
│ 기존 컴포넌트 → Context 새 컴포넌트 → useItemMasterStore│
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 2.2 연동 방식: 병행 운영
|
||||
|
||||
- **Context**: 기존 컴포넌트 호환성 유지
|
||||
- **Zustand**: 새 컴포넌트에서 직접 사용 가능
|
||||
- **점진적 마이그레이션**: Context → Zustand로 단계적 전환
|
||||
|
||||
### 2.3 수정된 파일
|
||||
|
||||
| 파일 | 변경 내용 |
|
||||
|------|----------|
|
||||
| `useInitialDataLoading.ts` | `useItemMasterStore` import, `initFromApi()` 호출 |
|
||||
|
||||
### 2.4 Zustand Store 기능
|
||||
|
||||
`src/stores/item-master/useItemMasterStore.ts` (1,139줄)
|
||||
|
||||
| 영역 | 기능 |
|
||||
|------|------|
|
||||
| 페이지 | loadPages, createPage, updatePage, deletePage |
|
||||
| 섹션 | loadSections, createSection, updateSection, deleteSection, reorderSections |
|
||||
| 필드 | loadFields, createField, updateField, deleteField, reorderFields |
|
||||
| BOM | loadBomItems, createBomItem, updateBomItem, deleteBomItem |
|
||||
| 속성 | addUnit, updateUnit, deleteUnit, addMaterial, updateMaterial, deleteMaterial |
|
||||
| API | initFromApi() - API 호출 후 정규화된 상태 저장 |
|
||||
|
||||
---
|
||||
|
||||
## Phase 3: 테스트 (다음 단계)
|
||||
|
||||
### 3.1 품목 유형별 등록 테스트
|
||||
|
||||
| # | 품목 유형 | 테스트 URL | 상태 |
|
||||
|---|----------|-----------|------|
|
||||
| 1 | FG (제품) | `/ko/items/create` → 제품 선택 | ⬜ |
|
||||
| 2 | PT (부품) - 절곡 | `/ko/items/create` → 부품 → 절곡부품 | ⬜ |
|
||||
| 3 | PT (부품) - 조립 | `/ko/items/create` → 부품 → 조립부품 | ⬜ |
|
||||
| 4 | PT (부품) - 구매 | `/ko/items/create` → 부품 → 구매부품 | ⬜ |
|
||||
| 5 | SM (부자재) | `/ko/items/create` → 부자재 선택 | ⬜ |
|
||||
| 6 | RM (원자재) | `/ko/items/create` → 원자재 선택 | ⬜ |
|
||||
| 7 | CS (소모품) | `/ko/items/create` → 소모품 선택 | ⬜ |
|
||||
|
||||
---
|
||||
|
||||
## 작업 로그
|
||||
|
||||
| 날짜 | 작업 내용 | 커밋 |
|
||||
|------|----------|------|
|
||||
| 2025-12-24 | Phase 1+2 훅/컴포넌트 분리 | `a823ae0` |
|
||||
| 2025-12-24 | unused 코드 정리 및 import 최적화 | `1664599` |
|
||||
| 2025-12-24 | 타입 에러 및 무한 로딩 버그 수정 | `028932d` |
|
||||
| 2025-12-24 | **Zustand 연동 완료** | (현재) |
|
||||
|
||||
---
|
||||
|
||||
## 다음 단계
|
||||
|
||||
1. 수동 테스트 진행
|
||||
2. 새 컴포넌트에서 `useItemMasterStore` 직접 사용
|
||||
3. Context 의존성 점진적 제거
|
||||
4. 동적 페이지 생성 구현
|
||||
|
||||
---
|
||||
|
||||
## 참고 문서
|
||||
|
||||
- `[PLAN-2025-12-24] hook-extraction-plan.md` - 훅 분리 계획서
|
||||
- `src/stores/item-master/useItemMasterStore.ts` - Zustand Store
|
||||
- `src/stores/item-master/types.ts` - Store 타입 정의
|
||||
- `src/stores/item-master/normalizers.ts` - API 응답 정규화
|
||||
@@ -0,0 +1,134 @@
|
||||
# 품목기준관리 리팩토링 세션 컨텍스트
|
||||
|
||||
> **브랜치**: `feature/item-master-zustand`
|
||||
> **날짜**: 2025-12-24
|
||||
> **상태**: Phase 2 완료, 커밋 대기
|
||||
|
||||
---
|
||||
|
||||
## 세션 요약 (12차 세션)
|
||||
|
||||
### 완료된 작업
|
||||
- [x] 브랜치 상태 확인 (`feature/item-master-zustand`)
|
||||
- [x] 기존 작업 혼동 정리 (품목관리 CRUD vs 품목기준관리 설정)
|
||||
- [x] 작업 대상 파일 확인 (`ItemMasterDataManagement.tsx` - 1,799줄)
|
||||
- [x] 기존 훅 분리 상태 파악 (7개 훅 이미 존재)
|
||||
- [x] `ItemMasterDataManagement.tsx` 상세 분석 완료
|
||||
- [x] 훅 분리 계획서 작성 (`[PLAN-2025-12-24] hook-extraction-plan.md`)
|
||||
- [x] **Phase 1: 신규 훅 4개 생성**
|
||||
- `useInitialDataLoading.ts` - 초기 데이터 로딩 (~130줄)
|
||||
- `useImportManagement.ts` - 섹션/필드 Import (~100줄)
|
||||
- `useReorderManagement.ts` - 드래그앤드롭 순서 변경 (~80줄)
|
||||
- `useDeleteManagement.ts` - 삭제/언링크 핸들러 (~100줄)
|
||||
- [x] **Phase 2: UI 컴포넌트 2개 생성**
|
||||
- `AttributeTabContent.tsx` - 속성 탭 콘텐츠 (~340줄)
|
||||
- `ItemMasterDialogs.tsx` - 다이얼로그 통합 (~540줄)
|
||||
- [x] 빌드 테스트 통과
|
||||
|
||||
### 현재 상태
|
||||
- **메인 컴포넌트**: 1,799줄 → ~1,478줄 (약 320줄 감소)
|
||||
- **신규 훅**: 4개 생성 및 통합
|
||||
- **신규 UI 컴포넌트**: 2개 생성 (향후 추가 통합 가능)
|
||||
- **빌드**: 통과
|
||||
|
||||
### 다음 TODO (커밋 후)
|
||||
1. Git 커밋 (Phase 1, 2 변경사항)
|
||||
2. Phase 3: 추가 코드 정리 (선택적)
|
||||
- 속성 탭 내용을 `AttributeTabContent`로 완전 대체 (추가 ~500줄 감소 가능)
|
||||
- 다이얼로그들을 `ItemMasterDialogs`로 완전 대체
|
||||
3. Zustand 도입 (3방향 동기화 문제 해결)
|
||||
|
||||
---
|
||||
|
||||
## 핵심 정보
|
||||
|
||||
### 페이지 구분 (중요!)
|
||||
|
||||
| 페이지 | URL | 컴포넌트 | 상태 |
|
||||
|--------|-----|----------|------|
|
||||
| 품목관리 CRUD | `/items/` | `DynamicItemForm` | ✅ 훅 분리 완료 (master 적용됨) |
|
||||
| **품목기준관리 설정** | `/master-data/item-master-data-management` | `ItemMasterDataManagement` | ⏳ **훅 분리 진행 중** |
|
||||
|
||||
### 현재 파일 구조
|
||||
|
||||
```
|
||||
src/components/items/ItemMasterDataManagement/
|
||||
├── ItemMasterDataManagement.tsx ← ~1,478줄 (리팩토링 후)
|
||||
├── hooks/ (11개 - 7개 기존 + 4개 신규)
|
||||
│ ├── usePageManagement.ts
|
||||
│ ├── useSectionManagement.ts
|
||||
│ ├── useFieldManagement.ts
|
||||
│ ├── useMasterFieldManagement.ts
|
||||
│ ├── useTemplateManagement.ts
|
||||
│ ├── useAttributeManagement.ts
|
||||
│ ├── useTabManagement.ts
|
||||
│ ├── useInitialDataLoading.ts ← NEW
|
||||
│ ├── useImportManagement.ts ← NEW
|
||||
│ ├── useReorderManagement.ts ← NEW
|
||||
│ └── useDeleteManagement.ts ← NEW
|
||||
├── components/ (5개 - 3개 기존 + 2개 신규)
|
||||
│ ├── DraggableSection.tsx
|
||||
│ ├── DraggableField.tsx
|
||||
│ ├── ConditionalDisplayUI.tsx
|
||||
│ ├── AttributeTabContent.tsx ← NEW
|
||||
│ └── ItemMasterDialogs.tsx ← NEW
|
||||
├── services/ (6개)
|
||||
├── dialogs/ (13개)
|
||||
├── tabs/ (4개)
|
||||
└── utils/ (1개)
|
||||
```
|
||||
|
||||
### 브랜치 상태
|
||||
|
||||
```
|
||||
master (원본 보존)
|
||||
│
|
||||
└── feature/item-master-zustand (현재)
|
||||
├── Zustand 테스트 페이지 (/items-management-test/) - 놔둠
|
||||
├── Zustand 스토어 (stores/item-master/) - 나중에 사용
|
||||
└── 기존 품목기준관리 페이지 - 훅 분리 진행 중
|
||||
```
|
||||
|
||||
### 작업 진행률
|
||||
|
||||
```
|
||||
시작: ItemMasterDataManagement.tsx 1,799줄
|
||||
↓ Phase 1: 훅 분리 (4개 신규 훅)
|
||||
현재: ~1,478줄 (-321줄, -18%)
|
||||
↓ Phase 2: UI 컴포넌트 분리 (2개 신규 컴포넌트 생성)
|
||||
↓ Phase 3: 추가 통합 (선택적)
|
||||
목표: ~500줄 (메인 컴포넌트)
|
||||
↓ Zustand 적용
|
||||
최종: 3방향 동기화 문제 해결
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 생성된 파일 목록
|
||||
|
||||
### 신규 훅 (Phase 1)
|
||||
1. `hooks/useInitialDataLoading.ts` - 초기 데이터 로딩, 에러 처리
|
||||
2. `hooks/useImportManagement.ts` - 섹션/필드 Import 다이얼로그 상태 및 핸들러
|
||||
3. `hooks/useReorderManagement.ts` - 드래그앤드롭 순서 변경
|
||||
4. `hooks/useDeleteManagement.ts` - 삭제, 언링크, 초기화 핸들러
|
||||
|
||||
### 신규 UI 컴포넌트 (Phase 2)
|
||||
1. `components/AttributeTabContent.tsx` - 속성 탭 전체 UI
|
||||
2. `components/ItemMasterDialogs.tsx` - 모든 다이얼로그 통합 렌더링
|
||||
|
||||
---
|
||||
|
||||
## 참고 문서
|
||||
|
||||
- `[PLAN-2025-12-24] hook-extraction-plan.md` - 훅 분리 계획서 (상세)
|
||||
- `[DESIGN-2025-12-20] item-master-zustand-refactoring.md` - Zustand 설계서
|
||||
- `[IMPL-2025-12-24] item-master-test-and-zustand.md` - 테스트 체크리스트
|
||||
|
||||
---
|
||||
|
||||
## 다음 세션 시작 명령
|
||||
|
||||
```
|
||||
품목기준관리 설정 페이지(ItemMasterDataManagement.tsx) 추가 리팩토링 또는 Zustand 도입 진행해줘.
|
||||
[NEXT-2025-12-24] item-master-refactoring-session.md 문서 확인하고 시작해.
|
||||
```
|
||||
270
claudedocs/item-master/[PLAN-2025-12-24] hook-extraction-plan.md
Normal file
270
claudedocs/item-master/[PLAN-2025-12-24] hook-extraction-plan.md
Normal file
@@ -0,0 +1,270 @@
|
||||
# ItemMasterDataManagement 훅 분리 계획서
|
||||
|
||||
> **날짜**: 2025-12-24
|
||||
> **대상 파일**: `src/components/items/ItemMasterDataManagement.tsx` (1,799줄)
|
||||
> **목표**: ~500줄로 축소
|
||||
|
||||
---
|
||||
|
||||
## 현재 구조 분석
|
||||
|
||||
### 파일 구성
|
||||
|
||||
| 구간 | 줄 수 | 내용 |
|
||||
|------|-------|------|
|
||||
| Import | 1-61 | React, UI, 다이얼로그, 훅 import |
|
||||
| 상수 | 63-91 | ITEM_TYPE_OPTIONS, INPUT_TYPE_OPTIONS |
|
||||
| Context 구조분해 | 94-124 | useItemMaster에서 20+개 함수/상태 |
|
||||
| 훅 초기화 | 127-286 | 7개 훅 + 150+개 상태 구조분해 |
|
||||
| useMemo | 298-372 | sectionsAsTemplates 변환 |
|
||||
| useState/useEffect | 374-504 | 로딩, 에러, 모바일, Import 상태 |
|
||||
| 핸들러 | 519-743 | Import, Clone, Delete, Reorder |
|
||||
| UI 렌더링 | 746-1799 | Tabs + 13개 다이얼로그 |
|
||||
|
||||
### 기존 훅 (7개)
|
||||
|
||||
```
|
||||
src/components/items/ItemMasterDataManagement/hooks/
|
||||
├── usePageManagement.ts - 페이지 CRUD
|
||||
├── useSectionManagement.ts - 섹션 CRUD
|
||||
├── useFieldManagement.ts - 필드 CRUD
|
||||
├── useMasterFieldManagement.ts - 마스터 필드 CRUD
|
||||
├── useTemplateManagement.ts - 템플릿 관리
|
||||
├── useAttributeManagement.ts - 속성 관리
|
||||
└── useTabManagement.ts - 탭 관리
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 분리 계획
|
||||
|
||||
### Phase 1: 신규 훅 생성 (4개)
|
||||
|
||||
#### 1. `useInitialDataLoading` (~100줄 분리)
|
||||
|
||||
**분리 대상:**
|
||||
- 초기 데이터 로딩 useEffect (387-492줄)
|
||||
- 로딩/에러 상태 (isInitialLoading, error)
|
||||
- transformers 호출 로직
|
||||
|
||||
**반환값:**
|
||||
```typescript
|
||||
{
|
||||
isInitialLoading: boolean;
|
||||
error: string | null;
|
||||
reload: () => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. `useImportManagement` (~80줄 분리)
|
||||
|
||||
**분리 대상:**
|
||||
- Import 다이얼로그 상태 (512-516줄)
|
||||
- handleImportSection (519-530줄)
|
||||
- handleImportField (540-559줄)
|
||||
- handleCloneSection (562-570줄)
|
||||
|
||||
**반환값:**
|
||||
```typescript
|
||||
{
|
||||
// 상태
|
||||
isImportSectionDialogOpen, setIsImportSectionDialogOpen,
|
||||
isImportFieldDialogOpen, setIsImportFieldDialogOpen,
|
||||
selectedImportSectionId, setSelectedImportSectionId,
|
||||
selectedImportFieldId, setSelectedImportFieldId,
|
||||
importFieldTargetSectionId, setImportFieldTargetSectionId,
|
||||
// 핸들러
|
||||
handleImportSection,
|
||||
handleImportField,
|
||||
handleCloneSection,
|
||||
}
|
||||
```
|
||||
|
||||
#### 3. `useReorderManagement` (~60줄 분리)
|
||||
|
||||
**분리 대상:**
|
||||
- moveSection (650-668줄)
|
||||
- moveField (672-702줄)
|
||||
|
||||
**반환값:**
|
||||
```typescript
|
||||
{
|
||||
moveSection: (dragIndex: number, hoverIndex: number) => Promise<void>;
|
||||
moveField: (sectionId: number, dragFieldId: number, hoverFieldId: number) => Promise<void>;
|
||||
}
|
||||
```
|
||||
|
||||
#### 4. `useDeleteManagement` (~50줄 분리)
|
||||
|
||||
**분리 대상:**
|
||||
- handleDeletePageWithTracking (582-588줄)
|
||||
- handleDeleteSectionWithTracking (591-597줄)
|
||||
- handleUnlinkFieldWithTracking (601-609줄)
|
||||
- handleResetAllData (705-743줄)
|
||||
|
||||
**반환값:**
|
||||
```typescript
|
||||
{
|
||||
handleDeletePage: (pageId: number) => void;
|
||||
handleDeleteSection: (pageId: number, sectionId: number) => void;
|
||||
handleUnlinkField: (pageId: string, sectionId: string, fieldId: string) => Promise<void>;
|
||||
handleResetAllData: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 2: UI 컴포넌트 분리 (2개)
|
||||
|
||||
#### 1. `AttributeTabContent` (~400줄 분리)
|
||||
|
||||
**분리 대상:**
|
||||
- 속성 탭 내용 (807-1331줄)
|
||||
- 단위/재질/표면처리 반복 UI 통합
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface AttributeTabContentProps {
|
||||
activeAttributeTab: string;
|
||||
attributeSubTabs: AttributeSubTab[];
|
||||
unitOptions: UnitOption[];
|
||||
materialOptions: MaterialOption[];
|
||||
surfaceTreatmentOptions: SurfaceTreatmentOption[];
|
||||
customAttributeOptions: Record<string, Option[]>;
|
||||
attributeColumns: Record<string, Column[]>;
|
||||
itemMasterFields: ItemMasterField[];
|
||||
// 핸들러들...
|
||||
}
|
||||
```
|
||||
|
||||
#### 2. `ItemMasterDialogs` (~280줄 분리)
|
||||
|
||||
**분리 대상:**
|
||||
- 13개 다이얼로그 렌더링 (1442-1797줄)
|
||||
|
||||
**Props:**
|
||||
```typescript
|
||||
interface ItemMasterDialogsProps {
|
||||
// 모든 다이얼로그 관련 props
|
||||
}
|
||||
```
|
||||
|
||||
### Phase 3: 코드 정리
|
||||
|
||||
1. **래퍼 함수 제거** (~30줄)
|
||||
- `handleAddSectionWrapper` 등을 훅 내부로 이동
|
||||
- `selectedPage`를 훅 파라미터로 전달
|
||||
|
||||
2. **unused 변수 정리**
|
||||
- `_mounted`, `_isLoading` 등 제거
|
||||
|
||||
3. **Import 최적화**
|
||||
- 사용하지 않는 import 제거
|
||||
|
||||
---
|
||||
|
||||
## 예상 결과
|
||||
|
||||
### 줄 수 변화
|
||||
|
||||
| 항목 | 현재 | 분리 후 |
|
||||
|------|------|---------|
|
||||
| Import | 61 | 40 |
|
||||
| 상수 | 28 | 28 |
|
||||
| 훅 사용 | 160 | 60 |
|
||||
| useMemo | 75 | 75 |
|
||||
| 상태/Effect | 130 | 20 |
|
||||
| 핸들러 | 225 | 30 |
|
||||
| UI 렌더링 | 1,053 | 300 |
|
||||
| **합계** | **1,799** | **~550** |
|
||||
|
||||
### 새 파일 구조
|
||||
|
||||
```
|
||||
src/components/items/ItemMasterDataManagement/
|
||||
├── ItemMasterDataManagement.tsx ← ~550줄 (메인)
|
||||
├── hooks/
|
||||
│ ├── index.ts
|
||||
│ ├── usePageManagement.ts (기존)
|
||||
│ ├── useSectionManagement.ts (기존)
|
||||
│ ├── useFieldManagement.ts (기존)
|
||||
│ ├── useMasterFieldManagement.ts (기존)
|
||||
│ ├── useTemplateManagement.ts (기존)
|
||||
│ ├── useAttributeManagement.ts (기존)
|
||||
│ ├── useTabManagement.ts (기존)
|
||||
│ ├── useInitialDataLoading.ts ← NEW
|
||||
│ ├── useImportManagement.ts ← NEW
|
||||
│ ├── useReorderManagement.ts ← NEW
|
||||
│ └── useDeleteManagement.ts ← NEW
|
||||
├── components/
|
||||
│ ├── AttributeTabContent.tsx ← NEW
|
||||
│ └── ItemMasterDialogs.tsx ← NEW
|
||||
├── dialogs/ (기존 13개)
|
||||
├── tabs/ (기존 4개)
|
||||
├── services/ (기존 6개)
|
||||
└── utils/ (기존 1개)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 작업 순서
|
||||
|
||||
### Step 1: useInitialDataLoading 훅 생성
|
||||
- [ ] 훅 파일 생성
|
||||
- [ ] 로딩/에러 상태 이동
|
||||
- [ ] useEffect 이동
|
||||
- [ ] 메인 컴포넌트에서 사용
|
||||
|
||||
### Step 2: useImportManagement 훅 생성
|
||||
- [ ] 훅 파일 생성
|
||||
- [ ] Import 상태 이동
|
||||
- [ ] 핸들러 이동
|
||||
- [ ] 메인 컴포넌트에서 사용
|
||||
|
||||
### Step 3: useReorderManagement 훅 생성
|
||||
- [ ] 훅 파일 생성
|
||||
- [ ] moveSection, moveField 이동
|
||||
- [ ] 메인 컴포넌트에서 사용
|
||||
|
||||
### Step 4: useDeleteManagement 훅 생성
|
||||
- [ ] 훅 파일 생성
|
||||
- [ ] Delete/Unlink 핸들러 이동
|
||||
- [ ] 메인 컴포넌트에서 사용
|
||||
|
||||
### Step 5: AttributeTabContent 컴포넌트 분리
|
||||
- [ ] 컴포넌트 파일 생성
|
||||
- [ ] 속성 탭 UI 이동
|
||||
- [ ] 반복 코드 통합
|
||||
|
||||
### Step 6: ItemMasterDialogs 컴포넌트 분리
|
||||
- [ ] 컴포넌트 파일 생성
|
||||
- [ ] 13개 다이얼로그 이동
|
||||
|
||||
### Step 7: 정리 및 테스트
|
||||
- [ ] 래퍼 함수 정리
|
||||
- [ ] unused 코드 제거
|
||||
- [ ] 빌드 확인
|
||||
- [ ] 수동 테스트
|
||||
|
||||
---
|
||||
|
||||
## 리스크 및 주의사항
|
||||
|
||||
1. **Context 의존성**: 훅들이 `useItemMaster` Context에 의존
|
||||
- 해결: 필요한 함수만 훅 파라미터로 전달
|
||||
|
||||
2. **상태 공유**: 여러 훅에서 동일 상태 사용
|
||||
- 해결: 공통 상태는 메인 컴포넌트에서 관리
|
||||
|
||||
3. **타입 호환성**: 기존 훅의 setter 타입 문제
|
||||
- 해결: `as any` 임시 사용 또는 타입 수정
|
||||
|
||||
4. **테스트**: 페이지 기능이 많아 수동 테스트 필요
|
||||
- 해결: 체크리스트 작성하여 순차 테스트
|
||||
|
||||
---
|
||||
|
||||
## 다음 단계
|
||||
|
||||
1. 이 계획서 확인 후 작업 시작
|
||||
2. Step 1부터 순차 진행
|
||||
3. 각 Step 완료 후 빌드 확인
|
||||
4. 최종 수동 테스트
|
||||
@@ -0,0 +1,98 @@
|
||||
# [IMPL-2026-01-05] 카테고리관리 페이지 구현 체크리스트
|
||||
|
||||
## 개요
|
||||
- **위치**: 발주관리 > 기준정보 > 카테고리관리
|
||||
- **URL**: `/ko/juil/order/base-info/categories`
|
||||
- **참조 페이지**: `/ko/settings/ranks` (직급관리)
|
||||
- **기능**: 동일, 텍스트/라벨만 다름
|
||||
|
||||
## 스크린샷 분석
|
||||
|
||||
### UI 구성
|
||||
| 구성요소 | 내용 |
|
||||
|---------|------|
|
||||
| 타이틀 | 카테고리관리 |
|
||||
| 설명 | 카테고리를 등록하고 관리합니다. |
|
||||
| 입력필드 라벨 | 카테고리 |
|
||||
| 입력필드 placeholder | 카테고리를 입력해주세요 |
|
||||
| 테이블 컬럼 | 카테고리, 작업 |
|
||||
| 기본 데이터 | 슬라이드 OPEN 사이즈, 모터, 공정자재, 철물 |
|
||||
|
||||
### Description 영역 (참고용, UI 미구현)
|
||||
1. 추가 버튼 클릭 시 목록 최하단에 추가
|
||||
2. 드래그&드롭으로 순서 변경
|
||||
3. 수정 버튼 → 수정 팝업
|
||||
4. 삭제 버튼 → 조건별 Alert:
|
||||
- 품목 사용 중: "(카테고리명)을 사용하고 있는 품목이 있습니다. 모두 변경 후 삭제가 가능합니다."
|
||||
- 미사용: "정말 삭제하시겠습니까?" → "삭제가 되었습니다."
|
||||
- 기본 카테고리: "기본 카테고리는 삭제가 불가합니다."
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
### Phase 1: 파일 구조 생성
|
||||
- [x] `src/app/[locale]/(protected)/juil/order/base-info/categories/page.tsx` 생성
|
||||
- [x] `src/components/business/juil/category-management/` 디렉토리 생성
|
||||
|
||||
### Phase 2: 컴포넌트 구현 (RankManagement 복제 + 수정)
|
||||
- [x] `index.tsx` - CategoryManagement 메인 컴포넌트
|
||||
- 타이틀: "카테고리관리"
|
||||
- 설명: "카테고리를 등록하고 관리합니다. 드래그하여 순서를 변경할 수 있습니다."
|
||||
- 아이콘: `FolderTree`
|
||||
- 입력 placeholder: "카테고리를 입력해주세요"
|
||||
- [x] `types.ts` - Category 타입 정의
|
||||
- [x] `actions.ts` - Server Actions (목데이터)
|
||||
- [x] `CategoryDialog.tsx` - 수정 다이얼로그
|
||||
|
||||
### Phase 3: 텍스트 변경 사항
|
||||
| 원본 (ranks) | 변경 (categories) | 상태 |
|
||||
|-------------|-------------------|------|
|
||||
| 직급 | 카테고리 | ✅ |
|
||||
| 직급관리 | 카테고리관리 | ✅ |
|
||||
| 사원의 직급을 관리합니다 | 카테고리를 등록하고 관리합니다 | ✅ |
|
||||
| 직급명을 입력하세요 | 카테고리를 입력해주세요 | ✅ |
|
||||
| 직급이 추가되었습니다 | 카테고리가 추가되었습니다 | ✅ |
|
||||
| 직급이 수정되었습니다 | 카테고리가 수정되었습니다 | ✅ |
|
||||
| 직급이 삭제되었습니다 | 카테고리가 삭제되었습니다 | ✅ |
|
||||
| 등록된 직급이 없습니다 | 등록된 카테고리가 없습니다 | ✅ |
|
||||
|
||||
### Phase 4: 삭제 로직 (삭제 조건 처리)
|
||||
- [x] 기본 카테고리 삭제 불가 로직 추가 (`isDefault` 플래그)
|
||||
- [x] 조건별 Alert 메시지 분기 (actions.ts의 `errorType` 반환)
|
||||
- [ ] 품목 사용 여부 체크 로직 추가 (추후 API 연동 시)
|
||||
|
||||
### Phase 5: 목데이터 설정
|
||||
- [x] 기본 카테고리 4개 설정 완료
|
||||
```typescript
|
||||
const mockCategories = [
|
||||
{ id: '1', name: '슬라이드 OPEN 사이즈', order: 1, isDefault: true },
|
||||
{ id: '2', name: '모터', order: 2, isDefault: true },
|
||||
{ id: '3', name: '공정자재', order: 3, isDefault: true },
|
||||
{ id: '4', name: '철물', order: 4, isDefault: true },
|
||||
];
|
||||
```
|
||||
|
||||
### Phase 6: 테스트 URL 문서 업데이트
|
||||
- [x] `claudedocs/[REF] juil-pages-test-urls.md` 업데이트
|
||||
- 발주관리 > 기준정보 섹션 추가
|
||||
- 카테고리관리 URL 추가
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/[locale]/(protected)/juil/order/
|
||||
│ └── base-info/
|
||||
│ └── categories/
|
||||
│ └── page.tsx
|
||||
└── components/business/juil/
|
||||
└── category-management/
|
||||
├── index.tsx
|
||||
├── types.ts
|
||||
├── actions.ts
|
||||
└── CategoryDialog.tsx
|
||||
```
|
||||
|
||||
## 진행 상태
|
||||
- 생성일: 2026-01-05
|
||||
- 상태: ✅ 완료 (목데이터 기반)
|
||||
- 남은 작업: API 연동 시 품목 사용 여부 체크 로직 추가
|
||||
209
claudedocs/juil/[IMPL-2026-01-05] item-management-checklist.md
Normal file
209
claudedocs/juil/[IMPL-2026-01-05] item-management-checklist.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# [IMPL-2026-01-05] 품목관리 페이지 구현 체크리스트
|
||||
|
||||
## 개요
|
||||
- **위치**: 발주관리 > 기준정보 > 품목관리
|
||||
- **URL**: `/ko/juil/order/base-info/items`
|
||||
- **참조 템플릿**: IntegratedListTemplateV2 (리스트 페이지 표준)
|
||||
- **기능**: 품목 CRUD, 필터링, 검색, 정렬
|
||||
|
||||
## 스크린샷 분석
|
||||
|
||||
### 헤더 영역
|
||||
| 구성요소 | 내용 |
|
||||
|---------|------|
|
||||
| 타이틀 | 품목관리 |
|
||||
| 설명 | 품목을 등록하여 관리합니다. |
|
||||
| 날짜 필터 | 날짜 범위 선택 (DateRangePicker) |
|
||||
| 빠른 날짜 버튼 | 전체년도, 전전월, 전월, 당월, 어제, 오늘 |
|
||||
| 액션 버튼 | 품목 등록 (빨간색 primary) |
|
||||
|
||||
### 통계 카드
|
||||
| 카드 | 내용 |
|
||||
|------|------|
|
||||
| 전체 품목 | 전체 품목 수 표시 |
|
||||
| 사용 품목 | 사용 중인 품목 수 표시 |
|
||||
|
||||
### 검색 및 필터 영역
|
||||
| 구성요소 | 내용 |
|
||||
|---------|------|
|
||||
| 검색 입력 | 품목명 검색 |
|
||||
| 선택 카운트 | N건 / N건 선택 |
|
||||
| 삭제 버튼 | 선택된 항목 일괄 삭제 |
|
||||
|
||||
### 테이블 컬럼
|
||||
| 컬럼 | 타입 | 필터 옵션 |
|
||||
|------|------|----------|
|
||||
| 체크박스 | checkbox | - |
|
||||
| 품목번호 | text | - |
|
||||
| 물품유형 | select filter | 전체, 제품, 부품, 소모품, 공과 |
|
||||
| 카테고리 | select filter + search | 전체, 기본, (카테고리 목록) |
|
||||
| 품목명 | text | - |
|
||||
| 규격 | select filter | 전체, 인정, 비인정 |
|
||||
| 단위 | text | - |
|
||||
| 구분 | select filter | 전체, 경품발주, 원자재발주, 외주발주 |
|
||||
| 상태 | badge | 승인, 작업 |
|
||||
| 작업 | actions | 수정(연필 아이콘) |
|
||||
|
||||
### Description 영역 (참고용, UI 미구현)
|
||||
1. 품목 등록 버튼 - 클릭 시 품목 상세 등록 화면으로 이동
|
||||
2. 물품유형 셀렉트 박스 - 전체/제품/부품/소모품/공과 (디폴트: 전체)
|
||||
3. 카테고리 셀렉트 박스, 검색 - 전체/기본/카테고리 목록 (디폴트: 전체)
|
||||
4. 규격 셀렉트 박스 - 전체/인정/비인정 (디폴트: 전체)
|
||||
5. 구분 셀렉트 박스 - 전체/경품발주/원자재발주/외주발주 (디폴트: 전체)
|
||||
6. 상태 셀렉트 박스 - 전체/사용/중지 (디폴트: 전체)
|
||||
7. 정렬 셀렉트 박스 - 최신순/등록순 (디폴트: 최신순)
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
### Phase 1: 파일 구조 생성
|
||||
- [x] `src/app/[locale]/(protected)/juil/order/base-info/items/page.tsx` 생성
|
||||
- [x] `src/components/business/juil/item-management/` 디렉토리 생성
|
||||
|
||||
### Phase 2: 타입 및 상수 정의
|
||||
- [x] `types.ts` - Item 타입 정의
|
||||
```typescript
|
||||
interface Item {
|
||||
id: string;
|
||||
itemNumber: string; // 품목번호
|
||||
itemType: ItemType; // 물품유형
|
||||
categoryId: string; // 카테고리 ID
|
||||
categoryName: string; // 카테고리명
|
||||
itemName: string; // 품목명
|
||||
specification: string; // 규격 (인쇄/비인쇄)
|
||||
unit: string; // 단위
|
||||
orderType: OrderType; // 구분
|
||||
status: ItemStatus; // 상태
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
- [x] `constants.ts` - 필터 옵션 상수 정의
|
||||
```typescript
|
||||
// 물품유형
|
||||
const ITEM_TYPES = ['전체', '제품', '부품', '소모품', '공과'];
|
||||
|
||||
// 규격
|
||||
const SPECIFICATIONS = ['전체', '인정', '비인정'];
|
||||
|
||||
// 구분
|
||||
const ORDER_TYPES = ['전체', '경품발주', '원자재발주', '외주발주'];
|
||||
|
||||
// 상태
|
||||
const ITEM_STATUSES = ['전체', '사용', '중지'];
|
||||
|
||||
// 정렬
|
||||
const SORT_OPTIONS = ['최신순', '등록순'];
|
||||
```
|
||||
|
||||
### Phase 3: 메인 컴포넌트 구현
|
||||
- [x] `index.tsx` - ItemManagement 메인 컴포넌트 (export)
|
||||
- [x] `ItemManagementClient.tsx` - 클라이언트 컴포넌트
|
||||
- IntegratedListTemplateV2 사용
|
||||
- 헤더: 타이틀, 설명, 날짜필터, 품목등록 버튼
|
||||
- 통계 카드: StatCards 컴포넌트 활용
|
||||
- 테이블: 컬럼 헤더 필터 포함
|
||||
- 검색 및 삭제 기능
|
||||
|
||||
### Phase 4: 테이블 컬럼 설정
|
||||
- [x] 테이블 컬럼 정의 (ItemManagementClient.tsx 내 포함)
|
||||
- 체크박스 컬럼
|
||||
- 품목번호 컬럼
|
||||
- 물품유형 컬럼 (헤더 필터 Select)
|
||||
- 카테고리 컬럼 (헤더 필터 Select + 검색)
|
||||
- 품목명 컬럼
|
||||
- 규격 컬럼 (헤더 필터 Select)
|
||||
- 단위 컬럼
|
||||
- 구분 컬럼 (헤더 필터 Select)
|
||||
- 상태 컬럼 (Badge 표시)
|
||||
- 작업 컬럼 (수정 버튼)
|
||||
|
||||
### Phase 5: Server Actions (목데이터)
|
||||
- [x] `actions.ts` - Server Actions 구현
|
||||
- `getItemList()` - 품목 목록 조회
|
||||
- `getItemStats()` - 통계 조회
|
||||
- `deleteItem()` - 품목 삭제
|
||||
- `deleteItems()` - 품목 일괄 삭제
|
||||
- `getCategoryOptions()` - 카테고리 목록 조회
|
||||
|
||||
### Phase 6: 목데이터 설정
|
||||
```typescript
|
||||
const mockItems: Item[] = [
|
||||
{ id: '1', itemNumber: '123123', itemType: '제품', categoryName: '카테고리명', itemName: '품목명', specification: '인쇄', unit: 'SET', orderType: '외주발주', status: '승인' },
|
||||
{ id: '2', itemNumber: '123123', itemType: '부품', categoryName: '카테고리명', itemName: '품목명', specification: '비인쇄', unit: 'SET', orderType: '외주발주', status: '승인' },
|
||||
{ id: '3', itemNumber: '123123', itemType: '소모품', categoryName: '카테고리명', itemName: '품목명', specification: '인쇄', unit: 'SET', orderType: '외주발주', status: '승인' },
|
||||
{ id: '4', itemNumber: '123123', itemType: '공과', categoryName: '카테고리명', itemName: '품목명', specification: '비인쇄', unit: 'EA', orderType: '공과', status: '작업' },
|
||||
{ id: '5', itemNumber: '123123', itemType: '부품', categoryName: '카테고리명', itemName: '품목명', specification: '인쇄', unit: 'EA', orderType: '원자재발주', status: '작업' },
|
||||
{ id: '6', itemNumber: '123123', itemType: '소모품', categoryName: '카테고리명', itemName: '품목명', specification: '비인쇄', unit: '승인', orderType: '외주발주', status: '작업' },
|
||||
{ id: '7', itemNumber: '123123', itemType: '소모품', categoryName: '카테고리명', itemName: '품목명', specification: '인쇄', unit: '승인', orderType: '공과', status: '작업' },
|
||||
];
|
||||
|
||||
const mockStats = {
|
||||
totalItems: 7,
|
||||
activeItems: 5,
|
||||
};
|
||||
```
|
||||
|
||||
### Phase 7: 헤더 필터 컴포넌트
|
||||
- [x] tableHeaderActions 영역에 Select 필터 구현
|
||||
- 물품유형 필터
|
||||
- 규격 필터
|
||||
- 구분 필터
|
||||
- 정렬 필터
|
||||
|
||||
### Phase 8: 등록/상세/수정 페이지 구현
|
||||
- [x] 품목 등록 버튼 클릭 → `/ko/juil/order/base-info/items/new` 이동
|
||||
- [x] 수정 버튼 클릭 → `/ko/juil/order/base-info/items/[id]?mode=edit` 이동
|
||||
- [x] 등록/수정/상세 페이지 구현 (ItemDetailClient.tsx)
|
||||
- [x] Server Actions (getItem, createItem, updateItem) 구현
|
||||
- [x] 발주 항목 동적 추가/삭제 기능
|
||||
|
||||
### Phase 9: 테스트 URL 문서 업데이트
|
||||
- [x] `claudedocs/[REF] juil-pages-test-urls.md` 업데이트
|
||||
- 품목관리 URL 추가
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/[locale]/(protected)/juil/order/
|
||||
│ └── base-info/
|
||||
│ └── items/
|
||||
│ ├── page.tsx
|
||||
│ ├── new/
|
||||
│ │ └── page.tsx
|
||||
│ └── [id]/
|
||||
│ └── page.tsx
|
||||
└── components/business/juil/
|
||||
└── item-management/
|
||||
├── index.tsx
|
||||
├── ItemManagementClient.tsx
|
||||
├── ItemDetailClient.tsx
|
||||
├── types.ts
|
||||
├── constants.ts
|
||||
└── actions.ts
|
||||
```
|
||||
|
||||
## 참조 컴포넌트
|
||||
- `IntegratedListTemplateV2` - 리스트 템플릿
|
||||
- `StatCards` - 통계 카드
|
||||
- `DateRangePicker` - 날짜 범위 선택
|
||||
- `Select` - 필터 셀렉트박스
|
||||
- `Badge` - 상태 표시
|
||||
- `Button` - 버튼
|
||||
- `Checkbox` - 체크박스
|
||||
|
||||
## UI 구현 참고
|
||||
- 컬럼 헤더 내 필터 Select: 기존 프로젝트 내 유사 구현 검색 필요
|
||||
- 날짜 빠른 선택 버튼 그룹: 기존 컴포넌트 활용 또는 신규 구현
|
||||
|
||||
## 진행 상태
|
||||
- 생성일: 2026-01-05
|
||||
- 상태: ✅ 전체 완료 (리스트 + 상세/등록/수정)
|
||||
|
||||
## 히스토리
|
||||
| 날짜 | 작업 내용 | 상태 |
|
||||
|------|----------|------|
|
||||
| 2026-01-05 | 체크리스트 작성 | ✅ |
|
||||
| 2026-01-05 | 리스트 페이지 구현 (Phase 1-7, 9) | ✅ |
|
||||
| 2026-01-05 | 규격 필터 수정 (인쇄/비인쇄 → 인정/비인정) | ✅ |
|
||||
| 2026-01-05 | 상세/등록/수정 페이지 구현 (Phase 8) | ✅ |
|
||||
@@ -0,0 +1,119 @@
|
||||
# [IMPL-2026-01-05] 단가관리 리스트 페이지 구현 체크리스트
|
||||
|
||||
## 개요
|
||||
- **위치**: 발주관리 > 기준정보 > 단가관리
|
||||
- **URL**: `/ko/juil/order/base-info/pricing`
|
||||
- **참조 페이지**: `/ko/juil/order/order-management` (OrderManagementListClient)
|
||||
- **패턴**: IntegratedListTemplateV2 + StatCards
|
||||
|
||||
## 스크린샷 분석
|
||||
|
||||
### UI 구성
|
||||
|
||||
#### 1. 헤더 영역
|
||||
| 구성요소 | 내용 |
|
||||
|---------|------|
|
||||
| 타이틀 | 단가관리 |
|
||||
| 설명 | 단가를 등록하고 관리합니다. |
|
||||
|
||||
#### 2. 달력 + 액션 버튼 영역
|
||||
| 구성요소 | 내용 |
|
||||
|---------|------|
|
||||
| 날짜 선택 | DateRangeSelector (2025-09-01 ~ 2025-09-03) |
|
||||
| 액션 버튼들 | 담당단가, 진행단가, 확정, 발행, 이력, 오류, **단가 등록** |
|
||||
|
||||
#### 3. StatCards (통계 카드)
|
||||
| 카드 | 값 | 설명 |
|
||||
|------|-----|------|
|
||||
| 미완료 | 9 | 미완료 단가 |
|
||||
| 확정 | 5 | 확정된 단가 |
|
||||
| 발행 | 4 | 발행된 단가 |
|
||||
|
||||
#### 4. 필터 영역 (테이블 헤더)
|
||||
| 필터 | 옵션 | 기본값 |
|
||||
|------|------|--------|
|
||||
| 품목유형 | 전체, 박스, 부속, 소모품, 공과 | 전체 |
|
||||
| 카테고리 | 전기, (카테고리 목록) | - |
|
||||
| 규격 | 전체, 진행, 미진행 | 전체 |
|
||||
| 구분 | 전체, 금동량, 임의적용가, 미구분 | 전체 |
|
||||
| 상세 | 전체, 사용, 유지, 미등록 | 전체 |
|
||||
| 정렬 | 최신순, 등록순 | 최신순 |
|
||||
|
||||
#### 5. 테이블 컬럼
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| 체크박스 | 행 선택 |
|
||||
| 단가번호 | 단가 고유번호 |
|
||||
| 품목유형 | 박스/부속/소모품/공과 |
|
||||
| 카테고리 | 품목 카테고리 |
|
||||
| 품목 | 품목명 |
|
||||
| 금액량 | 수량 정보 |
|
||||
| 정량 | 정량 정보 |
|
||||
| 단가 | 단가 금액 |
|
||||
| 구매처 | 구매처 정보 |
|
||||
| 예상단가 | 예상 단가 |
|
||||
| 이전단가 | 이전 단가 |
|
||||
| 판매단가 | 판매 단가 |
|
||||
| 실적 | 실적 정보 |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
### Phase 1: 파일 구조 생성
|
||||
- [x] `src/app/[locale]/(protected)/juil/order/base-info/pricing/page.tsx` 생성
|
||||
- [x] `src/components/business/juil/pricing-management/` 디렉토리 생성
|
||||
|
||||
### Phase 2: 타입 및 상수 정의
|
||||
- [x] `types.ts` - Pricing 타입, 필터 옵션, 상태 스타일
|
||||
- Pricing 인터페이스
|
||||
- PricingStats 인터페이스
|
||||
- 품목유형 옵션 (ITEM_TYPE_OPTIONS)
|
||||
- 규격 옵션 (SPEC_OPTIONS)
|
||||
- 구분 옵션 (DIVISION_OPTIONS)
|
||||
- 상세 옵션 (DETAIL_OPTIONS)
|
||||
- 정렬 옵션 (SORT_OPTIONS)
|
||||
- 상태 스타일 (PRICING_STATUS_STYLES)
|
||||
|
||||
### Phase 3: Server Actions (목데이터)
|
||||
- [x] `actions.ts`
|
||||
- getPricingList() - 목록 조회
|
||||
- getPricingStats() - 통계 조회
|
||||
- deletePricing() - 단일 삭제
|
||||
- deletePricings() - 일괄 삭제
|
||||
|
||||
### Phase 4: 리스트 컴포넌트
|
||||
- [x] `PricingListClient.tsx`
|
||||
- IntegratedListTemplateV2 사용
|
||||
- DateRangeSelector (날짜 범위 선택)
|
||||
- StatCards (미완료/확정/발행)
|
||||
- 필터 셀렉트 박스들 (품목유형, 규격, 구분, 상세, 정렬)
|
||||
- 액션 버튼들 (담당단가, 진행단가, 확정, 발행, 이력, 오류, 단가 등록)
|
||||
- 테이블 렌더링
|
||||
- 모바일 카드 렌더링
|
||||
- 삭제 다이얼로그
|
||||
|
||||
### Phase 5: 목데이터 설정
|
||||
- [x] 7개 목데이터 설정 완료
|
||||
|
||||
### Phase 6: 테스트 URL 문서 업데이트
|
||||
- [x] `claudedocs/[REF] juil-pages-test-urls.md` 업데이트
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/[locale]/(protected)/juil/order/
|
||||
│ └── base-info/
|
||||
│ └── pricing/
|
||||
│ └── page.tsx
|
||||
└── components/business/juil/
|
||||
└── pricing-management/
|
||||
├── index.ts
|
||||
├── types.ts
|
||||
├── actions.ts
|
||||
└── PricingListClient.tsx
|
||||
```
|
||||
|
||||
## 진행 상태
|
||||
- 생성일: 2026-01-05
|
||||
- 상태: ✅ 완료 (목데이터 기반)
|
||||
- 남은 작업: API 연동 시 실제 데이터 연결
|
||||
@@ -0,0 +1,231 @@
|
||||
# EstimateDetailForm.tsx 파일 분할 계획서
|
||||
|
||||
## 현황 분석
|
||||
|
||||
- **파일 위치**: `src/components/business/juil/estimates/EstimateDetailForm.tsx`
|
||||
- **현재 라인 수**: 2,088줄
|
||||
- **문제점**: 단일 파일에 모든 섹션, 핸들러, 상태 관리가 집중되어 유지보수 어려움
|
||||
|
||||
## 파일 구조 분석
|
||||
|
||||
### 현재 구조 (라인 범위)
|
||||
|
||||
| 구분 | 라인 | 설명 |
|
||||
|------|------|------|
|
||||
| Imports | 1-56 | React, UI 컴포넌트, 타입 |
|
||||
| 상수/유틸 | 58-75 | MOCK_MATERIALS, MOCK_EXPENSES, formatAmount |
|
||||
| Props | 77-81 | EstimateDetailFormProps |
|
||||
| State | 88-127 | formData, 로딩, 다이얼로그, 모달 상태 |
|
||||
| 핸들러 - 네비게이션 | 130-140 | handleBack, handleEdit, handleCancel |
|
||||
| 핸들러 - 저장/삭제 | 143-182 | handleSave, handleConfirmSave, handleDelete, handleConfirmDelete |
|
||||
| 핸들러 - 견적 요약 | 185-227 | handleAddSummaryItem, handleRemoveSummaryItem, handleSummaryItemChange |
|
||||
| 핸들러 - 공과 상세 | 230-259 | handleAddExpenseItem, handleRemoveExpenseItem, handleExpenseItemChange |
|
||||
| 핸들러 - 단가 조정 | 262-283 | handlePriceAdjustmentChange |
|
||||
| 핸들러 - 견적 상세 | 286-343 | handleAddDetailItem, handleRemoveDetailItem, handleDetailItemChange |
|
||||
| 핸들러 - 파일 업로드 | 346-435 | handleDocumentUpload, handleDocumentRemove, 드래그앤드롭 |
|
||||
| useMemo | 438-482 | pageTitle, pageDescription, headerActions |
|
||||
| JSX - 견적 정보 | 496-526 | 견적 정보 Card |
|
||||
| JSX - 현장설명회 | 528-551 | 현장설명회 정보 Card |
|
||||
| JSX - 입찰 정보 | 553-736 | 입찰 정보 Card + 파일 업로드 |
|
||||
| JSX - 견적 요약 | 738-890 | 견적 요약 정보 Table |
|
||||
| JSX - 공과 상세 | 892-1071 | 공과 상세 Table |
|
||||
| JSX - 단가 조정 | 1073-1224 | 품목 단가 조정 Table |
|
||||
| JSX - 견적 상세 | 1226-2017 | 견적 상세 Table (가장 큰 섹션) |
|
||||
| 모달/다이얼로그 | 2020-2085 | 전자결재, 견적서, 삭제/저장 다이얼로그 |
|
||||
|
||||
---
|
||||
|
||||
## 분할 계획
|
||||
|
||||
### 1단계: 섹션 컴포넌트 분리
|
||||
|
||||
```
|
||||
src/components/business/juil/estimates/
|
||||
├── EstimateDetailForm.tsx # 메인 컴포넌트 (축소)
|
||||
├── sections/
|
||||
│ ├── index.ts # 섹션 export
|
||||
│ ├── EstimateInfoSection.tsx # 견적 정보 + 현장설명회 + 입찰 정보
|
||||
│ ├── EstimateSummarySection.tsx # 견적 요약 정보
|
||||
│ ├── ExpenseDetailSection.tsx # 공과 상세
|
||||
│ ├── PriceAdjustmentSection.tsx # 품목 단가 조정
|
||||
│ └── EstimateDetailTableSection.tsx # 견적 상세 테이블
|
||||
├── hooks/
|
||||
│ ├── index.ts # hooks export
|
||||
│ └── useEstimateCalculations.ts # 계산 로직 (면적, 무게, 단가 등)
|
||||
└── utils/
|
||||
├── index.ts # utils export
|
||||
├── constants.ts # MOCK_MATERIALS, MOCK_EXPENSES
|
||||
└── formatters.ts # formatAmount
|
||||
```
|
||||
|
||||
### 2단계: 각 파일 상세
|
||||
|
||||
#### 2.1 constants.ts (~20줄)
|
||||
```typescript
|
||||
// MOCK_MATERIALS, MOCK_EXPENSES 이동
|
||||
export const MOCK_MATERIALS = [...];
|
||||
export const MOCK_EXPENSES = [...];
|
||||
```
|
||||
|
||||
#### 2.2 formatters.ts (~10줄)
|
||||
```typescript
|
||||
// formatAmount 함수 이동
|
||||
export function formatAmount(amount: number): string { ... }
|
||||
```
|
||||
|
||||
#### 2.3 useEstimateCalculations.ts (~100줄)
|
||||
```typescript
|
||||
// 견적 상세 테이블의 계산 로직 분리
|
||||
// - 면적, 무게, 철제스크린, 코킹, 레일, 하장 등 계산
|
||||
// - 합계 계산 로직
|
||||
export function useEstimateCalculations(
|
||||
item: EstimateDetailItem,
|
||||
priceAdjustmentData: PriceAdjustmentData,
|
||||
useAdjustedPrice: boolean
|
||||
) { ... }
|
||||
|
||||
export function calculateTotals(
|
||||
items: EstimateDetailItem[],
|
||||
priceAdjustmentData: PriceAdjustmentData,
|
||||
useAdjustedPrice: boolean
|
||||
) { ... }
|
||||
```
|
||||
|
||||
#### 2.4 EstimateInfoSection.tsx (~250줄)
|
||||
```typescript
|
||||
// 견적 정보 + 현장설명회 + 입찰 정보 Card 3개
|
||||
// 파일 업로드 영역 포함
|
||||
interface EstimateInfoSectionProps {
|
||||
formData: EstimateDetailFormData;
|
||||
setFormData: React.Dispatch<React.SetStateAction<EstimateDetailFormData>>;
|
||||
isViewMode: boolean;
|
||||
documentInputRef: React.RefObject<HTMLInputElement>;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.5 EstimateSummarySection.tsx (~200줄)
|
||||
```typescript
|
||||
// 견적 요약 정보 테이블
|
||||
interface EstimateSummarySectionProps {
|
||||
summaryItems: EstimateSummaryItem[];
|
||||
summaryMemo: string;
|
||||
isViewMode: boolean;
|
||||
onAddItem: () => void;
|
||||
onRemoveItem: (id: string) => void;
|
||||
onItemChange: (id: string, field: keyof EstimateSummaryItem, value: string | number) => void;
|
||||
onMemoChange: (memo: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.6 ExpenseDetailSection.tsx (~200줄)
|
||||
```typescript
|
||||
// 공과 상세 테이블
|
||||
interface ExpenseDetailSectionProps {
|
||||
expenseItems: ExpenseItem[];
|
||||
isViewMode: boolean;
|
||||
onAddItems: (count: number) => void;
|
||||
onRemoveSelected: () => void;
|
||||
onItemChange: (id: string, field: keyof ExpenseItem, value: string | number) => void;
|
||||
onSelectItem: (id: string, selected: boolean) => void;
|
||||
onSelectAll: (selected: boolean) => void;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.7 PriceAdjustmentSection.tsx (~200줄)
|
||||
```typescript
|
||||
// 품목 단가 조정 테이블
|
||||
interface PriceAdjustmentSectionProps {
|
||||
priceAdjustmentData: PriceAdjustmentData;
|
||||
isViewMode: boolean;
|
||||
onPriceChange: (key: string, value: number) => void;
|
||||
onSave: () => void;
|
||||
onApplyAll: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.8 EstimateDetailTableSection.tsx (~600줄)
|
||||
```typescript
|
||||
// 견적 상세 테이블 (가장 큰 섹션)
|
||||
interface EstimateDetailTableSectionProps {
|
||||
detailItems: EstimateDetailItem[];
|
||||
priceAdjustmentData: PriceAdjustmentData;
|
||||
useAdjustedPrice: boolean;
|
||||
isViewMode: boolean;
|
||||
onAddItems: (count: number) => void;
|
||||
onRemoveItem: (id: string) => void;
|
||||
onRemoveSelected: () => void;
|
||||
onItemChange: (id: string, field: keyof EstimateDetailItem, value: string | number) => void;
|
||||
onSelectItem: (id: string, selected: boolean) => void;
|
||||
onSelectAll: (selected: boolean) => void;
|
||||
onApplyAdjustedPrice: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 분할 후 예상 라인 수
|
||||
|
||||
| 파일 | 예상 라인 수 |
|
||||
|------|-------------|
|
||||
| EstimateDetailForm.tsx (메인) | ~300줄 |
|
||||
| EstimateInfoSection.tsx | ~250줄 |
|
||||
| EstimateSummarySection.tsx | ~200줄 |
|
||||
| ExpenseDetailSection.tsx | ~200줄 |
|
||||
| PriceAdjustmentSection.tsx | ~200줄 |
|
||||
| EstimateDetailTableSection.tsx | ~600줄 |
|
||||
| useEstimateCalculations.ts | ~100줄 |
|
||||
| constants.ts | ~20줄 |
|
||||
| formatters.ts | ~10줄 |
|
||||
| **총합** | ~1,880줄 (약 10% 감소) |
|
||||
|
||||
---
|
||||
|
||||
## 실행 순서
|
||||
|
||||
### Phase 1: 유틸리티 분리 (5분)
|
||||
- [ ] `utils/constants.ts` 생성
|
||||
- [ ] `utils/formatters.ts` 생성
|
||||
- [ ] `utils/index.ts` 생성
|
||||
|
||||
### Phase 2: 계산 로직 분리 (10분)
|
||||
- [ ] `hooks/useEstimateCalculations.ts` 생성
|
||||
- [ ] `hooks/index.ts` 생성
|
||||
|
||||
### Phase 3: 섹션 컴포넌트 분리 (30분)
|
||||
- [ ] `sections/EstimateInfoSection.tsx` 생성
|
||||
- [ ] `sections/EstimateSummarySection.tsx` 생성
|
||||
- [ ] `sections/ExpenseDetailSection.tsx` 생성
|
||||
- [ ] `sections/PriceAdjustmentSection.tsx` 생성
|
||||
- [ ] `sections/EstimateDetailTableSection.tsx` 생성
|
||||
- [ ] `sections/index.ts` 생성
|
||||
|
||||
### Phase 4: 메인 컴포넌트 리팩토링 (10분)
|
||||
- [ ] EstimateDetailForm.tsx에서 분리된 컴포넌트 import
|
||||
- [ ] 핸들러 정리 및 props 전달
|
||||
- [ ] 불필요한 코드 제거
|
||||
|
||||
### Phase 5: 검증 (5분)
|
||||
- [ ] TypeScript 빌드 확인
|
||||
- [ ] 기능 동작 확인
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **상태 관리**: formData, setFormData는 메인 컴포넌트에서 관리, 섹션에 props로 전달
|
||||
2. **타입 일관성**: 기존 types.ts의 타입 그대로 사용
|
||||
3. **핸들러 위치**: 핸들러는 메인 컴포넌트에 유지, 섹션에 콜백으로 전달
|
||||
4. **조정단가 상태**: appliedPrices, useAdjustedPrice는 메인 컴포넌트에서 관리
|
||||
|
||||
---
|
||||
|
||||
## 5가지 수정사항 (분할 후 진행)
|
||||
|
||||
| # | 항목 | 수정 위치 (분할 후) |
|
||||
|---|------|-------------------|
|
||||
| 2 | 품목 단가 초기화 → 품목 단가만 | PriceAdjustmentSection.tsx |
|
||||
| 3 | 견적 상세 인풋 필드 추가 | EstimateDetailTableSection.tsx |
|
||||
| 4 | 견적 상세 초기화 버튼 수정 | EstimateDetailTableSection.tsx |
|
||||
| 5 | 각 섹션별 초기화 분리 | 각 Section 컴포넌트 |
|
||||
@@ -0,0 +1,292 @@
|
||||
# OrderDetailForm.tsx 분리 계획서
|
||||
|
||||
**생성일**: 2026-01-05
|
||||
**현재 파일 크기**: 1,273줄
|
||||
**목표**: 유지보수성 향상을 위한 컴포넌트 분리
|
||||
|
||||
---
|
||||
|
||||
## 현재 파일 구조 분석
|
||||
|
||||
| 영역 | 라인 | 비율 | 내용 |
|
||||
|------|------|------|------|
|
||||
| Import & Types | 1-69 | 5% | 의존성 및 타입 import |
|
||||
| Props Interface | 70-74 | 0.5% | 컴포넌트 props |
|
||||
| State & Hooks | 76-113 | 3% | 상태 관리 (12개 useState) |
|
||||
| Handlers | 114-433 | 25% | 핸들러 함수들 (20+개) |
|
||||
| JSX Render | 435-1271 | 66% | UI 렌더링 |
|
||||
|
||||
### 주요 핸들러 분류 (114-433줄)
|
||||
- **Navigation**: handleBack, handleEdit, handleCancel (114-125)
|
||||
- **Form Field**: handleFieldChange (127-133)
|
||||
- **CRUD Operations**: handleSave, handleDelete, handleDuplicate (135-199)
|
||||
- **Category Operations**: handleAddCategory, handleDeleteCategory, handleCategoryChange (206-247)
|
||||
- **Item Operations**: handleAddItems, handleDeleteSelectedItems, handleDeleteAllItems, handleItemChange (249-327)
|
||||
- **Selection**: handleToggleSelection, handleToggleSelectAll (330-357)
|
||||
- **Calendar**: handleCalendarDateClick, handleCalendarMonthChange (359-385)
|
||||
|
||||
### 주요 JSX 영역 (435-1271줄)
|
||||
- **발주 정보 Card**: 447-559 (112줄)
|
||||
- **계약 정보 Card**: 561-694 (133줄)
|
||||
- **발주 스케줄 Calendar**: 696-715 (19줄)
|
||||
- **발주 상세 테이블**: 717-1172 (455줄) ⚠️ **가장 큰 부분**
|
||||
- **카테고리 추가 버튼**: 1174-1182 (8줄)
|
||||
- **비고 Card**: 1184-1198 (14줄)
|
||||
- **Dialogs**: 1201-1261 (60줄)
|
||||
- **Document Modal**: 1263-1270 (7줄)
|
||||
|
||||
---
|
||||
|
||||
## 분리 계획
|
||||
|
||||
### Phase 1: 커스텀 훅 분리
|
||||
|
||||
**파일**: `hooks/useOrderDetailForm.ts`
|
||||
**예상 크기**: ~250줄
|
||||
|
||||
```typescript
|
||||
// 추출할 내용
|
||||
- formData 상태 관리
|
||||
- selectedItems, addCounts, categoryFilters 상태
|
||||
- calendarDate, selectedCalendarDate 상태
|
||||
- 모든 핸들러 함수들
|
||||
- calendarEvents useMemo
|
||||
```
|
||||
|
||||
**장점**:
|
||||
- 비즈니스 로직과 UI 분리
|
||||
- 테스트 용이성 향상
|
||||
- 재사용 가능
|
||||
|
||||
---
|
||||
|
||||
### Phase 2: 카드 컴포넌트 분리
|
||||
|
||||
#### 2-1. `cards/OrderInfoCard.tsx`
|
||||
**예상 크기**: ~120줄
|
||||
|
||||
```typescript
|
||||
interface OrderInfoCardProps {
|
||||
formData: OrderDetailFormData;
|
||||
isViewMode: boolean;
|
||||
onFieldChange: (field: keyof OrderDetailFormData, value: any) => void;
|
||||
}
|
||||
```
|
||||
|
||||
**포함 내용**: 발주번호, 발주일, 구분, 상태, 발주담당자, 화물도착지
|
||||
|
||||
---
|
||||
|
||||
#### 2-2. `cards/ContractInfoCard.tsx`
|
||||
**예상 크기**: ~150줄
|
||||
|
||||
```typescript
|
||||
interface ContractInfoCardProps {
|
||||
formData: OrderDetailFormData;
|
||||
isViewMode: boolean;
|
||||
isEditMode: boolean;
|
||||
onFieldChange: (field: keyof OrderDetailFormData, value: any) => void;
|
||||
}
|
||||
```
|
||||
|
||||
**포함 내용**: 거래처명, 현장명, 계약번호, 공사PM, 공사담당자
|
||||
|
||||
---
|
||||
|
||||
#### 2-3. `cards/OrderScheduleCard.tsx`
|
||||
**예상 크기**: ~50줄
|
||||
|
||||
```typescript
|
||||
interface OrderScheduleCardProps {
|
||||
events: ScheduleEvent[];
|
||||
currentDate: Date;
|
||||
selectedDate: Date | null;
|
||||
onDateClick: (date: Date) => void;
|
||||
onMonthChange: (date: Date) => void;
|
||||
}
|
||||
```
|
||||
|
||||
**포함 내용**: ScheduleCalendar 래핑
|
||||
|
||||
---
|
||||
|
||||
#### 2-4. `cards/OrderMemoCard.tsx`
|
||||
**예상 크기**: ~40줄
|
||||
|
||||
```typescript
|
||||
interface OrderMemoCardProps {
|
||||
memo: string;
|
||||
isViewMode: boolean;
|
||||
onMemoChange: (value: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
**포함 내용**: 비고 Textarea
|
||||
|
||||
---
|
||||
|
||||
### Phase 3: 테이블 컴포넌트 분리 (가장 중요)
|
||||
|
||||
#### 3-1. `tables/OrderDetailItemTable.tsx`
|
||||
**예상 크기**: ~350줄
|
||||
|
||||
```typescript
|
||||
interface OrderDetailItemTableProps {
|
||||
category: OrderDetailCategory;
|
||||
isEditMode: boolean;
|
||||
isViewMode: boolean;
|
||||
selectedItems: Set<string>;
|
||||
addCount: number;
|
||||
onAddCountChange: (count: number) => void;
|
||||
onAddItems: (count: number) => void;
|
||||
onDeleteSelectedItems: () => void;
|
||||
onDeleteAllItems: () => void;
|
||||
onCategoryChange: (field: keyof OrderDetailCategory, value: string) => void;
|
||||
onItemChange: (itemId: string, field: keyof OrderDetailItem, value: any) => void;
|
||||
onToggleSelection: (itemId: string) => void;
|
||||
onToggleSelectAll: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
**포함 내용**:
|
||||
- 카드 헤더 (왼쪽: 발주 상세/N건 선택/삭제, 오른쪽: 숫자/추가/카테고리/🗑️)
|
||||
- 테이블 전체 (TableHeader + TableBody)
|
||||
- 합계 행
|
||||
|
||||
---
|
||||
|
||||
#### 3-2. `tables/OrderDetailItemRow.tsx` (선택적)
|
||||
**예상 크기**: ~150줄
|
||||
|
||||
```typescript
|
||||
interface OrderDetailItemRowProps {
|
||||
item: OrderDetailItem;
|
||||
index: number;
|
||||
isEditMode: boolean;
|
||||
isSelected: boolean;
|
||||
onItemChange: (field: keyof OrderDetailItem, value: any) => void;
|
||||
onToggleSelection: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
**포함 내용**: 단일 테이블 행 렌더링
|
||||
|
||||
---
|
||||
|
||||
### Phase 4: 다이얼로그 분리
|
||||
|
||||
#### 4-1. `dialogs/OrderDialogs.tsx`
|
||||
**예상 크기**: ~80줄
|
||||
|
||||
```typescript
|
||||
interface OrderDialogsProps {
|
||||
// 저장 다이얼로그
|
||||
showSaveDialog: boolean;
|
||||
onSaveDialogChange: (open: boolean) => void;
|
||||
onConfirmSave: () => void;
|
||||
// 삭제 다이얼로그
|
||||
showDeleteDialog: boolean;
|
||||
onDeleteDialogChange: (open: boolean) => void;
|
||||
onConfirmDelete: () => void;
|
||||
// 카테고리 삭제 다이얼로그
|
||||
showCategoryDeleteDialog: string | null;
|
||||
onCategoryDeleteDialogChange: (categoryId: string | null) => void;
|
||||
onConfirmDeleteCategory: () => void;
|
||||
// 공통
|
||||
isLoading: boolean;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 분리 후 예상 구조
|
||||
|
||||
```
|
||||
src/components/business/juil/order-management/
|
||||
├── OrderDetailForm.tsx (~200줄, 메인 컴포넌트)
|
||||
├── hooks/
|
||||
│ └── useOrderDetailForm.ts (~250줄, 비즈니스 로직)
|
||||
├── cards/
|
||||
│ ├── OrderInfoCard.tsx (~120줄)
|
||||
│ ├── ContractInfoCard.tsx (~150줄)
|
||||
│ ├── OrderScheduleCard.tsx (~50줄)
|
||||
│ └── OrderMemoCard.tsx (~40줄)
|
||||
├── tables/
|
||||
│ ├── OrderDetailItemTable.tsx (~350줄)
|
||||
│ └── OrderDetailItemRow.tsx (~150줄, 선택적)
|
||||
├── dialogs/
|
||||
│ └── OrderDialogs.tsx (~80줄)
|
||||
├── modals/
|
||||
│ └── OrderDocumentModal.tsx (기존)
|
||||
├── actions.ts (기존)
|
||||
└── types.ts (기존)
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 분리 전후 비교
|
||||
|
||||
| 지표 | Before | After |
|
||||
|------|--------|-------|
|
||||
| 메인 파일 크기 | 1,273줄 | ~200줄 |
|
||||
| 가장 큰 파일 | 1,273줄 | ~350줄 |
|
||||
| 파일 개수 | 1 | 8-9 |
|
||||
| 테스트 용이성 | 낮음 | 높음 |
|
||||
| 재사용성 | 낮음 | 중간 |
|
||||
|
||||
---
|
||||
|
||||
## 실행 체크리스트
|
||||
|
||||
### Phase 1: 커스텀 훅 분리
|
||||
- [ ] `hooks/useOrderDetailForm.ts` 생성
|
||||
- [ ] 상태 변수들 이동
|
||||
- [ ] 핸들러 함수들 이동
|
||||
- [ ] useMemo 이동
|
||||
- [ ] OrderDetailForm.tsx에서 훅 사용
|
||||
|
||||
### Phase 2: 카드 컴포넌트 분리
|
||||
- [ ] `cards/OrderInfoCard.tsx` 생성
|
||||
- [ ] `cards/ContractInfoCard.tsx` 생성
|
||||
- [ ] `cards/OrderScheduleCard.tsx` 생성
|
||||
- [ ] `cards/OrderMemoCard.tsx` 생성
|
||||
- [ ] OrderDetailForm.tsx에서 import 및 사용
|
||||
|
||||
### Phase 3: 테이블 컴포넌트 분리
|
||||
- [ ] `tables/OrderDetailItemTable.tsx` 생성
|
||||
- [ ] `tables/OrderDetailItemRow.tsx` 생성 (선택적)
|
||||
- [ ] OrderDetailForm.tsx에서 import 및 사용
|
||||
|
||||
### Phase 4: 다이얼로그 분리
|
||||
- [ ] `dialogs/OrderDialogs.tsx` 생성
|
||||
- [ ] OrderDetailForm.tsx에서 import 및 사용
|
||||
|
||||
### Phase 5: 최종 검증
|
||||
- [ ] TypeScript 타입 오류 없음
|
||||
- [ ] ESLint 경고 없음
|
||||
- [ ] 빌드 성공
|
||||
- [ ] 기능 테스트 (view/edit 모드)
|
||||
- [ ] 불필요한 import 제거
|
||||
|
||||
---
|
||||
|
||||
## 우선순위 권장
|
||||
|
||||
1. **Phase 1 (Hook)** + **Phase 3 (Table)** 먼저 진행
|
||||
- 가장 큰 효과 (전체 코드의 ~60% 분리)
|
||||
- 테이블이 455줄로 가장 큼
|
||||
|
||||
2. Phase 2 (Cards) 진행
|
||||
- 추가 ~360줄 분리
|
||||
|
||||
3. Phase 4 (Dialogs) 진행
|
||||
- 마무리 정리
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
- **타입 export**: 새 컴포넌트에서 사용할 타입들 types.ts에서 export 확인
|
||||
- **props drilling**: 너무 깊어지면 Context 고려
|
||||
- **테스트**: 분리 후 view/edit 모드 모두 테스트 필수
|
||||
- **점진적 진행**: 한 번에 모든 분리보다 단계별 진행 권장
|
||||
@@ -0,0 +1,323 @@
|
||||
# 발주관리 페이지 구현 계획서
|
||||
|
||||
> **작성일**: 2026-01-05
|
||||
> **작업 경로**: `/juil/order/order-management`
|
||||
> **상태**: ✅ 구현 완료
|
||||
|
||||
---
|
||||
|
||||
## 📋 스크린샷 분석 결과
|
||||
|
||||
### 화면 구성
|
||||
|
||||
#### 1. 상단 - 발주 스케줄 (달력 영역)
|
||||
| 요소 | 설명 |
|
||||
|------|------|
|
||||
| **뷰 전환** | 주(Week) / 월(Month) 탭 전환 |
|
||||
| **년월 네비게이션** | 2025년 12월 ◀ ▶ 버튼 |
|
||||
| **필터** | 작업반장별 필터 (이번년+8주 화살표 버튼) |
|
||||
| **일정 바(Bar)** | "담당자 - 현장명 / 발주번호" 형태로 여러 날에 걸쳐 표시 |
|
||||
| **일정 색상** | 회색(완료), 파란색(진행중) 구분 |
|
||||
| **일자 뱃지** | 빨간 원 안에 숫자 (06, 07, 08 등) - 상태/건수 표시 |
|
||||
| **더보기** | +15 형태로 해당 일자에 추가 일정 있음 표시 |
|
||||
| **달력 클릭** | 특정 일자 클릭 시 아래 리스트에 해당 일자 데이터만 필터링 |
|
||||
|
||||
#### 2. 하단 - 발주 목록 (리스트 영역)
|
||||
| 요소 | 설명 |
|
||||
|------|------|
|
||||
| **날짜 범위** | 2025-09-01 ~ 2025-09-03 형태 |
|
||||
| **빠른 필터 탭** | 당해년도 / 전년도 / 전월 / 당월 / 어제 / 오늘 |
|
||||
| **검색** | 검색창 + 건수 표시 (7건, 12건 선택) |
|
||||
| **상태 필터** | 빨간 원 숫자 버튼들 (전체/상태별) |
|
||||
| **삭제 버튼** | 선택된 항목 삭제 |
|
||||
|
||||
#### 3. 테이블 컬럼
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| 체크박스 | 선택 |
|
||||
| 계약일련번호 | - |
|
||||
| 거래처 | 회사명 |
|
||||
| 현장명 | 작업 현장 |
|
||||
| 병동 | - |
|
||||
| 공 | - |
|
||||
| 시APM | 담당 PM |
|
||||
| 발주번호 | 발주 식별 번호 |
|
||||
| 발주번 담자 | 발주 담당자 |
|
||||
| 발주처 | - |
|
||||
| 작업반 시공품 | 작업 내용 |
|
||||
| 기간 | 작업 기간 |
|
||||
| 구분 | 상태 구분 |
|
||||
| 실적 납품일 | 실제 납품 완료일 |
|
||||
| 납품일 | 예정 납품일 |
|
||||
|
||||
#### 4. 작업 버튼 (선택 시)
|
||||
- 수정 버튼
|
||||
- 삭제 버튼
|
||||
|
||||
---
|
||||
|
||||
## 🏗️ 구현 범위
|
||||
|
||||
### Phase 1: 공통 달력 컴포넌트 (ScheduleCalendar)
|
||||
**재사용 가능한 스케줄 달력 컴포넌트**
|
||||
|
||||
```
|
||||
src/components/common/
|
||||
└── ScheduleCalendar/
|
||||
├── index.tsx # 메인 컴포넌트
|
||||
├── ScheduleCalendar.tsx # 달력 본체
|
||||
├── CalendarHeader.tsx # 헤더 (년월/뷰전환/필터)
|
||||
├── MonthView.tsx # 월간 뷰
|
||||
├── WeekView.tsx # 주간 뷰
|
||||
├── ScheduleBar.tsx # 일정 바 컴포넌트
|
||||
├── DayCell.tsx # 일자 셀 컴포넌트
|
||||
├── MorePopover.tsx # +N 더보기 팝오버
|
||||
├── types.ts # 타입 정의
|
||||
└── utils.ts # 유틸리티 함수
|
||||
```
|
||||
|
||||
**기능 요구사항**:
|
||||
- [ ] 월간/주간 뷰 전환
|
||||
- [ ] 년월 네비게이션 (이전/다음)
|
||||
- [ ] 일정 바(Bar) 렌더링 (여러 날에 걸침)
|
||||
- [ ] 일정 색상 구분 (상태별)
|
||||
- [ ] 일자별 뱃지 숫자 표시
|
||||
- [ ] +N 더보기 기능 (3개 초과 시)
|
||||
- [ ] 일자 클릭 이벤트 콜백
|
||||
- [ ] 필터 영역 slot (외부에서 주입)
|
||||
- [ ] 반응형 디자인
|
||||
|
||||
### Phase 2: 발주관리 리스트 페이지
|
||||
**페이지 및 컴포넌트 구조**
|
||||
|
||||
```
|
||||
src/app/[locale]/(protected)/juil/order/
|
||||
└── order-management/
|
||||
└── page.tsx # 페이지 엔트리
|
||||
|
||||
src/components/business/juil/order-management/
|
||||
├── OrderManagementListClient.tsx # 메인 클라이언트 컴포넌트
|
||||
├── OrderCalendarSection.tsx # 달력 섹션 (ScheduleCalendar 사용)
|
||||
├── OrderListSection.tsx # 리스트 섹션
|
||||
├── OrderStatusFilter.tsx # 상태 필터 (빨간 원 숫자)
|
||||
├── OrderDateFilter.tsx # 날짜 빠른 필터 (당해년도/전월 등)
|
||||
├── types.ts # 타입 정의
|
||||
├── actions.ts # Server Actions
|
||||
└── index.ts # 배럴 export
|
||||
```
|
||||
|
||||
**기능 요구사항**:
|
||||
- [ ] 달력과 리스트 통합 레이아웃
|
||||
- [ ] 달력 일자 클릭 → 리스트 필터 연동
|
||||
- [ ] 날짜 범위 선택
|
||||
- [ ] 빠른 날짜 필터 (당해년도/전년도/전월/당월/어제/오늘)
|
||||
- [ ] 상태별 필터 (빨간 원 숫자 버튼)
|
||||
- [ ] 검색 기능
|
||||
- [ ] 테이블 (체크박스/정렬/페이지네이션)
|
||||
- [ ] 선택 시 작업 버튼 표시
|
||||
- [ ] 삭제 기능
|
||||
|
||||
---
|
||||
|
||||
## 📦 기술 의존성
|
||||
|
||||
### 새로 설치 필요
|
||||
```bash
|
||||
# FullCalendar 라이브러리 (또는 커스텀 구현)
|
||||
npm install @fullcalendar/core @fullcalendar/react @fullcalendar/daygrid @fullcalendar/timegrid @fullcalendar/interaction
|
||||
```
|
||||
|
||||
**대안**: FullCalendar 없이 커스텀 달력 컴포넌트로 구현
|
||||
- 장점: 번들 사이즈 감소, 완전한 커스터마이징
|
||||
- 단점: 구현 복잡도 증가
|
||||
|
||||
### 기존 사용
|
||||
- `IntegratedListTemplateV2` - 리스트 템플릿
|
||||
- `DateRangeSelector` - 날짜 범위 선택
|
||||
- `date-fns` - 날짜 유틸리티
|
||||
|
||||
---
|
||||
|
||||
## 🔧 세부 구현 체크리스트
|
||||
|
||||
### Phase 1: 공통 달력 컴포넌트 (ScheduleCalendar)
|
||||
|
||||
#### 1.1 기본 구조 및 타입 정의
|
||||
- [ ] `types.ts` 생성 (ScheduleEvent, CalendarView, CalendarProps 등)
|
||||
- [ ] `utils.ts` 생성 (날짜 계산, 일정 위치 계산 등)
|
||||
- [ ] 컴포넌트 폴더 구조 생성
|
||||
|
||||
#### 1.2 CalendarHeader 컴포넌트
|
||||
- [ ] 년월 표시 및 네비게이션 (◀ ▶)
|
||||
- [ ] 주/월 뷰 전환 탭
|
||||
- [ ] 필터 slot (children으로 외부 주입)
|
||||
|
||||
#### 1.3 MonthView 컴포넌트
|
||||
- [ ] 월간 그리드 레이아웃 (7x6)
|
||||
- [ ] 요일 헤더 (일~토)
|
||||
- [ ] 날짜 셀 렌더링
|
||||
- [ ] 이전/다음 달 날짜 표시 (opacity 처리)
|
||||
- [ ] 오늘 날짜 하이라이트
|
||||
|
||||
#### 1.4 WeekView 컴포넌트
|
||||
- [ ] 주간 그리드 레이아웃 (7 컬럼)
|
||||
- [ ] 요일 헤더 (날짜 + 요일)
|
||||
- [ ] 날짜 셀 렌더링
|
||||
|
||||
#### 1.5 DayCell 컴포넌트
|
||||
- [ ] 날짜 숫자 표시
|
||||
- [ ] 뱃지 숫자 표시 (빨간 원)
|
||||
- [ ] 클릭 이벤트 처리
|
||||
- [ ] 선택 상태 스타일
|
||||
|
||||
#### 1.6 ScheduleBar 컴포넌트
|
||||
- [ ] 일정 바 렌더링 (시작~종료 날짜)
|
||||
- [ ] 여러 날에 걸치는 바 계산 (주 단위 분할)
|
||||
- [ ] 색상 구분 (상태별)
|
||||
- [ ] 호버/클릭 이벤트
|
||||
- [ ] 텍스트 truncate 처리
|
||||
|
||||
#### 1.7 MorePopover 컴포넌트
|
||||
- [ ] +N 버튼 렌더링
|
||||
- [ ] 팝오버로 숨겨진 일정 목록 표시
|
||||
- [ ] 일정 항목 클릭 이벤트
|
||||
|
||||
#### 1.8 메인 ScheduleCalendar 컴포넌트
|
||||
- [ ] 상태 관리 (현재 월, 뷰 모드, 선택된 날짜)
|
||||
- [ ] 일정 데이터 받아서 렌더링
|
||||
- [ ] 이벤트 콜백 (onDateClick, onEventClick, onMonthChange)
|
||||
- [ ] 반응형 처리
|
||||
|
||||
### Phase 2: 발주관리 리스트 페이지
|
||||
|
||||
#### 2.1 타입 및 설정
|
||||
- [ ] `types.ts` - Order 타입, 필터 옵션, 상태 정의
|
||||
- [ ] `actions.ts` - Server Actions (목업 데이터)
|
||||
|
||||
#### 2.2 page.tsx
|
||||
- [ ] 페이지 라우트 생성
|
||||
- [ ] 메타데이터 설정
|
||||
- [ ] 클라이언트 컴포넌트 import
|
||||
|
||||
#### 2.3 OrderDateFilter 컴포넌트
|
||||
- [ ] 빠른 날짜 필터 버튼 (당해년도/전년도/전월/당월/어제/오늘)
|
||||
- [ ] 클릭 시 날짜 범위 계산
|
||||
- [ ] 활성화 상태 스타일
|
||||
|
||||
#### 2.4 OrderStatusFilter 컴포넌트
|
||||
- [ ] 상태별 필터 버튼 (빨간 원 숫자)
|
||||
- [ ] 전체/상태별 카운트 표시
|
||||
- [ ] 선택 상태 스타일
|
||||
|
||||
#### 2.5 OrderCalendarSection 컴포넌트
|
||||
- [ ] ScheduleCalendar 사용
|
||||
- [ ] 필터 영역 (작업반장 셀렉트)
|
||||
- [ ] 일자 클릭 이벤트 → 리스트 필터 연동
|
||||
- [ ] 스케줄 데이터 매핑
|
||||
|
||||
#### 2.6 OrderListSection 컴포넌트
|
||||
- [ ] IntegratedListTemplateV2 기반
|
||||
- [ ] 테이블 컬럼 정의
|
||||
- [ ] 행 렌더링 (체크박스, 데이터, 작업 버튼)
|
||||
- [ ] 선택 시 작업 버튼 표시
|
||||
- [ ] 모바일 카드 렌더링
|
||||
|
||||
#### 2.7 OrderManagementListClient 컴포넌트
|
||||
- [ ] 전체 상태 관리 (달력 + 리스트 연동)
|
||||
- [ ] 달력 일자 선택 → 리스트 필터
|
||||
- [ ] 날짜 범위 필터
|
||||
- [ ] 상태 필터
|
||||
- [ ] 검색 필터
|
||||
- [ ] 정렬
|
||||
- [ ] 페이지네이션
|
||||
- [ ] 삭제 기능
|
||||
|
||||
### Phase 3: 통합 테스트 및 마무리
|
||||
- [ ] 달력-리스트 연동 테스트
|
||||
- [ ] 반응형 테스트
|
||||
- [ ] 목업 데이터 검증
|
||||
- [ ] 테스트 URL 등록
|
||||
|
||||
---
|
||||
|
||||
## 🎨 디자인 명세
|
||||
|
||||
### 달력 색상
|
||||
| 상태 | 바 색상 | 뱃지 색상 |
|
||||
|------|---------|-----------|
|
||||
| 완료 | 회색 (`bg-gray-400`) | - |
|
||||
| 진행중 | 파란색 (`bg-blue-500`) | 빨간색 (`bg-red-500`) |
|
||||
| 대기 | 노란색 (`bg-yellow-500`) | 빨간색 (`bg-red-500`) |
|
||||
|
||||
### 레이아웃
|
||||
```
|
||||
+--------------------------------------------------+
|
||||
| 📅 발주관리 [발주 등록] |
|
||||
+--------------------------------------------------+
|
||||
| [발주 스케줄] |
|
||||
| +----------------------------------------------+ |
|
||||
| | 2025년 12월 [주] [월] [작업반장 ▼] | |
|
||||
| | ◀ ▶ | |
|
||||
| |----------------------------------------------|
|
||||
| | 일 | 월 | 화 | 수 | 목 | 금 | 토 | |
|
||||
| |----------------------------------------------|
|
||||
| | | | 1 | 2 | 3 | 4 | 5 | |
|
||||
| | 📊 | | ━━━━━━━━━━━━━━━━━━━ 일정바 ━━━━━━ | |
|
||||
| |----------------------------------------------|
|
||||
| | 6 | 7 | 8 | 9 | 10 | 11 | 12 | |
|
||||
| | ⓪ | ⓪ | | | | | | |
|
||||
| +----------------------------------------------+ |
|
||||
+--------------------------------------------------+
|
||||
| [발주 목록] |
|
||||
| +----------------------------------------------+ |
|
||||
| | 2025-09-01 ~ 2025-09-03 | |
|
||||
| | [당해년도][전년도][전월][당월][어제][오늘] | |
|
||||
| |----------------------------------------------|
|
||||
| | 🔍 검색... 7건 | ⓿ ❶ ❷ ❸ | [삭제] | |
|
||||
| |----------------------------------------------|
|
||||
| | ☐ | 번호 | 거래처 | 현장명 | ... | 작업 | |
|
||||
| | ☐ | 1 | A사 | 현장1 | ... | [버튼들] | |
|
||||
| +----------------------------------------------+ |
|
||||
+--------------------------------------------------+
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 📝 참고사항
|
||||
|
||||
### 달력 라이브러리 선택
|
||||
**추천: 커스텀 구현**
|
||||
- FullCalendar는 기능이 과도하고 번들 사이즈가 큼
|
||||
- 스크린샷의 요구사항은 커스텀으로 충분히 구현 가능
|
||||
- `date-fns` 활용하여 날짜 계산
|
||||
|
||||
### 기존 패턴 준수
|
||||
- `IntegratedListTemplateV2` 사용
|
||||
- `DateRangeSelector` 재사용
|
||||
- `StructureReviewListClient` 패턴 참조
|
||||
|
||||
### 향후 확장
|
||||
- 다른 페이지에서 ScheduleCalendar 재사용
|
||||
- 일정 등록/수정 모달 추가 예정
|
||||
- 드래그 앤 드롭 일정 이동 (선택적)
|
||||
|
||||
---
|
||||
|
||||
## ✅ 작업 순서
|
||||
|
||||
1. **Phase 1.1-1.2**: 타입 정의 및 CalendarHeader
|
||||
2. **Phase 1.3-1.4**: MonthView / WeekView
|
||||
3. **Phase 1.5-1.6**: DayCell / ScheduleBar
|
||||
4. **Phase 1.7-1.8**: MorePopover / 메인 컴포넌트
|
||||
5. **Phase 2.1-2.2**: 발주관리 타입 및 페이지
|
||||
6. **Phase 2.3-2.4**: 날짜/상태 필터
|
||||
7. **Phase 2.5-2.6**: 달력/리스트 섹션
|
||||
8. **Phase 2.7**: 메인 클라이언트 컴포넌트
|
||||
9. **Phase 3**: 통합 테스트
|
||||
|
||||
---
|
||||
|
||||
## 🔗 관련 문서
|
||||
- `[REF] juil-project-structure.md` - 주일 프로젝트 구조
|
||||
- `StructureReviewListClient.tsx` - 리스트 패턴 참조
|
||||
- `IntegratedListTemplateV2.tsx` - 템플릿 참조
|
||||
69
package-lock.json
generated
69
package-lock.json
generated
@@ -40,19 +40,20 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"immer": "^11.0.1",
|
||||
"lucide-react": "^0.552.0",
|
||||
"next": "^15.5.7",
|
||||
"next": "^15.5.9",
|
||||
"next-intl": "^4.4.0",
|
||||
"react": "^19.2.1",
|
||||
"react": "^19.2.3",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-hook-form": "^7.66.0",
|
||||
"recharts": "^3.4.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.1.12",
|
||||
"zustand": "^5.0.8"
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
@@ -944,9 +945,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@next/env": {
|
||||
"version": "15.5.7",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.7.tgz",
|
||||
"integrity": "sha512-4h6Y2NyEkIEN7Z8YxkA27pq6zTkS09bUSYC0xjd0NpwFxjnIKeZEeH591o5WECSmjpUhLn3H2QLJcDye3Uzcvg==",
|
||||
"version": "15.5.9",
|
||||
"resolved": "https://registry.npmjs.org/@next/env/-/env-15.5.9.tgz",
|
||||
"integrity": "sha512-4GlTZ+EJM7WaW2HEZcyU317tIQDjkQIyENDLxYJfSWlfqguN+dHkZgyQTV/7ykvobU7yEH5gKvreNrH4B6QgIg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@next/eslint-plugin-next": {
|
||||
@@ -3443,6 +3444,16 @@
|
||||
}
|
||||
}
|
||||
},
|
||||
"node_modules/@reduxjs/toolkit/node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/@remirror/core-constants": {
|
||||
"version": "3.0.0",
|
||||
"resolved": "https://registry.npmjs.org/@remirror/core-constants/-/core-constants-3.0.0.tgz",
|
||||
@@ -6912,9 +6923,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"version": "11.0.1",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-11.0.1.tgz",
|
||||
"integrity": "sha512-naDCyggtcBWANtIrjQEajhhBEuL9b0Zg4zmlWK2CzS6xCWSE39/vvf4LqnMjUAWHBhot4m9MHCM/Z+mfWhUkiA==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
@@ -8011,12 +8022,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/next": {
|
||||
"version": "15.5.7",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.7.tgz",
|
||||
"integrity": "sha512-+t2/0jIJ48kUpGKkdlhgkv+zPTEOoXyr60qXe68eB/pl3CMJaLeIGjzp5D6Oqt25hCBiBTt8wEeeAzfJvUKnPQ==",
|
||||
"version": "15.5.9",
|
||||
"resolved": "https://registry.npmjs.org/next/-/next-15.5.9.tgz",
|
||||
"integrity": "sha512-agNLK89seZEtC5zUHwtut0+tNrc0Xw4FT/Dg+B/VLEo9pAcS9rtTKpek3V6kVcVwsB2YlqMaHdfZL4eLEVYuCg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@next/env": "15.5.7",
|
||||
"@next/env": "15.5.9",
|
||||
"@swc/helpers": "0.5.15",
|
||||
"caniuse-lite": "^1.0.30001579",
|
||||
"postcss": "8.4.31",
|
||||
@@ -8702,9 +8713,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/react": {
|
||||
"version": "19.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.1.tgz",
|
||||
"integrity": "sha512-DGrYcCWK7tvYMnWh79yrPHt+vdx9tY+1gPZa7nJQtO/p8bLTDaHp4dzwEhQB7pZ4Xe3ok4XKuEPrVuc+wlpkmw==",
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react/-/react-19.2.3.tgz",
|
||||
"integrity": "sha512-Ku/hhYbVjOQnXDZFv2+RibmLFGwFdeeKHFcOTlrt7xplBnya5OGn/hIRDsqDiSUcfORsDC7MPxwork8jBwsIWA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
@@ -8732,15 +8743,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/react-dom": {
|
||||
"version": "19.2.1",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.1.tgz",
|
||||
"integrity": "sha512-ibrK8llX2a4eOskq1mXKu/TGZj9qzomO+sNfO98M6d9zIPOEhlBkMkBUBLd1vgS0gQsLDBzA+8jJBVXDnfHmJg==",
|
||||
"version": "19.2.3",
|
||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.3.tgz",
|
||||
"integrity": "sha512-yELu4WmLPw5Mr/lmeEpox5rw3RETacE++JgHqQzd2dg+YbJuat3jH4ingc+WPZhxaoFzdv9y33G+F7Nl5O0GBg==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"scheduler": "^0.27.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"react": "^19.2.1"
|
||||
"react": "^19.2.3"
|
||||
}
|
||||
},
|
||||
"node_modules/react-hook-form": {
|
||||
@@ -8887,6 +8898,16 @@
|
||||
"react-is": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/recharts/node_modules/immer": {
|
||||
"version": "10.2.0",
|
||||
"resolved": "https://registry.npmjs.org/immer/-/immer-10.2.0.tgz",
|
||||
"integrity": "sha512-d/+XTN3zfODyjr89gM3mPq1WNX2B8pYsu7eORitdwyA2sBubnTl3laYlBk4sXY5FUa5qTZGBDPJICVbvqzjlbw==",
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
"type": "opencollective",
|
||||
"url": "https://opencollective.com/immer"
|
||||
}
|
||||
},
|
||||
"node_modules/redux": {
|
||||
"version": "5.0.1",
|
||||
"resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz",
|
||||
@@ -10104,9 +10125,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/zustand": {
|
||||
"version": "5.0.8",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.8.tgz",
|
||||
"integrity": "sha512-gyPKpIaxY9XcO2vSMrLbiER7QMAMGOQZVRdJ6Zi782jkbzZygq5GI9nG8g+sMgitRtndwaBSl7uiqC49o1SSiw==",
|
||||
"version": "5.0.9",
|
||||
"resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.9.tgz",
|
||||
"integrity": "sha512-ALBtUj0AfjJt3uNRQoL1tL2tMvj6Gp/6e39dnfT6uzpelGru8v1tPOGBzayOWbPJvujM8JojDk3E1LxeFisBNg==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=12.20.0"
|
||||
|
||||
@@ -44,19 +44,20 @@
|
||||
"clsx": "^2.1.1",
|
||||
"cmdk": "^1.1.1",
|
||||
"date-fns": "^4.1.0",
|
||||
"immer": "^11.0.1",
|
||||
"lucide-react": "^0.552.0",
|
||||
"next": "^15.5.7",
|
||||
"next": "^15.5.9",
|
||||
"next-intl": "^4.4.0",
|
||||
"react": "^19.2.1",
|
||||
"react": "^19.2.3",
|
||||
"react-day-picker": "^9.11.1",
|
||||
"react-dom": "^19.2.1",
|
||||
"react-dom": "^19.2.3",
|
||||
"react-hook-form": "^7.66.0",
|
||||
"recharts": "^3.4.1",
|
||||
"sonner": "^2.0.7",
|
||||
"tailwind-merge": "^3.3.1",
|
||||
"vaul": "^1.1.2",
|
||||
"zod": "^4.1.12",
|
||||
"zustand": "^5.0.8"
|
||||
"zustand": "^5.0.9"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.57.0",
|
||||
|
||||
@@ -66,7 +66,7 @@ export default function MobileAttendancePage() {
|
||||
|
||||
const fetchTodayAttendance = async () => {
|
||||
try {
|
||||
const result = await getTodayAttendance(userId);
|
||||
const result = await getTodayAttendance();
|
||||
if (result.success && result.data) {
|
||||
// 이미 출근한 경우
|
||||
if (result.data.checkIn) {
|
||||
|
||||
@@ -21,13 +21,18 @@ export default function EmployeeEditPage() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await getEmployeeById(id);
|
||||
// __authError 처리
|
||||
if (data && '__authError' in data) {
|
||||
router.push('/ko/login');
|
||||
return;
|
||||
}
|
||||
setEmployee(data);
|
||||
} catch (error) {
|
||||
console.error('[EmployeeEditPage] fetchEmployee error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [params.id]);
|
||||
}, [params.id, router]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEmployee();
|
||||
|
||||
@@ -33,13 +33,18 @@ export default function EmployeeDetailPage() {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const data = await getEmployeeById(id);
|
||||
// __authError 처리
|
||||
if (data && '__authError' in data) {
|
||||
router.push('/ko/login');
|
||||
return;
|
||||
}
|
||||
setEmployee(data);
|
||||
} catch (error) {
|
||||
console.error('[EmployeeDetailPage] fetchEmployee error:', error);
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [params.id]);
|
||||
}, [params.id, router]);
|
||||
|
||||
useEffect(() => {
|
||||
fetchEmployee();
|
||||
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CategoryManagement } from '@/components/business/juil/category-management';
|
||||
|
||||
export default function CategoriesPage() {
|
||||
return <CategoryManagement />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ItemDetailClient } from '@/components/business/juil/item-management';
|
||||
|
||||
interface ItemDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ mode?: string }>;
|
||||
}
|
||||
|
||||
export default async function ItemDetailPage({ params, searchParams }: ItemDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const { mode } = await searchParams;
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
return <ItemDetailClient itemId={id} isEditMode={isEditMode} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ItemDetailClient } from '@/components/business/juil/item-management';
|
||||
|
||||
export default function ItemNewPage() {
|
||||
return <ItemDetailClient isNewMode />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ItemManagementClient } from '@/components/business/juil/item-management';
|
||||
|
||||
export default function ItemManagementPage() {
|
||||
return <ItemManagementClient />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { LaborDetailClient } from '@/components/business/juil/labor-management';
|
||||
|
||||
interface LaborDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ mode?: string }>;
|
||||
}
|
||||
|
||||
export default async function LaborDetailPage({ params, searchParams }: LaborDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const { mode } = await searchParams;
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
return <LaborDetailClient laborId={id} isEditMode={isEditMode} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LaborDetailClient } from '@/components/business/juil/labor-management';
|
||||
|
||||
export default function LaborNewPage() {
|
||||
return <LaborDetailClient isNewMode />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LaborManagementClient } from '@/components/business/juil/labor-management';
|
||||
|
||||
export default function LaborManagementPage() {
|
||||
return <LaborManagementClient />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import PricingDetailClient from '@/components/business/juil/pricing-management/PricingDetailClient';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function PricingEditPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
return <PricingDetailClient id={id} mode="edit" />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import PricingDetailClient from '@/components/business/juil/pricing-management/PricingDetailClient';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function PricingDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
return <PricingDetailClient id={id} mode="view" />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import PricingDetailClient from '@/components/business/juil/pricing-management/PricingDetailClient';
|
||||
|
||||
export default function PricingNewPage() {
|
||||
return <PricingDetailClient mode="create" />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import PricingListClient from '@/components/business/juil/pricing-management/PricingListClient';
|
||||
|
||||
export default function PricingPage() {
|
||||
return <PricingListClient />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { OrderDetailForm } from '@/components/business/juil/order-management';
|
||||
import { getOrderDetailFull } from '@/components/business/juil/order-management/actions';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface OrderEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function OrderEditPage({ params }: OrderEditPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
const result = await getOrderDetailFull(id);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <OrderDetailForm mode="edit" orderId={id} initialData={result.data} />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { OrderDetailForm } from '@/components/business/juil/order-management';
|
||||
import { getOrderDetailFull } from '@/components/business/juil/order-management/actions';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface OrderDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function OrderDetailPage({ params }: OrderDetailPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
const result = await getOrderDetailFull(id);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <OrderDetailForm mode="view" orderId={id} initialData={result.data} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { OrderManagementListClient } from '@/components/business/juil/order-management';
|
||||
|
||||
export default function OrderManagementPage() {
|
||||
return <OrderManagementListClient />;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import SiteDetailForm from '@/components/business/juil/site-management/SiteDetailForm';
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_SITE = {
|
||||
id: '1',
|
||||
siteCode: '123-12-12345',
|
||||
partnerId: '1',
|
||||
partnerName: '거래처명',
|
||||
siteName: '현장명',
|
||||
address: '',
|
||||
status: 'active' as const,
|
||||
createdAt: '2025-09-01T00:00:00Z',
|
||||
updatedAt: '2025-09-01T00:00:00Z',
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function SiteEditPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
// TODO: API에서 현장 정보 조회
|
||||
const site = { ...MOCK_SITE, id };
|
||||
|
||||
return <SiteDetailForm site={site} mode="edit" />;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import SiteDetailForm from '@/components/business/juil/site-management/SiteDetailForm';
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_SITE = {
|
||||
id: '1',
|
||||
siteCode: '123-12-12345',
|
||||
partnerId: '1',
|
||||
partnerName: '거래처명',
|
||||
siteName: '현장명',
|
||||
address: '',
|
||||
status: 'active' as const,
|
||||
createdAt: '2025-09-01T00:00:00Z',
|
||||
updatedAt: '2025-09-01T00:00:00Z',
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function SiteDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
// TODO: API에서 현장 정보 조회
|
||||
const site = { ...MOCK_SITE, id };
|
||||
|
||||
return <SiteDetailForm site={site} mode="view" />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SiteManagementListClient } from '@/components/business/juil/site-management';
|
||||
|
||||
export default function SiteManagementPage() {
|
||||
return <SiteManagementListClient />;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import StructureReviewDetailForm from '@/components/business/juil/structure-review/StructureReviewDetailForm';
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_REVIEW = {
|
||||
id: '1',
|
||||
reviewNumber: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '거래처명A',
|
||||
siteId: '1',
|
||||
siteName: '현장A',
|
||||
requestDate: '2025-12-12',
|
||||
reviewCompany: '회사명',
|
||||
reviewerName: '홍길동',
|
||||
reviewDate: '2025-12-15',
|
||||
completionDate: null,
|
||||
status: 'pending' as const,
|
||||
createdAt: '2025-12-01T00:00:00Z',
|
||||
updatedAt: '2025-12-01T00:00:00Z',
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function StructureReviewEditPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
// TODO: API에서 구조검토 정보 조회
|
||||
const review = { ...MOCK_REVIEW, id };
|
||||
|
||||
return <StructureReviewDetailForm review={review} mode="edit" />;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import StructureReviewDetailForm from '@/components/business/juil/structure-review/StructureReviewDetailForm';
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_REVIEW = {
|
||||
id: '1',
|
||||
reviewNumber: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '거래처명A',
|
||||
siteId: '1',
|
||||
siteName: '현장A',
|
||||
requestDate: '2025-12-12',
|
||||
reviewCompany: '회사명',
|
||||
reviewerName: '홍길동',
|
||||
reviewDate: '2025-12-15',
|
||||
completionDate: null,
|
||||
status: 'pending' as const,
|
||||
createdAt: '2025-12-01T00:00:00Z',
|
||||
updatedAt: '2025-12-01T00:00:00Z',
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function StructureReviewDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
// TODO: API에서 구조검토 정보 조회
|
||||
const review = { ...MOCK_REVIEW, id };
|
||||
|
||||
return <StructureReviewDetailForm review={review} mode="view" />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import StructureReviewListClient from '@/components/business/juil/structure-review/StructureReviewListClient';
|
||||
|
||||
export default function StructureReviewListPage() {
|
||||
return <StructureReviewListClient />;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { BiddingDetailForm, getBiddingDetail } from '@/components/business/juil/bidding';
|
||||
|
||||
interface BiddingEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function BiddingEditPage({ params }: BiddingEditPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getBiddingDetail(id);
|
||||
|
||||
return (
|
||||
<BiddingDetailForm
|
||||
mode="edit"
|
||||
biddingId={id}
|
||||
initialData={result.data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { BiddingDetailForm, getBiddingDetail } from '@/components/business/juil/bidding';
|
||||
|
||||
interface BiddingDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function BiddingDetailPage({ params }: BiddingDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getBiddingDetail(id);
|
||||
|
||||
return (
|
||||
<BiddingDetailForm
|
||||
mode="view"
|
||||
biddingId={id}
|
||||
initialData={result.data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { EstimateDetailForm } from '@/components/business/juil/estimates';
|
||||
import type { EstimateDetail } from '@/components/business/juil/estimates';
|
||||
|
||||
interface EstimateEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// 목업 데이터 - 추후 API 연동
|
||||
async function getEstimateDetail(id: string): Promise<EstimateDetail | null> {
|
||||
// TODO: 실제 API 연동
|
||||
const mockData: EstimateDetail = {
|
||||
id,
|
||||
estimateCode: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
projectName: '현장명',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '이름',
|
||||
itemCount: 21,
|
||||
estimateAmount: 1420000,
|
||||
completedDate: null,
|
||||
bidDate: '2025-12-12',
|
||||
status: 'pending',
|
||||
createdAt: '2025-12-01',
|
||||
updatedAt: '2025-12-01',
|
||||
createdBy: 'hong',
|
||||
siteBriefing: {
|
||||
briefingCode: '123123',
|
||||
partnerName: '회사명',
|
||||
companyName: '회사명',
|
||||
briefingDate: '2025-12-12',
|
||||
attendee: '이름',
|
||||
},
|
||||
bidInfo: {
|
||||
projectName: '현장명',
|
||||
bidDate: '2025-12-12',
|
||||
siteCount: 21,
|
||||
constructionPeriod: '2026-01-01 ~ 2026-12-10',
|
||||
constructionStartDate: '2026-01-01',
|
||||
constructionEndDate: '2026-12-10',
|
||||
vatType: 'excluded',
|
||||
workReport: '업무 보고 내용',
|
||||
documents: [
|
||||
{
|
||||
id: '1',
|
||||
fileName: 'abc.zip',
|
||||
fileUrl: '#',
|
||||
fileSize: 1024000,
|
||||
},
|
||||
],
|
||||
},
|
||||
summaryItems: [
|
||||
{
|
||||
id: '1',
|
||||
name: '서터 심창측공사',
|
||||
quantity: 1,
|
||||
unit: '식',
|
||||
materialCost: 78540000,
|
||||
laborCost: 15410000,
|
||||
totalCost: 93950000,
|
||||
remarks: '',
|
||||
},
|
||||
],
|
||||
expenseItems: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'public_1',
|
||||
amount: 10000,
|
||||
},
|
||||
],
|
||||
priceAdjustments: [
|
||||
{
|
||||
id: '1',
|
||||
category: '배합비',
|
||||
unitPrice: 10000,
|
||||
coating: 10000,
|
||||
batting: 10000,
|
||||
boxReinforce: 10500,
|
||||
painting: 10500,
|
||||
total: 51000,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
category: '재단비',
|
||||
unitPrice: 1375,
|
||||
coating: 0,
|
||||
batting: 0,
|
||||
boxReinforce: 0,
|
||||
painting: 0,
|
||||
total: 1375,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
category: '판매단가',
|
||||
unitPrice: 0,
|
||||
coating: 10000,
|
||||
batting: 10000,
|
||||
boxReinforce: 10500,
|
||||
painting: 10500,
|
||||
total: 41000,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
category: '조립단가',
|
||||
unitPrice: 10300,
|
||||
coating: 10300,
|
||||
batting: 10300,
|
||||
boxReinforce: 10500,
|
||||
painting: 10200,
|
||||
total: 51600,
|
||||
},
|
||||
],
|
||||
detailItems: [
|
||||
{
|
||||
id: '1',
|
||||
no: 1,
|
||||
name: 'FS530외/주차',
|
||||
material: 'screen',
|
||||
width: 2350,
|
||||
height: 2500,
|
||||
quantity: 1,
|
||||
box: 1,
|
||||
assembly: 0,
|
||||
coating: 0,
|
||||
batting: 0,
|
||||
mounting: 0,
|
||||
fitting: 0,
|
||||
controller: 0,
|
||||
widthConstruction: 0,
|
||||
heightConstruction: 0,
|
||||
materialCost: 1420000,
|
||||
laborCost: 510000,
|
||||
quantityPrice: 1930000,
|
||||
expenseQuantity: 5500,
|
||||
expenseTotal: 5500,
|
||||
totalCost: 1930000,
|
||||
otherCost: 0,
|
||||
marginCost: 0,
|
||||
totalPrice: 1930000,
|
||||
unitPrice: 1420000,
|
||||
expense: 0,
|
||||
marginRate: 0,
|
||||
unitQuantity: 1,
|
||||
expenseResult: 0,
|
||||
marginActual: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
no: 2,
|
||||
name: 'FS530외/주차',
|
||||
material: 'screen',
|
||||
width: 7500,
|
||||
height: 2500,
|
||||
quantity: 1,
|
||||
box: 1,
|
||||
assembly: 0,
|
||||
coating: 0,
|
||||
batting: 0,
|
||||
mounting: 0,
|
||||
fitting: 0,
|
||||
controller: 0,
|
||||
widthConstruction: 0,
|
||||
heightConstruction: 0,
|
||||
materialCost: 4720000,
|
||||
laborCost: 780000,
|
||||
quantityPrice: 5500000,
|
||||
expenseQuantity: 5500,
|
||||
expenseTotal: 5500,
|
||||
totalCost: 5500000,
|
||||
otherCost: 0,
|
||||
marginCost: 0,
|
||||
totalPrice: 5500000,
|
||||
unitPrice: 4720000,
|
||||
expense: 0,
|
||||
marginRate: 0,
|
||||
unitQuantity: 1,
|
||||
expenseResult: 0,
|
||||
marginActual: 0,
|
||||
},
|
||||
],
|
||||
approval: {
|
||||
approvers: [],
|
||||
references: [],
|
||||
},
|
||||
};
|
||||
|
||||
return mockData;
|
||||
}
|
||||
|
||||
export default async function EstimateEditPage({ params }: EstimateEditPageProps) {
|
||||
const { id } = await params;
|
||||
const detail = await getEstimateDetail(id);
|
||||
|
||||
return (
|
||||
<EstimateDetailForm
|
||||
mode="edit"
|
||||
estimateId={id}
|
||||
initialData={detail || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { EstimateDetailForm } from '@/components/business/juil/estimates';
|
||||
import type { EstimateDetail } from '@/components/business/juil/estimates';
|
||||
|
||||
interface EstimateDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// 목업 데이터 - 추후 API 연동
|
||||
async function getEstimateDetail(id: string): Promise<EstimateDetail | null> {
|
||||
// TODO: 실제 API 연동
|
||||
const mockData: EstimateDetail = {
|
||||
id,
|
||||
estimateCode: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
projectName: '현장명',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '이름',
|
||||
itemCount: 21,
|
||||
estimateAmount: 1420000,
|
||||
completedDate: null,
|
||||
bidDate: '2025-12-12',
|
||||
status: 'pending',
|
||||
createdAt: '2025-12-01',
|
||||
updatedAt: '2025-12-01',
|
||||
createdBy: 'hong',
|
||||
siteBriefing: {
|
||||
briefingCode: '123123',
|
||||
partnerName: '회사명',
|
||||
companyName: '회사명',
|
||||
briefingDate: '2025-12-12',
|
||||
attendee: '이름',
|
||||
},
|
||||
bidInfo: {
|
||||
projectName: '현장명',
|
||||
bidDate: '2025-12-12',
|
||||
siteCount: 21,
|
||||
constructionPeriod: '2026-01-01 ~ 2026-12-10',
|
||||
constructionStartDate: '2026-01-01',
|
||||
constructionEndDate: '2026-12-10',
|
||||
vatType: 'excluded',
|
||||
workReport: '업무 보고 내용',
|
||||
documents: [
|
||||
{
|
||||
id: '1',
|
||||
fileName: 'abc.zip',
|
||||
fileUrl: '#',
|
||||
fileSize: 1024000,
|
||||
},
|
||||
],
|
||||
},
|
||||
summaryItems: [
|
||||
{
|
||||
id: '1',
|
||||
name: '서터 심창측공사',
|
||||
quantity: 1,
|
||||
unit: '식',
|
||||
materialCost: 78540000,
|
||||
laborCost: 15410000,
|
||||
totalCost: 93950000,
|
||||
remarks: '',
|
||||
},
|
||||
],
|
||||
expenseItems: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'public_1',
|
||||
amount: 10000,
|
||||
},
|
||||
],
|
||||
priceAdjustments: [
|
||||
{
|
||||
id: '1',
|
||||
category: '배합비',
|
||||
unitPrice: 10000,
|
||||
coating: 10000,
|
||||
batting: 10000,
|
||||
boxReinforce: 10500,
|
||||
painting: 10500,
|
||||
total: 51000,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
category: '재단비',
|
||||
unitPrice: 1375,
|
||||
coating: 0,
|
||||
batting: 0,
|
||||
boxReinforce: 0,
|
||||
painting: 0,
|
||||
total: 1375,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
category: '판매단가',
|
||||
unitPrice: 0,
|
||||
coating: 10000,
|
||||
batting: 10000,
|
||||
boxReinforce: 10500,
|
||||
painting: 10500,
|
||||
total: 41000,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
category: '조립단가',
|
||||
unitPrice: 10300,
|
||||
coating: 10300,
|
||||
batting: 10300,
|
||||
boxReinforce: 10500,
|
||||
painting: 10200,
|
||||
total: 51600,
|
||||
},
|
||||
],
|
||||
detailItems: [
|
||||
{
|
||||
id: '1',
|
||||
no: 1,
|
||||
name: 'FS530외/주차',
|
||||
material: 'screen',
|
||||
width: 2350,
|
||||
height: 2500,
|
||||
quantity: 1,
|
||||
box: 1,
|
||||
assembly: 0,
|
||||
coating: 0,
|
||||
batting: 0,
|
||||
mounting: 0,
|
||||
fitting: 0,
|
||||
controller: 0,
|
||||
widthConstruction: 0,
|
||||
heightConstruction: 0,
|
||||
materialCost: 1420000,
|
||||
laborCost: 510000,
|
||||
quantityPrice: 1930000,
|
||||
expenseQuantity: 5500,
|
||||
expenseTotal: 5500,
|
||||
totalCost: 1930000,
|
||||
otherCost: 0,
|
||||
marginCost: 0,
|
||||
totalPrice: 1930000,
|
||||
unitPrice: 1420000,
|
||||
expense: 0,
|
||||
marginRate: 0,
|
||||
unitQuantity: 1,
|
||||
expenseResult: 0,
|
||||
marginActual: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
no: 2,
|
||||
name: 'FS530외/주차',
|
||||
material: 'screen',
|
||||
width: 7500,
|
||||
height: 2500,
|
||||
quantity: 1,
|
||||
box: 1,
|
||||
assembly: 0,
|
||||
coating: 0,
|
||||
batting: 0,
|
||||
mounting: 0,
|
||||
fitting: 0,
|
||||
controller: 0,
|
||||
widthConstruction: 0,
|
||||
heightConstruction: 0,
|
||||
materialCost: 4720000,
|
||||
laborCost: 780000,
|
||||
quantityPrice: 5500000,
|
||||
expenseQuantity: 5500,
|
||||
expenseTotal: 5500,
|
||||
totalCost: 5500000,
|
||||
otherCost: 0,
|
||||
marginCost: 0,
|
||||
totalPrice: 5500000,
|
||||
unitPrice: 4720000,
|
||||
expense: 0,
|
||||
marginRate: 0,
|
||||
unitQuantity: 1,
|
||||
expenseResult: 0,
|
||||
marginActual: 0,
|
||||
},
|
||||
],
|
||||
approval: {
|
||||
approvers: [],
|
||||
references: [],
|
||||
},
|
||||
};
|
||||
|
||||
return mockData;
|
||||
}
|
||||
|
||||
export default async function EstimateDetailPage({ params }: EstimateDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const detail = await getEstimateDetail(id);
|
||||
|
||||
return (
|
||||
<EstimateDetailForm
|
||||
mode="view"
|
||||
estimateId={id}
|
||||
initialData={detail || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { BiddingListClient } from '@/components/business/juil/bidding';
|
||||
|
||||
export default function BiddingPage() {
|
||||
return <BiddingListClient />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import ContractDetailForm from '@/components/business/juil/contract/ContractDetailForm';
|
||||
import { getContractDetail } from '@/components/business/juil/contract';
|
||||
|
||||
interface ContractEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ContractEditPage({ params }: ContractEditPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getContractDetail(id);
|
||||
|
||||
return (
|
||||
<ContractDetailForm
|
||||
mode="edit"
|
||||
contractId={id}
|
||||
initialData={result.success ? result.data : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import ContractDetailForm from '@/components/business/juil/contract/ContractDetailForm';
|
||||
import { getContractDetail } from '@/components/business/juil/contract';
|
||||
|
||||
interface ContractDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ContractDetailPage({ params }: ContractDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getContractDetail(id);
|
||||
|
||||
return (
|
||||
<ContractDetailForm
|
||||
mode="view"
|
||||
contractId={id}
|
||||
initialData={result.success ? result.data : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { HandoverReportDetailForm, getHandoverReportDetail } from '@/components/business/juil/handover-report';
|
||||
|
||||
interface HandoverReportEditPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
locale: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function HandoverReportEditPage({ params }: HandoverReportEditPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
// 서버에서 상세 데이터 조회
|
||||
const result = await getHandoverReportDetail(id);
|
||||
|
||||
return (
|
||||
<HandoverReportDetailForm
|
||||
mode="edit"
|
||||
reportId={id}
|
||||
initialData={result.data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { HandoverReportDetailForm, getHandoverReportDetail } from '@/components/business/juil/handover-report';
|
||||
|
||||
interface HandoverReportDetailPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
locale: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function HandoverReportDetailPage({ params }: HandoverReportDetailPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
// 서버에서 상세 데이터 조회
|
||||
const result = await getHandoverReportDetail(id);
|
||||
|
||||
return (
|
||||
<HandoverReportDetailForm
|
||||
mode="view"
|
||||
reportId={id}
|
||||
initialData={result.data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { HandoverReportListClient } from '@/components/business/juil/handover-report';
|
||||
|
||||
export default function HandoverReportPage() {
|
||||
return <HandoverReportListClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ContractListClient } from '@/components/business/juil/contract';
|
||||
|
||||
export default function ContractPage() {
|
||||
return <ContractListClient />;
|
||||
}
|
||||
@@ -7,102 +7,122 @@ import type { Account } from '@/components/settings/AccountManagement/types';
|
||||
// Mock 데이터 (API 연동 전 임시)
|
||||
const mockAccounts: Account[] = [
|
||||
{
|
||||
id: 'account-1',
|
||||
id: 1,
|
||||
bankCode: 'shinhan',
|
||||
bankName: '신한은행',
|
||||
accountNumber: '1234-1234-1234-1234',
|
||||
accountName: '운영계좌 1',
|
||||
accountHolder: '예금주1',
|
||||
status: 'active',
|
||||
isPrimary: true,
|
||||
createdAt: '2025-12-19T00:00:00.000Z',
|
||||
updatedAt: '2025-12-19T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'account-2',
|
||||
id: 2,
|
||||
bankCode: 'kb',
|
||||
bankName: 'KB국민은행',
|
||||
accountNumber: '1234-1234-1234-1235',
|
||||
accountName: '운영계좌 2',
|
||||
accountHolder: '예금주2',
|
||||
status: 'inactive',
|
||||
isPrimary: false,
|
||||
createdAt: '2025-12-19T00:00:00.000Z',
|
||||
updatedAt: '2025-12-19T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'account-3',
|
||||
id: 3,
|
||||
bankCode: 'woori',
|
||||
bankName: '우리은행',
|
||||
accountNumber: '1234-1234-1234-1236',
|
||||
accountName: '운영계좌 3',
|
||||
accountHolder: '예금주3',
|
||||
status: 'active',
|
||||
isPrimary: false,
|
||||
createdAt: '2025-12-19T00:00:00.000Z',
|
||||
updatedAt: '2025-12-19T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'account-4',
|
||||
id: 4,
|
||||
bankCode: 'hana',
|
||||
bankName: '하나은행',
|
||||
accountNumber: '1234-1234-1234-1237',
|
||||
accountName: '운영계좌 4',
|
||||
accountHolder: '예금주4',
|
||||
status: 'inactive',
|
||||
isPrimary: false,
|
||||
createdAt: '2025-12-19T00:00:00.000Z',
|
||||
updatedAt: '2025-12-19T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'account-5',
|
||||
id: 5,
|
||||
bankCode: 'nh',
|
||||
bankName: 'NH농협은행',
|
||||
accountNumber: '1234-1234-1234-1238',
|
||||
accountName: '운영계좌 5',
|
||||
accountHolder: '예금주5',
|
||||
status: 'active',
|
||||
isPrimary: false,
|
||||
createdAt: '2025-12-19T00:00:00.000Z',
|
||||
updatedAt: '2025-12-19T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'account-6',
|
||||
id: 6,
|
||||
bankCode: 'ibk',
|
||||
bankName: 'IBK기업은행',
|
||||
accountNumber: '1234-1234-1234-1239',
|
||||
accountName: '운영계좌 6',
|
||||
accountHolder: '예금주6',
|
||||
status: 'inactive',
|
||||
isPrimary: false,
|
||||
createdAt: '2025-12-19T00:00:00.000Z',
|
||||
updatedAt: '2025-12-19T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'account-7',
|
||||
id: 7,
|
||||
bankCode: 'shinhan',
|
||||
bankName: '신한은행',
|
||||
accountNumber: '1234-1234-1234-1240',
|
||||
accountName: '운영계좌 7',
|
||||
accountHolder: '예금주7',
|
||||
status: 'active',
|
||||
isPrimary: false,
|
||||
createdAt: '2025-12-19T00:00:00.000Z',
|
||||
updatedAt: '2025-12-19T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'account-8',
|
||||
id: 8,
|
||||
bankCode: 'kb',
|
||||
bankName: 'KB국민은행',
|
||||
accountNumber: '1234-1234-1234-1241',
|
||||
accountName: '운영계좌 8',
|
||||
accountHolder: '예금주8',
|
||||
status: 'inactive',
|
||||
isPrimary: false,
|
||||
createdAt: '2025-12-19T00:00:00.000Z',
|
||||
updatedAt: '2025-12-19T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'account-9',
|
||||
id: 9,
|
||||
bankCode: 'woori',
|
||||
bankName: '우리은행',
|
||||
accountNumber: '1234-1234-1234-1242',
|
||||
accountName: '운영계좌 9',
|
||||
accountHolder: '예금주9',
|
||||
status: 'active',
|
||||
isPrimary: false,
|
||||
createdAt: '2025-12-19T00:00:00.000Z',
|
||||
updatedAt: '2025-12-19T00:00:00.000Z',
|
||||
},
|
||||
{
|
||||
id: 'account-10',
|
||||
id: 10,
|
||||
bankCode: 'hana',
|
||||
bankName: '하나은행',
|
||||
accountNumber: '1234-1234-1234-1243',
|
||||
accountName: '운영계좌 10',
|
||||
accountHolder: '예금주10',
|
||||
status: 'inactive',
|
||||
isPrimary: false,
|
||||
createdAt: '2025-12-19T00:00:00.000Z',
|
||||
updatedAt: '2025-12-19T00:00:00.000Z',
|
||||
},
|
||||
@@ -110,7 +130,7 @@ const mockAccounts: Account[] = [
|
||||
|
||||
export default function AccountDetailPage() {
|
||||
const params = useParams();
|
||||
const accountId = params.id as string;
|
||||
const accountId = Number(params.id);
|
||||
|
||||
// Mock: 계좌 조회
|
||||
const account = mockAccounts.find(a => a.id === accountId);
|
||||
|
||||
@@ -453,4 +453,93 @@ html {
|
||||
|
||||
[data-slot="sheet-overlay"][data-state="closed"] {
|
||||
animation: fadeOut 200ms ease-out forwards;
|
||||
}
|
||||
|
||||
/* ==========================================
|
||||
Print Styles - 인쇄 시 문서만 출력
|
||||
========================================== */
|
||||
@media print {
|
||||
/* A4 용지 설정 */
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 10mm;
|
||||
}
|
||||
|
||||
/* 배경색 유지 */
|
||||
* {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
color-adjust: exact !important;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
인쇄 스타일 (JavaScript printArea 사용 시 기본값)
|
||||
======================================== */
|
||||
|
||||
/* 기본 설정 - printArea 유틸리티가 새 창에서 인쇄하므로 간단하게 유지 */
|
||||
html, body {
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
/* print-hidden 클래스 숨김 */
|
||||
.print-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
/* ========================================
|
||||
테이블 & 페이지 설정
|
||||
======================================== */
|
||||
|
||||
/* 페이지 나눔 방지 */
|
||||
table, figure, .page-break-avoid {
|
||||
page-break-inside: avoid;
|
||||
}
|
||||
|
||||
/* 인쇄용 테이블 스타일 */
|
||||
.print-area table {
|
||||
border-collapse: collapse !important;
|
||||
}
|
||||
|
||||
.print-area th,
|
||||
.print-area td {
|
||||
border: 1px solid #000 !important;
|
||||
}
|
||||
|
||||
/* print-area 내부 문서 wrapper - transform 제거 */
|
||||
.print-area > div {
|
||||
max-width: none !important;
|
||||
width: 100% !important;
|
||||
margin: 0 !important;
|
||||
padding: 0 !important;
|
||||
box-shadow: none !important;
|
||||
transform: none !important;
|
||||
}
|
||||
|
||||
/* 실제 문서 컨테이너 - A4에 맞게 조정 */
|
||||
.print-area > div > div {
|
||||
width: 100% !important;
|
||||
max-width: 190mm !important;
|
||||
min-height: auto !important;
|
||||
margin: 0 auto !important;
|
||||
padding: 5mm !important;
|
||||
box-shadow: none !important;
|
||||
font-size: 10pt !important;
|
||||
}
|
||||
|
||||
/* 테이블 폰트 크기 축소 */
|
||||
.print-area table {
|
||||
font-size: 9pt !important;
|
||||
}
|
||||
|
||||
.print-area .text-xs {
|
||||
font-size: 8pt !important;
|
||||
}
|
||||
|
||||
.print-area .text-sm {
|
||||
font-size: 9pt !important;
|
||||
}
|
||||
|
||||
.print-area .text-3xl {
|
||||
font-size: 18pt !important;
|
||||
}
|
||||
}
|
||||
@@ -334,7 +334,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
<Button variant="outline" className="text-red-500 border-red-200 hover:bg-red-50" onClick={handleDelete} disabled={isLoading}>
|
||||
{isLoading ? '처리중...' : '삭제'}
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
@@ -345,7 +345,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
<Button variant="outline" onClick={handleCancel} disabled={isLoading}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
{isLoading ? '처리중...' : (isNewMode ? '등록' : '저장')}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -917,7 +917,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
className="flex-1 bg-white"
|
||||
rows={2}
|
||||
/>
|
||||
<Button onClick={handleAddMemo} className="bg-orange-500 hover:bg-orange-600 self-end">
|
||||
<Button onClick={handleAddMemo} className="bg-blue-500 hover:bg-blue-600 self-end">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
@@ -988,7 +988,7 @@ export function BadDebtDetail({ mode, recordId, initialData }: BadDebtDetailProp
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmSave}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
>
|
||||
저장
|
||||
</AlertDialogAction>
|
||||
|
||||
@@ -7,6 +7,7 @@ import {
|
||||
Plus,
|
||||
X,
|
||||
Loader2,
|
||||
List,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -274,6 +275,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
{isViewMode ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
@@ -283,7 +285,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
|
||||
수정
|
||||
</Button>
|
||||
</>
|
||||
@@ -295,7 +297,7 @@ export function BillDetail({ billId, mode }: BillDetailProps) {
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
disabled={isSaving}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
>
|
||||
{isSaving && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{isNewMode ? '등록' : '저장'}
|
||||
|
||||
@@ -422,7 +422,7 @@ export function BillManagementClient({
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
|
||||
@@ -472,7 +472,7 @@ export function BillManagement({ initialVendorId, initialBillType }: BillManagem
|
||||
</Select>
|
||||
|
||||
{/* 저장 버튼 */}
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isSaving}>
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isSaving}>
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
{isSaving ? '저장중...' : '저장'}
|
||||
</Button>
|
||||
|
||||
@@ -364,7 +364,7 @@ export function CardTransactionInquiry({
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleSaveAccountSubject} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleSaveAccountSubject} className="bg-blue-500 hover:bg-blue-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
@@ -487,7 +487,7 @@ export function CardTransactionInquiry({
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmSaveAccountSubject}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? (
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Banknote,
|
||||
List,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -180,6 +181,7 @@ export function DepositDetail({ depositId, mode }: DepositDetailProps) {
|
||||
{isViewMode ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
@@ -190,7 +192,7 @@ export function DepositDetail({ depositId, mode }: DepositDetailProps) {
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
|
||||
수정
|
||||
</Button>
|
||||
</>
|
||||
@@ -202,7 +204,7 @@ export function DepositDetail({ depositId, mode }: DepositDetailProps) {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? '처리중...' : isNewMode ? '등록' : '저장'}
|
||||
|
||||
@@ -495,7 +495,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleSaveAccountSubject} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleSaveAccountSubject} className="bg-blue-500 hover:bg-blue-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
@@ -584,7 +584,7 @@ export function DepositManagement({ initialData, initialPagination }: DepositMan
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmSaveAccountSubject}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
|
||||
@@ -34,7 +34,7 @@ import {
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { FileText, Plus, X, Eye, Receipt } from 'lucide-react';
|
||||
import { FileText, Plus, X, Eye, Receipt, List } from 'lucide-react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
|
||||
@@ -293,6 +293,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
{isViewMode ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
@@ -302,7 +303,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
|
||||
수정
|
||||
</Button>
|
||||
</>
|
||||
@@ -312,7 +313,7 @@ export function PurchaseDetail({ purchaseId, mode }: PurchaseDetailProps) {
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isSaving}>
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isSaving}>
|
||||
{isSaving ? '저장 중...' : isNewMode ? '등록' : '저장'}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -539,7 +539,7 @@ export function PurchaseManagement() {
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleSaveAccountSubject} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleSaveAccountSubject} className="bg-blue-500 hover:bg-blue-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
@@ -622,7 +622,7 @@ export function PurchaseManagement() {
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmSaveAccountSubject}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
|
||||
@@ -55,6 +55,7 @@ function transformItem(item: VendorReceivablesApi): VendorReceivables {
|
||||
total: cat.amounts.total,
|
||||
},
|
||||
})),
|
||||
memo: (item as VendorReceivablesApi & { memo?: string }).memo ?? '',
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
|
||||
overdueVendorCount: 0,
|
||||
});
|
||||
const [isLoading, setIsLoading] = useState(!initialData?.items?.length);
|
||||
const [expandedMemos, setExpandedMemos] = useState<Set<string>>(new Set());
|
||||
|
||||
// ===== 데이터 로드 =====
|
||||
const loadData = useCallback(async () => {
|
||||
@@ -193,6 +194,19 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
|
||||
));
|
||||
}, []);
|
||||
|
||||
// ===== 메모 펼치기/접기 토글 =====
|
||||
const toggleMemoExpand = useCallback((vendorId: string) => {
|
||||
setExpandedMemos(prev => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(vendorId)) {
|
||||
newSet.delete(vendorId);
|
||||
} else {
|
||||
newSet.add(vendorId);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
// ===== 엑셀 다운로드 핸들러 =====
|
||||
const handleExcelDownload = useCallback(async () => {
|
||||
const result = await exportReceivablesExcel({
|
||||
@@ -315,6 +329,9 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
|
||||
// ===== 월 개수 (동적) =====
|
||||
const monthCount = monthLabels.length || 12;
|
||||
|
||||
// ===== 카테고리 순서 (메모 제외 - 별도 렌더링) =====
|
||||
const categoryOrder: CategoryType[] = ['sales', 'deposit', 'bill', 'receivable'];
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
{/* 페이지 헤더 */}
|
||||
@@ -395,7 +412,7 @@ export function ReceivablesStatus({ highlightVendorId, initialData, initialSumma
|
||||
size="sm"
|
||||
onClick={handleSave}
|
||||
disabled={isPending || totalChangedCount === 0}
|
||||
className="bg-orange-500 hover:bg-orange-600 disabled:opacity-50"
|
||||
className="bg-blue-500 hover:bg-blue-600 disabled:opacity-50"
|
||||
>
|
||||
{isPending ? (
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
|
||||
|
||||
@@ -47,6 +47,7 @@ export interface VendorReceivables {
|
||||
carryForwardBalance: number; // 이월잔액
|
||||
monthLabels: string[]; // 동적 월 레이블 (ex: ['25.02', '25.03', ...])
|
||||
categories: CategoryData[];
|
||||
memo?: string; // 거래처별 메모 (단일 텍스트)
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
Send,
|
||||
FileText,
|
||||
Loader2,
|
||||
List,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -310,6 +311,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
{isViewMode ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
@@ -319,7 +321,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
|
||||
수정
|
||||
</Button>
|
||||
</>
|
||||
@@ -329,7 +331,7 @@ export function SalesDetail({ mode, salesId }: SalesDetailProps) {
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600">
|
||||
{isNewMode ? '등록' : '저장'}
|
||||
</Button>
|
||||
</>
|
||||
|
||||
@@ -582,7 +582,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleSaveAccountSubject} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleSaveAccountSubject} className="bg-blue-500 hover:bg-blue-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
@@ -641,7 +641,7 @@ export function SalesManagement({ initialData, initialPagination }: SalesManagem
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmSaveAccountSubject}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
|
||||
@@ -3,7 +3,7 @@
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter, useSearchParams } from 'next/navigation';
|
||||
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
||||
import { FileText, Download, Pencil, Loader2 } from 'lucide-react';
|
||||
import { FileText, Download, Pencil, Loader2, List } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
@@ -171,6 +171,7 @@ export function VendorLedgerDetail({
|
||||
</div>
|
||||
</div>
|
||||
<Button variant="outline" size="sm" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
@@ -301,7 +301,7 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600" disabled={isSaving}>
|
||||
ㄷ <Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600" disabled={isSaving}>
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
@@ -312,7 +312,7 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
|
||||
<Button variant="outline" onClick={handleCancel} disabled={isSaving}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isSaving}>
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isSaving}>
|
||||
{isSaving ? '처리중...' : isNewMode ? '등록' : '저장'}
|
||||
</Button>
|
||||
</div>
|
||||
@@ -632,7 +632,7 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
|
||||
className="flex-1 bg-white"
|
||||
rows={2}
|
||||
/>
|
||||
<Button onClick={handleAddMemo} className="bg-orange-500 hover:bg-orange-600 self-end">
|
||||
<Button onClick={handleAddMemo} className="bg-blue-500 hover:bg-blue-600 self-end">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
@@ -708,7 +708,7 @@ export function VendorDetail({ mode, vendorId }: VendorDetailProps) {
|
||||
<AlertDialogCancel disabled={isSaving}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmSave}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
disabled={isSaving}
|
||||
>
|
||||
{isSaving ? '처리중...' : '확인'}
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Building2, Plus, X, Loader2 } from 'lucide-react';
|
||||
import { Building2, Plus, X, Loader2, List } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -125,6 +125,16 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
|
||||
// 새 메모 입력
|
||||
const [newMemo, setNewMemo] = useState('');
|
||||
|
||||
// 상세/수정 모드에서 로고 목데이터 초기화
|
||||
useEffect(() => {
|
||||
if (initialData && !formData.logoUrl) {
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
logoUrl: 'https://placehold.co/750x250/3b82f6/white?text=Vendor+Logo',
|
||||
}));
|
||||
}
|
||||
}, [initialData]);
|
||||
|
||||
// 필드 변경 핸들러
|
||||
const handleChange = useCallback((field: string, value: string | number | boolean) => {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
@@ -257,12 +267,13 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button variant="outline" className="text-red-500 border-red-200 hover:bg-red-50" onClick={handleDelete}>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
@@ -273,7 +284,7 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-600" disabled={isLoading}>
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
{isNewMode ? '등록' : '저장'}
|
||||
</Button>
|
||||
@@ -438,11 +449,21 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">회사 로고</Label>
|
||||
<div className="border-2 border-dashed border-gray-300 rounded-lg p-6 text-center">
|
||||
<p className="text-sm text-gray-500">750 X 250px, 10MB 이하의 PNG, JPEG, GIF</p>
|
||||
{!isViewMode && (
|
||||
<Button variant="outline" className="mt-2">
|
||||
이미지 업로드
|
||||
</Button>
|
||||
{formData.logoUrl ? (
|
||||
<img
|
||||
src={formData.logoUrl}
|
||||
alt="회사 로고"
|
||||
className="max-h-[100px] max-w-[300px] object-contain mx-auto"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<p className="text-sm text-gray-500">750 X 250px, 10MB 이하의 PNG, JPEG, GIF</p>
|
||||
{!isViewMode && (
|
||||
<Button variant="outline" className="mt-2">
|
||||
이미지 업로드
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
@@ -528,7 +549,7 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
|
||||
className="flex-1 bg-white"
|
||||
rows={2}
|
||||
/>
|
||||
<Button onClick={handleAddMemo} className="bg-orange-500 hover:bg-orange-600 self-end">
|
||||
<Button onClick={handleAddMemo} className="bg-blue-500 hover:bg-blue-600 self-end">
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
@@ -605,7 +626,7 @@ export function VendorDetailClient({ mode, vendorId, initialData }: VendorDetail
|
||||
<AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmSave}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
|
||||
@@ -1,6 +1,64 @@
|
||||
// ===== API 응답 타입 =====
|
||||
export interface ApiResponse<T> {
|
||||
success: boolean;
|
||||
data?: T;
|
||||
message?: string;
|
||||
}
|
||||
|
||||
export interface PaginatedResponse<T> {
|
||||
data: T[];
|
||||
total: number;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// ===== API 데이터 타입 (백엔드에서 오는 원본 데이터) =====
|
||||
export interface ClientApiData {
|
||||
id: number;
|
||||
client_code: string;
|
||||
name: string;
|
||||
client_type: 'SALES' | 'PURCHASE' | 'BOTH';
|
||||
business_no: string;
|
||||
contact_person: string;
|
||||
phone: string;
|
||||
mobile: string;
|
||||
fax: string;
|
||||
email: string;
|
||||
address: string;
|
||||
manager_name: string;
|
||||
manager_tel: string;
|
||||
system_manager: string;
|
||||
purchase_payment_day: string;
|
||||
sales_payment_day: string;
|
||||
business_type: string;
|
||||
business_item: string;
|
||||
bad_debt: boolean;
|
||||
memo: string;
|
||||
is_active: boolean;
|
||||
account_id: string;
|
||||
outstanding_amount: number;
|
||||
bad_debt_total: number;
|
||||
has_bad_debt: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
// ===== 거래처 구분 =====
|
||||
export type VendorCategory = 'sales' | 'purchase' | 'both';
|
||||
|
||||
// ===== API client_type ↔ VendorCategory 변환 =====
|
||||
export const CLIENT_TYPE_TO_CATEGORY: Record<string, VendorCategory> = {
|
||||
SALES: 'sales',
|
||||
PURCHASE: 'purchase',
|
||||
BOTH: 'both',
|
||||
};
|
||||
|
||||
export const CATEGORY_TO_CLIENT_TYPE: Record<VendorCategory, string> = {
|
||||
sales: 'SALES',
|
||||
purchase: 'PURCHASE',
|
||||
both: 'BOTH',
|
||||
};
|
||||
|
||||
export const VENDOR_CATEGORY_LABELS: Record<VendorCategory, string> = {
|
||||
sales: '매출',
|
||||
purchase: '매입',
|
||||
|
||||
@@ -4,6 +4,7 @@ import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
Banknote,
|
||||
List,
|
||||
} from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -180,6 +181,7 @@ export function WithdrawalDetail({ withdrawalId, mode }: WithdrawalDetailProps)
|
||||
{isViewMode ? (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
@@ -190,7 +192,7 @@ export function WithdrawalDetail({ withdrawalId, mode }: WithdrawalDetailProps)
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
|
||||
수정
|
||||
</Button>
|
||||
</>
|
||||
@@ -202,7 +204,7 @@ export function WithdrawalDetail({ withdrawalId, mode }: WithdrawalDetailProps)
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleSave}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? '처리중...' : isNewMode ? '등록' : '저장'}
|
||||
|
||||
@@ -489,7 +489,7 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button onClick={handleSaveAccountSubject} className="bg-orange-500 hover:bg-orange-600">
|
||||
<Button onClick={handleSaveAccountSubject} className="bg-blue-500 hover:bg-blue-600">
|
||||
<Save className="h-4 w-4 mr-2" />
|
||||
저장
|
||||
</Button>
|
||||
@@ -578,7 +578,7 @@ export function WithdrawalManagement({ initialData, initialPagination }: Withdra
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleConfirmSaveAccountSubject}
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
>
|
||||
확인
|
||||
</Button>
|
||||
|
||||
@@ -30,6 +30,7 @@ import {
|
||||
import { ProposalDocument } from './ProposalDocument';
|
||||
import { ExpenseReportDocument } from './ExpenseReportDocument';
|
||||
import { ExpenseEstimateDocument } from './ExpenseEstimateDocument';
|
||||
import { printArea } from '@/lib/print-utils';
|
||||
import type {
|
||||
DocumentType,
|
||||
DocumentDetailModalProps,
|
||||
@@ -68,7 +69,7 @@ export function DocumentDetailModal({
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
window.print();
|
||||
printArea({ title: `${getDocumentTitle()} 인쇄` });
|
||||
};
|
||||
|
||||
const handleSharePdf = () => {
|
||||
@@ -107,8 +108,8 @@ export function DocumentDetailModal({
|
||||
<DialogTitle>{getDocumentTitle()} 상세</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
|
||||
{/* 헤더 영역 - 고정 */}
|
||||
<div className="flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
|
||||
{/* 헤더 영역 - 고정 (인쇄 시 숨김) */}
|
||||
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
|
||||
<h2 className="text-lg font-semibold">{getDocumentTitle()} 상세</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
@@ -120,8 +121,8 @@ export function DocumentDetailModal({
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 - 고정 */}
|
||||
<div className="flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
|
||||
{/* 버튼 영역 - 고정 (인쇄 시 숨김) */}
|
||||
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
|
||||
{/* 기안함 모드 + 임시저장 상태: 복제, 상신, 인쇄 */}
|
||||
{mode === 'draft' && documentStatus === 'draft' && (
|
||||
<>
|
||||
@@ -191,8 +192,8 @@ export function DocumentDetailModal({
|
||||
</DropdownMenu> */}
|
||||
</div>
|
||||
|
||||
{/* 문서 영역 - 스크롤 */}
|
||||
<div className="flex-1 overflow-y-auto bg-gray-100 p-6">
|
||||
{/* 문서 영역 - 스크롤 (인쇄 시 이 영역만 출력) */}
|
||||
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
|
||||
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg">
|
||||
{renderDocument()}
|
||||
</div>
|
||||
|
||||
@@ -9,7 +9,7 @@
|
||||
|
||||
'use server';
|
||||
|
||||
import { cookies } from 'next/headers';
|
||||
import { serverFetch, getServerApiHeaders } from '@/lib/api/fetch-wrapper';
|
||||
|
||||
// ============================================
|
||||
// 타입 정의
|
||||
@@ -155,12 +155,10 @@ export async function checkIn(
|
||||
*/
|
||||
export async function checkOut(
|
||||
data: CheckOutRequest
|
||||
): Promise<{ success: boolean; data?: AttendanceRecord; error?: string }> {
|
||||
): Promise<{ success: boolean; data?: AttendanceRecord; error?: string; __authError?: boolean }> {
|
||||
try {
|
||||
const headers = await getApiHeaders();
|
||||
const response = await fetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/attendances/check-out`, {
|
||||
const { response, error } = await serverFetch(`${process.env.NEXT_PUBLIC_API_URL}/api/v1/attendances/check-out`, {
|
||||
method: 'POST',
|
||||
headers,
|
||||
body: JSON.stringify({
|
||||
user_id: data.userId,
|
||||
check_out: data.checkOut,
|
||||
@@ -195,11 +193,11 @@ export async function checkOut(
|
||||
success: false,
|
||||
error: result.message || '퇴근 기록에 실패했습니다.',
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('[checkOut] Error:', error);
|
||||
} catch (err) {
|
||||
console.error('[checkOut] Error:', err);
|
||||
return {
|
||||
success: false,
|
||||
error: error instanceof Error ? error.message : '퇴근 기록에 실패했습니다.',
|
||||
error: err instanceof Error ? err.message : '퇴근 기록에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
606
src/components/business/juil/bidding/BiddingDetailForm.tsx
Normal file
606
src/components/business/juil/bidding/BiddingDetailForm.tsx
Normal file
@@ -0,0 +1,606 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Loader2, List } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { toast } from 'sonner';
|
||||
import type { BiddingDetail, BiddingDetailFormData } from './types';
|
||||
import {
|
||||
BIDDING_STATUS_OPTIONS,
|
||||
BIDDING_STATUS_STYLES,
|
||||
BIDDING_STATUS_LABELS,
|
||||
VAT_TYPE_OPTIONS,
|
||||
getEmptyBiddingDetailFormData,
|
||||
biddingDetailToFormData,
|
||||
} from './types';
|
||||
import { updateBidding } from './actions';
|
||||
|
||||
// 금액 포맷팅
|
||||
function formatAmount(amount: number): string {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
}
|
||||
|
||||
interface BiddingDetailFormProps {
|
||||
mode: 'view' | 'edit';
|
||||
biddingId: string;
|
||||
initialData?: BiddingDetail;
|
||||
}
|
||||
|
||||
export default function BiddingDetailForm({
|
||||
mode,
|
||||
biddingId,
|
||||
initialData,
|
||||
}: BiddingDetailFormProps) {
|
||||
const router = useRouter();
|
||||
const isViewMode = mode === 'view';
|
||||
const _isEditMode = mode === 'edit';
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<BiddingDetailFormData>(
|
||||
initialData ? biddingDetailToFormData(initialData) : getEmptyBiddingDetailFormData()
|
||||
);
|
||||
|
||||
// 로딩 상태
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
|
||||
// 공과 합계 계산
|
||||
const expenseTotal = useMemo(() => {
|
||||
return formData.expenseItems.reduce((sum, item) => sum + item.amount, 0);
|
||||
}, [formData.expenseItems]);
|
||||
|
||||
// 견적 상세 합계 계산 (견적 상세 페이지와 동일)
|
||||
const estimateDetailTotals = useMemo(() => {
|
||||
return formData.estimateDetailItems.reduce(
|
||||
(acc, item) => ({
|
||||
weight: acc.weight + (item.weight || 0),
|
||||
area: acc.area + (item.area || 0),
|
||||
steelScreen: acc.steelScreen + (item.steelScreen || 0),
|
||||
caulking: acc.caulking + (item.caulking || 0),
|
||||
rail: acc.rail + (item.rail || 0),
|
||||
bottom: acc.bottom + (item.bottom || 0),
|
||||
boxReinforce: acc.boxReinforce + (item.boxReinforce || 0),
|
||||
shaft: acc.shaft + (item.shaft || 0),
|
||||
painting: acc.painting + (item.painting || 0),
|
||||
motor: acc.motor + (item.motor || 0),
|
||||
controller: acc.controller + (item.controller || 0),
|
||||
widthConstruction: acc.widthConstruction + (item.widthConstruction || 0),
|
||||
heightConstruction: acc.heightConstruction + (item.heightConstruction || 0),
|
||||
unitPrice: acc.unitPrice + (item.unitPrice || 0),
|
||||
expense: acc.expense + (item.expense || 0),
|
||||
quantity: acc.quantity + (item.quantity || 0),
|
||||
cost: acc.cost + (item.cost || 0),
|
||||
costExecution: acc.costExecution + (item.costExecution || 0),
|
||||
marginCost: acc.marginCost + (item.marginCost || 0),
|
||||
marginCostExecution: acc.marginCostExecution + (item.marginCostExecution || 0),
|
||||
expenseExecution: acc.expenseExecution + (item.expenseExecution || 0),
|
||||
}),
|
||||
{
|
||||
weight: 0,
|
||||
area: 0,
|
||||
steelScreen: 0,
|
||||
caulking: 0,
|
||||
rail: 0,
|
||||
bottom: 0,
|
||||
boxReinforce: 0,
|
||||
shaft: 0,
|
||||
painting: 0,
|
||||
motor: 0,
|
||||
controller: 0,
|
||||
widthConstruction: 0,
|
||||
heightConstruction: 0,
|
||||
unitPrice: 0,
|
||||
expense: 0,
|
||||
quantity: 0,
|
||||
cost: 0,
|
||||
costExecution: 0,
|
||||
marginCost: 0,
|
||||
marginCostExecution: 0,
|
||||
expenseExecution: 0,
|
||||
}
|
||||
);
|
||||
}, [formData.estimateDetailItems]);
|
||||
|
||||
// 네비게이션 핸들러
|
||||
const handleBack = useCallback(() => {
|
||||
router.push('/ko/juil/project/bidding');
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
router.push(`/ko/juil/project/bidding/${biddingId}/edit`);
|
||||
}, [router, biddingId]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
router.push(`/ko/juil/project/bidding/${biddingId}`);
|
||||
}, [router, biddingId]);
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = useCallback(() => {
|
||||
setShowSaveDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmSave = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateBidding(biddingId, formData);
|
||||
if (result.success) {
|
||||
toast.success('수정이 완료되었습니다.');
|
||||
setShowSaveDialog(false);
|
||||
router.push(`/ko/juil/project/bidding/${biddingId}`);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '저장에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router, biddingId, formData]);
|
||||
|
||||
// 필드 변경 핸들러
|
||||
const handleFieldChange = useCallback(
|
||||
(field: keyof BiddingDetailFormData, value: string | number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
[field]: value,
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 헤더 액션 버튼
|
||||
const headerActions = isViewMode ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>수정</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={isViewMode ? '입찰 상세' : '입찰 수정'}
|
||||
actions={headerActions}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 입찰 정보 섹션 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">입찰 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* 입찰번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label>입찰번호</Label>
|
||||
<Input value={formData.biddingCode} disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* 입찰자 */}
|
||||
<div className="space-y-2">
|
||||
<Label>입찰자</Label>
|
||||
<Input value={formData.bidderName} disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* 거래처명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>거래처명</Label>
|
||||
<Input value={formData.partnerName} disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* 현장명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>현장명</Label>
|
||||
<Input value={formData.projectName} disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* 입찰일자 */}
|
||||
<div className="space-y-2">
|
||||
<Label>입찰일자</Label>
|
||||
{isViewMode ? (
|
||||
<Input value={formData.biddingDate} disabled className="bg-muted" />
|
||||
) : (
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.biddingDate}
|
||||
onChange={(e) => handleFieldChange('biddingDate', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 개소 */}
|
||||
<div className="space-y-2">
|
||||
<Label>개소</Label>
|
||||
<Input value={formData.totalCount} disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* 공사기간 */}
|
||||
<div className="space-y-2">
|
||||
<Label>공사기간</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
{isViewMode ? (
|
||||
<Input
|
||||
value={`${formData.constructionStartDate} ~ ${formData.constructionEndDate}`}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.constructionStartDate}
|
||||
onChange={(e) =>
|
||||
handleFieldChange('constructionStartDate', e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span>~</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.constructionEndDate}
|
||||
onChange={(e) =>
|
||||
handleFieldChange('constructionEndDate', e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 부가세 */}
|
||||
<div className="space-y-2">
|
||||
<Label>부가세</Label>
|
||||
{isViewMode ? (
|
||||
<Input
|
||||
value={
|
||||
VAT_TYPE_OPTIONS.find((opt) => opt.value === formData.vatType)?.label ||
|
||||
formData.vatType
|
||||
}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={formData.vatType}
|
||||
onValueChange={(value) => handleFieldChange('vatType', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VAT_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 입찰금액 */}
|
||||
<div className="space-y-2">
|
||||
<Label>입찰금액</Label>
|
||||
<Input
|
||||
value={formatAmount(formData.biddingAmount)}
|
||||
disabled
|
||||
className="bg-muted text-right font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상태 */}
|
||||
<div className="space-y-2">
|
||||
<Label>상태</Label>
|
||||
{isViewMode ? (
|
||||
<div
|
||||
className={`flex h-10 items-center rounded-md border px-3 ${BIDDING_STATUS_STYLES[formData.status]}`}
|
||||
>
|
||||
{BIDDING_STATUS_LABELS[formData.status]}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value) => handleFieldChange('status', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BIDDING_STATUS_OPTIONS.filter((opt) => opt.value !== 'all').map(
|
||||
(option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 투찰일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>투찰일</Label>
|
||||
{isViewMode ? (
|
||||
<Input
|
||||
value={formData.submissionDate || '-'}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.submissionDate}
|
||||
onChange={(e) => handleFieldChange('submissionDate', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 확정일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>확정일</Label>
|
||||
{isViewMode ? (
|
||||
<Input
|
||||
value={formData.confirmDate || '-'}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.confirmDate}
|
||||
onChange={(e) => handleFieldChange('confirmDate', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label>비고</Label>
|
||||
{isViewMode ? (
|
||||
<Textarea
|
||||
value={formData.remarks || '-'}
|
||||
disabled
|
||||
className="min-h-[80px] resize-none bg-muted"
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
value={formData.remarks}
|
||||
onChange={(e) => handleFieldChange('remarks', e.target.value)}
|
||||
placeholder="비고를 입력하세요"
|
||||
className="min-h-[80px] resize-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 공과 상세 섹션 (읽기 전용) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">공과 상세</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60%]">공과</TableHead>
|
||||
<TableHead className="text-right">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formData.expenseItems.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="h-24 text-center text-muted-foreground">
|
||||
공과 내역이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<>
|
||||
{formData.expenseItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(item.amount)}원
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell>합계</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(expenseTotal)}원
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 견적 상세 섹션 (읽기 전용 - 견적 상세 페이지와 동일) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">견적 상세</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto max-h-[600px] rounded-md border">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-white z-10">
|
||||
<TableRow className="bg-gray-100">
|
||||
<TableHead className="w-[100px] text-center">명칭</TableHead>
|
||||
<TableHead className="w-[80px] text-center">제품</TableHead>
|
||||
<TableHead className="w-[70px] text-right">가로</TableHead>
|
||||
<TableHead className="w-[70px] text-right">세로</TableHead>
|
||||
<TableHead className="w-[70px] text-right">무게</TableHead>
|
||||
<TableHead className="w-[70px] text-right">면적</TableHead>
|
||||
<TableHead className="w-[90px] text-right">철제,스크린</TableHead>
|
||||
<TableHead className="w-[80px] text-right">코킹</TableHead>
|
||||
<TableHead className="w-[80px] text-right">레일</TableHead>
|
||||
<TableHead className="w-[80px] text-right">하장</TableHead>
|
||||
<TableHead className="w-[90px] text-right">박스+보강</TableHead>
|
||||
<TableHead className="w-[80px] text-right">샤프트</TableHead>
|
||||
<TableHead className="w-[80px] text-right">도장</TableHead>
|
||||
<TableHead className="w-[80px] text-right">모터</TableHead>
|
||||
<TableHead className="w-[80px] text-right">제어기</TableHead>
|
||||
<TableHead className="w-[100px] text-right">가로시공비</TableHead>
|
||||
<TableHead className="w-[100px] text-right">세로시공비</TableHead>
|
||||
<TableHead className="w-[90px] text-right">단가</TableHead>
|
||||
<TableHead className="w-[70px] text-right">공과율</TableHead>
|
||||
<TableHead className="w-[80px] text-right">공과</TableHead>
|
||||
<TableHead className="w-[50px] text-right">수량</TableHead>
|
||||
<TableHead className="w-[90px] text-right">원가</TableHead>
|
||||
<TableHead className="w-[90px] text-right">원가실행</TableHead>
|
||||
<TableHead className="w-[90px] text-right">마진원가</TableHead>
|
||||
<TableHead className="w-[100px] text-right">마진원가실행</TableHead>
|
||||
<TableHead className="w-[90px] text-right">공과실행</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formData.estimateDetailItems.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={26} className="h-24 text-center text-muted-foreground">
|
||||
견적 상세 내역이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<>
|
||||
{formData.estimateDetailItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="bg-gray-50">{item.name}</TableCell>
|
||||
<TableCell className="bg-gray-50">{item.material}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{item.width?.toFixed(2) || '0.00'}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{item.height?.toFixed(2) || '0.00'}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{item.weight?.toFixed(2) || '0.00'}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{item.area?.toFixed(2) || '0.00'}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.steelScreen || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.caulking || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.rail || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.bottom || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.boxReinforce || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.shaft || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.painting || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.motor || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.controller || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.widthConstruction || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.heightConstruction || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50 font-medium">{formatAmount(item.unitPrice || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{item.expenseRate || 0}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.expense || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{item.quantity || 0}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.cost || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.costExecution || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.marginCost || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50 font-medium">{formatAmount(item.marginCostExecution || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.expenseExecution || 0)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{/* 합계 행 */}
|
||||
<TableRow className="bg-orange-50 font-medium border-t-2 border-orange-300">
|
||||
<TableCell colSpan={4} className="text-center font-bold">합계</TableCell>
|
||||
<TableCell className="text-right">{estimateDetailTotals.weight.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right">{estimateDetailTotals.area.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.steelScreen)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.caulking)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.rail)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.bottom)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.boxReinforce)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.shaft)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.painting)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.motor)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.controller)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.widthConstruction)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.heightConstruction)}</TableCell>
|
||||
<TableCell className="text-right font-bold">{formatAmount(estimateDetailTotals.unitPrice)}</TableCell>
|
||||
<TableCell className="text-right">-</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.expense)}</TableCell>
|
||||
<TableCell className="text-right">{estimateDetailTotals.quantity}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.cost)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.costExecution)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.marginCost)}</TableCell>
|
||||
<TableCell className="text-right font-bold">{formatAmount(estimateDetailTotals.marginCostExecution)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.expenseExecution)}</TableCell>
|
||||
</TableRow>
|
||||
</>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 저장 확인 다이얼로그 */}
|
||||
<AlertDialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>입찰 수정</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
입찰 정보를 저장하시겠습니까?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmSave} disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
저장
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
574
src/components/business/juil/bidding/BiddingListClient.tsx
Normal file
574
src/components/business/juil/bidding/BiddingListClient.tsx
Normal file
@@ -0,0 +1,574 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, Clock, Trophy, Pencil } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import type { Bidding, BiddingStats } from './types';
|
||||
import {
|
||||
BIDDING_STATUS_OPTIONS,
|
||||
BIDDING_SORT_OPTIONS,
|
||||
BIDDING_STATUS_STYLES,
|
||||
BIDDING_STATUS_LABELS,
|
||||
} from './types';
|
||||
import { getBiddingList, getBiddingStats, deleteBidding, deleteBiddings } from './actions';
|
||||
|
||||
// 테이블 컬럼 정의 (체크박스, 번호, 입찰번호, 거래처, 현장명, 입찰자, 총 개소, 입찰금액, 입찰일, 투찰일, 확정일, 상태, 비고, 작업)
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'biddingCode', label: '입찰번호', className: 'w-[120px]' },
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
|
||||
{ key: 'projectName', label: '현장명', className: 'min-w-[120px]' },
|
||||
{ key: 'bidderName', label: '입찰자', className: 'w-[80px]' },
|
||||
{ key: 'totalCount', label: '총 개소', className: 'w-[80px] text-center' },
|
||||
{ key: 'biddingAmount', label: '입찰금액', className: 'w-[120px] text-right' },
|
||||
{ key: 'bidDate', label: '입찰일', className: 'w-[100px] text-center' },
|
||||
{ key: 'submissionDate', label: '투찰일', className: 'w-[100px] text-center' },
|
||||
{ key: 'confirmDate', label: '확정일', className: 'w-[100px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
||||
{ key: 'remarks', label: '비고', className: 'w-[120px]' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||
];
|
||||
|
||||
// 목업 거래처 목록 (다중선택용 - 빈 배열 = 전체)
|
||||
const MOCK_PARTNERS: MultiSelectOption[] = [
|
||||
{ value: '1', label: '이사대표' },
|
||||
{ value: '2', label: '야사건설' },
|
||||
{ value: '3', label: '여의건설' },
|
||||
];
|
||||
|
||||
// 목업 입찰자 목록 (다중선택용 - 빈 배열 = 전체)
|
||||
const MOCK_BIDDERS: MultiSelectOption[] = [
|
||||
{ value: 'hong', label: '홍길동' },
|
||||
{ value: 'kim', label: '김철수' },
|
||||
{ value: 'lee', label: '이영희' },
|
||||
];
|
||||
|
||||
// 금액 포맷팅
|
||||
function formatAmount(amount: number): string {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
}
|
||||
|
||||
// 날짜 포맷팅
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).replace(/\. /g, '-').replace('.', '');
|
||||
}
|
||||
|
||||
interface BiddingListClientProps {
|
||||
initialData?: Bidding[];
|
||||
initialStats?: BiddingStats;
|
||||
}
|
||||
|
||||
export default function BiddingListClient({ initialData = [], initialStats }: BiddingListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [biddings, setBiddings] = useState<Bidding[]>(initialData);
|
||||
const [stats, setStats] = useState<BiddingStats | null>(initialStats || null);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||
const [bidderFilters, setBidderFilters] = useState<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('biddingDateDesc');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'waiting' | 'awarded'>('all');
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getBiddingList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
getBiddingStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setBiddings(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredBiddings = useMemo(() => {
|
||||
return biddings.filter((bidding) => {
|
||||
// 상태 탭 필터
|
||||
if (activeStatTab === 'waiting' && bidding.status !== 'waiting') return false;
|
||||
if (activeStatTab === 'awarded' && bidding.status !== 'awarded') return false;
|
||||
|
||||
// 거래처 필터 (다중선택 - 빈 배열 = 전체)
|
||||
if (partnerFilters.length > 0) {
|
||||
if (!partnerFilters.includes(bidding.partnerId)) return false;
|
||||
}
|
||||
|
||||
// 입찰자 필터 (다중선택 - 빈 배열 = 전체)
|
||||
if (bidderFilters.length > 0) {
|
||||
if (!bidderFilters.includes(bidding.bidderId)) return false;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && bidding.status !== statusFilter) return false;
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
bidding.projectName.toLowerCase().includes(search) ||
|
||||
bidding.biddingCode.toLowerCase().includes(search) ||
|
||||
bidding.partnerName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [biddings, activeStatTab, partnerFilters, bidderFilters, statusFilter, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedBiddings = useMemo(() => {
|
||||
const sorted = [...filteredBiddings];
|
||||
switch (sortBy) {
|
||||
case 'biddingDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.biddingDate) return 1;
|
||||
if (!b.biddingDate) return -1;
|
||||
return new Date(b.biddingDate).getTime() - new Date(a.biddingDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'biddingDateAsc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.biddingDate) return 1;
|
||||
if (!b.biddingDate) return -1;
|
||||
return new Date(a.biddingDate).getTime() - new Date(b.biddingDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'submissionDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.submissionDate) return 1;
|
||||
if (!b.submissionDate) return -1;
|
||||
return new Date(b.submissionDate).getTime() - new Date(a.submissionDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'confirmDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.confirmDate) return 1;
|
||||
if (!b.confirmDate) return -1;
|
||||
return new Date(b.confirmDate).getTime() - new Date(a.confirmDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'projectNameAsc':
|
||||
sorted.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
|
||||
break;
|
||||
case 'projectNameDesc':
|
||||
sorted.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredBiddings, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedBiddings.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedBiddings.slice(start, start + itemsPerPage);
|
||||
}, [sortedBiddings, currentPage, itemsPerPage]);
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((b) => b.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(bidding: Bidding) => {
|
||||
router.push(`/ko/juil/project/bidding/${bidding.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, biddingId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/juil/project/bidding/${biddingId}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback((e: React.MouseEvent, biddingId: string) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTargetId(biddingId);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteTargetId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deleteBidding(deleteTargetId);
|
||||
if (result.success) {
|
||||
toast.success('입찰이 삭제되었습니다.');
|
||||
setBiddings((prev) => prev.filter((b) => b.id !== deleteTargetId));
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(deleteTargetId);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
}, [deleteTargetId]);
|
||||
|
||||
const handleBulkDeleteClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.warning('삭제할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setBulkDeleteDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
|
||||
const handleBulkDeleteConfirm = useCallback(async () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const ids = Array.from(selectedItems);
|
||||
const result = await deleteBiddings(ids);
|
||||
if (result.success) {
|
||||
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
|
||||
await loadData();
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
toast.error(result.error || '일괄 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('일괄 삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setBulkDeleteDialogOpen(false);
|
||||
}
|
||||
}, [selectedItems, loadData]);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(bidding: Bidding, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(bidding.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={bidding.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(bidding)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(bidding.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{bidding.biddingCode}</TableCell>
|
||||
<TableCell>{bidding.partnerName}</TableCell>
|
||||
<TableCell>{bidding.projectName}</TableCell>
|
||||
<TableCell>{bidding.bidderName}</TableCell>
|
||||
<TableCell className="text-center">{bidding.totalCount}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(bidding.biddingAmount)}</TableCell>
|
||||
<TableCell className="text-center">{formatDate(bidding.bidDate)}</TableCell>
|
||||
<TableCell className="text-center">{formatDate(bidding.submissionDate)}</TableCell>
|
||||
<TableCell className="text-center">{formatDate(bidding.confirmDate)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={BIDDING_STATUS_STYLES[bidding.status]}>
|
||||
{BIDDING_STATUS_LABELS[bidding.status]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="truncate max-w-[120px]" title={bidding.remarks}>
|
||||
{bidding.remarks || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, bidding.id)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(bidding: Bidding, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
<MobileCard
|
||||
title={bidding.projectName}
|
||||
subtitle={bidding.biddingCode}
|
||||
badge={BIDDING_STATUS_LABELS[bidding.status]}
|
||||
badgeVariant="secondary"
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => handleRowClick(bidding)}
|
||||
details={[
|
||||
{ label: '거래처', value: bidding.partnerName },
|
||||
{ label: '입찰금액', value: `${formatAmount(bidding.biddingAmount)}원` },
|
||||
{ label: '입찰일자', value: formatDate(bidding.biddingDate) },
|
||||
{ label: '총 개소', value: `${bidding.totalCount}` },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
);
|
||||
|
||||
// 헤더 액션 (날짜 필터) - 등록 버튼 없음 (견적완료 시 자동 등록)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
);
|
||||
|
||||
// Stats 카드 데이터 (전체 입찰, 입찰대기, 낙찰)
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '전체 입찰',
|
||||
value: stats?.total ?? 0,
|
||||
icon: FileText,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '입찰대기',
|
||||
value: stats?.waiting ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-orange-500',
|
||||
onClick: () => setActiveStatTab('waiting'),
|
||||
isActive: activeStatTab === 'waiting',
|
||||
},
|
||||
{
|
||||
label: '낙찰',
|
||||
value: stats?.awarded ?? 0,
|
||||
icon: Trophy,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('awarded'),
|
||||
isActive: activeStatTab === 'awarded',
|
||||
},
|
||||
];
|
||||
|
||||
// 테이블 헤더 액션 (총 건수 + 필터 4개: 거래처, 입찰자, 상태, 정렬)
|
||||
const tableHeaderActions = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {sortedBiddings.length}건
|
||||
</span>
|
||||
|
||||
{/* 거래처 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_PARTNERS}
|
||||
value={partnerFilters}
|
||||
onChange={setPartnerFilters}
|
||||
placeholder="거래처"
|
||||
searchPlaceholder="거래처 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 입찰자 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_BIDDERS}
|
||||
value={bidderFilters}
|
||||
onChange={setBidderFilters}
|
||||
placeholder="입찰자"
|
||||
searchPlaceholder="입찰자 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BIDDING_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="최신순 (입찰일)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BIDDING_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="입찰관리"
|
||||
description="입찰을 관리합니다 (견적완료 시 자동 등록)"
|
||||
icon={FileText}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="입찰번호, 거래처, 현장명 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedBiddings}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onBulkDelete={handleBulkDeleteClick}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedBiddings.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 단일 삭제 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>입찰 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 입찰을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일괄 삭제 다이얼로그 */}
|
||||
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>입찰 일괄 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 {selectedItems.size}개 입찰을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleBulkDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
574
src/components/business/juil/bidding/actions.ts
Normal file
574
src/components/business/juil/bidding/actions.ts
Normal file
@@ -0,0 +1,574 @@
|
||||
'use server';
|
||||
|
||||
import type {
|
||||
Bidding,
|
||||
BiddingStats,
|
||||
BiddingListResponse,
|
||||
BiddingFilter,
|
||||
BiddingDetail,
|
||||
BiddingDetailFormData,
|
||||
ExpenseItem,
|
||||
EstimateDetailItem,
|
||||
} from './types';
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_BIDDINGS: Bidding[] = [
|
||||
{
|
||||
id: '1',
|
||||
biddingCode: 'BID-2025-001',
|
||||
partnerId: '1',
|
||||
partnerName: '이사대표',
|
||||
projectName: '광장 아파트',
|
||||
biddingDate: '2025-01-25',
|
||||
totalCount: 15,
|
||||
biddingAmount: 71000000,
|
||||
bidDate: '2025-01-20',
|
||||
submissionDate: '2025-01-22',
|
||||
confirmDate: '2025-01-25',
|
||||
status: 'awarded',
|
||||
bidderId: 'hong',
|
||||
bidderName: '홍길동',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
createdBy: 'system',
|
||||
estimateId: '1',
|
||||
estimateCode: 'EST-2025-001',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
biddingCode: 'BID-2025-002',
|
||||
partnerId: '2',
|
||||
partnerName: '야사건설',
|
||||
projectName: '대림아파트',
|
||||
biddingDate: '2025-01-20',
|
||||
totalCount: 22,
|
||||
biddingAmount: 100000000,
|
||||
bidDate: '2025-01-18',
|
||||
submissionDate: null,
|
||||
confirmDate: null,
|
||||
status: 'waiting',
|
||||
bidderId: 'kim',
|
||||
bidderName: '김철수',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-02',
|
||||
updatedAt: '2025-01-02',
|
||||
createdBy: 'system',
|
||||
estimateId: '2',
|
||||
estimateCode: 'EST-2025-002',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
biddingCode: 'BID-2025-003',
|
||||
partnerId: '3',
|
||||
partnerName: '여의건설',
|
||||
projectName: '현장아파트',
|
||||
biddingDate: '2025-01-18',
|
||||
totalCount: 18,
|
||||
biddingAmount: 85000000,
|
||||
bidDate: '2025-01-15',
|
||||
submissionDate: '2025-01-16',
|
||||
confirmDate: '2025-01-18',
|
||||
status: 'awarded',
|
||||
bidderId: 'hong',
|
||||
bidderName: '홍길동',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-03',
|
||||
updatedAt: '2025-01-03',
|
||||
createdBy: 'system',
|
||||
estimateId: '3',
|
||||
estimateCode: 'EST-2025-003',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
biddingCode: 'BID-2025-004',
|
||||
partnerId: '1',
|
||||
partnerName: '이사대표',
|
||||
projectName: '송파타워',
|
||||
biddingDate: '2025-01-15',
|
||||
totalCount: 30,
|
||||
biddingAmount: 120000000,
|
||||
bidDate: '2025-01-12',
|
||||
submissionDate: '2025-01-13',
|
||||
confirmDate: '2025-01-15',
|
||||
status: 'failed',
|
||||
bidderId: 'lee',
|
||||
bidderName: '이영희',
|
||||
remarks: '가격 경쟁력 부족',
|
||||
createdAt: '2025-01-04',
|
||||
updatedAt: '2025-01-04',
|
||||
createdBy: 'system',
|
||||
estimateId: '4',
|
||||
estimateCode: 'EST-2025-004',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
biddingCode: 'BID-2025-005',
|
||||
partnerId: '2',
|
||||
partnerName: '야사건설',
|
||||
projectName: '강남센터',
|
||||
biddingDate: '2025-01-12',
|
||||
totalCount: 25,
|
||||
biddingAmount: 95000000,
|
||||
bidDate: '2025-01-10',
|
||||
submissionDate: '2025-01-11',
|
||||
confirmDate: null,
|
||||
status: 'submitted',
|
||||
bidderId: 'hong',
|
||||
bidderName: '홍길동',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-05',
|
||||
updatedAt: '2025-01-05',
|
||||
createdBy: 'system',
|
||||
estimateId: '5',
|
||||
estimateCode: 'EST-2025-005',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
biddingCode: 'BID-2025-006',
|
||||
partnerId: '3',
|
||||
partnerName: '여의건설',
|
||||
projectName: '목동센터',
|
||||
biddingDate: '2025-01-10',
|
||||
totalCount: 12,
|
||||
biddingAmount: 78000000,
|
||||
bidDate: '2025-01-08',
|
||||
submissionDate: '2025-01-09',
|
||||
confirmDate: '2025-01-10',
|
||||
status: 'invalid',
|
||||
bidderId: 'kim',
|
||||
bidderName: '김철수',
|
||||
remarks: '입찰 조건 미충족',
|
||||
createdAt: '2025-01-06',
|
||||
updatedAt: '2025-01-06',
|
||||
createdBy: 'system',
|
||||
estimateId: '6',
|
||||
estimateCode: 'EST-2025-006',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
biddingCode: 'BID-2025-007',
|
||||
partnerId: '1',
|
||||
partnerName: '이사대표',
|
||||
projectName: '서초타워',
|
||||
biddingDate: '2025-01-08',
|
||||
totalCount: 35,
|
||||
biddingAmount: 150000000,
|
||||
bidDate: '2025-01-05',
|
||||
submissionDate: null,
|
||||
confirmDate: null,
|
||||
status: 'waiting',
|
||||
bidderId: 'lee',
|
||||
bidderName: '이영희',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-07',
|
||||
updatedAt: '2025-01-07',
|
||||
createdBy: 'system',
|
||||
estimateId: '7',
|
||||
estimateCode: 'EST-2025-007',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
biddingCode: 'BID-2025-008',
|
||||
partnerId: '2',
|
||||
partnerName: '야사건설',
|
||||
projectName: '청담프로젝트',
|
||||
biddingDate: '2025-01-05',
|
||||
totalCount: 40,
|
||||
biddingAmount: 200000000,
|
||||
bidDate: '2025-01-03',
|
||||
submissionDate: '2025-01-04',
|
||||
confirmDate: '2025-01-05',
|
||||
status: 'awarded',
|
||||
bidderId: 'hong',
|
||||
bidderName: '홍길동',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-08',
|
||||
updatedAt: '2025-01-08',
|
||||
createdBy: 'system',
|
||||
estimateId: '8',
|
||||
estimateCode: 'EST-2025-008',
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
biddingCode: 'BID-2025-009',
|
||||
partnerId: '3',
|
||||
partnerName: '여의건설',
|
||||
projectName: '잠실센터',
|
||||
biddingDate: '2025-01-03',
|
||||
totalCount: 20,
|
||||
biddingAmount: 88000000,
|
||||
bidDate: '2025-01-01',
|
||||
submissionDate: null,
|
||||
confirmDate: null,
|
||||
status: 'hold',
|
||||
bidderId: 'kim',
|
||||
bidderName: '김철수',
|
||||
remarks: '검토 대기 중',
|
||||
createdAt: '2025-01-09',
|
||||
updatedAt: '2025-01-09',
|
||||
createdBy: 'system',
|
||||
estimateId: '9',
|
||||
estimateCode: 'EST-2025-009',
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
biddingCode: 'BID-2025-010',
|
||||
partnerId: '1',
|
||||
partnerName: '이사대표',
|
||||
projectName: '역삼빌딩',
|
||||
biddingDate: '2025-01-01',
|
||||
totalCount: 10,
|
||||
biddingAmount: 65000000,
|
||||
bidDate: '2024-12-28',
|
||||
submissionDate: null,
|
||||
confirmDate: null,
|
||||
status: 'waiting',
|
||||
bidderId: 'lee',
|
||||
bidderName: '이영희',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-10',
|
||||
updatedAt: '2025-01-10',
|
||||
createdBy: 'system',
|
||||
estimateId: '10',
|
||||
estimateCode: 'EST-2025-010',
|
||||
},
|
||||
];
|
||||
|
||||
// 입찰 목록 조회
|
||||
export async function getBiddingList(filter?: BiddingFilter): Promise<{
|
||||
success: boolean;
|
||||
data?: BiddingListResponse;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
let filteredData = [...MOCK_BIDDINGS];
|
||||
|
||||
// 검색 필터
|
||||
if (filter?.search) {
|
||||
const search = filter.search.toLowerCase();
|
||||
filteredData = filteredData.filter(
|
||||
(item) =>
|
||||
item.biddingCode.toLowerCase().includes(search) ||
|
||||
item.partnerName.toLowerCase().includes(search) ||
|
||||
item.projectName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (filter?.status && filter.status !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.status === filter.status);
|
||||
}
|
||||
|
||||
// 거래처 필터
|
||||
if (filter?.partnerId && filter.partnerId !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.partnerId === filter.partnerId);
|
||||
}
|
||||
|
||||
// 입찰자 필터
|
||||
if (filter?.bidderId && filter.bidderId !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.bidderId === filter.bidderId);
|
||||
}
|
||||
|
||||
// 날짜 필터
|
||||
if (filter?.startDate) {
|
||||
filteredData = filteredData.filter(
|
||||
(item) => item.biddingDate && item.biddingDate >= filter.startDate!
|
||||
);
|
||||
}
|
||||
if (filter?.endDate) {
|
||||
filteredData = filteredData.filter(
|
||||
(item) => item.biddingDate && item.biddingDate <= filter.endDate!
|
||||
);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
const sortBy = filter?.sortBy || 'biddingDateDesc';
|
||||
switch (sortBy) {
|
||||
case 'biddingDateDesc':
|
||||
filteredData.sort((a, b) => {
|
||||
if (!a.biddingDate) return 1;
|
||||
if (!b.biddingDate) return -1;
|
||||
return new Date(b.biddingDate).getTime() - new Date(a.biddingDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'biddingDateAsc':
|
||||
filteredData.sort((a, b) => {
|
||||
if (!a.biddingDate) return 1;
|
||||
if (!b.biddingDate) return -1;
|
||||
return new Date(a.biddingDate).getTime() - new Date(b.biddingDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'submissionDateDesc':
|
||||
filteredData.sort((a, b) => {
|
||||
if (!a.submissionDate) return 1;
|
||||
if (!b.submissionDate) return -1;
|
||||
return new Date(b.submissionDate).getTime() - new Date(a.submissionDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'confirmDateDesc':
|
||||
filteredData.sort((a, b) => {
|
||||
if (!a.confirmDate) return 1;
|
||||
if (!b.confirmDate) return -1;
|
||||
return new Date(b.confirmDate).getTime() - new Date(a.confirmDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
filteredData.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
filteredData.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'projectNameAsc':
|
||||
filteredData.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
|
||||
break;
|
||||
case 'projectNameDesc':
|
||||
filteredData.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
|
||||
break;
|
||||
}
|
||||
|
||||
// 페이지네이션
|
||||
const page = filter?.page || 1;
|
||||
const size = filter?.size || 20;
|
||||
const startIndex = (page - 1) * size;
|
||||
const paginatedData = filteredData.slice(startIndex, startIndex + size);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: paginatedData,
|
||||
total: filteredData.length,
|
||||
page,
|
||||
size,
|
||||
totalPages: Math.ceil(filteredData.length / size),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getBiddingList error:', error);
|
||||
return { success: false, error: '입찰 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 입찰 통계 조회
|
||||
export async function getBiddingStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: BiddingStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const stats: BiddingStats = {
|
||||
total: MOCK_BIDDINGS.length,
|
||||
waiting: MOCK_BIDDINGS.filter((b) => b.status === 'waiting').length,
|
||||
awarded: MOCK_BIDDINGS.filter((b) => b.status === 'awarded').length,
|
||||
};
|
||||
|
||||
return { success: true, data: stats };
|
||||
} catch (error) {
|
||||
console.error('getBiddingStats error:', error);
|
||||
return { success: false, error: '통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 입찰 단건 조회
|
||||
export async function getBidding(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: Bidding;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const bidding = MOCK_BIDDINGS.find((b) => b.id === id);
|
||||
if (!bidding) {
|
||||
return { success: false, error: '입찰 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: bidding };
|
||||
} catch (error) {
|
||||
console.error('getBidding error:', error);
|
||||
return { success: false, error: '입찰 정보를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 입찰 삭제
|
||||
export async function deleteBidding(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const index = MOCK_BIDDINGS.findIndex((b) => b.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '입찰 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('deleteBidding error:', error);
|
||||
return { success: false, error: '입찰 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 입찰 일괄 삭제
|
||||
export async function deleteBiddings(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('deleteBiddings error:', error);
|
||||
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 공과 상세 목업 데이터
|
||||
const MOCK_EXPENSE_ITEMS: ExpenseItem[] = [
|
||||
{ id: '1', name: '설계비', amount: 5000000 },
|
||||
{ id: '2', name: '운반비', amount: 3000000 },
|
||||
{ id: '3', name: '기타경비', amount: 2000000 },
|
||||
];
|
||||
|
||||
// 견적 상세 목업 데이터
|
||||
const MOCK_ESTIMATE_DETAIL_ITEMS: EstimateDetailItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
no: 1,
|
||||
name: '방화문',
|
||||
material: 'SUS304',
|
||||
width: 1000,
|
||||
height: 2100,
|
||||
quantity: 10,
|
||||
box: 2,
|
||||
coating: 1,
|
||||
batting: 0,
|
||||
mounting: 1,
|
||||
shift: 0,
|
||||
painting: 1,
|
||||
motor: 0,
|
||||
controller: 0,
|
||||
unitPrice: 1500000,
|
||||
expense: 100000,
|
||||
expenseQuantity: 10,
|
||||
totalPrice: 16000000,
|
||||
marginRate: 15,
|
||||
marginCost: 2400000,
|
||||
progressPayment: 8000000,
|
||||
execution: 13600000,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
no: 2,
|
||||
name: '자동문',
|
||||
material: 'AL',
|
||||
width: 1800,
|
||||
height: 2400,
|
||||
quantity: 5,
|
||||
box: 1,
|
||||
coating: 1,
|
||||
batting: 1,
|
||||
mounting: 1,
|
||||
shift: 1,
|
||||
painting: 0,
|
||||
motor: 1,
|
||||
controller: 1,
|
||||
unitPrice: 3500000,
|
||||
expense: 200000,
|
||||
expenseQuantity: 5,
|
||||
totalPrice: 18500000,
|
||||
marginRate: 18,
|
||||
marginCost: 3330000,
|
||||
progressPayment: 9250000,
|
||||
execution: 15170000,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
no: 3,
|
||||
name: '셔터',
|
||||
material: 'STEEL',
|
||||
width: 3000,
|
||||
height: 3500,
|
||||
quantity: 3,
|
||||
box: 1,
|
||||
coating: 1,
|
||||
batting: 0,
|
||||
mounting: 1,
|
||||
shift: 0,
|
||||
painting: 1,
|
||||
motor: 1,
|
||||
controller: 1,
|
||||
unitPrice: 8000000,
|
||||
expense: 500000,
|
||||
expenseQuantity: 3,
|
||||
totalPrice: 25500000,
|
||||
marginRate: 20,
|
||||
marginCost: 5100000,
|
||||
progressPayment: 12750000,
|
||||
execution: 20400000,
|
||||
},
|
||||
];
|
||||
|
||||
// 입찰 상세 조회
|
||||
export async function getBiddingDetail(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: BiddingDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const bidding = MOCK_BIDDINGS.find((b) => b.id === id);
|
||||
if (!bidding) {
|
||||
return { success: false, error: '입찰 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
// 상세 데이터 생성
|
||||
const biddingDetail: BiddingDetail = {
|
||||
...bidding,
|
||||
constructionStartDate: '2025-02-01',
|
||||
constructionEndDate: '2025-04-30',
|
||||
vatType: 'excluded',
|
||||
expenseItems: MOCK_EXPENSE_ITEMS,
|
||||
estimateDetailItems: MOCK_ESTIMATE_DETAIL_ITEMS,
|
||||
};
|
||||
|
||||
return { success: true, data: biddingDetail };
|
||||
} catch (error) {
|
||||
console.error('getBiddingDetail error:', error);
|
||||
return { success: false, error: '입찰 상세를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 입찰 수정
|
||||
export async function updateBidding(
|
||||
id: string,
|
||||
data: Partial<BiddingDetailFormData>
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const index = MOCK_BIDDINGS.findIndex((b) => b.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '입찰 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
// 목업에서는 실제 업데이트하지 않음
|
||||
console.log('Updating bidding:', id, data);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('updateBidding error:', error);
|
||||
return { success: false, error: '입찰 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
4
src/components/business/juil/bidding/index.ts
Normal file
4
src/components/business/juil/bidding/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as BiddingListClient } from './BiddingListClient';
|
||||
export { default as BiddingDetailForm } from './BiddingDetailForm';
|
||||
export * from './types';
|
||||
export * from './actions';
|
||||
263
src/components/business/juil/bidding/types.ts
Normal file
263
src/components/business/juil/bidding/types.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* 주일 기업 - 입찰관리 타입 정의
|
||||
*
|
||||
* 입찰 데이터는 견적 상세에서 견적완료 시 자동 등록됨
|
||||
* (별도 등록 기능 없음, 상세/수정만 가능)
|
||||
*/
|
||||
|
||||
// 입찰 상태
|
||||
export type BiddingStatus =
|
||||
| 'waiting' // 입찰대기
|
||||
| 'submitted' // 투찰
|
||||
| 'failed' // 탈락
|
||||
| 'invalid' // 유찰
|
||||
| 'awarded' // 낙찰
|
||||
| 'hold'; // 보류
|
||||
|
||||
// 입찰 타입
|
||||
export interface Bidding {
|
||||
id: string;
|
||||
biddingCode: string; // 입찰번호
|
||||
|
||||
// 기본 정보
|
||||
partnerId: string; // 거래처 ID
|
||||
partnerName: string; // 거래처명
|
||||
projectName: string; // 현장명
|
||||
|
||||
// 입찰 정보
|
||||
biddingDate: string | null; // 입찰일자
|
||||
totalCount: number; // 총 개소
|
||||
biddingAmount: number; // 입찰금액
|
||||
bidDate: string | null; // 입찰일
|
||||
submissionDate: string | null; // 투찰일
|
||||
confirmDate: string | null; // 확정일
|
||||
|
||||
// 상태 정보
|
||||
status: BiddingStatus;
|
||||
|
||||
// 입찰자
|
||||
bidderId: string;
|
||||
bidderName: string;
|
||||
|
||||
// 비고
|
||||
remarks: string;
|
||||
|
||||
// 메타 정보
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy: string;
|
||||
|
||||
// 연결된 견적 정보 (견적완료 시 자동 연결)
|
||||
estimateId: string;
|
||||
estimateCode: string;
|
||||
}
|
||||
|
||||
// 입찰 통계
|
||||
export interface BiddingStats {
|
||||
total: number; // 전체 입찰
|
||||
waiting: number; // 입찰대기
|
||||
awarded: number; // 낙찰
|
||||
}
|
||||
|
||||
// 입찰 필터
|
||||
export interface BiddingFilter {
|
||||
search?: string;
|
||||
status?: BiddingStatus | 'all';
|
||||
partnerId?: string;
|
||||
bidderId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
sortBy?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// API 응답 타입
|
||||
export interface BiddingListResponse {
|
||||
items: Bidding[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// 상태 옵션
|
||||
export const BIDDING_STATUS_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'waiting', label: '입찰대기' },
|
||||
{ value: 'submitted', label: '투찰' },
|
||||
{ value: 'failed', label: '탈락' },
|
||||
{ value: 'invalid', label: '유찰' },
|
||||
{ value: 'awarded', label: '낙찰' },
|
||||
{ value: 'hold', label: '보류' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
export const BIDDING_SORT_OPTIONS = [
|
||||
{ value: 'biddingDateDesc', label: '최신순 (입찰일)' },
|
||||
{ value: 'biddingDateAsc', label: '등록순 (입찰일)' },
|
||||
{ value: 'submissionDateDesc', label: '투찰일 최신순' },
|
||||
{ value: 'confirmDateDesc', label: '확정일 최신순' },
|
||||
{ value: 'partnerNameAsc', label: '거래처명 오름차순' },
|
||||
{ value: 'partnerNameDesc', label: '거래처명 내림차순' },
|
||||
{ value: 'projectNameAsc', label: '현장명 오름차순' },
|
||||
{ value: 'projectNameDesc', label: '현장명 내림차순' },
|
||||
];
|
||||
|
||||
// 상태별 스타일
|
||||
export const BIDDING_STATUS_STYLES: Record<BiddingStatus, string> = {
|
||||
waiting: 'text-orange-500 font-medium',
|
||||
submitted: 'text-blue-500 font-medium',
|
||||
failed: 'text-red-500 font-medium',
|
||||
invalid: 'text-gray-500 font-medium',
|
||||
awarded: 'text-green-600 font-medium',
|
||||
hold: 'text-gray-400 font-medium',
|
||||
};
|
||||
|
||||
export const BIDDING_STATUS_LABELS: Record<BiddingStatus, string> = {
|
||||
waiting: '입찰대기',
|
||||
submitted: '투찰',
|
||||
failed: '탈락',
|
||||
invalid: '유찰',
|
||||
awarded: '낙찰',
|
||||
hold: '보류',
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// 입찰 상세 관련 타입
|
||||
// =====================================================
|
||||
|
||||
// 공과 항목 (견적에서 가져옴)
|
||||
export interface ExpenseItem {
|
||||
id: string;
|
||||
name: string; // 공과명
|
||||
amount: number; // 금액
|
||||
}
|
||||
|
||||
// 견적 상세 항목 (견적에서 가져옴 - estimates와 동일한 구조)
|
||||
export interface EstimateDetailItem {
|
||||
id: string;
|
||||
no: number; // 번호
|
||||
name: string; // 명칭
|
||||
material: string; // 제품
|
||||
width: number; // 가로 (M)
|
||||
height: number; // 세로 (M)
|
||||
quantity: number; // 수량
|
||||
|
||||
// 계산값들
|
||||
weight: number; // 무게 = 면적*25
|
||||
area: number; // 면적 = (가로×0.16)*(세로×0.5)
|
||||
steelScreen: number; // 철제,스크린 = 면적*47500
|
||||
caulking: number; // 코킹 = (세로*4)*단가
|
||||
rail: number; // 레일 = (세로×0.2)*단가
|
||||
bottom: number; // 하장 = 가로*단가
|
||||
boxReinforce: number; // 박스+보강 = 가로*단가
|
||||
shaft: number; // 샤프트 = 가로*단가
|
||||
painting: number; // 도장 (셀렉트)
|
||||
motor: number; // 모터 (셀렉트)
|
||||
controller: number; // 제어기 (셀렉트)
|
||||
widthConstruction: number; // 가로시공비
|
||||
heightConstruction: number; // 세로시공비
|
||||
unitPrice: number; // 단가
|
||||
expenseRate: number; // 공과율
|
||||
expense: number; // 공과
|
||||
cost: number; // 원가
|
||||
costExecution: number; // 원가실행
|
||||
marginCost: number; // 마진원가
|
||||
marginCostExecution: number; // 마진원가실행
|
||||
expenseExecution: number; // 공과실행
|
||||
}
|
||||
|
||||
// 입찰 상세 전체 데이터
|
||||
export interface BiddingDetail extends Bidding {
|
||||
// 공사기간
|
||||
constructionStartDate: string;
|
||||
constructionEndDate: string;
|
||||
|
||||
// 부가세
|
||||
vatType: string;
|
||||
|
||||
// 공과 상세 (견적에서 가져옴 - 읽기 전용)
|
||||
expenseItems: ExpenseItem[];
|
||||
|
||||
// 견적 상세 (견적에서 가져옴 - 읽기 전용)
|
||||
estimateDetailItems: EstimateDetailItem[];
|
||||
}
|
||||
|
||||
// 입찰 상세 폼 데이터 (수정용)
|
||||
export interface BiddingDetailFormData {
|
||||
// 입찰 정보
|
||||
biddingCode: string;
|
||||
bidderId: string;
|
||||
bidderName: string;
|
||||
partnerName: string;
|
||||
projectName: string;
|
||||
biddingDate: string;
|
||||
totalCount: number;
|
||||
constructionStartDate: string;
|
||||
constructionEndDate: string;
|
||||
vatType: string;
|
||||
biddingAmount: number;
|
||||
status: BiddingStatus;
|
||||
submissionDate: string;
|
||||
confirmDate: string;
|
||||
remarks: string;
|
||||
|
||||
// 공과 상세 (읽기 전용)
|
||||
expenseItems: ExpenseItem[];
|
||||
|
||||
// 견적 상세 (읽기 전용)
|
||||
estimateDetailItems: EstimateDetailItem[];
|
||||
}
|
||||
|
||||
// 부가세 옵션
|
||||
export const VAT_TYPE_OPTIONS = [
|
||||
{ value: 'included', label: '부가세 포함' },
|
||||
{ value: 'excluded', label: '부가세 별도' },
|
||||
];
|
||||
|
||||
// 빈 폼 데이터 생성
|
||||
export function getEmptyBiddingDetailFormData(): BiddingDetailFormData {
|
||||
return {
|
||||
biddingCode: '',
|
||||
bidderId: '',
|
||||
bidderName: '',
|
||||
partnerName: '',
|
||||
projectName: '',
|
||||
biddingDate: '',
|
||||
totalCount: 0,
|
||||
constructionStartDate: '',
|
||||
constructionEndDate: '',
|
||||
vatType: 'excluded',
|
||||
biddingAmount: 0,
|
||||
status: 'waiting',
|
||||
submissionDate: '',
|
||||
confirmDate: '',
|
||||
remarks: '',
|
||||
expenseItems: [],
|
||||
estimateDetailItems: [],
|
||||
};
|
||||
}
|
||||
|
||||
// BiddingDetail을 FormData로 변환
|
||||
export function biddingDetailToFormData(detail: BiddingDetail): BiddingDetailFormData {
|
||||
return {
|
||||
biddingCode: detail.biddingCode,
|
||||
bidderId: detail.bidderId,
|
||||
bidderName: detail.bidderName,
|
||||
partnerName: detail.partnerName,
|
||||
projectName: detail.projectName,
|
||||
biddingDate: detail.biddingDate || '',
|
||||
totalCount: detail.totalCount,
|
||||
constructionStartDate: detail.constructionStartDate,
|
||||
constructionEndDate: detail.constructionEndDate,
|
||||
vatType: detail.vatType,
|
||||
biddingAmount: detail.biddingAmount,
|
||||
status: detail.status,
|
||||
submissionDate: detail.submissionDate || '',
|
||||
confirmDate: detail.confirmDate || '',
|
||||
remarks: detail.remarks,
|
||||
expenseItems: detail.expenseItems,
|
||||
estimateDetailItems: detail.estimateDetailItems,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import type { CategoryDialogProps } from './types';
|
||||
|
||||
/**
|
||||
* 카테고리 추가/수정 다이얼로그
|
||||
*/
|
||||
export function CategoryDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
mode,
|
||||
category,
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
}: CategoryDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
|
||||
// 다이얼로그 열릴 때 초기값 설정
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (mode === 'edit' && category) {
|
||||
setName(category.name);
|
||||
} else {
|
||||
setName('');
|
||||
}
|
||||
}
|
||||
}, [isOpen, mode, category]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim()) {
|
||||
onSubmit(name.trim());
|
||||
setName('');
|
||||
}
|
||||
};
|
||||
|
||||
const title = mode === 'add' ? '카테고리 추가' : '카테고리 수정';
|
||||
const submitText = mode === 'add' ? '등록' : '수정';
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 카테고리명 입력 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category-name">카테고리명</Label>
|
||||
<Input
|
||||
id="category-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="카테고리를 입력해주세요"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim() || isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : null}
|
||||
{submitText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
145
src/components/business/juil/category-management/actions.ts
Normal file
145
src/components/business/juil/category-management/actions.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
'use server';
|
||||
|
||||
import type { Category } from './types';
|
||||
|
||||
// ===== 목데이터 (추후 API 연동 시 교체) =====
|
||||
let mockCategories: Category[] = [
|
||||
{ id: '1', name: '슬라이드 OPEN 사이즈', order: 1, isDefault: true },
|
||||
{ id: '2', name: '모터', order: 2, isDefault: true },
|
||||
{ id: '3', name: '공정자재', order: 3, isDefault: true },
|
||||
{ id: '4', name: '철물', order: 4, isDefault: true },
|
||||
];
|
||||
|
||||
// 다음 ID 생성
|
||||
let nextId = 5;
|
||||
|
||||
// ===== 카테고리 목록 조회 =====
|
||||
export async function getCategories(): Promise<{
|
||||
success: boolean;
|
||||
data?: Category[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 목데이터 반환 (순서대로 정렬)
|
||||
const sortedCategories = [...mockCategories].sort((a, b) => a.order - b.order);
|
||||
return { success: true, data: sortedCategories };
|
||||
} catch (error) {
|
||||
console.error('[getCategories] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 카테고리 생성 =====
|
||||
export async function createCategory(data: {
|
||||
name: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: Category;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const newCategory: Category = {
|
||||
id: String(nextId++),
|
||||
name: data.name,
|
||||
order: mockCategories.length + 1,
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
mockCategories.push(newCategory);
|
||||
return { success: true, data: newCategory };
|
||||
} catch (error) {
|
||||
console.error('[createCategory] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 카테고리 수정 =====
|
||||
export async function updateCategory(
|
||||
id: string,
|
||||
data: { name?: string }
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: Category;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const index = mockCategories.findIndex(c => c.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '카테고리를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
mockCategories[index] = {
|
||||
...mockCategories[index],
|
||||
...data,
|
||||
};
|
||||
|
||||
return { success: true, data: mockCategories[index] };
|
||||
} catch (error) {
|
||||
console.error('[updateCategory] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 카테고리 삭제 =====
|
||||
export async function deleteCategory(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
errorType?: 'IN_USE' | 'DEFAULT' | 'GENERAL';
|
||||
}> {
|
||||
try {
|
||||
const category = mockCategories.find(c => c.id === id);
|
||||
|
||||
if (!category) {
|
||||
return { success: false, error: '카테고리를 찾을 수 없습니다.', errorType: 'GENERAL' };
|
||||
}
|
||||
|
||||
// 기본 카테고리는 삭제 불가
|
||||
if (category.isDefault) {
|
||||
return {
|
||||
success: false,
|
||||
error: '기본 카테고리는 삭제가 불가합니다.',
|
||||
errorType: 'DEFAULT'
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: 품목 사용 여부 체크 로직 (추후 API 연동 시)
|
||||
// 현재는 목데이터이므로 사용 중인 품목이 없다고 가정
|
||||
// const itemsUsingCategory = await checkItemsUsingCategory(id);
|
||||
// if (itemsUsingCategory.length > 0) {
|
||||
// return {
|
||||
// success: false,
|
||||
// error: `"${category.name}"을(를) 사용하고 있는 품목이 있습니다. 모두 변경 후 삭제가 가능합니다.`,
|
||||
// errorType: 'IN_USE'
|
||||
// };
|
||||
// }
|
||||
|
||||
mockCategories = mockCategories.filter(c => c.id !== id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[deleteCategory] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.', errorType: 'GENERAL' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 카테고리 순서 변경 =====
|
||||
export async function reorderCategories(
|
||||
items: { id: string; sort_order: number }[]
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 순서 업데이트
|
||||
items.forEach(item => {
|
||||
const category = mockCategories.find(c => c.id === item.id);
|
||||
if (category) {
|
||||
category.order = item.sort_order;
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[reorderCategories] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
372
src/components/business/juil/category-management/index.tsx
Normal file
372
src/components/business/juil/category-management/index.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { FolderTree, Plus, GripVertical, Pencil, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { CategoryDialog } from './CategoryDialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import type { Category } from './types';
|
||||
import {
|
||||
getCategories,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
reorderCategories,
|
||||
} from './actions';
|
||||
|
||||
export function CategoryManagement() {
|
||||
// 카테고리 데이터
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 입력 필드
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogMode, setDialogMode] = useState<'add' | 'edit'>('add');
|
||||
const [selectedCategory, setSelectedCategory] = useState<Category | undefined>();
|
||||
|
||||
// 삭제 확인 다이얼로그
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [categoryToDelete, setCategoryToDelete] = useState<Category | null>(null);
|
||||
|
||||
// 드래그 상태
|
||||
const [draggedItem, setDraggedItem] = useState<number | null>(null);
|
||||
|
||||
// 데이터 로드
|
||||
const loadCategories = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const result = await getCategories();
|
||||
if (result.success && result.data) {
|
||||
setCategories(result.data);
|
||||
} else {
|
||||
toast.error(result.error || '카테고리 목록을 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('카테고리 목록 조회 실패:', error);
|
||||
toast.error('카테고리 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, [loadCategories]);
|
||||
|
||||
// 카테고리 추가 (입력 필드에서 직접)
|
||||
const handleQuickAdd = async () => {
|
||||
if (!newCategoryName.trim() || isSubmitting) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const result = await createCategory({ name: newCategoryName.trim() });
|
||||
if (result.success && result.data) {
|
||||
setCategories(prev => [...prev, result.data!]);
|
||||
setNewCategoryName('');
|
||||
toast.success('카테고리가 추가되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '카테고리 추가에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('카테고리 추가 실패:', error);
|
||||
toast.error('카테고리 추가에 실패했습니다.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리 수정 다이얼로그 열기
|
||||
const handleEdit = (category: Category) => {
|
||||
setSelectedCategory(category);
|
||||
setDialogMode('edit');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
// 카테고리 삭제 확인
|
||||
const handleDelete = (category: Category) => {
|
||||
setCategoryToDelete(category);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 실행
|
||||
const confirmDelete = async () => {
|
||||
if (!categoryToDelete || isSubmitting) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const result = await deleteCategory(categoryToDelete.id);
|
||||
if (result.success) {
|
||||
setCategories(prev => prev.filter(c => c.id !== categoryToDelete.id));
|
||||
toast.success('카테고리가 삭제되었습니다.');
|
||||
} else {
|
||||
// 삭제 실패 유형에 따른 메시지
|
||||
toast.error(result.error || '카테고리 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('카테고리 삭제 실패:', error);
|
||||
toast.error('카테고리 삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setCategoryToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 다이얼로그 제출
|
||||
const handleDialogSubmit = async (name: string) => {
|
||||
if (dialogMode === 'edit' && selectedCategory) {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const result = await updateCategory(selectedCategory.id, { name });
|
||||
if (result.success) {
|
||||
setCategories(prev => prev.map(c =>
|
||||
c.id === selectedCategory.id ? { ...c, name } : c
|
||||
));
|
||||
toast.success('카테고리가 수정되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '카테고리 수정에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('카테고리 수정 실패:', error);
|
||||
toast.error('카테고리 수정에 실패했습니다.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
// 드래그 시작
|
||||
const handleDragStart = (e: React.DragEvent, index: number) => {
|
||||
setDraggedItem(index);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
// 드래그 종료 - 서버에 순서 저장
|
||||
const handleDragEnd = async () => {
|
||||
if (draggedItem === null) return;
|
||||
|
||||
setDraggedItem(null);
|
||||
|
||||
// 순서 변경 API 호출
|
||||
try {
|
||||
const items = categories.map((category, idx) => ({
|
||||
id: category.id,
|
||||
sort_order: idx + 1,
|
||||
}));
|
||||
const result = await reorderCategories(items);
|
||||
if (result.success) {
|
||||
toast.success('순서가 변경되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '순서 변경에 실패했습니다.');
|
||||
loadCategories();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('순서 변경 실패:', error);
|
||||
toast.error('순서 변경에 실패했습니다.');
|
||||
// 실패시 원래 순서로 복구
|
||||
loadCategories();
|
||||
}
|
||||
};
|
||||
|
||||
// 드래그 오버
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedItem === null || draggedItem === index) return;
|
||||
|
||||
const newCategories = [...categories];
|
||||
const draggedCategory = newCategories[draggedItem];
|
||||
newCategories.splice(draggedItem, 1);
|
||||
newCategories.splice(index, 0, draggedCategory);
|
||||
|
||||
// 순서 업데이트 (로컬)
|
||||
const reorderedCategories = newCategories.map((category, idx) => ({
|
||||
...category,
|
||||
order: idx + 1
|
||||
}));
|
||||
|
||||
setCategories(reorderedCategories);
|
||||
setDraggedItem(index);
|
||||
};
|
||||
|
||||
// 키보드로 추가 (한글 IME 조합 중에는 무시)
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
||||
handleQuickAdd();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="카테고리관리"
|
||||
description="카테고리를 등록하고 관리합니다. 드래그하여 순서를 변경할 수 있습니다."
|
||||
icon={FolderTree}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 카테고리 추가 입력 영역 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newCategoryName}
|
||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="카테고리를 입력해주세요"
|
||||
className="flex-1"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleQuickAdd}
|
||||
disabled={!newCategoryName.trim() || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카테고리 목록 */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">로딩 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{categories.map((category, index) => (
|
||||
<div
|
||||
key={category.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
className={`flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors cursor-move ${
|
||||
draggedItem === index ? 'opacity-50 bg-muted' : ''
|
||||
}`}
|
||||
>
|
||||
{/* 드래그 핸들 */}
|
||||
<GripVertical className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
|
||||
{/* 순서 번호 */}
|
||||
<span className="text-sm text-muted-foreground w-8">
|
||||
{index + 1}
|
||||
</span>
|
||||
|
||||
{/* 카테고리명 */}
|
||||
<span className="flex-1 font-medium">{category.name}</span>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(category)}
|
||||
className="h-8 w-8 p-0"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="sr-only">수정</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(category)}
|
||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">삭제</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{categories.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-muted-foreground">
|
||||
등록된 카테고리가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 안내 문구 */}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
※ 카테고리 순서는 드래그 앤 드롭으로 변경할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 수정 다이얼로그 */}
|
||||
<CategoryDialog
|
||||
isOpen={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
mode={dialogMode}
|
||||
category={selectedCategory}
|
||||
onSubmit={handleDialogSubmit}
|
||||
isLoading={isSubmitting}
|
||||
/>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>카테고리 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{categoryToDelete?.name}" 카테고리를 삭제하시겠습니까?
|
||||
{categoryToDelete?.isDefault && (
|
||||
<>
|
||||
<br />
|
||||
<span className="text-destructive font-medium">
|
||||
기본 카테고리는 삭제가 불가합니다.
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isSubmitting}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
disabled={isSubmitting || categoryToDelete?.isDefault}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : null}
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
21
src/components/business/juil/category-management/types.ts
Normal file
21
src/components/business/juil/category-management/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 카테고리 타입 정의
|
||||
*/
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
order: number;
|
||||
isDefault?: boolean;
|
||||
isActive?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CategoryDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode: 'add' | 'edit';
|
||||
category?: Category;
|
||||
onSubmit: (name: string) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
6
src/components/business/juil/common/index.ts
Normal file
6
src/components/business/juil/common/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Types
|
||||
export type { ApprovalPerson, ElectronicApproval } from './types';
|
||||
export { getEmptyElectronicApproval } from './types';
|
||||
|
||||
// Modals
|
||||
export { ElectronicApprovalModal } from './modals';
|
||||
@@ -0,0 +1,298 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { ElectronicApproval, ApprovalPerson } from '../types';
|
||||
|
||||
// 목업 부서 목록
|
||||
const MOCK_DEPARTMENTS = [
|
||||
{ value: 'sales', label: '영업부' },
|
||||
{ value: 'production', label: '생산부' },
|
||||
{ value: 'quality', label: '품질부' },
|
||||
{ value: 'management', label: '경영지원부' },
|
||||
];
|
||||
|
||||
// 목업 직책 목록
|
||||
const MOCK_POSITIONS = [
|
||||
{ value: 'staff', label: '사원' },
|
||||
{ value: 'senior', label: '주임' },
|
||||
{ value: 'assistant_manager', label: '대리' },
|
||||
{ value: 'manager', label: '과장' },
|
||||
{ value: 'deputy_manager', label: '차장' },
|
||||
{ value: 'general_manager', label: '부장' },
|
||||
{ value: 'director', label: '이사' },
|
||||
{ value: 'ceo', label: '대표' },
|
||||
];
|
||||
|
||||
// 목업 사원 목록
|
||||
const MOCK_EMPLOYEES = [
|
||||
{ value: 'hong', label: '홍길동' },
|
||||
{ value: 'kim', label: '김철수' },
|
||||
{ value: 'lee', label: '이영희' },
|
||||
{ value: 'park', label: '박지영' },
|
||||
{ value: 'choi', label: '최민수' },
|
||||
];
|
||||
|
||||
interface ElectronicApprovalModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
approval: ElectronicApproval;
|
||||
onSave: (approval: ElectronicApproval) => void;
|
||||
}
|
||||
|
||||
export function ElectronicApprovalModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
approval,
|
||||
onSave,
|
||||
}: ElectronicApprovalModalProps) {
|
||||
const [localApproval, setLocalApproval] = useState<ElectronicApproval>(approval);
|
||||
|
||||
// 결재자 추가
|
||||
const handleAddApprover = useCallback(() => {
|
||||
const newPerson: ApprovalPerson = {
|
||||
id: String(Date.now()),
|
||||
department: '',
|
||||
position: '',
|
||||
name: '',
|
||||
};
|
||||
setLocalApproval((prev) => ({
|
||||
...prev,
|
||||
approvers: [...prev.approvers, newPerson],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 결재자 삭제
|
||||
const handleRemoveApprover = useCallback((personId: string) => {
|
||||
setLocalApproval((prev) => ({
|
||||
...prev,
|
||||
approvers: prev.approvers.filter((p) => p.id !== personId),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 결재자 변경
|
||||
const handleApproverChange = useCallback(
|
||||
(personId: string, field: keyof ApprovalPerson, value: string) => {
|
||||
setLocalApproval((prev) => ({
|
||||
...prev,
|
||||
approvers: prev.approvers.map((p) =>
|
||||
p.id === personId ? { ...p, [field]: value } : p
|
||||
),
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 참조자 추가
|
||||
const handleAddReference = useCallback(() => {
|
||||
const newPerson: ApprovalPerson = {
|
||||
id: String(Date.now()),
|
||||
department: '',
|
||||
position: '',
|
||||
name: '',
|
||||
};
|
||||
setLocalApproval((prev) => ({
|
||||
...prev,
|
||||
references: [...prev.references, newPerson],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 참조자 삭제
|
||||
const handleRemoveReference = useCallback((personId: string) => {
|
||||
setLocalApproval((prev) => ({
|
||||
...prev,
|
||||
references: prev.references.filter((p) => p.id !== personId),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 참조자 변경
|
||||
const handleReferenceChange = useCallback(
|
||||
(personId: string, field: keyof ApprovalPerson, value: string) => {
|
||||
setLocalApproval((prev) => ({
|
||||
...prev,
|
||||
references: prev.references.map((p) =>
|
||||
p.id === personId ? { ...p, [field]: value } : p
|
||||
),
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 저장
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(localApproval);
|
||||
}, [localApproval, onSave]);
|
||||
|
||||
// 취소
|
||||
const handleCancel = useCallback(() => {
|
||||
setLocalApproval(approval);
|
||||
onClose();
|
||||
}, [approval, onClose]);
|
||||
|
||||
// 사람 선택 행 렌더링
|
||||
const renderPersonRow = (
|
||||
person: ApprovalPerson,
|
||||
index: number,
|
||||
type: 'approver' | 'reference'
|
||||
) => {
|
||||
const onChange =
|
||||
type === 'approver' ? handleApproverChange : handleReferenceChange;
|
||||
const onRemove =
|
||||
type === 'approver' ? handleRemoveApprover : handleRemoveReference;
|
||||
|
||||
return (
|
||||
<div key={person.id} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={person.department || undefined}
|
||||
onValueChange={(val) => onChange(person.id, 'department', val)}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="부서명" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_DEPARTMENTS.map((dept) => (
|
||||
<SelectItem key={dept.value} value={dept.value}>
|
||||
{dept.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-gray-400">/</span>
|
||||
<Select
|
||||
value={person.position || undefined}
|
||||
onValueChange={(val) => onChange(person.id, 'position', val)}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="직책명" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_POSITIONS.map((pos) => (
|
||||
<SelectItem key={pos.value} value={pos.value}>
|
||||
{pos.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-gray-400">/</span>
|
||||
<Select
|
||||
value={person.name || undefined}
|
||||
onValueChange={(val) => onChange(person.id, 'name', val)}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="이름" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_EMPLOYEES.map((emp) => (
|
||||
<SelectItem key={emp.value} value={emp.value}>
|
||||
{emp.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-blue-500 hover:text-blue-600 hover:bg-blue-50"
|
||||
onClick={() => onRemove(person.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="w-[95vw] max-w-[500px] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">전자결재</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* 결재선 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">결재선</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">부서 / 직책 / 이름</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddApprover}
|
||||
>
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{localApproval.approvers.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4 border rounded-lg">
|
||||
결재자를 추가해주세요.
|
||||
</p>
|
||||
) : (
|
||||
localApproval.approvers.map((person, index) =>
|
||||
renderPersonRow(person, index, 'approver')
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 참조 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">참조</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">부서 / 직책 / 이름</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddReference}
|
||||
>
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{localApproval.references.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4 border rounded-lg">
|
||||
참조자를 추가해주세요.
|
||||
</p>
|
||||
) : (
|
||||
localApproval.references.map((person, index) =>
|
||||
renderPersonRow(person, index, 'reference')
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-gray-800 hover:bg-gray-900">
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
1
src/components/business/juil/common/modals/index.ts
Normal file
1
src/components/business/juil/common/modals/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ElectronicApprovalModal } from './ElectronicApprovalModal';
|
||||
21
src/components/business/juil/common/types.ts
Normal file
21
src/components/business/juil/common/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// 결재자/참조자 정보
|
||||
export interface ApprovalPerson {
|
||||
id: string;
|
||||
department: string; // 부서
|
||||
position: string; // 직책
|
||||
name: string; // 이름
|
||||
}
|
||||
|
||||
// 전자결재 정보
|
||||
export interface ElectronicApproval {
|
||||
approvers: ApprovalPerson[]; // 결재선
|
||||
references: ApprovalPerson[]; // 참조
|
||||
}
|
||||
|
||||
// 빈 전자결재 데이터 생성
|
||||
export function getEmptyElectronicApproval(): ElectronicApproval {
|
||||
return {
|
||||
approvers: [],
|
||||
references: [],
|
||||
};
|
||||
}
|
||||
712
src/components/business/juil/contract/ContractDetailForm.tsx
Normal file
712
src/components/business/juil/contract/ContractDetailForm.tsx
Normal file
@@ -0,0 +1,712 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, Upload, X, Eye, Download } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { toast } from 'sonner';
|
||||
import type { ContractDetail, ContractFormData, ContractAttachment, ContractStatus } from './types';
|
||||
import {
|
||||
CONTRACT_STATUS_LABELS,
|
||||
VAT_TYPE_OPTIONS,
|
||||
getEmptyContractFormData,
|
||||
contractDetailToFormData,
|
||||
} from './types';
|
||||
import { updateContract, deleteContract } from './actions';
|
||||
import { downloadFileById } from '@/lib/utils/fileDownload';
|
||||
import { ContractDocumentModal } from './modals/ContractDocumentModal';
|
||||
import {
|
||||
ElectronicApprovalModal,
|
||||
type ElectronicApproval,
|
||||
getEmptyElectronicApproval,
|
||||
} from '../common';
|
||||
|
||||
// 금액 포맷팅
|
||||
function formatAmount(amount: number): string {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
}
|
||||
|
||||
// 파일 사이즈 포맷팅
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
interface ContractDetailFormProps {
|
||||
mode: 'view' | 'edit';
|
||||
contractId: string;
|
||||
initialData?: ContractDetail;
|
||||
}
|
||||
|
||||
export default function ContractDetailForm({
|
||||
mode,
|
||||
contractId,
|
||||
initialData,
|
||||
}: ContractDetailFormProps) {
|
||||
const router = useRouter();
|
||||
const isViewMode = mode === 'view';
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<ContractFormData>(
|
||||
initialData ? contractDetailToFormData(initialData) : getEmptyContractFormData()
|
||||
);
|
||||
|
||||
// 기존 첨부파일 (서버에서 가져온 파일)
|
||||
const [existingAttachments, setExistingAttachments] = useState<ContractAttachment[]>(
|
||||
initialData?.attachments || []
|
||||
);
|
||||
|
||||
// 새로 추가된 파일
|
||||
const [newAttachments, setNewAttachments] = useState<File[]>([]);
|
||||
|
||||
// 기존 계약서 파일 삭제 여부
|
||||
const [isContractFileDeleted, setIsContractFileDeleted] = useState(false);
|
||||
|
||||
// 로딩 상태
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
|
||||
// 모달 상태
|
||||
const [showDocumentModal, setShowDocumentModal] = useState(false);
|
||||
const [showApprovalModal, setShowApprovalModal] = useState(false);
|
||||
|
||||
// 전자결재 데이터
|
||||
const [approvalData, setApprovalData] = useState<ElectronicApproval>(
|
||||
getEmptyElectronicApproval()
|
||||
);
|
||||
|
||||
// 파일 업로드 ref
|
||||
const contractFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const attachmentInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 드래그 상태
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// 네비게이션 핸들러
|
||||
const handleBack = useCallback(() => {
|
||||
router.push('/ko/juil/project/contract');
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
router.push(`/ko/juil/project/contract/${contractId}/edit`);
|
||||
}, [router, contractId]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
router.push(`/ko/juil/project/contract/${contractId}`);
|
||||
}, [router, contractId]);
|
||||
|
||||
// 폼 필드 변경
|
||||
const handleFieldChange = useCallback(
|
||||
(field: keyof ContractFormData, value: string | number) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = useCallback(() => {
|
||||
setShowSaveDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmSave = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateContract(contractId, formData);
|
||||
if (result.success) {
|
||||
toast.success('수정이 완료되었습니다.');
|
||||
setShowSaveDialog(false);
|
||||
router.push(`/ko/juil/project/contract/${contractId}`);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '저장에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router, contractId, formData]);
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = useCallback(() => {
|
||||
setShowDeleteDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deleteContract(contractId);
|
||||
if (result.success) {
|
||||
toast.success('계약이 삭제되었습니다.');
|
||||
setShowDeleteDialog(false);
|
||||
router.push('/ko/juil/project/contract');
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router, contractId]);
|
||||
|
||||
// 계약서 파일 선택
|
||||
const handleContractFileSelect = useCallback(() => {
|
||||
contractFileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleContractFileChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.type !== 'application/pdf') {
|
||||
toast.error('PDF 파일만 업로드 가능합니다.');
|
||||
return;
|
||||
}
|
||||
setFormData((prev) => ({ ...prev, contractFile: file }));
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 첨부 파일 드래그 앤 드롭
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
setNewAttachments((prev) => [...prev, ...files]);
|
||||
}, []);
|
||||
|
||||
const handleAttachmentSelect = useCallback(() => {
|
||||
attachmentInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleAttachmentChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
setNewAttachments((prev) => [...prev, ...files]);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 기존 첨부파일 삭제
|
||||
const handleRemoveExistingAttachment = useCallback((id: string) => {
|
||||
setExistingAttachments((prev) => prev.filter((att) => att.id !== id));
|
||||
}, []);
|
||||
|
||||
// 새 첨부파일 삭제
|
||||
const handleRemoveNewAttachment = useCallback((index: number) => {
|
||||
setNewAttachments((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
// 기존 계약서 파일 삭제
|
||||
const handleRemoveContractFile = useCallback(() => {
|
||||
setIsContractFileDeleted(true);
|
||||
setFormData((prev) => ({ ...prev, contractFile: null }));
|
||||
}, []);
|
||||
|
||||
// 계약서 보기 핸들러
|
||||
const handleViewDocument = useCallback(() => {
|
||||
setShowDocumentModal(true);
|
||||
}, []);
|
||||
|
||||
// 파일 다운로드 핸들러
|
||||
const handleFileDownload = useCallback(async (fileId: string, fileName?: string) => {
|
||||
try {
|
||||
await downloadFileById(parseInt(fileId), fileName);
|
||||
} catch (error) {
|
||||
console.error('[ContractDetailForm] 다운로드 실패:', error);
|
||||
toast.error('파일 다운로드에 실패했습니다.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 전자결재 핸들러
|
||||
const handleApproval = useCallback(() => {
|
||||
setShowApprovalModal(true);
|
||||
}, []);
|
||||
|
||||
// 전자결재 저장
|
||||
const handleApprovalSave = useCallback((approval: ElectronicApproval) => {
|
||||
setApprovalData(approval);
|
||||
setShowApprovalModal(false);
|
||||
toast.success('전자결재 정보가 저장되었습니다.');
|
||||
}, []);
|
||||
|
||||
// 헤더 액션 버튼
|
||||
const headerActions = isViewMode ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleViewDocument}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
계약서 보기
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleApproval}>
|
||||
전자결재
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>수정</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isLoading}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="계약 상세"
|
||||
description="계약 정보를 관리합니다"
|
||||
icon={FileText}
|
||||
onBack={handleBack}
|
||||
actions={headerActions}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 계약 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">계약 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 계약번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label>계약번호</Label>
|
||||
<Input
|
||||
value={formData.contractCode}
|
||||
onChange={(e) => handleFieldChange('contractCode', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 계약담당자 */}
|
||||
<div className="space-y-2">
|
||||
<Label>계약담당자</Label>
|
||||
<Input
|
||||
value={formData.contractManagerName}
|
||||
onChange={(e) => handleFieldChange('contractManagerName', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 거래처명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>거래처명</Label>
|
||||
<Input
|
||||
value={formData.partnerName}
|
||||
onChange={(e) => handleFieldChange('partnerName', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 현장명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>현장명</Label>
|
||||
<Input
|
||||
value={formData.projectName}
|
||||
onChange={(e) => handleFieldChange('projectName', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 계약일자 */}
|
||||
<div className="space-y-2">
|
||||
<Label>계약일자</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.contractDate}
|
||||
onChange={(e) => handleFieldChange('contractDate', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 개소 */}
|
||||
<div className="space-y-2">
|
||||
<Label>개소</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.totalLocations}
|
||||
onChange={(e) => handleFieldChange('totalLocations', parseInt(e.target.value) || 0)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 계약기간 */}
|
||||
<div className="space-y-2">
|
||||
<Label>계약기간</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.contractStartDate}
|
||||
onChange={(e) => handleFieldChange('contractStartDate', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
<span>~</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.contractEndDate}
|
||||
onChange={(e) => handleFieldChange('contractEndDate', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 부가세 */}
|
||||
<div className="space-y-2">
|
||||
<Label>부가세</Label>
|
||||
<Select
|
||||
value={formData.vatType}
|
||||
onValueChange={(value) => handleFieldChange('vatType', value)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VAT_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 계약금액 */}
|
||||
<div className="space-y-2">
|
||||
<Label>계약금액</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formatAmount(formData.contractAmount)}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace(/[^0-9]/g, '');
|
||||
handleFieldChange('contractAmount', parseInt(value) || 0);
|
||||
}}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상태 */}
|
||||
<div className="space-y-2">
|
||||
<Label>상태</Label>
|
||||
<RadioGroup
|
||||
value={formData.status}
|
||||
onValueChange={(value) => handleFieldChange('status', value as ContractStatus)}
|
||||
disabled={isViewMode}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="pending" id="pending" />
|
||||
<Label htmlFor="pending" className="font-normal cursor-pointer">
|
||||
{CONTRACT_STATUS_LABELS.pending}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="completed" id="completed" />
|
||||
<Label htmlFor="completed" className="font-normal cursor-pointer">
|
||||
{CONTRACT_STATUS_LABELS.completed}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>비고</Label>
|
||||
<Textarea
|
||||
value={formData.remarks}
|
||||
onChange={(e) => handleFieldChange('remarks', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 계약서 관리 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">계약서 관리</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* 파일 선택 버튼 (수정 모드에서만) */}
|
||||
{isEditMode && (
|
||||
<Button variant="outline" onClick={handleContractFileSelect}>
|
||||
찾기
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 새로 선택한 파일 */}
|
||||
{formData.contractFile && (
|
||||
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{formData.contractFile.name}</span>
|
||||
<span className="text-xs text-blue-600">(새 파일)</span>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setFormData((prev) => ({ ...prev, contractFile: null }))}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기존 계약서 파일 */}
|
||||
{!isContractFileDeleted && initialData?.contractFile && !formData.contractFile && (
|
||||
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{initialData.contractFile.fileName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleFileDownload(initialData.contractFile!.id, initialData.contractFile!.fileName)}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
다운로드
|
||||
</Button>
|
||||
{isEditMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleRemoveContractFile}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일 없음 안내 */}
|
||||
{!formData.contractFile && (isContractFileDeleted || !initialData?.contractFile) && (
|
||||
<span className="text-sm text-muted-foreground">PDF 파일만 업로드 가능합니다</span>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={contractFileInputRef}
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
className="hidden"
|
||||
onChange={handleContractFileChange}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 계약 첨부 문서 관리 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">계약 첨부 문서 관리</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* 드래그 앤 드롭 영역 */}
|
||||
{isEditMode && (
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center mb-4 transition-colors cursor-pointer ${
|
||||
isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleAttachmentSelect}
|
||||
>
|
||||
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">
|
||||
클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일 목록 */}
|
||||
<div className="space-y-2">
|
||||
{/* 기존 첨부파일 */}
|
||||
{existingAttachments.map((att) => (
|
||||
<div
|
||||
key={att.id}
|
||||
className="flex items-center justify-between p-3 bg-muted rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{att.fileName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(att.fileSize)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleFileDownload(att.id, att.fileName)}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
다운로드
|
||||
</Button>
|
||||
{isEditMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveExistingAttachment(att.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 새로 추가된 파일 */}
|
||||
{newAttachments.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 bg-muted rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{file.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveNewAttachment(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={attachmentInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleAttachmentChange}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 저장 확인 다이얼로그 */}
|
||||
<AlertDialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>저장 확인</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
변경사항을 저장하시겠습니까?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmSave} disabled={isLoading}>
|
||||
저장
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>계약 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
이 계약을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isLoading}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 계약서 보기 모달 */}
|
||||
{initialData && (
|
||||
<ContractDocumentModal
|
||||
open={showDocumentModal}
|
||||
onOpenChange={setShowDocumentModal}
|
||||
contract={initialData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 전자결재 모달 */}
|
||||
<ElectronicApprovalModal
|
||||
isOpen={showApprovalModal}
|
||||
onClose={() => setShowApprovalModal(false)}
|
||||
approval={approvalData}
|
||||
onSave={handleApprovalSave}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
609
src/components/business/juil/contract/ContractListClient.tsx
Normal file
609
src/components/business/juil/contract/ContractListClient.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, Clock, CheckCircle, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import type { Contract, ContractStats } from './types';
|
||||
import {
|
||||
CONTRACT_STATUS_OPTIONS,
|
||||
CONTRACT_SORT_OPTIONS,
|
||||
CONTRACT_STATUS_STYLES,
|
||||
CONTRACT_STATUS_LABELS,
|
||||
} from './types';
|
||||
import { getContractList, getContractStats, deleteContract, deleteContracts } from './actions';
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
// 순서: 체크박스, 번호, 계약번호, 거래처, 현장명, 계약담당자, 공사PM, 총 개소, 계약금액, 계약기간, 상태, 작업
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'contractCode', label: '계약번호', className: 'w-[120px]' },
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
|
||||
{ key: 'projectName', label: '현장명', className: 'min-w-[150px]' },
|
||||
{ key: 'contractManager', label: '계약담당자', className: 'w-[100px] text-center' },
|
||||
{ key: 'constructionPM', label: '공사PM', className: 'w-[80px] text-center' },
|
||||
{ key: 'totalLocations', label: '총 개소', className: 'w-[80px] text-center' },
|
||||
{ key: 'contractAmount', label: '계약금액', className: 'w-[120px] text-right' },
|
||||
{ key: 'contractPeriod', label: '계약기간', className: 'w-[180px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||
];
|
||||
|
||||
// 목업 거래처 목록
|
||||
const MOCK_PARTNERS: MultiSelectOption[] = [
|
||||
{ value: '1', label: '통신공사' },
|
||||
{ value: '2', label: '야사건설' },
|
||||
{ value: '3', label: '여의건설' },
|
||||
];
|
||||
|
||||
// 목업 계약담당자 목록
|
||||
const MOCK_CONTRACT_MANAGERS: MultiSelectOption[] = [
|
||||
{ value: 'hong', label: '홍길동' },
|
||||
{ value: 'kim', label: '김철수' },
|
||||
{ value: 'lee', label: '이영희' },
|
||||
];
|
||||
|
||||
// 목업 공사PM 목록
|
||||
const MOCK_CONSTRUCTION_PMS: MultiSelectOption[] = [
|
||||
{ value: 'kim', label: '김PM' },
|
||||
{ value: 'lee', label: '이PM' },
|
||||
{ value: 'park', label: '박PM' },
|
||||
];
|
||||
|
||||
// 금액 포맷팅
|
||||
function formatAmount(amount: number): string {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
}
|
||||
|
||||
// 날짜 포맷팅
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).replace(/\. /g, '-').replace('.', '');
|
||||
}
|
||||
|
||||
// 계약기간 포맷팅
|
||||
function formatPeriod(startDate: string | null, endDate: string | null): string {
|
||||
const start = formatDate(startDate);
|
||||
const end = formatDate(endDate);
|
||||
if (start === '-' && end === '-') return '-';
|
||||
return `${start} ~ ${end}`;
|
||||
}
|
||||
|
||||
interface ContractListClientProps {
|
||||
initialData?: Contract[];
|
||||
initialStats?: ContractStats;
|
||||
}
|
||||
|
||||
export default function ContractListClient({
|
||||
initialData = [],
|
||||
initialStats,
|
||||
}: ContractListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [contracts, setContracts] = useState<Contract[]>(initialData);
|
||||
const [stats, setStats] = useState<ContractStats | null>(initialStats || null);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||
const [contractManagerFilters, setContractManagerFilters] = useState<string[]>([]);
|
||||
const [constructionPMFilters, setConstructionPMFilters] = useState<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('contractDateDesc');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getContractList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
getContractStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setContracts(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredContracts = useMemo(() => {
|
||||
return contracts.filter((contract) => {
|
||||
// 상태 탭 필터
|
||||
if (activeStatTab === 'pending' && contract.status !== 'pending') return false;
|
||||
if (activeStatTab === 'completed' && contract.status !== 'completed') return false;
|
||||
|
||||
// 거래처 필터
|
||||
if (partnerFilters.length > 0) {
|
||||
if (!partnerFilters.includes(contract.partnerId)) return false;
|
||||
}
|
||||
|
||||
// 계약담당자 필터
|
||||
if (contractManagerFilters.length > 0) {
|
||||
if (!contractManagerFilters.includes(contract.contractManagerId)) return false;
|
||||
}
|
||||
|
||||
// 공사PM 필터
|
||||
if (constructionPMFilters.length > 0) {
|
||||
if (!constructionPMFilters.includes(contract.constructionPMId || '')) return false;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && contract.status !== statusFilter) return false;
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
contract.projectName.toLowerCase().includes(search) ||
|
||||
contract.contractCode.toLowerCase().includes(search) ||
|
||||
contract.partnerName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [contracts, activeStatTab, partnerFilters, contractManagerFilters, constructionPMFilters, statusFilter, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedContracts = useMemo(() => {
|
||||
const sorted = [...filteredContracts];
|
||||
switch (sortBy) {
|
||||
case 'contractDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.contractStartDate) return 1;
|
||||
if (!b.contractStartDate) return -1;
|
||||
return new Date(b.contractStartDate).getTime() - new Date(a.contractStartDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'contractDateAsc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.contractStartDate) return 1;
|
||||
if (!b.contractStartDate) return -1;
|
||||
return new Date(a.contractStartDate).getTime() - new Date(b.contractStartDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'projectNameAsc':
|
||||
sorted.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
|
||||
break;
|
||||
case 'projectNameDesc':
|
||||
sorted.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
|
||||
break;
|
||||
case 'amountDesc':
|
||||
sorted.sort((a, b) => b.contractAmount - a.contractAmount);
|
||||
break;
|
||||
case 'amountAsc':
|
||||
sorted.sort((a, b) => a.contractAmount - b.contractAmount);
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredContracts, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedContracts.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedContracts.slice(start, start + itemsPerPage);
|
||||
}, [sortedContracts, currentPage, itemsPerPage]);
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((c) => c.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(contract: Contract) => {
|
||||
router.push(`/ko/juil/project/contract/${contract.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, contractId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/juil/project/contract/${contractId}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback((e: React.MouseEvent, contractId: string) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTargetId(contractId);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteTargetId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deleteContract(deleteTargetId);
|
||||
if (result.success) {
|
||||
toast.success('계약이 삭제되었습니다.');
|
||||
setContracts((prev) => prev.filter((c) => c.id !== deleteTargetId));
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(deleteTargetId);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
}, [deleteTargetId]);
|
||||
|
||||
const handleBulkDeleteClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.warning('삭제할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setBulkDeleteDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
|
||||
const handleBulkDeleteConfirm = useCallback(async () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const ids = Array.from(selectedItems);
|
||||
const result = await deleteContracts(ids);
|
||||
if (result.success) {
|
||||
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
|
||||
await loadData();
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
toast.error(result.error || '일괄 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('일괄 삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setBulkDeleteDialogOpen(false);
|
||||
}
|
||||
}, [selectedItems, loadData]);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
// 순서: 체크박스, 번호, 계약번호, 거래처, 현장명, 계약담당자, 공사PM, 총 개소, 계약금액, 계약기간, 상태, 작업
|
||||
const renderTableRow = useCallback(
|
||||
(contract: Contract, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(contract.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={contract.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(contract)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(contract.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{contract.contractCode}</TableCell>
|
||||
<TableCell>{contract.partnerName}</TableCell>
|
||||
<TableCell>{contract.projectName}</TableCell>
|
||||
<TableCell className="text-center">{contract.contractManagerName}</TableCell>
|
||||
<TableCell className="text-center">{contract.constructionPMName || '-'}</TableCell>
|
||||
<TableCell className="text-center">{contract.totalLocations}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(contract.contractAmount)}원</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{formatPeriod(contract.contractStartDate, contract.contractEndDate)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={CONTRACT_STATUS_STYLES[contract.status]}>
|
||||
{CONTRACT_STATUS_LABELS[contract.status]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, contract.id)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={(e) => handleDeleteClick(e, contract.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
|
||||
);
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(contract: Contract, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
<MobileCard
|
||||
title={contract.projectName}
|
||||
subtitle={contract.contractCode}
|
||||
badge={CONTRACT_STATUS_LABELS[contract.status]}
|
||||
badgeVariant="secondary"
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => handleRowClick(contract)}
|
||||
details={[
|
||||
{ label: '거래처', value: contract.partnerName },
|
||||
{ label: '총 개소', value: `${contract.totalLocations}개` },
|
||||
{ label: '계약금액', value: `${formatAmount(contract.contractAmount)}원` },
|
||||
{ label: '계약담당자', value: contract.contractManagerName },
|
||||
{ label: '공사PM', value: contract.constructionPMName || '-' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
);
|
||||
|
||||
// 헤더 액션 (날짜 필터만)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
);
|
||||
|
||||
// Stats 카드 데이터 (전체 계약, 계약대기, 계약완료)
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '전체 계약',
|
||||
value: stats?.total ?? 0,
|
||||
icon: FileText,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '계약대기',
|
||||
value: stats?.pending ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-orange-500',
|
||||
onClick: () => setActiveStatTab('pending'),
|
||||
isActive: activeStatTab === 'pending',
|
||||
},
|
||||
{
|
||||
label: '계약완료',
|
||||
value: stats?.completed ?? 0,
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('completed'),
|
||||
isActive: activeStatTab === 'completed',
|
||||
},
|
||||
];
|
||||
|
||||
// 테이블 헤더 액션 (총 건수 + 필터들)
|
||||
const tableHeaderActions = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {sortedContracts.length}건
|
||||
</span>
|
||||
|
||||
{/* 거래처 필터 */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_PARTNERS}
|
||||
value={partnerFilters}
|
||||
onChange={setPartnerFilters}
|
||||
placeholder="거래처"
|
||||
searchPlaceholder="거래처 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 계약담당자 필터 */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_CONTRACT_MANAGERS}
|
||||
value={contractManagerFilters}
|
||||
onChange={setContractManagerFilters}
|
||||
placeholder="계약담당자"
|
||||
searchPlaceholder="계약담당자 검색..."
|
||||
className="w-[130px]"
|
||||
/>
|
||||
|
||||
{/* 공사PM 필터 */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_CONSTRUCTION_PMS}
|
||||
value={constructionPMFilters}
|
||||
onChange={setConstructionPMFilters}
|
||||
placeholder="공사PM"
|
||||
searchPlaceholder="공사PM 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTRACT_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="최신순 (계약일)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTRACT_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="계약관리"
|
||||
description="계약 정보를 관리합니다"
|
||||
icon={FileText}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="계약번호, 거래처, 현장명 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedContracts}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onBulkDelete={handleBulkDeleteClick}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedContracts.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 단일 삭제 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>계약 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 계약을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일괄 삭제 다이얼로그 */}
|
||||
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>계약 일괄 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 {selectedItems.size}개 계약을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleBulkDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
517
src/components/business/juil/contract/actions.ts
Normal file
517
src/components/business/juil/contract/actions.ts
Normal file
@@ -0,0 +1,517 @@
|
||||
'use server';
|
||||
|
||||
import type {
|
||||
Contract,
|
||||
ContractDetail,
|
||||
ContractStats,
|
||||
ContractStageCount,
|
||||
ContractListResponse,
|
||||
ContractFilter,
|
||||
ContractFormData,
|
||||
} from './types';
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_CONTRACTS: Contract[] = [
|
||||
{
|
||||
id: '1',
|
||||
contractCode: 'CT-2025-001',
|
||||
partnerId: '1',
|
||||
partnerName: '통신공사',
|
||||
projectName: '강남역 통신시설 구축',
|
||||
contractManagerId: 'hong',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMId: 'kim',
|
||||
constructionPMName: '김PM',
|
||||
totalLocations: 15,
|
||||
contractAmount: 150000000,
|
||||
contractStartDate: '2025-12-17',
|
||||
contractEndDate: '2026-06-17',
|
||||
status: 'pending',
|
||||
stage: 'estimate_selected',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
createdBy: 'system',
|
||||
biddingId: '1',
|
||||
biddingCode: 'BID-2025-001',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
contractCode: 'CT-2025-002',
|
||||
partnerId: '2',
|
||||
partnerName: '야사건설',
|
||||
projectName: '판교 IT단지 배선공사',
|
||||
contractManagerId: 'hong',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMId: 'lee',
|
||||
constructionPMName: '이PM',
|
||||
totalLocations: 28,
|
||||
contractAmount: 280000000,
|
||||
contractStartDate: '2025-11-01',
|
||||
contractEndDate: '2026-03-31',
|
||||
status: 'pending',
|
||||
stage: 'estimate_progress',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-02',
|
||||
updatedAt: '2025-01-02',
|
||||
createdBy: 'system',
|
||||
biddingId: '2',
|
||||
biddingCode: 'BID-2025-002',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
contractCode: 'CT-2025-003',
|
||||
partnerId: '3',
|
||||
partnerName: '여의건설',
|
||||
projectName: '여의도 오피스빌딩 통신설비',
|
||||
contractManagerId: 'kim',
|
||||
contractManagerName: '김철수',
|
||||
constructionPMId: 'park',
|
||||
constructionPMName: '박PM',
|
||||
totalLocations: 42,
|
||||
contractAmount: 420000000,
|
||||
contractStartDate: '2025-10-15',
|
||||
contractEndDate: '2026-04-15',
|
||||
status: 'pending',
|
||||
stage: 'delivery',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-03',
|
||||
updatedAt: '2025-01-03',
|
||||
createdBy: 'system',
|
||||
biddingId: '3',
|
||||
biddingCode: 'BID-2025-003',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
contractCode: 'CT-2025-004',
|
||||
partnerId: '1',
|
||||
partnerName: '통신공사',
|
||||
projectName: '송파 데이터센터 증설',
|
||||
contractManagerId: 'hong',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMId: 'kim',
|
||||
constructionPMName: '김PM',
|
||||
totalLocations: 58,
|
||||
contractAmount: 580000000,
|
||||
contractStartDate: '2025-09-01',
|
||||
contractEndDate: '2026-02-28',
|
||||
status: 'completed',
|
||||
stage: 'inspection',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-04',
|
||||
updatedAt: '2025-01-04',
|
||||
createdBy: 'system',
|
||||
biddingId: '4',
|
||||
biddingCode: 'BID-2025-004',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
contractCode: 'CT-2025-005',
|
||||
partnerId: '2',
|
||||
partnerName: '야사건설',
|
||||
projectName: '분당 스마트빌딩 LAN공사',
|
||||
contractManagerId: 'lee',
|
||||
contractManagerName: '이영희',
|
||||
constructionPMId: 'lee',
|
||||
constructionPMName: '이PM',
|
||||
totalLocations: 12,
|
||||
contractAmount: 95000000,
|
||||
contractStartDate: '2025-12-01',
|
||||
contractEndDate: '2026-01-31',
|
||||
status: 'pending',
|
||||
stage: 'installation',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-05',
|
||||
updatedAt: '2025-01-05',
|
||||
createdBy: 'system',
|
||||
biddingId: '5',
|
||||
biddingCode: 'BID-2025-005',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
contractCode: 'CT-2025-006',
|
||||
partnerId: '3',
|
||||
partnerName: '여의건설',
|
||||
projectName: '마포 복합시설 CCTV설치',
|
||||
contractManagerId: 'hong',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMId: 'park',
|
||||
constructionPMName: '박PM',
|
||||
totalLocations: 8,
|
||||
contractAmount: 75000000,
|
||||
contractStartDate: '2025-08-01',
|
||||
contractEndDate: '2025-10-31',
|
||||
status: 'completed',
|
||||
stage: 'estimate_selected',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-06',
|
||||
updatedAt: '2025-01-06',
|
||||
createdBy: 'system',
|
||||
biddingId: '6',
|
||||
biddingCode: 'BID-2025-006',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
contractCode: 'CT-2025-007',
|
||||
partnerId: '1',
|
||||
partnerName: '통신공사',
|
||||
projectName: '용산 아파트 인터폰교체',
|
||||
contractManagerId: 'kim',
|
||||
contractManagerName: '김철수',
|
||||
constructionPMId: 'kim',
|
||||
constructionPMName: '김PM',
|
||||
totalLocations: 120,
|
||||
contractAmount: 45000000,
|
||||
contractStartDate: '2025-07-15',
|
||||
contractEndDate: '2025-09-15',
|
||||
status: 'completed',
|
||||
stage: 'estimate_progress',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-07',
|
||||
updatedAt: '2025-01-07',
|
||||
createdBy: 'system',
|
||||
biddingId: '7',
|
||||
biddingCode: 'BID-2025-007',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
contractCode: 'CT-2025-008',
|
||||
partnerId: '2',
|
||||
partnerName: '야사건설',
|
||||
projectName: '성수동 공장 방범설비',
|
||||
contractManagerId: 'lee',
|
||||
contractManagerName: '이영희',
|
||||
constructionPMId: 'lee',
|
||||
constructionPMName: '이PM',
|
||||
totalLocations: 24,
|
||||
contractAmount: 120000000,
|
||||
contractStartDate: '2025-11-15',
|
||||
contractEndDate: '2026-02-15',
|
||||
status: 'pending',
|
||||
stage: 'other',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-08',
|
||||
updatedAt: '2025-01-08',
|
||||
createdBy: 'system',
|
||||
biddingId: '8',
|
||||
biddingCode: 'BID-2025-008',
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
contractCode: 'CT-2025-009',
|
||||
partnerId: '3',
|
||||
partnerName: '여의건설',
|
||||
projectName: '강서 물류센터 네트워크',
|
||||
contractManagerId: 'hong',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMId: 'park',
|
||||
constructionPMName: '박PM',
|
||||
totalLocations: 35,
|
||||
contractAmount: 320000000,
|
||||
contractStartDate: '2025-06-01',
|
||||
contractEndDate: '2025-11-30',
|
||||
status: 'completed',
|
||||
stage: 'inspection',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-09',
|
||||
updatedAt: '2025-01-09',
|
||||
createdBy: 'system',
|
||||
biddingId: '9',
|
||||
biddingCode: 'BID-2025-009',
|
||||
},
|
||||
];
|
||||
|
||||
// 계약 목록 조회
|
||||
export async function getContractList(filter?: ContractFilter): Promise<{
|
||||
success: boolean;
|
||||
data?: ContractListResponse;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
let filteredData = [...MOCK_CONTRACTS];
|
||||
|
||||
// 검색 필터
|
||||
if (filter?.search) {
|
||||
const search = filter.search.toLowerCase();
|
||||
filteredData = filteredData.filter(
|
||||
(item) =>
|
||||
item.contractCode.toLowerCase().includes(search) ||
|
||||
item.partnerName.toLowerCase().includes(search) ||
|
||||
item.projectName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (filter?.status && filter.status !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.status === filter.status);
|
||||
}
|
||||
|
||||
// 단계 필터
|
||||
if (filter?.stage && filter.stage !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.stage === filter.stage);
|
||||
}
|
||||
|
||||
// 거래처 필터
|
||||
if (filter?.partnerId && filter.partnerId !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.partnerId === filter.partnerId);
|
||||
}
|
||||
|
||||
// 계약담당자 필터
|
||||
if (filter?.contractManagerId && filter.contractManagerId !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.contractManagerId === filter.contractManagerId);
|
||||
}
|
||||
|
||||
// 공사PM 필터
|
||||
if (filter?.constructionPMId && filter.constructionPMId !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.constructionPMId === filter.constructionPMId);
|
||||
}
|
||||
|
||||
// 날짜 필터
|
||||
if (filter?.startDate) {
|
||||
filteredData = filteredData.filter(
|
||||
(item) => item.contractStartDate && item.contractStartDate >= filter.startDate!
|
||||
);
|
||||
}
|
||||
if (filter?.endDate) {
|
||||
filteredData = filteredData.filter(
|
||||
(item) => item.contractEndDate && item.contractEndDate <= filter.endDate!
|
||||
);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
const sortBy = filter?.sortBy || 'contractDateDesc';
|
||||
switch (sortBy) {
|
||||
case 'contractDateDesc':
|
||||
filteredData.sort((a, b) => {
|
||||
if (!a.contractStartDate) return 1;
|
||||
if (!b.contractStartDate) return -1;
|
||||
return new Date(b.contractStartDate).getTime() - new Date(a.contractStartDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'contractDateAsc':
|
||||
filteredData.sort((a, b) => {
|
||||
if (!a.contractStartDate) return 1;
|
||||
if (!b.contractStartDate) return -1;
|
||||
return new Date(a.contractStartDate).getTime() - new Date(b.contractStartDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
filteredData.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
filteredData.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'projectNameAsc':
|
||||
filteredData.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
|
||||
break;
|
||||
case 'projectNameDesc':
|
||||
filteredData.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
|
||||
break;
|
||||
case 'amountDesc':
|
||||
filteredData.sort((a, b) => b.contractAmount - a.contractAmount);
|
||||
break;
|
||||
case 'amountAsc':
|
||||
filteredData.sort((a, b) => a.contractAmount - b.contractAmount);
|
||||
break;
|
||||
}
|
||||
|
||||
// 페이지네이션
|
||||
const page = filter?.page || 1;
|
||||
const size = filter?.size || 20;
|
||||
const startIndex = (page - 1) * size;
|
||||
const paginatedData = filteredData.slice(startIndex, startIndex + size);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: paginatedData,
|
||||
total: filteredData.length,
|
||||
page,
|
||||
size,
|
||||
totalPages: Math.ceil(filteredData.length / size),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getContractList error:', error);
|
||||
return { success: false, error: '계약 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 통계 조회
|
||||
export async function getContractStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: ContractStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const stats: ContractStats = {
|
||||
total: MOCK_CONTRACTS.length,
|
||||
pending: MOCK_CONTRACTS.filter((c) => c.status === 'pending').length,
|
||||
completed: MOCK_CONTRACTS.filter((c) => c.status === 'completed').length,
|
||||
};
|
||||
|
||||
return { success: true, data: stats };
|
||||
} catch (error) {
|
||||
console.error('getContractStats error:', error);
|
||||
return { success: false, error: '통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 단계별 건수 조회
|
||||
export async function getContractStageCounts(): Promise<{
|
||||
success: boolean;
|
||||
data?: ContractStageCount;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const counts: ContractStageCount = {
|
||||
estimateSelected: MOCK_CONTRACTS.filter((c) => c.stage === 'estimate_selected').length,
|
||||
estimateProgress: MOCK_CONTRACTS.filter((c) => c.stage === 'estimate_progress').length,
|
||||
delivery: MOCK_CONTRACTS.filter((c) => c.stage === 'delivery').length,
|
||||
installation: MOCK_CONTRACTS.filter((c) => c.stage === 'installation').length,
|
||||
inspection: MOCK_CONTRACTS.filter((c) => c.stage === 'inspection').length,
|
||||
other: MOCK_CONTRACTS.filter((c) => c.stage === 'other').length,
|
||||
};
|
||||
|
||||
return { success: true, data: counts };
|
||||
} catch (error) {
|
||||
console.error('getContractStageCounts error:', error);
|
||||
return { success: false, error: '단계별 건수를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 단건 조회
|
||||
export async function getContract(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: Contract;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const contract = MOCK_CONTRACTS.find((c) => c.id === id);
|
||||
if (!contract) {
|
||||
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: contract };
|
||||
} catch (error) {
|
||||
console.error('getContract error:', error);
|
||||
return { success: false, error: '계약 정보를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 삭제
|
||||
export async function deleteContract(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const index = MOCK_CONTRACTS.findIndex((c) => c.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('deleteContract error:', error);
|
||||
return { success: false, error: '계약 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 일괄 삭제
|
||||
export async function deleteContracts(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('deleteContracts error:', error);
|
||||
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 상세 조회 (첨부파일 포함)
|
||||
export async function getContractDetail(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: ContractDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const contract = MOCK_CONTRACTS.find((c) => c.id === id);
|
||||
if (!contract) {
|
||||
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
// ContractDetail로 변환 (첨부파일 목데이터 포함)
|
||||
const contractDetail: ContractDetail = {
|
||||
...contract,
|
||||
// 계약서 파일 목업 데이터
|
||||
contractFile: {
|
||||
id: '100',
|
||||
fileName: '계약서_CT-2025-001.pdf',
|
||||
fileUrl: '/files/contract.pdf',
|
||||
uploadedAt: contract.createdAt,
|
||||
},
|
||||
attachments: [
|
||||
{
|
||||
id: 'att-1',
|
||||
fileName: '견적서.pdf',
|
||||
fileSize: 1024000,
|
||||
fileUrl: '/files/estimate.pdf',
|
||||
uploadedAt: contract.createdAt,
|
||||
},
|
||||
{
|
||||
id: 'att-2',
|
||||
fileName: '시방서.pdf',
|
||||
fileSize: 2048000,
|
||||
fileUrl: '/files/spec.pdf',
|
||||
uploadedAt: contract.createdAt,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return { success: true, data: contractDetail };
|
||||
} catch (error) {
|
||||
console.error('getContractDetail error:', error);
|
||||
return { success: false, error: '계약 상세 정보를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 수정
|
||||
export async function updateContract(
|
||||
id: string,
|
||||
_data: Partial<ContractFormData>
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const index = MOCK_CONTRACTS.findIndex((c) => c.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
// TODO: 실제 API 연동 시 데이터 업데이트 로직
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('updateContract error:', error);
|
||||
return { success: false, error: '계약 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
5
src/components/business/juil/contract/index.ts
Normal file
5
src/components/business/juil/contract/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as ContractListClient } from './ContractListClient';
|
||||
export { default as ContractDetailForm } from './ContractDetailForm';
|
||||
export * from './types';
|
||||
export * from './actions';
|
||||
export * from './modals';
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
VisuallyHidden,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Edit,
|
||||
X as XIcon,
|
||||
Printer,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { printArea } from '@/lib/print-utils';
|
||||
import type { ContractDetail } from '../types';
|
||||
|
||||
interface ContractDocumentModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
contract: ContractDetail;
|
||||
}
|
||||
|
||||
export function ContractDocumentModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
contract,
|
||||
}: ContractDocumentModalProps) {
|
||||
// 수정
|
||||
const handleEdit = () => {
|
||||
toast.info('수정 기능은 준비 중입니다.');
|
||||
};
|
||||
|
||||
// 상신 (전자결재)
|
||||
const handleSubmit = () => {
|
||||
toast.info('전자결재 상신 기능은 준비 중입니다.');
|
||||
};
|
||||
|
||||
// 인쇄
|
||||
const handlePrint = () => {
|
||||
printArea({ title: '계약서 인쇄' });
|
||||
};
|
||||
|
||||
// PDF URL 확인
|
||||
const pdfUrl = contract.contractFile?.fileUrl;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>계약서 상세</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
|
||||
{/* 헤더 영역 - 고정 (인쇄 시 숨김) */}
|
||||
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
|
||||
<h2 className="text-lg font-semibold">계약서 상세</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 - 고정 (인쇄 시 숨김) */}
|
||||
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
|
||||
<Button variant="outline" size="sm" onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
수정
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onClick={handleSubmit} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Send className="h-4 w-4 mr-1" />
|
||||
상신
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="h-4 w-4 mr-1" />
|
||||
인쇄
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* PDF 뷰어 영역 - 스크롤 (인쇄 시 이 영역만 출력) */}
|
||||
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
|
||||
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg min-h-[297mm]">
|
||||
{pdfUrl ? (
|
||||
<iframe
|
||||
src={pdfUrl}
|
||||
className="w-full h-full min-h-[297mm]"
|
||||
title="계약서 PDF"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full min-h-[297mm] text-muted-foreground">
|
||||
<p>현재 등록된 계약서가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
1
src/components/business/juil/contract/modals/index.ts
Normal file
1
src/components/business/juil/contract/modals/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ContractDocumentModal } from './ContractDocumentModal';
|
||||
242
src/components/business/juil/contract/types.ts
Normal file
242
src/components/business/juil/contract/types.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* 주일 기업 - 계약관리 타입 정의
|
||||
*
|
||||
* 계약 데이터는 낙찰 후 자동 등록됨
|
||||
*/
|
||||
|
||||
// 계약 상태
|
||||
export type ContractStatus =
|
||||
| 'pending' // 계약대기
|
||||
| 'completed'; // 계약완료
|
||||
|
||||
// 계약 단계 (스크린샷의 상단 탭)
|
||||
export type ContractStage =
|
||||
| 'estimate_selected' // 견적선정
|
||||
| 'estimate_progress' // 견적진행
|
||||
| 'delivery' // 납품
|
||||
| 'installation' // 설치중
|
||||
| 'inspection' // 검수
|
||||
| 'other'; // 기타
|
||||
|
||||
// 계약 타입
|
||||
export interface Contract {
|
||||
id: string;
|
||||
contractCode: string; // 계약번호
|
||||
|
||||
// 기본 정보
|
||||
partnerId: string; // 거래처 ID
|
||||
partnerName: string; // 거래처명
|
||||
projectName: string; // 현장명
|
||||
|
||||
// 담당자 정보
|
||||
contractManagerId: string; // 계약담당자 ID
|
||||
contractManagerName: string; // 계약담당자
|
||||
constructionPMId: string; // 공사PM ID
|
||||
constructionPMName: string; // 공사PM
|
||||
|
||||
// 계약 정보
|
||||
totalLocations: number; // 총 개소
|
||||
contractAmount: number; // 계약금액
|
||||
contractStartDate: string | null; // 계약시작일
|
||||
contractEndDate: string | null; // 계약종료일
|
||||
|
||||
// 상태 정보
|
||||
status: ContractStatus; // 계약상태
|
||||
stage: ContractStage; // 계약단계
|
||||
|
||||
// 비고
|
||||
remarks: string;
|
||||
|
||||
// 메타 정보
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy: string;
|
||||
|
||||
// 연결된 입찰 정보
|
||||
biddingId: string;
|
||||
biddingCode: string;
|
||||
}
|
||||
|
||||
// 계약 통계
|
||||
export interface ContractStats {
|
||||
total: number; // 전체 계약
|
||||
pending: number; // 계약대기
|
||||
completed: number; // 계약완료
|
||||
}
|
||||
|
||||
// 단계별 건수
|
||||
export interface ContractStageCount {
|
||||
estimateSelected: number; // 견적선정
|
||||
estimateProgress: number; // 견적진행
|
||||
delivery: number; // 납품
|
||||
installation: number; // 설치중
|
||||
inspection: number; // 검수
|
||||
other: number; // 기타
|
||||
}
|
||||
|
||||
// 계약 필터
|
||||
export interface ContractFilter {
|
||||
search?: string;
|
||||
status?: ContractStatus | 'all';
|
||||
stage?: ContractStage | 'all';
|
||||
partnerId?: string;
|
||||
contractManagerId?: string;
|
||||
constructionPMId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
sortBy?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// API 응답 타입
|
||||
export interface ContractListResponse {
|
||||
items: Contract[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// 상태 옵션
|
||||
export const CONTRACT_STATUS_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'pending', label: '계약대기' },
|
||||
{ value: 'completed', label: '계약완료' },
|
||||
];
|
||||
|
||||
// 단계 옵션
|
||||
export const CONTRACT_STAGE_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'estimate_selected', label: '견적선정' },
|
||||
{ value: 'estimate_progress', label: '견적진행' },
|
||||
{ value: 'delivery', label: '납품' },
|
||||
{ value: 'installation', label: '설치중' },
|
||||
{ value: 'inspection', label: '검수' },
|
||||
{ value: 'other', label: '기타' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
export const CONTRACT_SORT_OPTIONS = [
|
||||
{ value: 'contractDateDesc', label: '최신순 (계약일)' },
|
||||
{ value: 'contractDateAsc', label: '등록순 (계약일)' },
|
||||
{ value: 'partnerNameAsc', label: '거래처명 오름차순' },
|
||||
{ value: 'partnerNameDesc', label: '거래처명 내림차순' },
|
||||
{ value: 'projectNameAsc', label: '현장명 오름차순' },
|
||||
{ value: 'projectNameDesc', label: '현장명 내림차순' },
|
||||
{ value: 'amountDesc', label: '계약금액 높은순' },
|
||||
{ value: 'amountAsc', label: '계약금액 낮은순' },
|
||||
];
|
||||
|
||||
// 상태별 스타일
|
||||
export const CONTRACT_STATUS_STYLES: Record<ContractStatus, string> = {
|
||||
pending: 'text-orange-500 font-medium',
|
||||
completed: 'text-green-600 font-medium',
|
||||
};
|
||||
|
||||
export const CONTRACT_STATUS_LABELS: Record<ContractStatus, string> = {
|
||||
pending: '계약대기',
|
||||
completed: '계약완료',
|
||||
};
|
||||
|
||||
// 단계별 라벨
|
||||
export const CONTRACT_STAGE_LABELS: Record<ContractStage, string> = {
|
||||
estimate_selected: '견적선정',
|
||||
estimate_progress: '견적진행',
|
||||
delivery: '납품',
|
||||
installation: '설치중',
|
||||
inspection: '검수',
|
||||
other: '기타',
|
||||
};
|
||||
|
||||
// 계약 상세 (상세/수정용 확장 타입)
|
||||
export interface ContractDetail extends Contract {
|
||||
// 계약서 파일
|
||||
contractFile?: {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileUrl: string;
|
||||
uploadedAt: string;
|
||||
} | null;
|
||||
// 첨부 문서 목록
|
||||
attachments: ContractAttachment[];
|
||||
}
|
||||
|
||||
// 첨부 문서 타입
|
||||
export interface ContractAttachment {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
fileUrl: string;
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
// 계약 폼 데이터
|
||||
export interface ContractFormData {
|
||||
contractCode: string;
|
||||
contractManagerId: string;
|
||||
contractManagerName: string;
|
||||
partnerId: string;
|
||||
partnerName: string;
|
||||
projectName: string;
|
||||
contractDate: string;
|
||||
totalLocations: number;
|
||||
contractStartDate: string;
|
||||
contractEndDate: string;
|
||||
vatType: string;
|
||||
contractAmount: number;
|
||||
status: ContractStatus;
|
||||
remarks: string;
|
||||
contractFile: File | null;
|
||||
attachments: File[];
|
||||
}
|
||||
|
||||
// 빈 폼 데이터 생성
|
||||
export function getEmptyContractFormData(): ContractFormData {
|
||||
return {
|
||||
contractCode: '',
|
||||
contractManagerId: '',
|
||||
contractManagerName: '',
|
||||
partnerId: '',
|
||||
partnerName: '',
|
||||
projectName: '',
|
||||
contractDate: '',
|
||||
totalLocations: 0,
|
||||
contractStartDate: '',
|
||||
contractEndDate: '',
|
||||
vatType: 'excluded',
|
||||
contractAmount: 0,
|
||||
status: 'pending',
|
||||
remarks: '',
|
||||
contractFile: null,
|
||||
attachments: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ContractDetail을 FormData로 변환
|
||||
export function contractDetailToFormData(detail: ContractDetail): ContractFormData {
|
||||
return {
|
||||
contractCode: detail.contractCode,
|
||||
contractManagerId: detail.contractManagerId,
|
||||
contractManagerName: detail.contractManagerName,
|
||||
partnerId: detail.partnerId,
|
||||
partnerName: detail.partnerName,
|
||||
projectName: detail.projectName,
|
||||
contractDate: detail.createdAt,
|
||||
totalLocations: detail.totalLocations,
|
||||
contractStartDate: detail.contractStartDate || '',
|
||||
contractEndDate: detail.contractEndDate || '',
|
||||
vatType: 'excluded',
|
||||
contractAmount: detail.contractAmount,
|
||||
status: detail.status,
|
||||
remarks: detail.remarks,
|
||||
contractFile: null,
|
||||
attachments: [],
|
||||
};
|
||||
}
|
||||
|
||||
// 부가세 옵션
|
||||
export const VAT_TYPE_OPTIONS = [
|
||||
{ value: 'included', label: '부가세 포함' },
|
||||
{ value: 'excluded', label: '부가세 별도' },
|
||||
];
|
||||
733
src/components/business/juil/estimates/EstimateDetailForm.tsx
Normal file
733
src/components/business/juil/estimates/EstimateDetailForm.tsx
Normal file
@@ -0,0 +1,733 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, Loader2, List } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { toast } from 'sonner';
|
||||
import type {
|
||||
EstimateDetail,
|
||||
EstimateDetailFormData,
|
||||
EstimateSummaryItem,
|
||||
ExpenseItem,
|
||||
EstimateDetailItem,
|
||||
BidDocument,
|
||||
PriceAdjustmentData,
|
||||
} from './types';
|
||||
import { getEmptyEstimateDetailFormData, estimateDetailToFormData } from './types';
|
||||
import { ElectronicApprovalModal } from './modals/ElectronicApprovalModal';
|
||||
import { EstimateDocumentModal } from './modals/EstimateDocumentModal';
|
||||
import { MOCK_MATERIALS, MOCK_EXPENSES } from './utils';
|
||||
import {
|
||||
EstimateInfoSection,
|
||||
EstimateSummarySection,
|
||||
ExpenseDetailSection,
|
||||
PriceAdjustmentSection,
|
||||
EstimateDetailTableSection,
|
||||
} from './sections';
|
||||
|
||||
interface EstimateDetailFormProps {
|
||||
mode: 'view' | 'edit';
|
||||
estimateId: string;
|
||||
initialData?: EstimateDetail;
|
||||
}
|
||||
|
||||
export default function EstimateDetailForm({
|
||||
mode,
|
||||
estimateId,
|
||||
initialData,
|
||||
}: EstimateDetailFormProps) {
|
||||
const router = useRouter();
|
||||
const isViewMode = mode === 'view';
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<EstimateDetailFormData>(
|
||||
initialData ? estimateDetailToFormData(initialData) : getEmptyEstimateDetailFormData()
|
||||
);
|
||||
|
||||
// 로딩 상태
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
|
||||
// 모달 상태
|
||||
const [showApprovalModal, setShowApprovalModal] = useState(false);
|
||||
const [showDocumentModal, setShowDocumentModal] = useState(false);
|
||||
|
||||
// 파일 업로드 ref
|
||||
const documentInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 드래그 상태
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// 적용된 조정단가 (전체 적용 버튼 클릭 시 복사됨)
|
||||
const [appliedPrices, setAppliedPrices] = useState<{
|
||||
caulking: number;
|
||||
rail: number;
|
||||
bottom: number;
|
||||
boxReinforce: number;
|
||||
shaft: number;
|
||||
painting: number;
|
||||
motor: number;
|
||||
controller: number;
|
||||
} | null>(null);
|
||||
|
||||
// 조정단가 적용 여부 (전체 적용 버튼 클릭 시에만 true)
|
||||
const useAdjustedPrice = appliedPrices !== null;
|
||||
|
||||
// ===== 네비게이션 핸들러 =====
|
||||
const handleBack = useCallback(() => {
|
||||
router.push('/ko/juil/project/bidding/estimates');
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
router.push(`/ko/juil/project/bidding/estimates/${estimateId}/edit`);
|
||||
}, [router, estimateId]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
router.push(`/ko/juil/project/bidding/estimates/${estimateId}`);
|
||||
}, [router, estimateId]);
|
||||
|
||||
// ===== 저장/삭제 핸들러 =====
|
||||
const handleSave = useCallback(() => {
|
||||
setShowSaveDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmSave = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
toast.success('수정이 완료되었습니다.');
|
||||
setShowSaveDialog(false);
|
||||
router.push(`/ko/juil/project/bidding/estimates/${estimateId}`);
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '저장에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router, estimateId]);
|
||||
|
||||
const handleDelete = useCallback(() => {
|
||||
setShowDeleteDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 1000));
|
||||
toast.success('견적이 삭제되었습니다.');
|
||||
setShowDeleteDialog(false);
|
||||
router.push('/ko/juil/project/bidding/estimates');
|
||||
router.refresh();
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router]);
|
||||
|
||||
// ===== 입찰 정보 핸들러 =====
|
||||
const handleBidInfoChange = useCallback((field: string, value: string | number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
bidInfo: { ...prev.bidInfo, [field]: value },
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 견적 요약 정보 핸들러 =====
|
||||
const handleAddSummaryItem = useCallback(() => {
|
||||
const newItem: EstimateSummaryItem = {
|
||||
id: String(Date.now()),
|
||||
name: '',
|
||||
quantity: 1,
|
||||
unit: '식',
|
||||
materialCost: 0,
|
||||
laborCost: 0,
|
||||
totalCost: 0,
|
||||
remarks: '',
|
||||
};
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
summaryItems: [...prev.summaryItems, newItem],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleRemoveSummaryItem = useCallback((itemId: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
summaryItems: prev.summaryItems.filter((item) => item.id !== itemId),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleSummaryItemChange = useCallback(
|
||||
(itemId: string, field: keyof EstimateSummaryItem, value: string | number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
summaryItems: prev.summaryItems.map((item) => {
|
||||
if (item.id === itemId) {
|
||||
const updated = { ...item, [field]: value };
|
||||
if (field === 'materialCost' || field === 'laborCost') {
|
||||
updated.totalCost = updated.materialCost + updated.laborCost;
|
||||
}
|
||||
return updated;
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleSummaryMemoChange = useCallback((memo: string) => {
|
||||
setFormData((prev) => ({ ...prev, summaryMemo: memo }));
|
||||
}, []);
|
||||
|
||||
// ===== 공과 상세 핸들러 =====
|
||||
const handleAddExpenseItems = useCallback((count: number) => {
|
||||
const newItems = Array.from({ length: count }, () => ({
|
||||
id: String(Date.now() + Math.random()),
|
||||
name: MOCK_EXPENSES[0]?.value || '',
|
||||
amount: 100000,
|
||||
selected: false,
|
||||
}));
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
expenseItems: [...prev.expenseItems, ...newItems],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleRemoveSelectedExpenseItems = useCallback(() => {
|
||||
const selectedIds = formData.expenseItems
|
||||
.filter((item) => item.selected)
|
||||
.map((item) => item.id);
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
expenseItems: prev.expenseItems.filter((item) => !selectedIds.includes(item.id)),
|
||||
}));
|
||||
}, [formData.expenseItems]);
|
||||
|
||||
const handleExpenseItemChange = useCallback(
|
||||
(itemId: string, field: keyof ExpenseItem, value: string | number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
expenseItems: prev.expenseItems.map((item) =>
|
||||
item.id === itemId ? { ...item, [field]: value } : item
|
||||
),
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleExpenseSelectItem = useCallback((id: string, selected: boolean) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
expenseItems: prev.expenseItems.map((item) =>
|
||||
item.id === id ? { ...item, selected } : item
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleExpenseSelectAll = useCallback((selected: boolean) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
expenseItems: prev.expenseItems.map((item) => ({ ...item, selected })),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// ===== 품목 단가 조정 핸들러 =====
|
||||
const handlePriceAdjustmentChange = useCallback(
|
||||
(key: keyof PriceAdjustmentData, value: number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
priceAdjustmentData: {
|
||||
...prev.priceAdjustmentData,
|
||||
[key]: {
|
||||
...prev.priceAdjustmentData[key],
|
||||
adjustedPrice: value,
|
||||
},
|
||||
},
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handlePriceAdjustmentSave = useCallback(() => {
|
||||
toast.success('단가가 저장되었습니다.');
|
||||
}, []);
|
||||
|
||||
const handlePriceAdjustmentApplyAll = useCallback(() => {
|
||||
const adjPrices = formData.priceAdjustmentData;
|
||||
setAppliedPrices({
|
||||
caulking: adjPrices.caulking.adjustedPrice,
|
||||
rail: adjPrices.rail.adjustedPrice,
|
||||
bottom: adjPrices.bottom.adjustedPrice,
|
||||
boxReinforce: adjPrices.boxReinforce.adjustedPrice,
|
||||
shaft: adjPrices.shaft.adjustedPrice,
|
||||
painting: adjPrices.painting.adjustedPrice,
|
||||
motor: adjPrices.motor.adjustedPrice,
|
||||
controller: adjPrices.controller.adjustedPrice,
|
||||
});
|
||||
toast.success('조정단가가 견적 상세에 적용되었습니다.');
|
||||
}, [formData.priceAdjustmentData]);
|
||||
|
||||
const handlePriceAdjustmentReset = useCallback(() => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
priceAdjustmentData: {
|
||||
caulking: { ...prev.priceAdjustmentData.caulking, adjustedPrice: prev.priceAdjustmentData.caulking.sellingPrice },
|
||||
rail: { ...prev.priceAdjustmentData.rail, adjustedPrice: prev.priceAdjustmentData.rail.sellingPrice },
|
||||
bottom: { ...prev.priceAdjustmentData.bottom, adjustedPrice: prev.priceAdjustmentData.bottom.sellingPrice },
|
||||
boxReinforce: { ...prev.priceAdjustmentData.boxReinforce, adjustedPrice: prev.priceAdjustmentData.boxReinforce.sellingPrice },
|
||||
shaft: { ...prev.priceAdjustmentData.shaft, adjustedPrice: prev.priceAdjustmentData.shaft.sellingPrice },
|
||||
painting: { ...prev.priceAdjustmentData.painting, adjustedPrice: prev.priceAdjustmentData.painting.sellingPrice },
|
||||
motor: { ...prev.priceAdjustmentData.motor, adjustedPrice: prev.priceAdjustmentData.motor.sellingPrice },
|
||||
controller: { ...prev.priceAdjustmentData.controller, adjustedPrice: prev.priceAdjustmentData.controller.sellingPrice },
|
||||
},
|
||||
}));
|
||||
toast.success('조정단가가 판매단가로 초기화되었습니다.');
|
||||
}, []);
|
||||
|
||||
// ===== 견적 상세 테이블 핸들러 =====
|
||||
const handleAddDetailItems = useCallback((count: number) => {
|
||||
const currentLength = formData.detailItems.length;
|
||||
const newItems: EstimateDetailItem[] = Array.from({ length: count }, (_, i) => ({
|
||||
id: String(Date.now() + Math.random() + i),
|
||||
no: currentLength + i + 1,
|
||||
name: '',
|
||||
material: MOCK_MATERIALS[0]?.value || '',
|
||||
width: 0,
|
||||
height: 0,
|
||||
quantity: 1,
|
||||
box: 0,
|
||||
assembly: 0,
|
||||
coating: 0,
|
||||
batting: 0,
|
||||
mounting: 0,
|
||||
fitting: 0,
|
||||
controller: 0,
|
||||
widthConstruction: 0,
|
||||
heightConstruction: 0,
|
||||
materialCost: 0,
|
||||
laborCost: 0,
|
||||
quantityPrice: 0,
|
||||
expenseQuantity: 0,
|
||||
expenseTotal: 0,
|
||||
totalCost: 0,
|
||||
otherCost: 0,
|
||||
marginCost: 0,
|
||||
totalPrice: 0,
|
||||
unitPrice: 0,
|
||||
expense: 0,
|
||||
marginRate: 1.03,
|
||||
unitQuantity: 0,
|
||||
expenseResult: 0,
|
||||
marginActual: 0,
|
||||
}));
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
detailItems: [...prev.detailItems, ...newItems],
|
||||
}));
|
||||
}, [formData.detailItems.length]);
|
||||
|
||||
const handleRemoveDetailItem = useCallback((itemId: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
detailItems: prev.detailItems.filter((item) => item.id !== itemId),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleRemoveSelectedDetailItems = useCallback(() => {
|
||||
const selectedIds = formData.detailItems
|
||||
.filter((item) => (item as unknown as { selected?: boolean }).selected)
|
||||
.map((item) => item.id);
|
||||
if (selectedIds.length === 0) {
|
||||
toast.error('삭제할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
detailItems: prev.detailItems.filter((item) => !selectedIds.includes(item.id)),
|
||||
}));
|
||||
toast.success(`${selectedIds.length}건이 삭제되었습니다.`);
|
||||
}, [formData.detailItems]);
|
||||
|
||||
const handleDetailItemChange = useCallback(
|
||||
(itemId: string, field: keyof EstimateDetailItem, value: string | number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
detailItems: prev.detailItems.map((item) =>
|
||||
item.id === itemId ? { ...item, [field]: value } : item
|
||||
),
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
const handleDetailSelectItem = useCallback((id: string, selected: boolean) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
detailItems: prev.detailItems.map((item) =>
|
||||
item.id === id ? { ...item, selected } as EstimateDetailItem : item
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleDetailSelectAll = useCallback((selected: boolean) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
detailItems: prev.detailItems.map((item) => ({ ...item, selected } as EstimateDetailItem)),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleApplyAdjustedPriceToSelected = useCallback(() => {
|
||||
const selectedItems = formData.detailItems.filter(
|
||||
(item) => (item as unknown as { selected?: boolean }).selected
|
||||
);
|
||||
if (selectedItems.length === 0) {
|
||||
toast.error('적용할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
const adjustedPrices = formData.priceAdjustmentData;
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
detailItems: prev.detailItems.map((item) => {
|
||||
if ((item as unknown as { selected?: boolean }).selected) {
|
||||
return {
|
||||
...item,
|
||||
adjustedCaulking: adjustedPrices.caulking.adjustedPrice,
|
||||
adjustedRail: adjustedPrices.rail.adjustedPrice,
|
||||
adjustedBottom: adjustedPrices.bottom.adjustedPrice,
|
||||
adjustedBoxReinforce: adjustedPrices.boxReinforce.adjustedPrice,
|
||||
adjustedShaft: adjustedPrices.shaft.adjustedPrice,
|
||||
adjustedPainting: adjustedPrices.painting.adjustedPrice,
|
||||
adjustedMotor: adjustedPrices.motor.adjustedPrice,
|
||||
adjustedController: adjustedPrices.controller.adjustedPrice,
|
||||
};
|
||||
}
|
||||
return item;
|
||||
}),
|
||||
}));
|
||||
toast.success(`${selectedItems.length}건에 조정 단가가 적용되었습니다.`);
|
||||
}, [formData.detailItems, formData.priceAdjustmentData]);
|
||||
|
||||
// 견적 상세 초기화: 각 항목의 사용자 수정값(calcXxx)을 초기화하여 자동 계산값으로 복원
|
||||
const handleDetailReset = useCallback(() => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
detailItems: prev.detailItems.map((item) => ({
|
||||
...item,
|
||||
selected: false,
|
||||
// 계산 필드 초기화 (undefined로 설정하면 자동 계산값 사용)
|
||||
calcWeight: undefined,
|
||||
calcArea: undefined,
|
||||
calcSteelScreen: undefined,
|
||||
calcCaulking: undefined,
|
||||
calcRail: undefined,
|
||||
calcBottom: undefined,
|
||||
calcBoxReinforce: undefined,
|
||||
calcShaft: undefined,
|
||||
calcUnitPrice: undefined,
|
||||
calcExpense: undefined,
|
||||
} as EstimateDetailItem)),
|
||||
}));
|
||||
toast.success('견적 상세가 초기화되었습니다.');
|
||||
}, []);
|
||||
|
||||
// ===== 파일 업로드 핸들러 =====
|
||||
const handleDocumentUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error('파일 크기는 10MB 이하여야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const doc: BidDocument = {
|
||||
id: String(Date.now()),
|
||||
fileName: file.name,
|
||||
fileUrl: URL.createObjectURL(file),
|
||||
fileSize: file.size,
|
||||
};
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
bidInfo: {
|
||||
...prev.bidInfo,
|
||||
documents: [...prev.bidInfo.documents, doc],
|
||||
},
|
||||
}));
|
||||
|
||||
if (documentInputRef.current) {
|
||||
documentInputRef.current.value = '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
const handleDocumentRemove = useCallback((docId: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
bidInfo: {
|
||||
...prev.bidInfo,
|
||||
documents: prev.bidInfo.documents.filter((d) => d.id !== docId),
|
||||
},
|
||||
}));
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isViewMode) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
},
|
||||
[isViewMode]
|
||||
);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback(
|
||||
(e: React.DragEvent<HTMLDivElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
if (isViewMode) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
files.forEach((file) => {
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error(`${file.name}: 파일 크기는 10MB 이하여야 합니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const doc: BidDocument = {
|
||||
id: String(Date.now() + Math.random()),
|
||||
fileName: file.name,
|
||||
fileUrl: URL.createObjectURL(file),
|
||||
fileSize: file.size,
|
||||
};
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
bidInfo: {
|
||||
...prev.bidInfo,
|
||||
documents: [...prev.bidInfo.documents, doc],
|
||||
},
|
||||
}));
|
||||
});
|
||||
},
|
||||
[isViewMode]
|
||||
);
|
||||
|
||||
// ===== 타이틀 및 설명 =====
|
||||
const pageTitle = useMemo(() => {
|
||||
return isEditMode ? '견적 수정' : '견적 상세';
|
||||
}, [isEditMode]);
|
||||
|
||||
const pageDescription = useMemo(() => {
|
||||
return isEditMode ? '견적 정보를 수정합니다' : '견적 정보를 등록하고 관리합니다';
|
||||
}, [isEditMode]);
|
||||
|
||||
// ===== 헤더 버튼 =====
|
||||
const headerActions = useMemo(() => {
|
||||
if (isViewMode) {
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={() => setShowDocumentModal(true)}>
|
||||
견적서 보기
|
||||
</Button>
|
||||
<Button variant="outline" onClick={() => setShowApprovalModal(true)}>
|
||||
전자결재
|
||||
</Button>
|
||||
<Button onClick={handleEdit} className="bg-blue-500 hover:bg-blue-600">
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
<List className="h-4 w-4 mr-2" />
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-red-500 border-red-200 hover:bg-red-50"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-blue-500 hover:bg-blue-600" disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}, [isViewMode, isLoading, handleBack, handleEdit, handleDelete, handleSave]);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={pageTitle}
|
||||
description={pageDescription}
|
||||
icon={FileText}
|
||||
actions={headerActions}
|
||||
onBack={handleBack}
|
||||
/>
|
||||
|
||||
<div className="space-y-8">
|
||||
{/* 견적 정보 + 현장설명회 + 입찰 정보 */}
|
||||
<EstimateInfoSection
|
||||
formData={formData}
|
||||
isViewMode={isViewMode}
|
||||
isDragging={isDragging}
|
||||
documentInputRef={documentInputRef}
|
||||
onFormDataChange={(updates) => setFormData((prev) => ({ ...prev, ...updates }))}
|
||||
onBidInfoChange={handleBidInfoChange}
|
||||
onDocumentUpload={handleDocumentUpload}
|
||||
onDocumentRemove={handleDocumentRemove}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
/>
|
||||
|
||||
{/* 견적 요약 정보 */}
|
||||
<EstimateSummarySection
|
||||
summaryItems={formData.summaryItems}
|
||||
summaryMemo={formData.summaryMemo}
|
||||
isViewMode={isViewMode}
|
||||
onAddItem={handleAddSummaryItem}
|
||||
onRemoveItem={handleRemoveSummaryItem}
|
||||
onItemChange={handleSummaryItemChange}
|
||||
onMemoChange={handleSummaryMemoChange}
|
||||
/>
|
||||
|
||||
{/* 공과 상세 */}
|
||||
<ExpenseDetailSection
|
||||
expenseItems={formData.expenseItems}
|
||||
isViewMode={isViewMode}
|
||||
onAddItems={handleAddExpenseItems}
|
||||
onRemoveSelected={handleRemoveSelectedExpenseItems}
|
||||
onItemChange={handleExpenseItemChange}
|
||||
onSelectItem={handleExpenseSelectItem}
|
||||
onSelectAll={handleExpenseSelectAll}
|
||||
/>
|
||||
|
||||
{/* 품목 단가 조정 */}
|
||||
<PriceAdjustmentSection
|
||||
priceAdjustmentData={formData.priceAdjustmentData}
|
||||
isViewMode={isViewMode}
|
||||
onPriceChange={handlePriceAdjustmentChange}
|
||||
onSave={handlePriceAdjustmentSave}
|
||||
onApplyAll={handlePriceAdjustmentApplyAll}
|
||||
onReset={handlePriceAdjustmentReset}
|
||||
/>
|
||||
|
||||
{/* 견적 상세 테이블 */}
|
||||
<EstimateDetailTableSection
|
||||
detailItems={formData.detailItems}
|
||||
appliedPrices={appliedPrices}
|
||||
isViewMode={isViewMode}
|
||||
onAddItems={handleAddDetailItems}
|
||||
onRemoveItem={handleRemoveDetailItem}
|
||||
onRemoveSelected={handleRemoveSelectedDetailItems}
|
||||
onItemChange={handleDetailItemChange}
|
||||
onSelectItem={handleDetailSelectItem}
|
||||
onSelectAll={handleDetailSelectAll}
|
||||
onApplyAdjustedPrice={handleApplyAdjustedPriceToSelected}
|
||||
onReset={handleDetailReset}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 전자결재 모달 */}
|
||||
<ElectronicApprovalModal
|
||||
isOpen={showApprovalModal}
|
||||
onClose={() => setShowApprovalModal(false)}
|
||||
approval={formData.approval}
|
||||
onSave={(approval) => {
|
||||
setFormData((prev) => ({ ...prev, approval }));
|
||||
setShowApprovalModal(false);
|
||||
toast.success('결재선이 저장되었습니다.');
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 견적서 모달 */}
|
||||
<EstimateDocumentModal
|
||||
isOpen={showDocumentModal}
|
||||
onClose={() => setShowDocumentModal(false)}
|
||||
formData={formData}
|
||||
estimateId={estimateId}
|
||||
/>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>견적 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
이 견적을 삭제하시겠습니까?
|
||||
<br />
|
||||
삭제된 견적은 복구할 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
className="bg-red-600 hover:bg-red-700"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 저장 확인 다이얼로그 */}
|
||||
<AlertDialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>수정 확인</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
견적 정보를 수정하시겠습니까?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmSave}
|
||||
className="bg-blue-500 hover:bg-blue-600"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
확인
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, FileTextIcon, FilePenLine, FileCheck, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import { FileText, FileTextIcon, Clock, FileCheck, Pencil } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
@@ -33,8 +34,6 @@ import {
|
||||
ESTIMATE_SORT_OPTIONS,
|
||||
STATUS_STYLES,
|
||||
STATUS_LABELS,
|
||||
AWARD_STATUS_LABELS,
|
||||
AWARD_STATUS_STYLES,
|
||||
} from './types';
|
||||
import { getEstimateList, getEstimateStats, deleteEstimate, deleteEstimates } from './actions';
|
||||
|
||||
@@ -45,26 +44,23 @@ const tableColumns: TableColumn[] = [
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[120px]' },
|
||||
{ key: 'projectName', label: '현장명', className: 'min-w-[150px]' },
|
||||
{ key: 'estimatorName', label: '견적자', className: 'w-[80px] text-center' },
|
||||
{ key: 'itemCount', label: '계', className: 'w-[60px] text-center' },
|
||||
{ key: 'itemCount', label: '총 개소', className: 'w-[80px] text-center' },
|
||||
{ key: 'estimateAmount', label: '견적금액', className: 'w-[120px] text-right' },
|
||||
{ key: 'distributionDate', label: '견적배부일', className: 'w-[110px] text-center' },
|
||||
{ key: 'completedDate', label: '견적완료일', className: 'w-[110px] text-center' },
|
||||
{ key: 'bidDate', label: '입찰일', className: 'w-[110px] text-center' },
|
||||
{ key: 'awardStatus', label: '낙찰', className: 'w-[70px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||
];
|
||||
|
||||
// 목업 거래처 목록
|
||||
const MOCK_PARTNERS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
// 목업 거래처 목록 (다중선택용 - 빈 배열 = 전체)
|
||||
const MOCK_PARTNERS: MultiSelectOption[] = [
|
||||
{ value: '1', label: '회사명' },
|
||||
{ value: '2', label: '야사 대림아파트' },
|
||||
{ value: '3', label: '여의 현장아파트' },
|
||||
];
|
||||
|
||||
// 목업 견적자 목록
|
||||
const MOCK_ESTIMATORS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
// 목업 견적자 목록 (다중선택용 - 빈 배열 = 전체)
|
||||
const MOCK_ESTIMATORS: MultiSelectOption[] = [
|
||||
{ value: 'hong', label: '홍길동' },
|
||||
{ value: 'kim', label: '김철수' },
|
||||
{ value: 'lee', label: '이영희' },
|
||||
@@ -87,8 +83,8 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
const [estimates, setEstimates] = useState<Estimate[]>(initialData);
|
||||
const [stats, setStats] = useState<EstimateStats | null>(initialStats || null);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [partnerFilter, setPartnerFilter] = useState<string>('all');
|
||||
const [estimatorFilter, setEstimatorFilter] = useState<string>('all');
|
||||
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||
const [estimatorFilters, setEstimatorFilters] = useState<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('latest');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
@@ -99,7 +95,7 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'drafting' | 'completed'>('all');
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 데이터 로드
|
||||
@@ -139,14 +135,18 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
const filteredEstimates = useMemo(() => {
|
||||
return estimates.filter((estimate) => {
|
||||
// 상태 탭 필터
|
||||
if (activeStatTab === 'drafting' && estimate.status !== 'drafting') return false;
|
||||
if (activeStatTab === 'pending' && estimate.status !== 'pending') return false;
|
||||
if (activeStatTab === 'completed' && estimate.status !== 'completed') return false;
|
||||
|
||||
// 거래처 필터
|
||||
if (partnerFilter !== 'all' && estimate.partnerId !== partnerFilter) return false;
|
||||
// 거래처 필터 (다중선택 - 빈 배열 = 전체)
|
||||
if (partnerFilters.length > 0) {
|
||||
if (!partnerFilters.includes(estimate.partnerId)) return false;
|
||||
}
|
||||
|
||||
// 견적자 필터
|
||||
if (estimatorFilter !== 'all' && estimate.estimatorId !== estimatorFilter) return false;
|
||||
// 견적자 필터 (다중선택 - 빈 배열 = 전체)
|
||||
if (estimatorFilters.length > 0) {
|
||||
if (!estimatorFilters.includes(estimate.estimatorId)) return false;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && estimate.status !== statusFilter) return false;
|
||||
@@ -162,23 +162,25 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [estimates, activeStatTab, partnerFilter, estimatorFilter, statusFilter, searchValue]);
|
||||
}, [estimates, activeStatTab, partnerFilters, estimatorFilters, statusFilter, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedEstimates = useMemo(() => {
|
||||
const sorted = [...filteredEstimates];
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
sorted.sort((a, b) => {
|
||||
const dateA = a.completedDate ? new Date(a.completedDate).getTime() : new Date(a.createdAt).getTime();
|
||||
const dateB = b.completedDate ? new Date(b.completedDate).getTime() : new Date(b.createdAt).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
break;
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'amountDesc':
|
||||
sorted.sort((a, b) => b.estimateAmount - a.estimateAmount);
|
||||
break;
|
||||
case 'amountAsc':
|
||||
sorted.sort((a, b) => a.estimateAmount - b.estimateAmount);
|
||||
sorted.sort((a, b) => {
|
||||
const dateA = a.completedDate ? new Date(a.completedDate).getTime() : new Date(a.createdAt).getTime();
|
||||
const dateB = b.completedDate ? new Date(b.completedDate).getTime() : new Date(b.createdAt).getTime();
|
||||
return dateA - dateB;
|
||||
});
|
||||
break;
|
||||
case 'bidDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
@@ -187,6 +189,18 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
return new Date(b.bidDate).getTime() - new Date(a.bidDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'projectNameAsc':
|
||||
sorted.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
|
||||
break;
|
||||
case 'projectNameDesc':
|
||||
sorted.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredEstimates, sortBy]);
|
||||
@@ -231,10 +245,6 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
router.push('/ko/juil/project/bidding/estimates/new');
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, estimateId: string) => {
|
||||
e.stopPropagation();
|
||||
@@ -329,13 +339,8 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
<TableCell className="text-center">{estimate.estimatorName}</TableCell>
|
||||
<TableCell className="text-center">{estimate.itemCount}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimate.estimateAmount)}</TableCell>
|
||||
<TableCell className="text-center">{estimate.distributionDate || '-'}</TableCell>
|
||||
<TableCell className="text-center">{estimate.completedDate || '-'}</TableCell>
|
||||
<TableCell className="text-center">{estimate.bidDate || '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={AWARD_STATUS_STYLES[estimate.awardStatus]}>
|
||||
{AWARD_STATUS_LABELS[estimate.awardStatus]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={STATUS_STYLES[estimate.status]}>
|
||||
{STATUS_LABELS[estimate.status]}
|
||||
@@ -352,21 +357,13 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={(e) => handleDeleteClick(e, estimate.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
@@ -393,26 +390,20 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
[handleRowClick]
|
||||
);
|
||||
|
||||
// 헤더 액션 (등록 버튼 + 날짜 필터)
|
||||
// 헤더 액션 (날짜 필터만 - 견적등록은 현장설명회 참석완료 시 자동 등록)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
extraActions={
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
견적 등록
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
// Stats 카드 데이터 (StatCards 컴포넌트용)
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '전체',
|
||||
label: '전체 견적',
|
||||
value: stats?.total ?? 0,
|
||||
icon: FileTextIcon,
|
||||
iconColor: 'text-blue-600',
|
||||
@@ -420,12 +411,12 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '견적작성중',
|
||||
value: stats?.drafting ?? 0,
|
||||
icon: FilePenLine,
|
||||
label: '견적대기',
|
||||
value: stats?.pending ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-orange-500',
|
||||
onClick: () => setActiveStatTab('drafting'),
|
||||
isActive: activeStatTab === 'drafting',
|
||||
onClick: () => setActiveStatTab('pending'),
|
||||
isActive: activeStatTab === 'pending',
|
||||
},
|
||||
{
|
||||
label: '견적완료',
|
||||
@@ -444,33 +435,25 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
총 {sortedEstimates.length}건
|
||||
</span>
|
||||
|
||||
{/* 거래처 필터 */}
|
||||
<Select value={partnerFilter} onValueChange={setPartnerFilter}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue placeholder="거래처" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_PARTNERS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 거래처 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_PARTNERS}
|
||||
value={partnerFilters}
|
||||
onChange={setPartnerFilters}
|
||||
placeholder="거래처"
|
||||
searchPlaceholder="거래처 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 견적자 필터 */}
|
||||
<Select value={estimatorFilter} onValueChange={setEstimatorFilter}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="견적자" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_ESTIMATORS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 견적자 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_ESTIMATORS}
|
||||
value={estimatorFilters}
|
||||
onChange={setEstimatorFilters}
|
||||
placeholder="견적자"
|
||||
searchPlaceholder="견적자 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
@@ -523,7 +506,6 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onBulkDelete={handleBulkDeleteClick}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
|
||||
@@ -19,10 +19,9 @@ const mockEstimates: Estimate[] = [
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 8,
|
||||
estimateAmount: 100000000,
|
||||
distributionDate: '2025-12-15',
|
||||
completedDate: null,
|
||||
bidDate: '2025-12-15',
|
||||
status: 'drafting',
|
||||
awardStatus: 'pending',
|
||||
status: 'pending',
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
createdBy: '홍길동',
|
||||
@@ -37,10 +36,9 @@ const mockEstimates: Estimate[] = [
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 8,
|
||||
estimateAmount: 100000000,
|
||||
distributionDate: '2025-12-15',
|
||||
completedDate: null,
|
||||
bidDate: '2025-12-15',
|
||||
status: 'drafting',
|
||||
awardStatus: 'pending',
|
||||
status: 'pending',
|
||||
createdAt: '2025-01-02',
|
||||
updatedAt: '2025-01-02',
|
||||
createdBy: '홍길동',
|
||||
@@ -55,10 +53,9 @@ const mockEstimates: Estimate[] = [
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 21,
|
||||
estimateAmount: 50000000,
|
||||
distributionDate: '2025-12-15',
|
||||
completedDate: null,
|
||||
bidDate: '2025-12-15',
|
||||
status: 'drafting',
|
||||
awardStatus: 'pending',
|
||||
status: 'pending',
|
||||
createdAt: '2025-01-03',
|
||||
updatedAt: '2025-01-03',
|
||||
createdBy: '홍길동',
|
||||
@@ -73,10 +70,9 @@ const mockEstimates: Estimate[] = [
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 0,
|
||||
estimateAmount: 10000000,
|
||||
distributionDate: null,
|
||||
completedDate: '2025-12-10',
|
||||
bidDate: '2025-12-15',
|
||||
status: 'completed',
|
||||
awardStatus: 'pending',
|
||||
createdAt: '2025-01-04',
|
||||
updatedAt: '2025-01-04',
|
||||
createdBy: '홍길동',
|
||||
@@ -91,10 +87,9 @@ const mockEstimates: Estimate[] = [
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 0,
|
||||
estimateAmount: 10000000,
|
||||
distributionDate: null,
|
||||
completedDate: '2025-12-11',
|
||||
bidDate: '2025-12-15',
|
||||
status: 'completed',
|
||||
awardStatus: 'pending',
|
||||
createdAt: '2025-01-05',
|
||||
updatedAt: '2025-01-05',
|
||||
createdBy: '홍길동',
|
||||
@@ -109,10 +104,9 @@ const mockEstimates: Estimate[] = [
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 0,
|
||||
estimateAmount: 10000000,
|
||||
distributionDate: null,
|
||||
completedDate: '2025-12-12',
|
||||
bidDate: '2025-12-15',
|
||||
status: 'completed',
|
||||
awardStatus: 'awarded',
|
||||
createdAt: '2025-01-06',
|
||||
updatedAt: '2025-01-06',
|
||||
createdBy: '홍길동',
|
||||
@@ -127,10 +121,9 @@ const mockEstimates: Estimate[] = [
|
||||
estimatorName: '김철수',
|
||||
itemCount: 15,
|
||||
estimateAmount: 200000000,
|
||||
distributionDate: '2025-12-18',
|
||||
completedDate: null,
|
||||
bidDate: '2025-12-20',
|
||||
status: 'drafting',
|
||||
awardStatus: 'pending',
|
||||
status: 'pending',
|
||||
createdAt: '2025-01-07',
|
||||
updatedAt: '2025-01-07',
|
||||
createdBy: '김철수',
|
||||
@@ -246,17 +239,15 @@ export async function getEstimate(
|
||||
export async function getEstimateStats(): Promise<{ success: boolean; data?: EstimateStats; error?: string }> {
|
||||
try {
|
||||
const total = mockEstimates.length;
|
||||
const drafting = mockEstimates.filter((e) => e.status === 'drafting').length;
|
||||
const pending = mockEstimates.filter((e) => e.status === 'pending').length;
|
||||
const completed = mockEstimates.filter((e) => e.status === 'completed').length;
|
||||
const awarded = mockEstimates.filter((e) => e.awardStatus === 'awarded').length;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total,
|
||||
drafting,
|
||||
pending,
|
||||
completed,
|
||||
awarded,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
1
src/components/business/juil/estimates/hooks/index.ts
Normal file
1
src/components/business/juil/estimates/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './useEstimateCalculations';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user