Merge remote-tracking branch 'origin/master'

# Conflicts:
#	src/components/accounting/ReceivablesStatus/index.tsx
This commit is contained in:
2026-01-06 21:22:23 +09:00
230 changed files with 34200 additions and 2402 deletions

View File

@@ -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)
### 인수인계 / 실측 / 발주 / 시공

View File

@@ -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) |

View File

@@ -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) |

View File

@@ -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. **마이그레이션**: 테스트 완료 후 기존 페이지 대체

View File

@@ -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 🆕
```

View 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. **팝업 차단 주의**: 브라우저 팝업 차단 시 인쇄 창이 열리지 않을 수 있음

View File

@@ -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 |
```

View File

@@ -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 응답 정규화

View File

@@ -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 문서 확인하고 시작해.
```

View 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. 최종 수동 테스트

View File

@@ -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 연동 시 품목 사용 여부 체크 로직 추가

View 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) | ✅ |

View File

@@ -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 연동 시 실제 데이터 연결

View File

@@ -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 컴포넌트 |

View File

@@ -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 모드 모두 테스트 필수
- **점진적 진행**: 한 번에 모든 분리보다 단계별 진행 권장

View File

@@ -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
View File

@@ -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"

View File

@@ -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",

View File

@@ -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) {

View File

@@ -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();

View File

@@ -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();

View File

@@ -0,0 +1,5 @@
import { CategoryManagement } from '@/components/business/juil/category-management';
export default function CategoriesPage() {
return <CategoryManagement />;
}

View File

@@ -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} />;
}

View File

@@ -0,0 +1,5 @@
import { ItemDetailClient } from '@/components/business/juil/item-management';
export default function ItemNewPage() {
return <ItemDetailClient isNewMode />;
}

View File

@@ -0,0 +1,5 @@
import { ItemManagementClient } from '@/components/business/juil/item-management';
export default function ItemManagementPage() {
return <ItemManagementClient />;
}

View File

@@ -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} />;
}

View File

@@ -0,0 +1,5 @@
import { LaborDetailClient } from '@/components/business/juil/labor-management';
export default function LaborNewPage() {
return <LaborDetailClient isNewMode />;
}

View File

@@ -0,0 +1,5 @@
import { LaborManagementClient } from '@/components/business/juil/labor-management';
export default function LaborManagementPage() {
return <LaborManagementClient />;
}

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import PricingDetailClient from '@/components/business/juil/pricing-management/PricingDetailClient';
export default function PricingNewPage() {
return <PricingDetailClient mode="create" />;
}

View File

@@ -0,0 +1,5 @@
import PricingListClient from '@/components/business/juil/pricing-management/PricingListClient';
export default function PricingPage() {
return <PricingListClient />;
}

View File

@@ -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} />;
}

View File

@@ -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} />;
}

View File

@@ -0,0 +1,5 @@
import { OrderManagementListClient } from '@/components/business/juil/order-management';
export default function OrderManagementPage() {
return <OrderManagementListClient />;
}

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import { SiteManagementListClient } from '@/components/business/juil/site-management';
export default function SiteManagementPage() {
return <SiteManagementListClient />;
}

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import StructureReviewListClient from '@/components/business/juil/structure-review/StructureReviewListClient';
export default function StructureReviewListPage() {
return <StructureReviewListClient />;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import { BiddingListClient } from '@/components/business/juil/bidding';
export default function BiddingPage() {
return <BiddingListClient />;
}

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
import { HandoverReportListClient } from '@/components/business/juil/handover-report';
export default function HandoverReportPage() {
return <HandoverReportListClient />;
}

View File

@@ -0,0 +1,5 @@
import { ContractListClient } from '@/components/business/juil/contract';
export default function ContractPage() {
return <ContractListClient />;
}

View File

@@ -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);

View File

@@ -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;
}
}

View File

@@ -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>

View File

@@ -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 ? '등록' : '저장'}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 ? (

View File

@@ -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 ? '등록' : '저장'}

View File

@@ -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>

View File

@@ -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>
</>

View File

@@ -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>

View File

@@ -55,6 +55,7 @@ function transformItem(item: VendorReceivablesApi): VendorReceivables {
total: cat.amounts.total,
},
})),
memo: (item as VendorReceivablesApi & { memo?: string }).memo ?? '',
};
}

View File

@@ -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" />

View File

@@ -47,6 +47,7 @@ export interface VendorReceivables {
carryForwardBalance: number; // 이월잔액
monthLabels: string[]; // 동적 월 레이블 (ex: ['25.02', '25.03', ...])
categories: CategoryData[];
memo?: string; // 거래처별 메모 (단일 텍스트)
}
/**

View File

@@ -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>
</>

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 ? '처리중...' : '확인'}

View File

@@ -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" />}

View File

@@ -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: '매입',

View File

@@ -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 ? '등록' : '저장'}

View File

@@ -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>

View File

@@ -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>

View File

@@ -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 : '퇴근 기록에 실패했습니다.',
};
}
}

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

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

View 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: '입찰 수정에 실패했습니다.' };
}
}

View File

@@ -0,0 +1,4 @@
export { default as BiddingListClient } from './BiddingListClient';
export { default as BiddingDetailForm } from './BiddingDetailForm';
export * from './types';
export * from './actions';

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

View File

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

View 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: '서버 오류가 발생했습니다.' };
}
}

View 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>
&quot;{categoryToDelete?.name}&quot; ?
{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>
);
}

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

View File

@@ -0,0 +1,6 @@
// Types
export type { ApprovalPerson, ElectronicApproval } from './types';
export { getEmptyElectronicApproval } from './types';
// Modals
export { ElectronicApprovalModal } from './modals';

View File

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

View File

@@ -0,0 +1 @@
export { ElectronicApprovalModal } from './ElectronicApprovalModal';

View 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: [],
};
}

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

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

View 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: '계약 수정에 실패했습니다.' };
}
}

View 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';

View File

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

View File

@@ -0,0 +1 @@
export { ContractDocumentModal } from './ContractDocumentModal';

View 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: '부가세 별도' },
];

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

View File

@@ -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,

View File

@@ -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) {

View File

@@ -0,0 +1 @@
export * from './useEstimateCalculations';

Some files were not shown because too many files have changed in this diff Show More