feat(WEB): 입찰/계약/주문관리 기능 추가 및 견적 상세 리팩토링
- 입찰관리: 목록/상세/수정 페이지 및 목업 데이터 - 계약관리: 목록/상세/수정 페이지 구현 - 주문관리: 수주/발주 목록 및 상세 페이지 구현 - 견적 상세 폼: 섹션별 분리 및 hooks/utils 리팩토링 - 품목관리, 카테고리관리, 단가관리 기능 추가 - 현장설명회/협력업체 폼 개선 - 프린트 유틸리티 공통화 (print-utils.ts) - 문서 모달 공통 컴포넌트 정리 - IntegratedListTemplateV2, StatCards 개선 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
@@ -1,5 +1,5 @@
|
||||
# Juil Enterprise Test URLs
|
||||
Last Updated: 2025-12-30
|
||||
Last Updated: 2026-01-05
|
||||
|
||||
### 대시보드
|
||||
| 페이지 | URL | 상태 |
|
||||
@@ -17,7 +17,29 @@ Last Updated: 2025-12-30
|
||||
|---|---|---|
|
||||
| **거래처 관리** | `/ko/juil/project/bidding/partners` | ✅ 완료 |
|
||||
| **현장설명회관리** | `/ko/juil/project/bidding/site-briefings` | ✅ 완료 |
|
||||
| **견적관리** | `/ko/juil/project/bidding/estimates` | 🆕 NEW |
|
||||
| **견적관리** | `/ko/juil/project/bidding/estimates` | ✅ 완료 |
|
||||
| **입찰관리** | `/ko/juil/project/bidding` | ✅ 완료 |
|
||||
|
||||
### 계약관리 (Contract)
|
||||
| 페이지 | URL | 상태 |
|
||||
|---|---|---|
|
||||
| **계약관리** | `/ko/juil/project/contract` | 🆕 NEW |
|
||||
| **인수인계보고서관리** | `/ko/juil/project/contract/handover-report` | 🆕 NEW |
|
||||
|
||||
### 발주관리 (Order)
|
||||
| 페이지 | URL | 상태 |
|
||||
|---|---|---|
|
||||
| **현장관리** | `/ko/juil/order/site-management` | 🆕 NEW |
|
||||
| **구조검토관리** | `/ko/juil/order/structure-review` | 🆕 NEW |
|
||||
| **발주관리** | `/ko/juil/order/order-management` | 🆕 NEW |
|
||||
|
||||
### 기준정보 (Base Info) - 발주관리 하위
|
||||
| 페이지 | URL | 상태 |
|
||||
|---|---|---|
|
||||
| **카테고리관리** | `/ko/juil/order/base-info/categories` | 🆕 NEW |
|
||||
| **품목관리** | `/ko/juil/order/base-info/items` | 🆕 NEW |
|
||||
| **단가관리** | `/ko/juil/order/base-info/pricing` | 🆕 NEW |
|
||||
| **노임관리** | `/ko/juil/order/base-info/labor` | 🆕 NEW |
|
||||
|
||||
## 공사 관리 (Construction)
|
||||
### 인수인계 / 실측 / 발주 / 시공
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
# claudedocs 문서 맵
|
||||
|
||||
> 프로젝트 기술 문서 인덱스 (Last Updated: 2025-12-30)
|
||||
> 프로젝트 기술 문서 인덱스 (Last Updated: 2026-01-02)
|
||||
|
||||
## ⭐ 빠른 참조
|
||||
|
||||
@@ -154,7 +154,9 @@ claudedocs/
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `[GUIDE-2025-12-29] vercel-deployment.md` | 🔴 **NEW** - Vercel 배포 가이드 (환경변수, CORS, 테스트 체크리스트) |
|
||||
| `[DESIGN-2026-01-02] document-modal-common-component.md` | 🔴 **NEW** - 문서 모달 공통 컴포넌트 설계 요구사항 (6개 모달 분석, 헤더/결재라인/테이블 조합형) |
|
||||
| `[GUIDE] print-area-utility.md` | 인쇄 모달 printArea 유틸리티 가이드 (8개 모달 적용, print-utils.ts) |
|
||||
| `[GUIDE-2025-12-29] vercel-deployment.md` | Vercel 배포 가이드 (환경변수, CORS, 테스트 체크리스트) |
|
||||
| `[PLAN-2025-12-23] common-component-extraction-plan.md` | 공통 컴포넌트 추출 계획서 (Phase 1-4, 체크리스트 포함, ~1,900줄 절감) |
|
||||
| `[ANALYSIS-2025-12-23] common-component-extraction-candidates.md` | 📋 공통 컴포넌트 추출 후보 분석 (다이얼로그 102개 중복, ~2,370줄 절감 예상) |
|
||||
| `[PLAN-2025-12-19] project-health-improvement.md` | ✅ **Phase 1 완료** - 프로젝트 헬스 개선 계획서 (타입에러 0개, API키 보안, SSR 수정) |
|
||||
@@ -214,6 +216,9 @@ claudedocs/
|
||||
|
||||
| 파일 | 설명 |
|
||||
|------|------|
|
||||
| `[IMPL-2026-01-05] item-management-checklist.md` | 🔴 **NEW** - 품목관리 구현 체크리스트 (발주관리 > 기준정보 > 품목관리) |
|
||||
| `[IMPL-2026-01-05] category-management-checklist.md` | 🔴 **NEW** - 카테고리관리 구현 체크리스트 (발주관리 > 기준정보) |
|
||||
| `[PLAN-2026-01-05] order-management-implementation.md` | 발주관리 페이지 구현 계획서 (달력+리스트, ScheduleCalendar 공통 컴포넌트) |
|
||||
| `[NEXT-2025-12-30] partner-management-session-context.md` | ⭐ **세션 체크포인트** - 거래처 관리 리스트 완료, 등록/상세/수정 예정 |
|
||||
| `[REF] juil-project-structure.md` | 주일 프로젝트 구조 가이드 (경로, 컴포넌트, 테스트 URL) |
|
||||
|
||||
|
||||
@@ -0,0 +1,181 @@
|
||||
# 문서 모달 공통 컴포넌트 설계 요구사항
|
||||
|
||||
## 현황 분석
|
||||
|
||||
### 기존 문서 모달 목록 (6개)
|
||||
| 컴포넌트 | 용도 | 헤더 구성 |
|
||||
|---------|------|----------|
|
||||
| ProcessWorkLogPreviewModal | 공정 작업일지 | 로고 + 제목 + 결재(3열) |
|
||||
| WorkLogModal | 생산 작업일지 | 로고 + 제목 + 결재(3열) |
|
||||
| EstimateDocumentModal | 견적서 | 제목 + 결재(3열) |
|
||||
| OrderDocumentModal | 수주문서(3종) | 제목만 |
|
||||
| ContractDocumentModal | 계약서 | PDF iframe |
|
||||
| HandoverReportDocumentModal | 인수인계보고서 | 결재(4열) 먼저 |
|
||||
|
||||
### 공통 패턴 ✅
|
||||
```
|
||||
1. 모달 프레임: Radix UI Dialog
|
||||
2. 인쇄 처리: print-hidden + print-area 클래스
|
||||
3. 인쇄 유틸: printArea() 함수 (lib/print-utils.ts)
|
||||
4. 용지 크기: max-w-[210mm] (A4 기준)
|
||||
5. 레이아웃: 고정 헤더 + 스크롤 문서 영역
|
||||
```
|
||||
|
||||
### 변동 영역 🔄
|
||||
```
|
||||
1. 문서 헤더 (가장 복잡)
|
||||
- 결재라인: 3열/4열/없음
|
||||
- 로고: 있음/없음, 좌측/우측
|
||||
- 제목: 중앙/좌측, 부제목 유무
|
||||
- 문서번호/날짜: 위치 다양
|
||||
|
||||
2. 버튼 영역
|
||||
- 인쇄만/수정+인쇄/상신+인쇄+삭제 등
|
||||
|
||||
3. 본문 테이블
|
||||
- 컬럼 구성, 합계행, 소계 등
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 공통 컴포넌트 제안
|
||||
|
||||
### 1. PrintableDocumentModal (Base)
|
||||
모달 프레임 + 인쇄 기능만 담당
|
||||
|
||||
```tsx
|
||||
interface PrintableDocumentModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
title: string; // 모달 타이틀
|
||||
width?: 'sm' | 'md' | 'lg'; // 800px | 900px | 1000px
|
||||
actions?: ReactNode; // 버튼 영역 (수정/상신/삭제 등)
|
||||
children: ReactNode; // 문서 본문
|
||||
}
|
||||
```
|
||||
|
||||
**제공 기능**:
|
||||
- Dialog 래퍼 + print-hidden 헤더
|
||||
- 인쇄 버튼 (기본 제공)
|
||||
- print-area 문서 영역 + A4 스타일
|
||||
|
||||
### 2. DocumentHeader (Composable)
|
||||
문서 헤더 조합용 컴포넌트들
|
||||
|
||||
```tsx
|
||||
// 결재라인 컴포넌트
|
||||
<ApprovalLine
|
||||
columns={3} // 3열 또는 4열
|
||||
approvers={[
|
||||
{ role: '작성', name: '홍길동', date: '2026-01-02' },
|
||||
{ role: '검토', name: '김철수', date: '' },
|
||||
{ role: '승인', name: '박영희', date: '' }
|
||||
]}
|
||||
/>
|
||||
|
||||
// 문서 타이틀 컴포넌트
|
||||
<DocumentTitle
|
||||
title="견적서"
|
||||
subtitle="QUOTATION" // 선택
|
||||
code="EST-2026-001" // 문서번호 (선택)
|
||||
date="2026-01-02" // 작성일자 (선택)
|
||||
/>
|
||||
|
||||
// 회사 로고 컴포넌트
|
||||
<CompanyLogo
|
||||
type="KD" | "정동기업" | "custom"
|
||||
customSrc?: string
|
||||
/>
|
||||
```
|
||||
|
||||
### 3. 헤더 레이아웃 프리셋
|
||||
|
||||
```tsx
|
||||
// 3열 레이아웃: 로고 | 타이틀 | 결재
|
||||
<DocumentHeaderLayout variant="three-column">
|
||||
<CompanyLogo type="KD" />
|
||||
<DocumentTitle title="작업일지" />
|
||||
<ApprovalLine columns={3} approvers={...} />
|
||||
</DocumentHeaderLayout>
|
||||
|
||||
// 2열 레이아웃: 타이틀 | 결재
|
||||
<DocumentHeaderLayout variant="two-column">
|
||||
<DocumentTitle title="견적서" code="EST-001" />
|
||||
<ApprovalLine columns={3} approvers={...} />
|
||||
</DocumentHeaderLayout>
|
||||
|
||||
// 1열 레이아웃: 타이틀만 (중앙)
|
||||
<DocumentHeaderLayout variant="single">
|
||||
<DocumentTitle title="거래명세서" centered />
|
||||
</DocumentHeaderLayout>
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 컴포넌트 구조 제안
|
||||
|
||||
```
|
||||
src/components/common/document/
|
||||
├── PrintableDocumentModal.tsx # 기본 모달 프레임
|
||||
├── DocumentHeader/
|
||||
│ ├── index.tsx # 헤더 레이아웃
|
||||
│ ├── ApprovalLine.tsx # 결재라인
|
||||
│ ├── DocumentTitle.tsx # 문서 타이틀
|
||||
│ └── CompanyLogo.tsx # 회사 로고
|
||||
├── DocumentTable/
|
||||
│ ├── index.tsx # 기본 문서 테이블
|
||||
│ ├── SummaryRow.tsx # 합계행
|
||||
│ └── InfoGrid.tsx # 정보 그리드 (2×4 등)
|
||||
└── index.ts # 배럴 export
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 마이그레이션 우선순위
|
||||
|
||||
| 우선순위 | 컴포넌트 | 이유 |
|
||||
|---------|---------|------|
|
||||
| 1 | WorkLogModal 계열 (2개) | 구조 동일, 가장 표준적 |
|
||||
| 2 | EstimateDocumentModal | 결재라인 + 복잡한 테이블 |
|
||||
| 3 | OrderDocumentModal | 3종 문서 분기 포함 |
|
||||
| 4 | HandoverReportDocumentModal | 다른 헤더 구성 |
|
||||
| 5 | ContractDocumentModal | PDF 특수 케이스 |
|
||||
|
||||
---
|
||||
|
||||
## 결정 필요 사항
|
||||
|
||||
### Q1. 헤더 구성 접근법
|
||||
- **A) 프리셋 기반**: 3가지 레이아웃 프리셋으로 제한
|
||||
- **B) 완전 조합형**: 블록 컴포넌트 자유 조합
|
||||
|
||||
### Q2. 결재라인 데이터
|
||||
- **A) Props 직접 전달**: 사용처에서 데이터 구성
|
||||
- **B) Context/Hook**: 문서별 결재 설정 중앙 관리
|
||||
|
||||
### Q3. 테이블 공통화 범위
|
||||
- **A) 스타일만 공통화**: 테이블 래퍼 + CSS
|
||||
- **B) 구조까지 공통화**: columns 정의 + 렌더링 로직
|
||||
|
||||
---
|
||||
|
||||
## 예상 작업량
|
||||
|
||||
| 단계 | 내용 | 예상 파일 수 |
|
||||
|------|------|-------------|
|
||||
| 1 | 공통 컴포넌트 생성 | 8개 |
|
||||
| 2 | 기존 모달 리팩토링 | 6개 |
|
||||
| 3 | 테스트 및 검증 | - |
|
||||
|
||||
---
|
||||
|
||||
## 참고: 인쇄 유틸리티
|
||||
|
||||
```ts
|
||||
// src/lib/print-utils.ts
|
||||
printArea(options?: { title?: string; styles?: string })
|
||||
```
|
||||
|
||||
- `.print-area` 클래스 요소를 새 창에서 인쇄
|
||||
- A4 용지 설정 자동 적용
|
||||
- 기존 스타일시트 자동 로드
|
||||
194
claudedocs/guides/[GUIDE] print-area-utility.md
Normal file
194
claudedocs/guides/[GUIDE] print-area-utility.md
Normal file
@@ -0,0 +1,194 @@
|
||||
# 인쇄 모달 printArea 유틸리티 적용 가이드
|
||||
|
||||
> 작성일: 2026-01-02
|
||||
> 적용 범위: 모든 인쇄 가능한 모달/다이얼로그
|
||||
|
||||
## 개요
|
||||
|
||||
기존 `window.print()` 방식은 Radix UI Dialog 포털 구조로 인해 CSS `@media print` 제어가 어렵고, 인쇄 시 모달 헤더/버튼이 함께 출력되거나 여러 페이지로 나뉘는 문제가 있었습니다.
|
||||
|
||||
이를 해결하기 위해 JavaScript 기반 `printArea()` 유틸리티를 도입하여 `.print-area` 영역만 새 창에서 인쇄하도록 통일했습니다.
|
||||
|
||||
## 공통 컴포넌트 변경
|
||||
|
||||
### 1. print-utils.ts (신규)
|
||||
|
||||
**파일 위치**: `/src/lib/print-utils.ts`
|
||||
|
||||
```typescript
|
||||
interface PrintOptions {
|
||||
title?: string; // 브라우저 인쇄 다이얼로그에 표시될 제목
|
||||
styles?: string; // 추가 CSS 스타일
|
||||
closeAfterPrint?: boolean; // 인쇄 후 창 닫기 (기본: true)
|
||||
}
|
||||
|
||||
// 특정 요소 인쇄
|
||||
export function printElement(
|
||||
elementOrSelector: HTMLElement | string,
|
||||
options?: PrintOptions
|
||||
): void;
|
||||
|
||||
// .print-area 클래스 요소 인쇄 (주로 사용)
|
||||
export function printArea(options?: PrintOptions): void;
|
||||
```
|
||||
|
||||
**동작 방식**:
|
||||
1. 새 창 열기
|
||||
2. 현재 페이지의 스타일시트 복사
|
||||
3. `.print-area` 요소 내용 복제
|
||||
4. `.print-hidden` 요소 제거
|
||||
5. A4 용지에 맞는 인쇄 스타일 적용
|
||||
6. 자동 인쇄 실행 후 창 닫기
|
||||
|
||||
### 2. globals.css 인쇄 스타일 (간소화)
|
||||
|
||||
**파일 위치**: `/src/app/globals.css`
|
||||
|
||||
```css
|
||||
@media print {
|
||||
@page {
|
||||
size: A4 portrait;
|
||||
margin: 10mm;
|
||||
}
|
||||
|
||||
* {
|
||||
-webkit-print-color-adjust: exact !important;
|
||||
print-color-adjust: exact !important;
|
||||
}
|
||||
|
||||
html, body {
|
||||
background: white !important;
|
||||
}
|
||||
|
||||
.print-hidden {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## 적용된 모달 목록
|
||||
|
||||
| 컴포넌트 | 파일 경로 | 인쇄 제목 |
|
||||
|---------|----------|----------|
|
||||
| DocumentDetailModal | `src/components/approval/DocumentDetail/index.tsx` | 문서 타입별 (품의서, 기안서 등) |
|
||||
| ProcessWorkLogPreviewModal | `src/components/process-management/ProcessWorkLogPreviewModal.tsx` | 작업일지 템플릿명 |
|
||||
| ReceivingReceiptDialog | `src/components/material/ReceivingManagement/ReceivingReceiptDialog.tsx` | 입고증 인쇄 |
|
||||
| WorkLogModal | `src/components/production/WorkerScreen/WorkLogModal.tsx` | 작업일지 인쇄 |
|
||||
| OrderDocumentModal | `src/components/orders/documents/OrderDocumentModal.tsx` | 계약서/거래명세서/발주서 |
|
||||
| ShipmentDetail | `src/components/outbound/ShipmentManagement/ShipmentDetail.tsx` | 출고증/거래명세서/납품확인서 |
|
||||
| EstimateDocumentModal | `src/components/business/juil/estimates/modals/EstimateDocumentModal.tsx` | 견적서 인쇄 |
|
||||
| ContractDocumentModal | `src/components/business/juil/contract/modals/ContractDocumentModal.tsx` | 계약서 인쇄 |
|
||||
|
||||
## 사용 방법
|
||||
|
||||
### 기본 사용법
|
||||
|
||||
```tsx
|
||||
import { printArea } from '@/lib/print-utils';
|
||||
|
||||
// 인쇄 핸들러
|
||||
const handlePrint = () => {
|
||||
printArea({ title: '문서 인쇄' });
|
||||
};
|
||||
```
|
||||
|
||||
### 모달 구조 규칙
|
||||
|
||||
인쇄 가능한 모달은 다음 구조를 따라야 합니다:
|
||||
|
||||
```tsx
|
||||
<Dialog>
|
||||
<DialogContent>
|
||||
{/* 헤더 영역 - 인쇄에서 제외 */}
|
||||
<div className="print-hidden">
|
||||
<h2>문서 제목</h2>
|
||||
<Button onClick={handlePrint}>인쇄</Button>
|
||||
<Button onClick={onClose}>닫기</Button>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 - 인쇄에서 제외 */}
|
||||
<div className="print-hidden">
|
||||
<Button>수정</Button>
|
||||
<Button>인쇄</Button>
|
||||
</div>
|
||||
|
||||
{/* 문서 영역 - 이 영역만 인쇄됨 */}
|
||||
<div className="print-area">
|
||||
{/* 실제 문서 내용 */}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
```
|
||||
|
||||
### CSS 클래스 규칙
|
||||
|
||||
| 클래스 | 용도 |
|
||||
|--------|------|
|
||||
| `.print-area` | 인쇄될 영역 (필수) |
|
||||
| `.print-hidden` | 인쇄에서 제외할 영역 (헤더, 버튼 등) |
|
||||
|
||||
## 이전 방식 vs 새 방식
|
||||
|
||||
### 이전 방식 (문제점)
|
||||
|
||||
```tsx
|
||||
const handlePrint = () => {
|
||||
window.print(); // 전체 페이지 인쇄 시도
|
||||
};
|
||||
```
|
||||
|
||||
**문제점**:
|
||||
- Radix UI 포털 구조로 CSS `@media print` 제어 어려움
|
||||
- `visibility: hidden` 사용 시 빈 공간으로 인해 3-4페이지로 출력
|
||||
- `display: none` 사용 시 빈 페이지 출력
|
||||
- 모달 헤더/버튼이 함께 인쇄됨
|
||||
|
||||
### 새 방식 (해결)
|
||||
|
||||
```tsx
|
||||
const handlePrint = () => {
|
||||
printArea({ title: '문서 인쇄' });
|
||||
};
|
||||
```
|
||||
|
||||
**장점**:
|
||||
- 새 창에서 `.print-area` 내용만 추출하여 인쇄
|
||||
- Radix UI 포털 구조 영향 없음
|
||||
- 항상 1페이지로 깔끔하게 인쇄
|
||||
- 문서 내용만 인쇄 (헤더/버튼 제외)
|
||||
|
||||
## 새 인쇄 모달 추가 시
|
||||
|
||||
1. `printArea` import 추가
|
||||
2. `handlePrint` 함수에서 `printArea()` 호출
|
||||
3. 모달 구조에 `.print-hidden` / `.print-area` 클래스 적용
|
||||
|
||||
```tsx
|
||||
import { printArea } from '@/lib/print-utils';
|
||||
|
||||
export function NewDocumentModal() {
|
||||
const handlePrint = () => {
|
||||
printArea({ title: '새 문서 인쇄' });
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog>
|
||||
<DialogContent>
|
||||
<div className="print-hidden">
|
||||
{/* 헤더/버튼 */}
|
||||
</div>
|
||||
<div className="print-area">
|
||||
{/* 인쇄될 문서 내용 */}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
```
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **`.print-area` 클래스 필수**: 인쇄 영역에 반드시 `.print-area` 클래스 적용
|
||||
2. **중첩 `.print-area` 금지**: 하나의 모달에 `.print-area`는 하나만 존재해야 함
|
||||
3. **스타일 복제**: 인쇄 시 현재 페이지의 스타일시트가 자동으로 복사됨
|
||||
4. **팝업 차단 주의**: 브라우저 팝업 차단 시 인쇄 창이 열리지 않을 수 있음
|
||||
@@ -0,0 +1,98 @@
|
||||
# [IMPL-2026-01-05] 카테고리관리 페이지 구현 체크리스트
|
||||
|
||||
## 개요
|
||||
- **위치**: 발주관리 > 기준정보 > 카테고리관리
|
||||
- **URL**: `/ko/juil/order/base-info/categories`
|
||||
- **참조 페이지**: `/ko/settings/ranks` (직급관리)
|
||||
- **기능**: 동일, 텍스트/라벨만 다름
|
||||
|
||||
## 스크린샷 분석
|
||||
|
||||
### UI 구성
|
||||
| 구성요소 | 내용 |
|
||||
|---------|------|
|
||||
| 타이틀 | 카테고리관리 |
|
||||
| 설명 | 카테고리를 등록하고 관리합니다. |
|
||||
| 입력필드 라벨 | 카테고리 |
|
||||
| 입력필드 placeholder | 카테고리를 입력해주세요 |
|
||||
| 테이블 컬럼 | 카테고리, 작업 |
|
||||
| 기본 데이터 | 슬라이드 OPEN 사이즈, 모터, 공정자재, 철물 |
|
||||
|
||||
### Description 영역 (참고용, UI 미구현)
|
||||
1. 추가 버튼 클릭 시 목록 최하단에 추가
|
||||
2. 드래그&드롭으로 순서 변경
|
||||
3. 수정 버튼 → 수정 팝업
|
||||
4. 삭제 버튼 → 조건별 Alert:
|
||||
- 품목 사용 중: "(카테고리명)을 사용하고 있는 품목이 있습니다. 모두 변경 후 삭제가 가능합니다."
|
||||
- 미사용: "정말 삭제하시겠습니까?" → "삭제가 되었습니다."
|
||||
- 기본 카테고리: "기본 카테고리는 삭제가 불가합니다."
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
### Phase 1: 파일 구조 생성
|
||||
- [x] `src/app/[locale]/(protected)/juil/order/base-info/categories/page.tsx` 생성
|
||||
- [x] `src/components/business/juil/category-management/` 디렉토리 생성
|
||||
|
||||
### Phase 2: 컴포넌트 구현 (RankManagement 복제 + 수정)
|
||||
- [x] `index.tsx` - CategoryManagement 메인 컴포넌트
|
||||
- 타이틀: "카테고리관리"
|
||||
- 설명: "카테고리를 등록하고 관리합니다. 드래그하여 순서를 변경할 수 있습니다."
|
||||
- 아이콘: `FolderTree`
|
||||
- 입력 placeholder: "카테고리를 입력해주세요"
|
||||
- [x] `types.ts` - Category 타입 정의
|
||||
- [x] `actions.ts` - Server Actions (목데이터)
|
||||
- [x] `CategoryDialog.tsx` - 수정 다이얼로그
|
||||
|
||||
### Phase 3: 텍스트 변경 사항
|
||||
| 원본 (ranks) | 변경 (categories) | 상태 |
|
||||
|-------------|-------------------|------|
|
||||
| 직급 | 카테고리 | ✅ |
|
||||
| 직급관리 | 카테고리관리 | ✅ |
|
||||
| 사원의 직급을 관리합니다 | 카테고리를 등록하고 관리합니다 | ✅ |
|
||||
| 직급명을 입력하세요 | 카테고리를 입력해주세요 | ✅ |
|
||||
| 직급이 추가되었습니다 | 카테고리가 추가되었습니다 | ✅ |
|
||||
| 직급이 수정되었습니다 | 카테고리가 수정되었습니다 | ✅ |
|
||||
| 직급이 삭제되었습니다 | 카테고리가 삭제되었습니다 | ✅ |
|
||||
| 등록된 직급이 없습니다 | 등록된 카테고리가 없습니다 | ✅ |
|
||||
|
||||
### Phase 4: 삭제 로직 (삭제 조건 처리)
|
||||
- [x] 기본 카테고리 삭제 불가 로직 추가 (`isDefault` 플래그)
|
||||
- [x] 조건별 Alert 메시지 분기 (actions.ts의 `errorType` 반환)
|
||||
- [ ] 품목 사용 여부 체크 로직 추가 (추후 API 연동 시)
|
||||
|
||||
### Phase 5: 목데이터 설정
|
||||
- [x] 기본 카테고리 4개 설정 완료
|
||||
```typescript
|
||||
const mockCategories = [
|
||||
{ id: '1', name: '슬라이드 OPEN 사이즈', order: 1, isDefault: true },
|
||||
{ id: '2', name: '모터', order: 2, isDefault: true },
|
||||
{ id: '3', name: '공정자재', order: 3, isDefault: true },
|
||||
{ id: '4', name: '철물', order: 4, isDefault: true },
|
||||
];
|
||||
```
|
||||
|
||||
### Phase 6: 테스트 URL 문서 업데이트
|
||||
- [x] `claudedocs/[REF] juil-pages-test-urls.md` 업데이트
|
||||
- 발주관리 > 기준정보 섹션 추가
|
||||
- 카테고리관리 URL 추가
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/[locale]/(protected)/juil/order/
|
||||
│ └── base-info/
|
||||
│ └── categories/
|
||||
│ └── page.tsx
|
||||
└── components/business/juil/
|
||||
└── category-management/
|
||||
├── index.tsx
|
||||
├── types.ts
|
||||
├── actions.ts
|
||||
└── CategoryDialog.tsx
|
||||
```
|
||||
|
||||
## 진행 상태
|
||||
- 생성일: 2026-01-05
|
||||
- 상태: ✅ 완료 (목데이터 기반)
|
||||
- 남은 작업: API 연동 시 품목 사용 여부 체크 로직 추가
|
||||
209
claudedocs/juil/[IMPL-2026-01-05] item-management-checklist.md
Normal file
209
claudedocs/juil/[IMPL-2026-01-05] item-management-checklist.md
Normal file
@@ -0,0 +1,209 @@
|
||||
# [IMPL-2026-01-05] 품목관리 페이지 구현 체크리스트
|
||||
|
||||
## 개요
|
||||
- **위치**: 발주관리 > 기준정보 > 품목관리
|
||||
- **URL**: `/ko/juil/order/base-info/items`
|
||||
- **참조 템플릿**: IntegratedListTemplateV2 (리스트 페이지 표준)
|
||||
- **기능**: 품목 CRUD, 필터링, 검색, 정렬
|
||||
|
||||
## 스크린샷 분석
|
||||
|
||||
### 헤더 영역
|
||||
| 구성요소 | 내용 |
|
||||
|---------|------|
|
||||
| 타이틀 | 품목관리 |
|
||||
| 설명 | 품목을 등록하여 관리합니다. |
|
||||
| 날짜 필터 | 날짜 범위 선택 (DateRangePicker) |
|
||||
| 빠른 날짜 버튼 | 전체년도, 전전월, 전월, 당월, 어제, 오늘 |
|
||||
| 액션 버튼 | 품목 등록 (빨간색 primary) |
|
||||
|
||||
### 통계 카드
|
||||
| 카드 | 내용 |
|
||||
|------|------|
|
||||
| 전체 품목 | 전체 품목 수 표시 |
|
||||
| 사용 품목 | 사용 중인 품목 수 표시 |
|
||||
|
||||
### 검색 및 필터 영역
|
||||
| 구성요소 | 내용 |
|
||||
|---------|------|
|
||||
| 검색 입력 | 품목명 검색 |
|
||||
| 선택 카운트 | N건 / N건 선택 |
|
||||
| 삭제 버튼 | 선택된 항목 일괄 삭제 |
|
||||
|
||||
### 테이블 컬럼
|
||||
| 컬럼 | 타입 | 필터 옵션 |
|
||||
|------|------|----------|
|
||||
| 체크박스 | checkbox | - |
|
||||
| 품목번호 | text | - |
|
||||
| 물품유형 | select filter | 전체, 제품, 부품, 소모품, 공과 |
|
||||
| 카테고리 | select filter + search | 전체, 기본, (카테고리 목록) |
|
||||
| 품목명 | text | - |
|
||||
| 규격 | select filter | 전체, 인정, 비인정 |
|
||||
| 단위 | text | - |
|
||||
| 구분 | select filter | 전체, 경품발주, 원자재발주, 외주발주 |
|
||||
| 상태 | badge | 승인, 작업 |
|
||||
| 작업 | actions | 수정(연필 아이콘) |
|
||||
|
||||
### Description 영역 (참고용, UI 미구현)
|
||||
1. 품목 등록 버튼 - 클릭 시 품목 상세 등록 화면으로 이동
|
||||
2. 물품유형 셀렉트 박스 - 전체/제품/부품/소모품/공과 (디폴트: 전체)
|
||||
3. 카테고리 셀렉트 박스, 검색 - 전체/기본/카테고리 목록 (디폴트: 전체)
|
||||
4. 규격 셀렉트 박스 - 전체/인정/비인정 (디폴트: 전체)
|
||||
5. 구분 셀렉트 박스 - 전체/경품발주/원자재발주/외주발주 (디폴트: 전체)
|
||||
6. 상태 셀렉트 박스 - 전체/사용/중지 (디폴트: 전체)
|
||||
7. 정렬 셀렉트 박스 - 최신순/등록순 (디폴트: 최신순)
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
### Phase 1: 파일 구조 생성
|
||||
- [x] `src/app/[locale]/(protected)/juil/order/base-info/items/page.tsx` 생성
|
||||
- [x] `src/components/business/juil/item-management/` 디렉토리 생성
|
||||
|
||||
### Phase 2: 타입 및 상수 정의
|
||||
- [x] `types.ts` - Item 타입 정의
|
||||
```typescript
|
||||
interface Item {
|
||||
id: string;
|
||||
itemNumber: string; // 품목번호
|
||||
itemType: ItemType; // 물품유형
|
||||
categoryId: string; // 카테고리 ID
|
||||
categoryName: string; // 카테고리명
|
||||
itemName: string; // 품목명
|
||||
specification: string; // 규격 (인쇄/비인쇄)
|
||||
unit: string; // 단위
|
||||
orderType: OrderType; // 구분
|
||||
status: ItemStatus; // 상태
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
- [x] `constants.ts` - 필터 옵션 상수 정의
|
||||
```typescript
|
||||
// 물품유형
|
||||
const ITEM_TYPES = ['전체', '제품', '부품', '소모품', '공과'];
|
||||
|
||||
// 규격
|
||||
const SPECIFICATIONS = ['전체', '인정', '비인정'];
|
||||
|
||||
// 구분
|
||||
const ORDER_TYPES = ['전체', '경품발주', '원자재발주', '외주발주'];
|
||||
|
||||
// 상태
|
||||
const ITEM_STATUSES = ['전체', '사용', '중지'];
|
||||
|
||||
// 정렬
|
||||
const SORT_OPTIONS = ['최신순', '등록순'];
|
||||
```
|
||||
|
||||
### Phase 3: 메인 컴포넌트 구현
|
||||
- [x] `index.tsx` - ItemManagement 메인 컴포넌트 (export)
|
||||
- [x] `ItemManagementClient.tsx` - 클라이언트 컴포넌트
|
||||
- IntegratedListTemplateV2 사용
|
||||
- 헤더: 타이틀, 설명, 날짜필터, 품목등록 버튼
|
||||
- 통계 카드: StatCards 컴포넌트 활용
|
||||
- 테이블: 컬럼 헤더 필터 포함
|
||||
- 검색 및 삭제 기능
|
||||
|
||||
### Phase 4: 테이블 컬럼 설정
|
||||
- [x] 테이블 컬럼 정의 (ItemManagementClient.tsx 내 포함)
|
||||
- 체크박스 컬럼
|
||||
- 품목번호 컬럼
|
||||
- 물품유형 컬럼 (헤더 필터 Select)
|
||||
- 카테고리 컬럼 (헤더 필터 Select + 검색)
|
||||
- 품목명 컬럼
|
||||
- 규격 컬럼 (헤더 필터 Select)
|
||||
- 단위 컬럼
|
||||
- 구분 컬럼 (헤더 필터 Select)
|
||||
- 상태 컬럼 (Badge 표시)
|
||||
- 작업 컬럼 (수정 버튼)
|
||||
|
||||
### Phase 5: Server Actions (목데이터)
|
||||
- [x] `actions.ts` - Server Actions 구현
|
||||
- `getItemList()` - 품목 목록 조회
|
||||
- `getItemStats()` - 통계 조회
|
||||
- `deleteItem()` - 품목 삭제
|
||||
- `deleteItems()` - 품목 일괄 삭제
|
||||
- `getCategoryOptions()` - 카테고리 목록 조회
|
||||
|
||||
### Phase 6: 목데이터 설정
|
||||
```typescript
|
||||
const mockItems: Item[] = [
|
||||
{ id: '1', itemNumber: '123123', itemType: '제품', categoryName: '카테고리명', itemName: '품목명', specification: '인쇄', unit: 'SET', orderType: '외주발주', status: '승인' },
|
||||
{ id: '2', itemNumber: '123123', itemType: '부품', categoryName: '카테고리명', itemName: '품목명', specification: '비인쇄', unit: 'SET', orderType: '외주발주', status: '승인' },
|
||||
{ id: '3', itemNumber: '123123', itemType: '소모품', categoryName: '카테고리명', itemName: '품목명', specification: '인쇄', unit: 'SET', orderType: '외주발주', status: '승인' },
|
||||
{ id: '4', itemNumber: '123123', itemType: '공과', categoryName: '카테고리명', itemName: '품목명', specification: '비인쇄', unit: 'EA', orderType: '공과', status: '작업' },
|
||||
{ id: '5', itemNumber: '123123', itemType: '부품', categoryName: '카테고리명', itemName: '품목명', specification: '인쇄', unit: 'EA', orderType: '원자재발주', status: '작업' },
|
||||
{ id: '6', itemNumber: '123123', itemType: '소모품', categoryName: '카테고리명', itemName: '품목명', specification: '비인쇄', unit: '승인', orderType: '외주발주', status: '작업' },
|
||||
{ id: '7', itemNumber: '123123', itemType: '소모품', categoryName: '카테고리명', itemName: '품목명', specification: '인쇄', unit: '승인', orderType: '공과', status: '작업' },
|
||||
];
|
||||
|
||||
const mockStats = {
|
||||
totalItems: 7,
|
||||
activeItems: 5,
|
||||
};
|
||||
```
|
||||
|
||||
### Phase 7: 헤더 필터 컴포넌트
|
||||
- [x] tableHeaderActions 영역에 Select 필터 구현
|
||||
- 물품유형 필터
|
||||
- 규격 필터
|
||||
- 구분 필터
|
||||
- 정렬 필터
|
||||
|
||||
### Phase 8: 등록/상세/수정 페이지 구현
|
||||
- [x] 품목 등록 버튼 클릭 → `/ko/juil/order/base-info/items/new` 이동
|
||||
- [x] 수정 버튼 클릭 → `/ko/juil/order/base-info/items/[id]?mode=edit` 이동
|
||||
- [x] 등록/수정/상세 페이지 구현 (ItemDetailClient.tsx)
|
||||
- [x] Server Actions (getItem, createItem, updateItem) 구현
|
||||
- [x] 발주 항목 동적 추가/삭제 기능
|
||||
|
||||
### Phase 9: 테스트 URL 문서 업데이트
|
||||
- [x] `claudedocs/[REF] juil-pages-test-urls.md` 업데이트
|
||||
- 품목관리 URL 추가
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/[locale]/(protected)/juil/order/
|
||||
│ └── base-info/
|
||||
│ └── items/
|
||||
│ ├── page.tsx
|
||||
│ ├── new/
|
||||
│ │ └── page.tsx
|
||||
│ └── [id]/
|
||||
│ └── page.tsx
|
||||
└── components/business/juil/
|
||||
└── item-management/
|
||||
├── index.tsx
|
||||
├── ItemManagementClient.tsx
|
||||
├── ItemDetailClient.tsx
|
||||
├── types.ts
|
||||
├── constants.ts
|
||||
└── actions.ts
|
||||
```
|
||||
|
||||
## 참조 컴포넌트
|
||||
- `IntegratedListTemplateV2` - 리스트 템플릿
|
||||
- `StatCards` - 통계 카드
|
||||
- `DateRangePicker` - 날짜 범위 선택
|
||||
- `Select` - 필터 셀렉트박스
|
||||
- `Badge` - 상태 표시
|
||||
- `Button` - 버튼
|
||||
- `Checkbox` - 체크박스
|
||||
|
||||
## UI 구현 참고
|
||||
- 컬럼 헤더 내 필터 Select: 기존 프로젝트 내 유사 구현 검색 필요
|
||||
- 날짜 빠른 선택 버튼 그룹: 기존 컴포넌트 활용 또는 신규 구현
|
||||
|
||||
## 진행 상태
|
||||
- 생성일: 2026-01-05
|
||||
- 상태: ✅ 전체 완료 (리스트 + 상세/등록/수정)
|
||||
|
||||
## 히스토리
|
||||
| 날짜 | 작업 내용 | 상태 |
|
||||
|------|----------|------|
|
||||
| 2026-01-05 | 체크리스트 작성 | ✅ |
|
||||
| 2026-01-05 | 리스트 페이지 구현 (Phase 1-7, 9) | ✅ |
|
||||
| 2026-01-05 | 규격 필터 수정 (인쇄/비인쇄 → 인정/비인정) | ✅ |
|
||||
| 2026-01-05 | 상세/등록/수정 페이지 구현 (Phase 8) | ✅ |
|
||||
@@ -0,0 +1,119 @@
|
||||
# [IMPL-2026-01-05] 단가관리 리스트 페이지 구현 체크리스트
|
||||
|
||||
## 개요
|
||||
- **위치**: 발주관리 > 기준정보 > 단가관리
|
||||
- **URL**: `/ko/juil/order/base-info/pricing`
|
||||
- **참조 페이지**: `/ko/juil/order/order-management` (OrderManagementListClient)
|
||||
- **패턴**: IntegratedListTemplateV2 + StatCards
|
||||
|
||||
## 스크린샷 분석
|
||||
|
||||
### UI 구성
|
||||
|
||||
#### 1. 헤더 영역
|
||||
| 구성요소 | 내용 |
|
||||
|---------|------|
|
||||
| 타이틀 | 단가관리 |
|
||||
| 설명 | 단가를 등록하고 관리합니다. |
|
||||
|
||||
#### 2. 달력 + 액션 버튼 영역
|
||||
| 구성요소 | 내용 |
|
||||
|---------|------|
|
||||
| 날짜 선택 | DateRangeSelector (2025-09-01 ~ 2025-09-03) |
|
||||
| 액션 버튼들 | 담당단가, 진행단가, 확정, 발행, 이력, 오류, **단가 등록** |
|
||||
|
||||
#### 3. StatCards (통계 카드)
|
||||
| 카드 | 값 | 설명 |
|
||||
|------|-----|------|
|
||||
| 미완료 | 9 | 미완료 단가 |
|
||||
| 확정 | 5 | 확정된 단가 |
|
||||
| 발행 | 4 | 발행된 단가 |
|
||||
|
||||
#### 4. 필터 영역 (테이블 헤더)
|
||||
| 필터 | 옵션 | 기본값 |
|
||||
|------|------|--------|
|
||||
| 품목유형 | 전체, 박스, 부속, 소모품, 공과 | 전체 |
|
||||
| 카테고리 | 전기, (카테고리 목록) | - |
|
||||
| 규격 | 전체, 진행, 미진행 | 전체 |
|
||||
| 구분 | 전체, 금동량, 임의적용가, 미구분 | 전체 |
|
||||
| 상세 | 전체, 사용, 유지, 미등록 | 전체 |
|
||||
| 정렬 | 최신순, 등록순 | 최신순 |
|
||||
|
||||
#### 5. 테이블 컬럼
|
||||
| 컬럼 | 설명 |
|
||||
|------|------|
|
||||
| 체크박스 | 행 선택 |
|
||||
| 단가번호 | 단가 고유번호 |
|
||||
| 품목유형 | 박스/부속/소모품/공과 |
|
||||
| 카테고리 | 품목 카테고리 |
|
||||
| 품목 | 품목명 |
|
||||
| 금액량 | 수량 정보 |
|
||||
| 정량 | 정량 정보 |
|
||||
| 단가 | 단가 금액 |
|
||||
| 구매처 | 구매처 정보 |
|
||||
| 예상단가 | 예상 단가 |
|
||||
| 이전단가 | 이전 단가 |
|
||||
| 판매단가 | 판매 단가 |
|
||||
| 실적 | 실적 정보 |
|
||||
|
||||
## 구현 체크리스트
|
||||
|
||||
### Phase 1: 파일 구조 생성
|
||||
- [x] `src/app/[locale]/(protected)/juil/order/base-info/pricing/page.tsx` 생성
|
||||
- [x] `src/components/business/juil/pricing-management/` 디렉토리 생성
|
||||
|
||||
### Phase 2: 타입 및 상수 정의
|
||||
- [x] `types.ts` - Pricing 타입, 필터 옵션, 상태 스타일
|
||||
- Pricing 인터페이스
|
||||
- PricingStats 인터페이스
|
||||
- 품목유형 옵션 (ITEM_TYPE_OPTIONS)
|
||||
- 규격 옵션 (SPEC_OPTIONS)
|
||||
- 구분 옵션 (DIVISION_OPTIONS)
|
||||
- 상세 옵션 (DETAIL_OPTIONS)
|
||||
- 정렬 옵션 (SORT_OPTIONS)
|
||||
- 상태 스타일 (PRICING_STATUS_STYLES)
|
||||
|
||||
### Phase 3: Server Actions (목데이터)
|
||||
- [x] `actions.ts`
|
||||
- getPricingList() - 목록 조회
|
||||
- getPricingStats() - 통계 조회
|
||||
- deletePricing() - 단일 삭제
|
||||
- deletePricings() - 일괄 삭제
|
||||
|
||||
### Phase 4: 리스트 컴포넌트
|
||||
- [x] `PricingListClient.tsx`
|
||||
- IntegratedListTemplateV2 사용
|
||||
- DateRangeSelector (날짜 범위 선택)
|
||||
- StatCards (미완료/확정/발행)
|
||||
- 필터 셀렉트 박스들 (품목유형, 규격, 구분, 상세, 정렬)
|
||||
- 액션 버튼들 (담당단가, 진행단가, 확정, 발행, 이력, 오류, 단가 등록)
|
||||
- 테이블 렌더링
|
||||
- 모바일 카드 렌더링
|
||||
- 삭제 다이얼로그
|
||||
|
||||
### Phase 5: 목데이터 설정
|
||||
- [x] 7개 목데이터 설정 완료
|
||||
|
||||
### Phase 6: 테스트 URL 문서 업데이트
|
||||
- [x] `claudedocs/[REF] juil-pages-test-urls.md` 업데이트
|
||||
|
||||
## 파일 구조
|
||||
|
||||
```
|
||||
src/
|
||||
├── app/[locale]/(protected)/juil/order/
|
||||
│ └── base-info/
|
||||
│ └── pricing/
|
||||
│ └── page.tsx
|
||||
└── components/business/juil/
|
||||
└── pricing-management/
|
||||
├── index.ts
|
||||
├── types.ts
|
||||
├── actions.ts
|
||||
└── PricingListClient.tsx
|
||||
```
|
||||
|
||||
## 진행 상태
|
||||
- 생성일: 2026-01-05
|
||||
- 상태: ✅ 완료 (목데이터 기반)
|
||||
- 남은 작업: API 연동 시 실제 데이터 연결
|
||||
@@ -0,0 +1,231 @@
|
||||
# EstimateDetailForm.tsx 파일 분할 계획서
|
||||
|
||||
## 현황 분석
|
||||
|
||||
- **파일 위치**: `src/components/business/juil/estimates/EstimateDetailForm.tsx`
|
||||
- **현재 라인 수**: 2,088줄
|
||||
- **문제점**: 단일 파일에 모든 섹션, 핸들러, 상태 관리가 집중되어 유지보수 어려움
|
||||
|
||||
## 파일 구조 분석
|
||||
|
||||
### 현재 구조 (라인 범위)
|
||||
|
||||
| 구분 | 라인 | 설명 |
|
||||
|------|------|------|
|
||||
| Imports | 1-56 | React, UI 컴포넌트, 타입 |
|
||||
| 상수/유틸 | 58-75 | MOCK_MATERIALS, MOCK_EXPENSES, formatAmount |
|
||||
| Props | 77-81 | EstimateDetailFormProps |
|
||||
| State | 88-127 | formData, 로딩, 다이얼로그, 모달 상태 |
|
||||
| 핸들러 - 네비게이션 | 130-140 | handleBack, handleEdit, handleCancel |
|
||||
| 핸들러 - 저장/삭제 | 143-182 | handleSave, handleConfirmSave, handleDelete, handleConfirmDelete |
|
||||
| 핸들러 - 견적 요약 | 185-227 | handleAddSummaryItem, handleRemoveSummaryItem, handleSummaryItemChange |
|
||||
| 핸들러 - 공과 상세 | 230-259 | handleAddExpenseItem, handleRemoveExpenseItem, handleExpenseItemChange |
|
||||
| 핸들러 - 단가 조정 | 262-283 | handlePriceAdjustmentChange |
|
||||
| 핸들러 - 견적 상세 | 286-343 | handleAddDetailItem, handleRemoveDetailItem, handleDetailItemChange |
|
||||
| 핸들러 - 파일 업로드 | 346-435 | handleDocumentUpload, handleDocumentRemove, 드래그앤드롭 |
|
||||
| useMemo | 438-482 | pageTitle, pageDescription, headerActions |
|
||||
| JSX - 견적 정보 | 496-526 | 견적 정보 Card |
|
||||
| JSX - 현장설명회 | 528-551 | 현장설명회 정보 Card |
|
||||
| JSX - 입찰 정보 | 553-736 | 입찰 정보 Card + 파일 업로드 |
|
||||
| JSX - 견적 요약 | 738-890 | 견적 요약 정보 Table |
|
||||
| JSX - 공과 상세 | 892-1071 | 공과 상세 Table |
|
||||
| JSX - 단가 조정 | 1073-1224 | 품목 단가 조정 Table |
|
||||
| JSX - 견적 상세 | 1226-2017 | 견적 상세 Table (가장 큰 섹션) |
|
||||
| 모달/다이얼로그 | 2020-2085 | 전자결재, 견적서, 삭제/저장 다이얼로그 |
|
||||
|
||||
---
|
||||
|
||||
## 분할 계획
|
||||
|
||||
### 1단계: 섹션 컴포넌트 분리
|
||||
|
||||
```
|
||||
src/components/business/juil/estimates/
|
||||
├── EstimateDetailForm.tsx # 메인 컴포넌트 (축소)
|
||||
├── sections/
|
||||
│ ├── index.ts # 섹션 export
|
||||
│ ├── EstimateInfoSection.tsx # 견적 정보 + 현장설명회 + 입찰 정보
|
||||
│ ├── EstimateSummarySection.tsx # 견적 요약 정보
|
||||
│ ├── ExpenseDetailSection.tsx # 공과 상세
|
||||
│ ├── PriceAdjustmentSection.tsx # 품목 단가 조정
|
||||
│ └── EstimateDetailTableSection.tsx # 견적 상세 테이블
|
||||
├── hooks/
|
||||
│ ├── index.ts # hooks export
|
||||
│ └── useEstimateCalculations.ts # 계산 로직 (면적, 무게, 단가 등)
|
||||
└── utils/
|
||||
├── index.ts # utils export
|
||||
├── constants.ts # MOCK_MATERIALS, MOCK_EXPENSES
|
||||
└── formatters.ts # formatAmount
|
||||
```
|
||||
|
||||
### 2단계: 각 파일 상세
|
||||
|
||||
#### 2.1 constants.ts (~20줄)
|
||||
```typescript
|
||||
// MOCK_MATERIALS, MOCK_EXPENSES 이동
|
||||
export const MOCK_MATERIALS = [...];
|
||||
export const MOCK_EXPENSES = [...];
|
||||
```
|
||||
|
||||
#### 2.2 formatters.ts (~10줄)
|
||||
```typescript
|
||||
// formatAmount 함수 이동
|
||||
export function formatAmount(amount: number): string { ... }
|
||||
```
|
||||
|
||||
#### 2.3 useEstimateCalculations.ts (~100줄)
|
||||
```typescript
|
||||
// 견적 상세 테이블의 계산 로직 분리
|
||||
// - 면적, 무게, 철제스크린, 코킹, 레일, 하장 등 계산
|
||||
// - 합계 계산 로직
|
||||
export function useEstimateCalculations(
|
||||
item: EstimateDetailItem,
|
||||
priceAdjustmentData: PriceAdjustmentData,
|
||||
useAdjustedPrice: boolean
|
||||
) { ... }
|
||||
|
||||
export function calculateTotals(
|
||||
items: EstimateDetailItem[],
|
||||
priceAdjustmentData: PriceAdjustmentData,
|
||||
useAdjustedPrice: boolean
|
||||
) { ... }
|
||||
```
|
||||
|
||||
#### 2.4 EstimateInfoSection.tsx (~250줄)
|
||||
```typescript
|
||||
// 견적 정보 + 현장설명회 + 입찰 정보 Card 3개
|
||||
// 파일 업로드 영역 포함
|
||||
interface EstimateInfoSectionProps {
|
||||
formData: EstimateDetailFormData;
|
||||
setFormData: React.Dispatch<React.SetStateAction<EstimateDetailFormData>>;
|
||||
isViewMode: boolean;
|
||||
documentInputRef: React.RefObject<HTMLInputElement>;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.5 EstimateSummarySection.tsx (~200줄)
|
||||
```typescript
|
||||
// 견적 요약 정보 테이블
|
||||
interface EstimateSummarySectionProps {
|
||||
summaryItems: EstimateSummaryItem[];
|
||||
summaryMemo: string;
|
||||
isViewMode: boolean;
|
||||
onAddItem: () => void;
|
||||
onRemoveItem: (id: string) => void;
|
||||
onItemChange: (id: string, field: keyof EstimateSummaryItem, value: string | number) => void;
|
||||
onMemoChange: (memo: string) => void;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.6 ExpenseDetailSection.tsx (~200줄)
|
||||
```typescript
|
||||
// 공과 상세 테이블
|
||||
interface ExpenseDetailSectionProps {
|
||||
expenseItems: ExpenseItem[];
|
||||
isViewMode: boolean;
|
||||
onAddItems: (count: number) => void;
|
||||
onRemoveSelected: () => void;
|
||||
onItemChange: (id: string, field: keyof ExpenseItem, value: string | number) => void;
|
||||
onSelectItem: (id: string, selected: boolean) => void;
|
||||
onSelectAll: (selected: boolean) => void;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.7 PriceAdjustmentSection.tsx (~200줄)
|
||||
```typescript
|
||||
// 품목 단가 조정 테이블
|
||||
interface PriceAdjustmentSectionProps {
|
||||
priceAdjustmentData: PriceAdjustmentData;
|
||||
isViewMode: boolean;
|
||||
onPriceChange: (key: string, value: number) => void;
|
||||
onSave: () => void;
|
||||
onApplyAll: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
#### 2.8 EstimateDetailTableSection.tsx (~600줄)
|
||||
```typescript
|
||||
// 견적 상세 테이블 (가장 큰 섹션)
|
||||
interface EstimateDetailTableSectionProps {
|
||||
detailItems: EstimateDetailItem[];
|
||||
priceAdjustmentData: PriceAdjustmentData;
|
||||
useAdjustedPrice: boolean;
|
||||
isViewMode: boolean;
|
||||
onAddItems: (count: number) => void;
|
||||
onRemoveItem: (id: string) => void;
|
||||
onRemoveSelected: () => void;
|
||||
onItemChange: (id: string, field: keyof EstimateDetailItem, value: string | number) => void;
|
||||
onSelectItem: (id: string, selected: boolean) => void;
|
||||
onSelectAll: (selected: boolean) => void;
|
||||
onApplyAdjustedPrice: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 분할 후 예상 라인 수
|
||||
|
||||
| 파일 | 예상 라인 수 |
|
||||
|------|-------------|
|
||||
| EstimateDetailForm.tsx (메인) | ~300줄 |
|
||||
| EstimateInfoSection.tsx | ~250줄 |
|
||||
| EstimateSummarySection.tsx | ~200줄 |
|
||||
| ExpenseDetailSection.tsx | ~200줄 |
|
||||
| PriceAdjustmentSection.tsx | ~200줄 |
|
||||
| EstimateDetailTableSection.tsx | ~600줄 |
|
||||
| useEstimateCalculations.ts | ~100줄 |
|
||||
| constants.ts | ~20줄 |
|
||||
| formatters.ts | ~10줄 |
|
||||
| **총합** | ~1,880줄 (약 10% 감소) |
|
||||
|
||||
---
|
||||
|
||||
## 실행 순서
|
||||
|
||||
### Phase 1: 유틸리티 분리 (5분)
|
||||
- [ ] `utils/constants.ts` 생성
|
||||
- [ ] `utils/formatters.ts` 생성
|
||||
- [ ] `utils/index.ts` 생성
|
||||
|
||||
### Phase 2: 계산 로직 분리 (10분)
|
||||
- [ ] `hooks/useEstimateCalculations.ts` 생성
|
||||
- [ ] `hooks/index.ts` 생성
|
||||
|
||||
### Phase 3: 섹션 컴포넌트 분리 (30분)
|
||||
- [ ] `sections/EstimateInfoSection.tsx` 생성
|
||||
- [ ] `sections/EstimateSummarySection.tsx` 생성
|
||||
- [ ] `sections/ExpenseDetailSection.tsx` 생성
|
||||
- [ ] `sections/PriceAdjustmentSection.tsx` 생성
|
||||
- [ ] `sections/EstimateDetailTableSection.tsx` 생성
|
||||
- [ ] `sections/index.ts` 생성
|
||||
|
||||
### Phase 4: 메인 컴포넌트 리팩토링 (10분)
|
||||
- [ ] EstimateDetailForm.tsx에서 분리된 컴포넌트 import
|
||||
- [ ] 핸들러 정리 및 props 전달
|
||||
- [ ] 불필요한 코드 제거
|
||||
|
||||
### Phase 5: 검증 (5분)
|
||||
- [ ] TypeScript 빌드 확인
|
||||
- [ ] 기능 동작 확인
|
||||
|
||||
---
|
||||
|
||||
## 주의사항
|
||||
|
||||
1. **상태 관리**: formData, setFormData는 메인 컴포넌트에서 관리, 섹션에 props로 전달
|
||||
2. **타입 일관성**: 기존 types.ts의 타입 그대로 사용
|
||||
3. **핸들러 위치**: 핸들러는 메인 컴포넌트에 유지, 섹션에 콜백으로 전달
|
||||
4. **조정단가 상태**: appliedPrices, useAdjustedPrice는 메인 컴포넌트에서 관리
|
||||
|
||||
---
|
||||
|
||||
## 5가지 수정사항 (분할 후 진행)
|
||||
|
||||
| # | 항목 | 수정 위치 (분할 후) |
|
||||
|---|------|-------------------|
|
||||
| 2 | 품목 단가 초기화 → 품목 단가만 | PriceAdjustmentSection.tsx |
|
||||
| 3 | 견적 상세 인풋 필드 추가 | EstimateDetailTableSection.tsx |
|
||||
| 4 | 견적 상세 초기화 버튼 수정 | EstimateDetailTableSection.tsx |
|
||||
| 5 | 각 섹션별 초기화 분리 | 각 Section 컴포넌트 |
|
||||
@@ -0,0 +1,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` - 템플릿 참조
|
||||
@@ -0,0 +1,5 @@
|
||||
import { CategoryManagement } from '@/components/business/juil/category-management';
|
||||
|
||||
export default function CategoriesPage() {
|
||||
return <CategoryManagement />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { ItemDetailClient } from '@/components/business/juil/item-management';
|
||||
|
||||
interface ItemDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ mode?: string }>;
|
||||
}
|
||||
|
||||
export default async function ItemDetailPage({ params, searchParams }: ItemDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const { mode } = await searchParams;
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
return <ItemDetailClient itemId={id} isEditMode={isEditMode} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ItemDetailClient } from '@/components/business/juil/item-management';
|
||||
|
||||
export default function ItemNewPage() {
|
||||
return <ItemDetailClient isNewMode />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ItemManagementClient } from '@/components/business/juil/item-management';
|
||||
|
||||
export default function ItemManagementPage() {
|
||||
return <ItemManagementClient />;
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { LaborDetailClient } from '@/components/business/juil/labor-management';
|
||||
|
||||
interface LaborDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
searchParams: Promise<{ mode?: string }>;
|
||||
}
|
||||
|
||||
export default async function LaborDetailPage({ params, searchParams }: LaborDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const { mode } = await searchParams;
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
return <LaborDetailClient laborId={id} isEditMode={isEditMode} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LaborDetailClient } from '@/components/business/juil/labor-management';
|
||||
|
||||
export default function LaborNewPage() {
|
||||
return <LaborDetailClient isNewMode />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { LaborManagementClient } from '@/components/business/juil/labor-management';
|
||||
|
||||
export default function LaborManagementPage() {
|
||||
return <LaborManagementClient />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import PricingDetailClient from '@/components/business/juil/pricing-management/PricingDetailClient';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function PricingEditPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
return <PricingDetailClient id={id} mode="edit" />;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import PricingDetailClient from '@/components/business/juil/pricing-management/PricingDetailClient';
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function PricingDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
return <PricingDetailClient id={id} mode="view" />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import PricingDetailClient from '@/components/business/juil/pricing-management/PricingDetailClient';
|
||||
|
||||
export default function PricingNewPage() {
|
||||
return <PricingDetailClient mode="create" />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import PricingListClient from '@/components/business/juil/pricing-management/PricingListClient';
|
||||
|
||||
export default function PricingPage() {
|
||||
return <PricingListClient />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { OrderDetailForm } from '@/components/business/juil/order-management';
|
||||
import { getOrderDetailFull } from '@/components/business/juil/order-management/actions';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface OrderEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function OrderEditPage({ params }: OrderEditPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
const result = await getOrderDetailFull(id);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <OrderDetailForm mode="edit" orderId={id} initialData={result.data} />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { OrderDetailForm } from '@/components/business/juil/order-management';
|
||||
import { getOrderDetailFull } from '@/components/business/juil/order-management/actions';
|
||||
import { notFound } from 'next/navigation';
|
||||
|
||||
interface OrderDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function OrderDetailPage({ params }: OrderDetailPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
const result = await getOrderDetailFull(id);
|
||||
|
||||
if (!result.success || !result.data) {
|
||||
notFound();
|
||||
}
|
||||
|
||||
return <OrderDetailForm mode="view" orderId={id} initialData={result.data} />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { OrderManagementListClient } from '@/components/business/juil/order-management';
|
||||
|
||||
export default function OrderManagementPage() {
|
||||
return <OrderManagementListClient />;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import SiteDetailForm from '@/components/business/juil/site-management/SiteDetailForm';
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_SITE = {
|
||||
id: '1',
|
||||
siteCode: '123-12-12345',
|
||||
partnerId: '1',
|
||||
partnerName: '거래처명',
|
||||
siteName: '현장명',
|
||||
address: '',
|
||||
status: 'active' as const,
|
||||
createdAt: '2025-09-01T00:00:00Z',
|
||||
updatedAt: '2025-09-01T00:00:00Z',
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function SiteEditPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
// TODO: API에서 현장 정보 조회
|
||||
const site = { ...MOCK_SITE, id };
|
||||
|
||||
return <SiteDetailForm site={site} mode="edit" />;
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import SiteDetailForm from '@/components/business/juil/site-management/SiteDetailForm';
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_SITE = {
|
||||
id: '1',
|
||||
siteCode: '123-12-12345',
|
||||
partnerId: '1',
|
||||
partnerName: '거래처명',
|
||||
siteName: '현장명',
|
||||
address: '',
|
||||
status: 'active' as const,
|
||||
createdAt: '2025-09-01T00:00:00Z',
|
||||
updatedAt: '2025-09-01T00:00:00Z',
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function SiteDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
// TODO: API에서 현장 정보 조회
|
||||
const site = { ...MOCK_SITE, id };
|
||||
|
||||
return <SiteDetailForm site={site} mode="view" />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { SiteManagementListClient } from '@/components/business/juil/site-management';
|
||||
|
||||
export default function SiteManagementPage() {
|
||||
return <SiteManagementListClient />;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import StructureReviewDetailForm from '@/components/business/juil/structure-review/StructureReviewDetailForm';
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_REVIEW = {
|
||||
id: '1',
|
||||
reviewNumber: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '거래처명A',
|
||||
siteId: '1',
|
||||
siteName: '현장A',
|
||||
requestDate: '2025-12-12',
|
||||
reviewCompany: '회사명',
|
||||
reviewerName: '홍길동',
|
||||
reviewDate: '2025-12-15',
|
||||
completionDate: null,
|
||||
status: 'pending' as const,
|
||||
createdAt: '2025-12-01T00:00:00Z',
|
||||
updatedAt: '2025-12-01T00:00:00Z',
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function StructureReviewEditPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
// TODO: API에서 구조검토 정보 조회
|
||||
const review = { ...MOCK_REVIEW, id };
|
||||
|
||||
return <StructureReviewDetailForm review={review} mode="edit" />;
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import StructureReviewDetailForm from '@/components/business/juil/structure-review/StructureReviewDetailForm';
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_REVIEW = {
|
||||
id: '1',
|
||||
reviewNumber: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '거래처명A',
|
||||
siteId: '1',
|
||||
siteName: '현장A',
|
||||
requestDate: '2025-12-12',
|
||||
reviewCompany: '회사명',
|
||||
reviewerName: '홍길동',
|
||||
reviewDate: '2025-12-15',
|
||||
completionDate: null,
|
||||
status: 'pending' as const,
|
||||
createdAt: '2025-12-01T00:00:00Z',
|
||||
updatedAt: '2025-12-01T00:00:00Z',
|
||||
};
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function StructureReviewDetailPage({ params }: PageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
// TODO: API에서 구조검토 정보 조회
|
||||
const review = { ...MOCK_REVIEW, id };
|
||||
|
||||
return <StructureReviewDetailForm review={review} mode="view" />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import StructureReviewListClient from '@/components/business/juil/structure-review/StructureReviewListClient';
|
||||
|
||||
export default function StructureReviewListPage() {
|
||||
return <StructureReviewListClient />;
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { BiddingDetailForm, getBiddingDetail } from '@/components/business/juil/bidding';
|
||||
|
||||
interface BiddingEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function BiddingEditPage({ params }: BiddingEditPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getBiddingDetail(id);
|
||||
|
||||
return (
|
||||
<BiddingDetailForm
|
||||
mode="edit"
|
||||
biddingId={id}
|
||||
initialData={result.data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { BiddingDetailForm, getBiddingDetail } from '@/components/business/juil/bidding';
|
||||
|
||||
interface BiddingDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function BiddingDetailPage({ params }: BiddingDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getBiddingDetail(id);
|
||||
|
||||
return (
|
||||
<BiddingDetailForm
|
||||
mode="view"
|
||||
biddingId={id}
|
||||
initialData={result.data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { EstimateDetailForm } from '@/components/business/juil/estimates';
|
||||
import type { EstimateDetail } from '@/components/business/juil/estimates';
|
||||
|
||||
interface EstimateEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// 목업 데이터 - 추후 API 연동
|
||||
async function getEstimateDetail(id: string): Promise<EstimateDetail | null> {
|
||||
// TODO: 실제 API 연동
|
||||
const mockData: EstimateDetail = {
|
||||
id,
|
||||
estimateCode: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
projectName: '현장명',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '이름',
|
||||
itemCount: 21,
|
||||
estimateAmount: 1420000,
|
||||
completedDate: null,
|
||||
bidDate: '2025-12-12',
|
||||
status: 'pending',
|
||||
createdAt: '2025-12-01',
|
||||
updatedAt: '2025-12-01',
|
||||
createdBy: 'hong',
|
||||
siteBriefing: {
|
||||
briefingCode: '123123',
|
||||
partnerName: '회사명',
|
||||
companyName: '회사명',
|
||||
briefingDate: '2025-12-12',
|
||||
attendee: '이름',
|
||||
},
|
||||
bidInfo: {
|
||||
projectName: '현장명',
|
||||
bidDate: '2025-12-12',
|
||||
siteCount: 21,
|
||||
constructionPeriod: '2026-01-01 ~ 2026-12-10',
|
||||
constructionStartDate: '2026-01-01',
|
||||
constructionEndDate: '2026-12-10',
|
||||
vatType: 'excluded',
|
||||
workReport: '업무 보고 내용',
|
||||
documents: [
|
||||
{
|
||||
id: '1',
|
||||
fileName: 'abc.zip',
|
||||
fileUrl: '#',
|
||||
fileSize: 1024000,
|
||||
},
|
||||
],
|
||||
},
|
||||
summaryItems: [
|
||||
{
|
||||
id: '1',
|
||||
name: '서터 심창측공사',
|
||||
quantity: 1,
|
||||
unit: '식',
|
||||
materialCost: 78540000,
|
||||
laborCost: 15410000,
|
||||
totalCost: 93950000,
|
||||
remarks: '',
|
||||
},
|
||||
],
|
||||
expenseItems: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'public_1',
|
||||
amount: 10000,
|
||||
},
|
||||
],
|
||||
priceAdjustments: [
|
||||
{
|
||||
id: '1',
|
||||
category: '배합비',
|
||||
unitPrice: 10000,
|
||||
coating: 10000,
|
||||
batting: 10000,
|
||||
boxReinforce: 10500,
|
||||
painting: 10500,
|
||||
total: 51000,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
category: '재단비',
|
||||
unitPrice: 1375,
|
||||
coating: 0,
|
||||
batting: 0,
|
||||
boxReinforce: 0,
|
||||
painting: 0,
|
||||
total: 1375,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
category: '판매단가',
|
||||
unitPrice: 0,
|
||||
coating: 10000,
|
||||
batting: 10000,
|
||||
boxReinforce: 10500,
|
||||
painting: 10500,
|
||||
total: 41000,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
category: '조립단가',
|
||||
unitPrice: 10300,
|
||||
coating: 10300,
|
||||
batting: 10300,
|
||||
boxReinforce: 10500,
|
||||
painting: 10200,
|
||||
total: 51600,
|
||||
},
|
||||
],
|
||||
detailItems: [
|
||||
{
|
||||
id: '1',
|
||||
no: 1,
|
||||
name: 'FS530외/주차',
|
||||
material: 'screen',
|
||||
width: 2350,
|
||||
height: 2500,
|
||||
quantity: 1,
|
||||
box: 1,
|
||||
assembly: 0,
|
||||
coating: 0,
|
||||
batting: 0,
|
||||
mounting: 0,
|
||||
fitting: 0,
|
||||
controller: 0,
|
||||
widthConstruction: 0,
|
||||
heightConstruction: 0,
|
||||
materialCost: 1420000,
|
||||
laborCost: 510000,
|
||||
quantityPrice: 1930000,
|
||||
expenseQuantity: 5500,
|
||||
expenseTotal: 5500,
|
||||
totalCost: 1930000,
|
||||
otherCost: 0,
|
||||
marginCost: 0,
|
||||
totalPrice: 1930000,
|
||||
unitPrice: 1420000,
|
||||
expense: 0,
|
||||
marginRate: 0,
|
||||
unitQuantity: 1,
|
||||
expenseResult: 0,
|
||||
marginActual: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
no: 2,
|
||||
name: 'FS530외/주차',
|
||||
material: 'screen',
|
||||
width: 7500,
|
||||
height: 2500,
|
||||
quantity: 1,
|
||||
box: 1,
|
||||
assembly: 0,
|
||||
coating: 0,
|
||||
batting: 0,
|
||||
mounting: 0,
|
||||
fitting: 0,
|
||||
controller: 0,
|
||||
widthConstruction: 0,
|
||||
heightConstruction: 0,
|
||||
materialCost: 4720000,
|
||||
laborCost: 780000,
|
||||
quantityPrice: 5500000,
|
||||
expenseQuantity: 5500,
|
||||
expenseTotal: 5500,
|
||||
totalCost: 5500000,
|
||||
otherCost: 0,
|
||||
marginCost: 0,
|
||||
totalPrice: 5500000,
|
||||
unitPrice: 4720000,
|
||||
expense: 0,
|
||||
marginRate: 0,
|
||||
unitQuantity: 1,
|
||||
expenseResult: 0,
|
||||
marginActual: 0,
|
||||
},
|
||||
],
|
||||
approval: {
|
||||
approvers: [],
|
||||
references: [],
|
||||
},
|
||||
};
|
||||
|
||||
return mockData;
|
||||
}
|
||||
|
||||
export default async function EstimateEditPage({ params }: EstimateEditPageProps) {
|
||||
const { id } = await params;
|
||||
const detail = await getEstimateDetail(id);
|
||||
|
||||
return (
|
||||
<EstimateDetailForm
|
||||
mode="edit"
|
||||
estimateId={id}
|
||||
initialData={detail || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,201 @@
|
||||
import { EstimateDetailForm } from '@/components/business/juil/estimates';
|
||||
import type { EstimateDetail } from '@/components/business/juil/estimates';
|
||||
|
||||
interface EstimateDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
// 목업 데이터 - 추후 API 연동
|
||||
async function getEstimateDetail(id: string): Promise<EstimateDetail | null> {
|
||||
// TODO: 실제 API 연동
|
||||
const mockData: EstimateDetail = {
|
||||
id,
|
||||
estimateCode: '123123',
|
||||
partnerId: '1',
|
||||
partnerName: '회사명',
|
||||
projectName: '현장명',
|
||||
estimatorId: 'hong',
|
||||
estimatorName: '이름',
|
||||
itemCount: 21,
|
||||
estimateAmount: 1420000,
|
||||
completedDate: null,
|
||||
bidDate: '2025-12-12',
|
||||
status: 'pending',
|
||||
createdAt: '2025-12-01',
|
||||
updatedAt: '2025-12-01',
|
||||
createdBy: 'hong',
|
||||
siteBriefing: {
|
||||
briefingCode: '123123',
|
||||
partnerName: '회사명',
|
||||
companyName: '회사명',
|
||||
briefingDate: '2025-12-12',
|
||||
attendee: '이름',
|
||||
},
|
||||
bidInfo: {
|
||||
projectName: '현장명',
|
||||
bidDate: '2025-12-12',
|
||||
siteCount: 21,
|
||||
constructionPeriod: '2026-01-01 ~ 2026-12-10',
|
||||
constructionStartDate: '2026-01-01',
|
||||
constructionEndDate: '2026-12-10',
|
||||
vatType: 'excluded',
|
||||
workReport: '업무 보고 내용',
|
||||
documents: [
|
||||
{
|
||||
id: '1',
|
||||
fileName: 'abc.zip',
|
||||
fileUrl: '#',
|
||||
fileSize: 1024000,
|
||||
},
|
||||
],
|
||||
},
|
||||
summaryItems: [
|
||||
{
|
||||
id: '1',
|
||||
name: '서터 심창측공사',
|
||||
quantity: 1,
|
||||
unit: '식',
|
||||
materialCost: 78540000,
|
||||
laborCost: 15410000,
|
||||
totalCost: 93950000,
|
||||
remarks: '',
|
||||
},
|
||||
],
|
||||
expenseItems: [
|
||||
{
|
||||
id: '1',
|
||||
name: 'public_1',
|
||||
amount: 10000,
|
||||
},
|
||||
],
|
||||
priceAdjustments: [
|
||||
{
|
||||
id: '1',
|
||||
category: '배합비',
|
||||
unitPrice: 10000,
|
||||
coating: 10000,
|
||||
batting: 10000,
|
||||
boxReinforce: 10500,
|
||||
painting: 10500,
|
||||
total: 51000,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
category: '재단비',
|
||||
unitPrice: 1375,
|
||||
coating: 0,
|
||||
batting: 0,
|
||||
boxReinforce: 0,
|
||||
painting: 0,
|
||||
total: 1375,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
category: '판매단가',
|
||||
unitPrice: 0,
|
||||
coating: 10000,
|
||||
batting: 10000,
|
||||
boxReinforce: 10500,
|
||||
painting: 10500,
|
||||
total: 41000,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
category: '조립단가',
|
||||
unitPrice: 10300,
|
||||
coating: 10300,
|
||||
batting: 10300,
|
||||
boxReinforce: 10500,
|
||||
painting: 10200,
|
||||
total: 51600,
|
||||
},
|
||||
],
|
||||
detailItems: [
|
||||
{
|
||||
id: '1',
|
||||
no: 1,
|
||||
name: 'FS530외/주차',
|
||||
material: 'screen',
|
||||
width: 2350,
|
||||
height: 2500,
|
||||
quantity: 1,
|
||||
box: 1,
|
||||
assembly: 0,
|
||||
coating: 0,
|
||||
batting: 0,
|
||||
mounting: 0,
|
||||
fitting: 0,
|
||||
controller: 0,
|
||||
widthConstruction: 0,
|
||||
heightConstruction: 0,
|
||||
materialCost: 1420000,
|
||||
laborCost: 510000,
|
||||
quantityPrice: 1930000,
|
||||
expenseQuantity: 5500,
|
||||
expenseTotal: 5500,
|
||||
totalCost: 1930000,
|
||||
otherCost: 0,
|
||||
marginCost: 0,
|
||||
totalPrice: 1930000,
|
||||
unitPrice: 1420000,
|
||||
expense: 0,
|
||||
marginRate: 0,
|
||||
unitQuantity: 1,
|
||||
expenseResult: 0,
|
||||
marginActual: 0,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
no: 2,
|
||||
name: 'FS530외/주차',
|
||||
material: 'screen',
|
||||
width: 7500,
|
||||
height: 2500,
|
||||
quantity: 1,
|
||||
box: 1,
|
||||
assembly: 0,
|
||||
coating: 0,
|
||||
batting: 0,
|
||||
mounting: 0,
|
||||
fitting: 0,
|
||||
controller: 0,
|
||||
widthConstruction: 0,
|
||||
heightConstruction: 0,
|
||||
materialCost: 4720000,
|
||||
laborCost: 780000,
|
||||
quantityPrice: 5500000,
|
||||
expenseQuantity: 5500,
|
||||
expenseTotal: 5500,
|
||||
totalCost: 5500000,
|
||||
otherCost: 0,
|
||||
marginCost: 0,
|
||||
totalPrice: 5500000,
|
||||
unitPrice: 4720000,
|
||||
expense: 0,
|
||||
marginRate: 0,
|
||||
unitQuantity: 1,
|
||||
expenseResult: 0,
|
||||
marginActual: 0,
|
||||
},
|
||||
],
|
||||
approval: {
|
||||
approvers: [],
|
||||
references: [],
|
||||
},
|
||||
};
|
||||
|
||||
return mockData;
|
||||
}
|
||||
|
||||
export default async function EstimateDetailPage({ params }: EstimateDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const detail = await getEstimateDetail(id);
|
||||
|
||||
return (
|
||||
<EstimateDetailForm
|
||||
mode="view"
|
||||
estimateId={id}
|
||||
initialData={detail || undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { BiddingListClient } from '@/components/business/juil/bidding';
|
||||
|
||||
export default function BiddingPage() {
|
||||
return <BiddingListClient />;
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import ContractDetailForm from '@/components/business/juil/contract/ContractDetailForm';
|
||||
import { getContractDetail } from '@/components/business/juil/contract';
|
||||
|
||||
interface ContractEditPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ContractEditPage({ params }: ContractEditPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getContractDetail(id);
|
||||
|
||||
return (
|
||||
<ContractDetailForm
|
||||
mode="edit"
|
||||
contractId={id}
|
||||
initialData={result.success ? result.data : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import ContractDetailForm from '@/components/business/juil/contract/ContractDetailForm';
|
||||
import { getContractDetail } from '@/components/business/juil/contract';
|
||||
|
||||
interface ContractDetailPageProps {
|
||||
params: Promise<{ id: string }>;
|
||||
}
|
||||
|
||||
export default async function ContractDetailPage({ params }: ContractDetailPageProps) {
|
||||
const { id } = await params;
|
||||
const result = await getContractDetail(id);
|
||||
|
||||
return (
|
||||
<ContractDetailForm
|
||||
mode="view"
|
||||
contractId={id}
|
||||
initialData={result.success ? result.data : undefined}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { HandoverReportDetailForm, getHandoverReportDetail } from '@/components/business/juil/handover-report';
|
||||
|
||||
interface HandoverReportEditPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
locale: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function HandoverReportEditPage({ params }: HandoverReportEditPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
// 서버에서 상세 데이터 조회
|
||||
const result = await getHandoverReportDetail(id);
|
||||
|
||||
return (
|
||||
<HandoverReportDetailForm
|
||||
mode="edit"
|
||||
reportId={id}
|
||||
initialData={result.data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
import { HandoverReportDetailForm, getHandoverReportDetail } from '@/components/business/juil/handover-report';
|
||||
|
||||
interface HandoverReportDetailPageProps {
|
||||
params: Promise<{
|
||||
id: string;
|
||||
locale: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export default async function HandoverReportDetailPage({ params }: HandoverReportDetailPageProps) {
|
||||
const { id } = await params;
|
||||
|
||||
// 서버에서 상세 데이터 조회
|
||||
const result = await getHandoverReportDetail(id);
|
||||
|
||||
return (
|
||||
<HandoverReportDetailForm
|
||||
mode="view"
|
||||
reportId={id}
|
||||
initialData={result.data}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { HandoverReportListClient } from '@/components/business/juil/handover-report';
|
||||
|
||||
export default function HandoverReportPage() {
|
||||
return <HandoverReportListClient />;
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { ContractListClient } from '@/components/business/juil/contract';
|
||||
|
||||
export default function ContractPage() {
|
||||
return <ContractListClient />;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,6 @@
|
||||
'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 { Button } from '@/components/ui/button';
|
||||
@@ -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 }));
|
||||
@@ -438,11 +448,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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
605
src/components/business/juil/bidding/BiddingDetailForm.tsx
Normal file
605
src/components/business/juil/bidding/BiddingDetailForm.tsx
Normal file
@@ -0,0 +1,605 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Loader2 } 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}>
|
||||
목록
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>수정</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={isViewMode ? '입찰 상세' : '입찰 수정'}
|
||||
actions={headerActions}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 입찰 정보 섹션 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">입찰 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 gap-4 md:grid-cols-2 lg:grid-cols-4">
|
||||
{/* 입찰번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label>입찰번호</Label>
|
||||
<Input value={formData.biddingCode} disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* 입찰자 */}
|
||||
<div className="space-y-2">
|
||||
<Label>입찰자</Label>
|
||||
<Input value={formData.bidderName} disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* 거래처명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>거래처명</Label>
|
||||
<Input value={formData.partnerName} disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* 현장명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>현장명</Label>
|
||||
<Input value={formData.projectName} disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* 입찰일자 */}
|
||||
<div className="space-y-2">
|
||||
<Label>입찰일자</Label>
|
||||
{isViewMode ? (
|
||||
<Input value={formData.biddingDate} disabled className="bg-muted" />
|
||||
) : (
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.biddingDate}
|
||||
onChange={(e) => handleFieldChange('biddingDate', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 개소 */}
|
||||
<div className="space-y-2">
|
||||
<Label>개소</Label>
|
||||
<Input value={formData.totalCount} disabled className="bg-muted" />
|
||||
</div>
|
||||
|
||||
{/* 공사기간 */}
|
||||
<div className="space-y-2">
|
||||
<Label>공사기간</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
{isViewMode ? (
|
||||
<Input
|
||||
value={`${formData.constructionStartDate} ~ ${formData.constructionEndDate}`}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.constructionStartDate}
|
||||
onChange={(e) =>
|
||||
handleFieldChange('constructionStartDate', e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span>~</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.constructionEndDate}
|
||||
onChange={(e) =>
|
||||
handleFieldChange('constructionEndDate', e.target.value)
|
||||
}
|
||||
className="flex-1"
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 부가세 */}
|
||||
<div className="space-y-2">
|
||||
<Label>부가세</Label>
|
||||
{isViewMode ? (
|
||||
<Input
|
||||
value={
|
||||
VAT_TYPE_OPTIONS.find((opt) => opt.value === formData.vatType)?.label ||
|
||||
formData.vatType
|
||||
}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
) : (
|
||||
<Select
|
||||
value={formData.vatType}
|
||||
onValueChange={(value) => handleFieldChange('vatType', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VAT_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 입찰금액 */}
|
||||
<div className="space-y-2">
|
||||
<Label>입찰금액</Label>
|
||||
<Input
|
||||
value={formatAmount(formData.biddingAmount)}
|
||||
disabled
|
||||
className="bg-muted text-right font-medium"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상태 */}
|
||||
<div className="space-y-2">
|
||||
<Label>상태</Label>
|
||||
{isViewMode ? (
|
||||
<div
|
||||
className={`flex h-10 items-center rounded-md border px-3 ${BIDDING_STATUS_STYLES[formData.status]}`}
|
||||
>
|
||||
{BIDDING_STATUS_LABELS[formData.status]}
|
||||
</div>
|
||||
) : (
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(value) => handleFieldChange('status', value)}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BIDDING_STATUS_OPTIONS.filter((opt) => opt.value !== 'all').map(
|
||||
(option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
)
|
||||
)}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 투찰일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>투찰일</Label>
|
||||
{isViewMode ? (
|
||||
<Input
|
||||
value={formData.submissionDate || '-'}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.submissionDate}
|
||||
onChange={(e) => handleFieldChange('submissionDate', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 확정일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>확정일</Label>
|
||||
{isViewMode ? (
|
||||
<Input
|
||||
value={formData.confirmDate || '-'}
|
||||
disabled
|
||||
className="bg-muted"
|
||||
/>
|
||||
) : (
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.confirmDate}
|
||||
onChange={(e) => handleFieldChange('confirmDate', e.target.value)}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label>비고</Label>
|
||||
{isViewMode ? (
|
||||
<Textarea
|
||||
value={formData.remarks || '-'}
|
||||
disabled
|
||||
className="min-h-[80px] resize-none bg-muted"
|
||||
/>
|
||||
) : (
|
||||
<Textarea
|
||||
value={formData.remarks}
|
||||
onChange={(e) => handleFieldChange('remarks', e.target.value)}
|
||||
placeholder="비고를 입력하세요"
|
||||
className="min-h-[80px] resize-none"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 공과 상세 섹션 (읽기 전용) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">공과 상세</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="rounded-md border">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60%]">공과</TableHead>
|
||||
<TableHead className="text-right">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formData.expenseItems.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={2} className="h-24 text-center text-muted-foreground">
|
||||
공과 내역이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<>
|
||||
{formData.expenseItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(item.amount)}원
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell>합계</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(expenseTotal)}원
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 견적 상세 섹션 (읽기 전용 - 견적 상세 페이지와 동일) */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">견적 상세</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto max-h-[600px] rounded-md border">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-white z-10">
|
||||
<TableRow className="bg-gray-100">
|
||||
<TableHead className="w-[100px] text-center">명칭</TableHead>
|
||||
<TableHead className="w-[80px] text-center">제품</TableHead>
|
||||
<TableHead className="w-[70px] text-right">가로</TableHead>
|
||||
<TableHead className="w-[70px] text-right">세로</TableHead>
|
||||
<TableHead className="w-[70px] text-right">무게</TableHead>
|
||||
<TableHead className="w-[70px] text-right">면적</TableHead>
|
||||
<TableHead className="w-[90px] text-right">철제,스크린</TableHead>
|
||||
<TableHead className="w-[80px] text-right">코킹</TableHead>
|
||||
<TableHead className="w-[80px] text-right">레일</TableHead>
|
||||
<TableHead className="w-[80px] text-right">하장</TableHead>
|
||||
<TableHead className="w-[90px] text-right">박스+보강</TableHead>
|
||||
<TableHead className="w-[80px] text-right">샤프트</TableHead>
|
||||
<TableHead className="w-[80px] text-right">도장</TableHead>
|
||||
<TableHead className="w-[80px] text-right">모터</TableHead>
|
||||
<TableHead className="w-[80px] text-right">제어기</TableHead>
|
||||
<TableHead className="w-[100px] text-right">가로시공비</TableHead>
|
||||
<TableHead className="w-[100px] text-right">세로시공비</TableHead>
|
||||
<TableHead className="w-[90px] text-right">단가</TableHead>
|
||||
<TableHead className="w-[70px] text-right">공과율</TableHead>
|
||||
<TableHead className="w-[80px] text-right">공과</TableHead>
|
||||
<TableHead className="w-[50px] text-right">수량</TableHead>
|
||||
<TableHead className="w-[90px] text-right">원가</TableHead>
|
||||
<TableHead className="w-[90px] text-right">원가실행</TableHead>
|
||||
<TableHead className="w-[90px] text-right">마진원가</TableHead>
|
||||
<TableHead className="w-[100px] text-right">마진원가실행</TableHead>
|
||||
<TableHead className="w-[90px] text-right">공과실행</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formData.estimateDetailItems.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={26} className="h-24 text-center text-muted-foreground">
|
||||
견적 상세 내역이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<>
|
||||
{formData.estimateDetailItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="bg-gray-50">{item.name}</TableCell>
|
||||
<TableCell className="bg-gray-50">{item.material}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{item.width?.toFixed(2) || '0.00'}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{item.height?.toFixed(2) || '0.00'}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{item.weight?.toFixed(2) || '0.00'}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{item.area?.toFixed(2) || '0.00'}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.steelScreen || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.caulking || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.rail || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.bottom || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.boxReinforce || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.shaft || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.painting || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.motor || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.controller || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.widthConstruction || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.heightConstruction || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50 font-medium">{formatAmount(item.unitPrice || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{item.expenseRate || 0}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.expense || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{item.quantity || 0}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.cost || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.costExecution || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.marginCost || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50 font-medium">{formatAmount(item.marginCostExecution || 0)}</TableCell>
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(item.expenseExecution || 0)}</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
{/* 합계 행 */}
|
||||
<TableRow className="bg-orange-50 font-medium border-t-2 border-orange-300">
|
||||
<TableCell colSpan={4} className="text-center font-bold">합계</TableCell>
|
||||
<TableCell className="text-right">{estimateDetailTotals.weight.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right">{estimateDetailTotals.area.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.steelScreen)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.caulking)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.rail)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.bottom)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.boxReinforce)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.shaft)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.painting)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.motor)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.controller)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.widthConstruction)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.heightConstruction)}</TableCell>
|
||||
<TableCell className="text-right font-bold">{formatAmount(estimateDetailTotals.unitPrice)}</TableCell>
|
||||
<TableCell className="text-right">-</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.expense)}</TableCell>
|
||||
<TableCell className="text-right">{estimateDetailTotals.quantity}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.cost)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.costExecution)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.marginCost)}</TableCell>
|
||||
<TableCell className="text-right font-bold">{formatAmount(estimateDetailTotals.marginCostExecution)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimateDetailTotals.expenseExecution)}</TableCell>
|
||||
</TableRow>
|
||||
</>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 저장 확인 다이얼로그 */}
|
||||
<AlertDialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>입찰 수정</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
입찰 정보를 저장하시겠습니까?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isLoading}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmSave} disabled={isLoading}>
|
||||
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
|
||||
저장
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
574
src/components/business/juil/bidding/BiddingListClient.tsx
Normal file
574
src/components/business/juil/bidding/BiddingListClient.tsx
Normal file
@@ -0,0 +1,574 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, Clock, Trophy, Pencil } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import type { Bidding, BiddingStats } from './types';
|
||||
import {
|
||||
BIDDING_STATUS_OPTIONS,
|
||||
BIDDING_SORT_OPTIONS,
|
||||
BIDDING_STATUS_STYLES,
|
||||
BIDDING_STATUS_LABELS,
|
||||
} from './types';
|
||||
import { getBiddingList, getBiddingStats, deleteBidding, deleteBiddings } from './actions';
|
||||
|
||||
// 테이블 컬럼 정의 (체크박스, 번호, 입찰번호, 거래처, 현장명, 입찰자, 총 개소, 입찰금액, 입찰일, 투찰일, 확정일, 상태, 비고, 작업)
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'biddingCode', label: '입찰번호', className: 'w-[120px]' },
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
|
||||
{ key: 'projectName', label: '현장명', className: 'min-w-[120px]' },
|
||||
{ key: 'bidderName', label: '입찰자', className: 'w-[80px]' },
|
||||
{ key: 'totalCount', label: '총 개소', className: 'w-[80px] text-center' },
|
||||
{ key: 'biddingAmount', label: '입찰금액', className: 'w-[120px] text-right' },
|
||||
{ key: 'bidDate', label: '입찰일', className: 'w-[100px] text-center' },
|
||||
{ key: 'submissionDate', label: '투찰일', className: 'w-[100px] text-center' },
|
||||
{ key: 'confirmDate', label: '확정일', className: 'w-[100px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
||||
{ key: 'remarks', label: '비고', className: 'w-[120px]' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||
];
|
||||
|
||||
// 목업 거래처 목록 (다중선택용 - 빈 배열 = 전체)
|
||||
const MOCK_PARTNERS: MultiSelectOption[] = [
|
||||
{ value: '1', label: '이사대표' },
|
||||
{ value: '2', label: '야사건설' },
|
||||
{ value: '3', label: '여의건설' },
|
||||
];
|
||||
|
||||
// 목업 입찰자 목록 (다중선택용 - 빈 배열 = 전체)
|
||||
const MOCK_BIDDERS: MultiSelectOption[] = [
|
||||
{ value: 'hong', label: '홍길동' },
|
||||
{ value: 'kim', label: '김철수' },
|
||||
{ value: 'lee', label: '이영희' },
|
||||
];
|
||||
|
||||
// 금액 포맷팅
|
||||
function formatAmount(amount: number): string {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
}
|
||||
|
||||
// 날짜 포맷팅
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).replace(/\. /g, '-').replace('.', '');
|
||||
}
|
||||
|
||||
interface BiddingListClientProps {
|
||||
initialData?: Bidding[];
|
||||
initialStats?: BiddingStats;
|
||||
}
|
||||
|
||||
export default function BiddingListClient({ initialData = [], initialStats }: BiddingListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [biddings, setBiddings] = useState<Bidding[]>(initialData);
|
||||
const [stats, setStats] = useState<BiddingStats | null>(initialStats || null);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||
const [bidderFilters, setBidderFilters] = useState<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('biddingDateDesc');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'waiting' | 'awarded'>('all');
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getBiddingList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
getBiddingStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setBiddings(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredBiddings = useMemo(() => {
|
||||
return biddings.filter((bidding) => {
|
||||
// 상태 탭 필터
|
||||
if (activeStatTab === 'waiting' && bidding.status !== 'waiting') return false;
|
||||
if (activeStatTab === 'awarded' && bidding.status !== 'awarded') return false;
|
||||
|
||||
// 거래처 필터 (다중선택 - 빈 배열 = 전체)
|
||||
if (partnerFilters.length > 0) {
|
||||
if (!partnerFilters.includes(bidding.partnerId)) return false;
|
||||
}
|
||||
|
||||
// 입찰자 필터 (다중선택 - 빈 배열 = 전체)
|
||||
if (bidderFilters.length > 0) {
|
||||
if (!bidderFilters.includes(bidding.bidderId)) return false;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && bidding.status !== statusFilter) return false;
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
bidding.projectName.toLowerCase().includes(search) ||
|
||||
bidding.biddingCode.toLowerCase().includes(search) ||
|
||||
bidding.partnerName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [biddings, activeStatTab, partnerFilters, bidderFilters, statusFilter, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedBiddings = useMemo(() => {
|
||||
const sorted = [...filteredBiddings];
|
||||
switch (sortBy) {
|
||||
case 'biddingDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.biddingDate) return 1;
|
||||
if (!b.biddingDate) return -1;
|
||||
return new Date(b.biddingDate).getTime() - new Date(a.biddingDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'biddingDateAsc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.biddingDate) return 1;
|
||||
if (!b.biddingDate) return -1;
|
||||
return new Date(a.biddingDate).getTime() - new Date(b.biddingDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'submissionDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.submissionDate) return 1;
|
||||
if (!b.submissionDate) return -1;
|
||||
return new Date(b.submissionDate).getTime() - new Date(a.submissionDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'confirmDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.confirmDate) return 1;
|
||||
if (!b.confirmDate) return -1;
|
||||
return new Date(b.confirmDate).getTime() - new Date(a.confirmDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'projectNameAsc':
|
||||
sorted.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
|
||||
break;
|
||||
case 'projectNameDesc':
|
||||
sorted.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredBiddings, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedBiddings.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedBiddings.slice(start, start + itemsPerPage);
|
||||
}, [sortedBiddings, currentPage, itemsPerPage]);
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((b) => b.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(bidding: Bidding) => {
|
||||
router.push(`/ko/juil/project/bidding/${bidding.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, biddingId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/juil/project/bidding/${biddingId}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback((e: React.MouseEvent, biddingId: string) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTargetId(biddingId);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteTargetId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deleteBidding(deleteTargetId);
|
||||
if (result.success) {
|
||||
toast.success('입찰이 삭제되었습니다.');
|
||||
setBiddings((prev) => prev.filter((b) => b.id !== deleteTargetId));
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(deleteTargetId);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
}, [deleteTargetId]);
|
||||
|
||||
const handleBulkDeleteClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.warning('삭제할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setBulkDeleteDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
|
||||
const handleBulkDeleteConfirm = useCallback(async () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const ids = Array.from(selectedItems);
|
||||
const result = await deleteBiddings(ids);
|
||||
if (result.success) {
|
||||
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
|
||||
await loadData();
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
toast.error(result.error || '일괄 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('일괄 삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setBulkDeleteDialogOpen(false);
|
||||
}
|
||||
}, [selectedItems, loadData]);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(bidding: Bidding, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(bidding.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={bidding.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(bidding)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(bidding.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{bidding.biddingCode}</TableCell>
|
||||
<TableCell>{bidding.partnerName}</TableCell>
|
||||
<TableCell>{bidding.projectName}</TableCell>
|
||||
<TableCell>{bidding.bidderName}</TableCell>
|
||||
<TableCell className="text-center">{bidding.totalCount}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(bidding.biddingAmount)}</TableCell>
|
||||
<TableCell className="text-center">{formatDate(bidding.bidDate)}</TableCell>
|
||||
<TableCell className="text-center">{formatDate(bidding.submissionDate)}</TableCell>
|
||||
<TableCell className="text-center">{formatDate(bidding.confirmDate)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={BIDDING_STATUS_STYLES[bidding.status]}>
|
||||
{BIDDING_STATUS_LABELS[bidding.status]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="truncate max-w-[120px]" title={bidding.remarks}>
|
||||
{bidding.remarks || '-'}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, bidding.id)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(bidding: Bidding, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
<MobileCard
|
||||
title={bidding.projectName}
|
||||
subtitle={bidding.biddingCode}
|
||||
badge={BIDDING_STATUS_LABELS[bidding.status]}
|
||||
badgeVariant="secondary"
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => handleRowClick(bidding)}
|
||||
details={[
|
||||
{ label: '거래처', value: bidding.partnerName },
|
||||
{ label: '입찰금액', value: `${formatAmount(bidding.biddingAmount)}원` },
|
||||
{ label: '입찰일자', value: formatDate(bidding.biddingDate) },
|
||||
{ label: '총 개소', value: `${bidding.totalCount}` },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
);
|
||||
|
||||
// 헤더 액션 (날짜 필터) - 등록 버튼 없음 (견적완료 시 자동 등록)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
);
|
||||
|
||||
// Stats 카드 데이터 (전체 입찰, 입찰대기, 낙찰)
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '전체 입찰',
|
||||
value: stats?.total ?? 0,
|
||||
icon: FileText,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '입찰대기',
|
||||
value: stats?.waiting ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-orange-500',
|
||||
onClick: () => setActiveStatTab('waiting'),
|
||||
isActive: activeStatTab === 'waiting',
|
||||
},
|
||||
{
|
||||
label: '낙찰',
|
||||
value: stats?.awarded ?? 0,
|
||||
icon: Trophy,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('awarded'),
|
||||
isActive: activeStatTab === 'awarded',
|
||||
},
|
||||
];
|
||||
|
||||
// 테이블 헤더 액션 (총 건수 + 필터 4개: 거래처, 입찰자, 상태, 정렬)
|
||||
const tableHeaderActions = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {sortedBiddings.length}건
|
||||
</span>
|
||||
|
||||
{/* 거래처 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_PARTNERS}
|
||||
value={partnerFilters}
|
||||
onChange={setPartnerFilters}
|
||||
placeholder="거래처"
|
||||
searchPlaceholder="거래처 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 입찰자 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_BIDDERS}
|
||||
value={bidderFilters}
|
||||
onChange={setBidderFilters}
|
||||
placeholder="입찰자"
|
||||
searchPlaceholder="입찰자 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BIDDING_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="최신순 (입찰일)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{BIDDING_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="입찰관리"
|
||||
description="입찰을 관리합니다 (견적완료 시 자동 등록)"
|
||||
icon={FileText}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="입찰번호, 거래처, 현장명 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedBiddings}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onBulkDelete={handleBulkDeleteClick}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedBiddings.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 단일 삭제 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>입찰 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 입찰을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일괄 삭제 다이얼로그 */}
|
||||
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>입찰 일괄 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 {selectedItems.size}개 입찰을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleBulkDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
574
src/components/business/juil/bidding/actions.ts
Normal file
574
src/components/business/juil/bidding/actions.ts
Normal file
@@ -0,0 +1,574 @@
|
||||
'use server';
|
||||
|
||||
import type {
|
||||
Bidding,
|
||||
BiddingStats,
|
||||
BiddingListResponse,
|
||||
BiddingFilter,
|
||||
BiddingDetail,
|
||||
BiddingDetailFormData,
|
||||
ExpenseItem,
|
||||
EstimateDetailItem,
|
||||
} from './types';
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_BIDDINGS: Bidding[] = [
|
||||
{
|
||||
id: '1',
|
||||
biddingCode: 'BID-2025-001',
|
||||
partnerId: '1',
|
||||
partnerName: '이사대표',
|
||||
projectName: '광장 아파트',
|
||||
biddingDate: '2025-01-25',
|
||||
totalCount: 15,
|
||||
biddingAmount: 71000000,
|
||||
bidDate: '2025-01-20',
|
||||
submissionDate: '2025-01-22',
|
||||
confirmDate: '2025-01-25',
|
||||
status: 'awarded',
|
||||
bidderId: 'hong',
|
||||
bidderName: '홍길동',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
createdBy: 'system',
|
||||
estimateId: '1',
|
||||
estimateCode: 'EST-2025-001',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
biddingCode: 'BID-2025-002',
|
||||
partnerId: '2',
|
||||
partnerName: '야사건설',
|
||||
projectName: '대림아파트',
|
||||
biddingDate: '2025-01-20',
|
||||
totalCount: 22,
|
||||
biddingAmount: 100000000,
|
||||
bidDate: '2025-01-18',
|
||||
submissionDate: null,
|
||||
confirmDate: null,
|
||||
status: 'waiting',
|
||||
bidderId: 'kim',
|
||||
bidderName: '김철수',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-02',
|
||||
updatedAt: '2025-01-02',
|
||||
createdBy: 'system',
|
||||
estimateId: '2',
|
||||
estimateCode: 'EST-2025-002',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
biddingCode: 'BID-2025-003',
|
||||
partnerId: '3',
|
||||
partnerName: '여의건설',
|
||||
projectName: '현장아파트',
|
||||
biddingDate: '2025-01-18',
|
||||
totalCount: 18,
|
||||
biddingAmount: 85000000,
|
||||
bidDate: '2025-01-15',
|
||||
submissionDate: '2025-01-16',
|
||||
confirmDate: '2025-01-18',
|
||||
status: 'awarded',
|
||||
bidderId: 'hong',
|
||||
bidderName: '홍길동',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-03',
|
||||
updatedAt: '2025-01-03',
|
||||
createdBy: 'system',
|
||||
estimateId: '3',
|
||||
estimateCode: 'EST-2025-003',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
biddingCode: 'BID-2025-004',
|
||||
partnerId: '1',
|
||||
partnerName: '이사대표',
|
||||
projectName: '송파타워',
|
||||
biddingDate: '2025-01-15',
|
||||
totalCount: 30,
|
||||
biddingAmount: 120000000,
|
||||
bidDate: '2025-01-12',
|
||||
submissionDate: '2025-01-13',
|
||||
confirmDate: '2025-01-15',
|
||||
status: 'failed',
|
||||
bidderId: 'lee',
|
||||
bidderName: '이영희',
|
||||
remarks: '가격 경쟁력 부족',
|
||||
createdAt: '2025-01-04',
|
||||
updatedAt: '2025-01-04',
|
||||
createdBy: 'system',
|
||||
estimateId: '4',
|
||||
estimateCode: 'EST-2025-004',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
biddingCode: 'BID-2025-005',
|
||||
partnerId: '2',
|
||||
partnerName: '야사건설',
|
||||
projectName: '강남센터',
|
||||
biddingDate: '2025-01-12',
|
||||
totalCount: 25,
|
||||
biddingAmount: 95000000,
|
||||
bidDate: '2025-01-10',
|
||||
submissionDate: '2025-01-11',
|
||||
confirmDate: null,
|
||||
status: 'submitted',
|
||||
bidderId: 'hong',
|
||||
bidderName: '홍길동',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-05',
|
||||
updatedAt: '2025-01-05',
|
||||
createdBy: 'system',
|
||||
estimateId: '5',
|
||||
estimateCode: 'EST-2025-005',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
biddingCode: 'BID-2025-006',
|
||||
partnerId: '3',
|
||||
partnerName: '여의건설',
|
||||
projectName: '목동센터',
|
||||
biddingDate: '2025-01-10',
|
||||
totalCount: 12,
|
||||
biddingAmount: 78000000,
|
||||
bidDate: '2025-01-08',
|
||||
submissionDate: '2025-01-09',
|
||||
confirmDate: '2025-01-10',
|
||||
status: 'invalid',
|
||||
bidderId: 'kim',
|
||||
bidderName: '김철수',
|
||||
remarks: '입찰 조건 미충족',
|
||||
createdAt: '2025-01-06',
|
||||
updatedAt: '2025-01-06',
|
||||
createdBy: 'system',
|
||||
estimateId: '6',
|
||||
estimateCode: 'EST-2025-006',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
biddingCode: 'BID-2025-007',
|
||||
partnerId: '1',
|
||||
partnerName: '이사대표',
|
||||
projectName: '서초타워',
|
||||
biddingDate: '2025-01-08',
|
||||
totalCount: 35,
|
||||
biddingAmount: 150000000,
|
||||
bidDate: '2025-01-05',
|
||||
submissionDate: null,
|
||||
confirmDate: null,
|
||||
status: 'waiting',
|
||||
bidderId: 'lee',
|
||||
bidderName: '이영희',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-07',
|
||||
updatedAt: '2025-01-07',
|
||||
createdBy: 'system',
|
||||
estimateId: '7',
|
||||
estimateCode: 'EST-2025-007',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
biddingCode: 'BID-2025-008',
|
||||
partnerId: '2',
|
||||
partnerName: '야사건설',
|
||||
projectName: '청담프로젝트',
|
||||
biddingDate: '2025-01-05',
|
||||
totalCount: 40,
|
||||
biddingAmount: 200000000,
|
||||
bidDate: '2025-01-03',
|
||||
submissionDate: '2025-01-04',
|
||||
confirmDate: '2025-01-05',
|
||||
status: 'awarded',
|
||||
bidderId: 'hong',
|
||||
bidderName: '홍길동',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-08',
|
||||
updatedAt: '2025-01-08',
|
||||
createdBy: 'system',
|
||||
estimateId: '8',
|
||||
estimateCode: 'EST-2025-008',
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
biddingCode: 'BID-2025-009',
|
||||
partnerId: '3',
|
||||
partnerName: '여의건설',
|
||||
projectName: '잠실센터',
|
||||
biddingDate: '2025-01-03',
|
||||
totalCount: 20,
|
||||
biddingAmount: 88000000,
|
||||
bidDate: '2025-01-01',
|
||||
submissionDate: null,
|
||||
confirmDate: null,
|
||||
status: 'hold',
|
||||
bidderId: 'kim',
|
||||
bidderName: '김철수',
|
||||
remarks: '검토 대기 중',
|
||||
createdAt: '2025-01-09',
|
||||
updatedAt: '2025-01-09',
|
||||
createdBy: 'system',
|
||||
estimateId: '9',
|
||||
estimateCode: 'EST-2025-009',
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
biddingCode: 'BID-2025-010',
|
||||
partnerId: '1',
|
||||
partnerName: '이사대표',
|
||||
projectName: '역삼빌딩',
|
||||
biddingDate: '2025-01-01',
|
||||
totalCount: 10,
|
||||
biddingAmount: 65000000,
|
||||
bidDate: '2024-12-28',
|
||||
submissionDate: null,
|
||||
confirmDate: null,
|
||||
status: 'waiting',
|
||||
bidderId: 'lee',
|
||||
bidderName: '이영희',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-10',
|
||||
updatedAt: '2025-01-10',
|
||||
createdBy: 'system',
|
||||
estimateId: '10',
|
||||
estimateCode: 'EST-2025-010',
|
||||
},
|
||||
];
|
||||
|
||||
// 입찰 목록 조회
|
||||
export async function getBiddingList(filter?: BiddingFilter): Promise<{
|
||||
success: boolean;
|
||||
data?: BiddingListResponse;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
let filteredData = [...MOCK_BIDDINGS];
|
||||
|
||||
// 검색 필터
|
||||
if (filter?.search) {
|
||||
const search = filter.search.toLowerCase();
|
||||
filteredData = filteredData.filter(
|
||||
(item) =>
|
||||
item.biddingCode.toLowerCase().includes(search) ||
|
||||
item.partnerName.toLowerCase().includes(search) ||
|
||||
item.projectName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (filter?.status && filter.status !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.status === filter.status);
|
||||
}
|
||||
|
||||
// 거래처 필터
|
||||
if (filter?.partnerId && filter.partnerId !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.partnerId === filter.partnerId);
|
||||
}
|
||||
|
||||
// 입찰자 필터
|
||||
if (filter?.bidderId && filter.bidderId !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.bidderId === filter.bidderId);
|
||||
}
|
||||
|
||||
// 날짜 필터
|
||||
if (filter?.startDate) {
|
||||
filteredData = filteredData.filter(
|
||||
(item) => item.biddingDate && item.biddingDate >= filter.startDate!
|
||||
);
|
||||
}
|
||||
if (filter?.endDate) {
|
||||
filteredData = filteredData.filter(
|
||||
(item) => item.biddingDate && item.biddingDate <= filter.endDate!
|
||||
);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
const sortBy = filter?.sortBy || 'biddingDateDesc';
|
||||
switch (sortBy) {
|
||||
case 'biddingDateDesc':
|
||||
filteredData.sort((a, b) => {
|
||||
if (!a.biddingDate) return 1;
|
||||
if (!b.biddingDate) return -1;
|
||||
return new Date(b.biddingDate).getTime() - new Date(a.biddingDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'biddingDateAsc':
|
||||
filteredData.sort((a, b) => {
|
||||
if (!a.biddingDate) return 1;
|
||||
if (!b.biddingDate) return -1;
|
||||
return new Date(a.biddingDate).getTime() - new Date(b.biddingDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'submissionDateDesc':
|
||||
filteredData.sort((a, b) => {
|
||||
if (!a.submissionDate) return 1;
|
||||
if (!b.submissionDate) return -1;
|
||||
return new Date(b.submissionDate).getTime() - new Date(a.submissionDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'confirmDateDesc':
|
||||
filteredData.sort((a, b) => {
|
||||
if (!a.confirmDate) return 1;
|
||||
if (!b.confirmDate) return -1;
|
||||
return new Date(b.confirmDate).getTime() - new Date(a.confirmDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
filteredData.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
filteredData.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'projectNameAsc':
|
||||
filteredData.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
|
||||
break;
|
||||
case 'projectNameDesc':
|
||||
filteredData.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
|
||||
break;
|
||||
}
|
||||
|
||||
// 페이지네이션
|
||||
const page = filter?.page || 1;
|
||||
const size = filter?.size || 20;
|
||||
const startIndex = (page - 1) * size;
|
||||
const paginatedData = filteredData.slice(startIndex, startIndex + size);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: paginatedData,
|
||||
total: filteredData.length,
|
||||
page,
|
||||
size,
|
||||
totalPages: Math.ceil(filteredData.length / size),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getBiddingList error:', error);
|
||||
return { success: false, error: '입찰 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 입찰 통계 조회
|
||||
export async function getBiddingStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: BiddingStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const stats: BiddingStats = {
|
||||
total: MOCK_BIDDINGS.length,
|
||||
waiting: MOCK_BIDDINGS.filter((b) => b.status === 'waiting').length,
|
||||
awarded: MOCK_BIDDINGS.filter((b) => b.status === 'awarded').length,
|
||||
};
|
||||
|
||||
return { success: true, data: stats };
|
||||
} catch (error) {
|
||||
console.error('getBiddingStats error:', error);
|
||||
return { success: false, error: '통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 입찰 단건 조회
|
||||
export async function getBidding(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: Bidding;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const bidding = MOCK_BIDDINGS.find((b) => b.id === id);
|
||||
if (!bidding) {
|
||||
return { success: false, error: '입찰 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: bidding };
|
||||
} catch (error) {
|
||||
console.error('getBidding error:', error);
|
||||
return { success: false, error: '입찰 정보를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 입찰 삭제
|
||||
export async function deleteBidding(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const index = MOCK_BIDDINGS.findIndex((b) => b.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '입찰 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('deleteBidding error:', error);
|
||||
return { success: false, error: '입찰 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 입찰 일괄 삭제
|
||||
export async function deleteBiddings(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('deleteBiddings error:', error);
|
||||
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 공과 상세 목업 데이터
|
||||
const MOCK_EXPENSE_ITEMS: ExpenseItem[] = [
|
||||
{ id: '1', name: '설계비', amount: 5000000 },
|
||||
{ id: '2', name: '운반비', amount: 3000000 },
|
||||
{ id: '3', name: '기타경비', amount: 2000000 },
|
||||
];
|
||||
|
||||
// 견적 상세 목업 데이터
|
||||
const MOCK_ESTIMATE_DETAIL_ITEMS: EstimateDetailItem[] = [
|
||||
{
|
||||
id: '1',
|
||||
no: 1,
|
||||
name: '방화문',
|
||||
material: 'SUS304',
|
||||
width: 1000,
|
||||
height: 2100,
|
||||
quantity: 10,
|
||||
box: 2,
|
||||
coating: 1,
|
||||
batting: 0,
|
||||
mounting: 1,
|
||||
shift: 0,
|
||||
painting: 1,
|
||||
motor: 0,
|
||||
controller: 0,
|
||||
unitPrice: 1500000,
|
||||
expense: 100000,
|
||||
expenseQuantity: 10,
|
||||
totalPrice: 16000000,
|
||||
marginRate: 15,
|
||||
marginCost: 2400000,
|
||||
progressPayment: 8000000,
|
||||
execution: 13600000,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
no: 2,
|
||||
name: '자동문',
|
||||
material: 'AL',
|
||||
width: 1800,
|
||||
height: 2400,
|
||||
quantity: 5,
|
||||
box: 1,
|
||||
coating: 1,
|
||||
batting: 1,
|
||||
mounting: 1,
|
||||
shift: 1,
|
||||
painting: 0,
|
||||
motor: 1,
|
||||
controller: 1,
|
||||
unitPrice: 3500000,
|
||||
expense: 200000,
|
||||
expenseQuantity: 5,
|
||||
totalPrice: 18500000,
|
||||
marginRate: 18,
|
||||
marginCost: 3330000,
|
||||
progressPayment: 9250000,
|
||||
execution: 15170000,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
no: 3,
|
||||
name: '셔터',
|
||||
material: 'STEEL',
|
||||
width: 3000,
|
||||
height: 3500,
|
||||
quantity: 3,
|
||||
box: 1,
|
||||
coating: 1,
|
||||
batting: 0,
|
||||
mounting: 1,
|
||||
shift: 0,
|
||||
painting: 1,
|
||||
motor: 1,
|
||||
controller: 1,
|
||||
unitPrice: 8000000,
|
||||
expense: 500000,
|
||||
expenseQuantity: 3,
|
||||
totalPrice: 25500000,
|
||||
marginRate: 20,
|
||||
marginCost: 5100000,
|
||||
progressPayment: 12750000,
|
||||
execution: 20400000,
|
||||
},
|
||||
];
|
||||
|
||||
// 입찰 상세 조회
|
||||
export async function getBiddingDetail(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: BiddingDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const bidding = MOCK_BIDDINGS.find((b) => b.id === id);
|
||||
if (!bidding) {
|
||||
return { success: false, error: '입찰 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
// 상세 데이터 생성
|
||||
const biddingDetail: BiddingDetail = {
|
||||
...bidding,
|
||||
constructionStartDate: '2025-02-01',
|
||||
constructionEndDate: '2025-04-30',
|
||||
vatType: 'excluded',
|
||||
expenseItems: MOCK_EXPENSE_ITEMS,
|
||||
estimateDetailItems: MOCK_ESTIMATE_DETAIL_ITEMS,
|
||||
};
|
||||
|
||||
return { success: true, data: biddingDetail };
|
||||
} catch (error) {
|
||||
console.error('getBiddingDetail error:', error);
|
||||
return { success: false, error: '입찰 상세를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 입찰 수정
|
||||
export async function updateBidding(
|
||||
id: string,
|
||||
data: Partial<BiddingDetailFormData>
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const index = MOCK_BIDDINGS.findIndex((b) => b.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '입찰 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
// 목업에서는 실제 업데이트하지 않음
|
||||
console.log('Updating bidding:', id, data);
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('updateBidding error:', error);
|
||||
return { success: false, error: '입찰 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
4
src/components/business/juil/bidding/index.ts
Normal file
4
src/components/business/juil/bidding/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { default as BiddingListClient } from './BiddingListClient';
|
||||
export { default as BiddingDetailForm } from './BiddingDetailForm';
|
||||
export * from './types';
|
||||
export * from './actions';
|
||||
263
src/components/business/juil/bidding/types.ts
Normal file
263
src/components/business/juil/bidding/types.ts
Normal file
@@ -0,0 +1,263 @@
|
||||
/**
|
||||
* 주일 기업 - 입찰관리 타입 정의
|
||||
*
|
||||
* 입찰 데이터는 견적 상세에서 견적완료 시 자동 등록됨
|
||||
* (별도 등록 기능 없음, 상세/수정만 가능)
|
||||
*/
|
||||
|
||||
// 입찰 상태
|
||||
export type BiddingStatus =
|
||||
| 'waiting' // 입찰대기
|
||||
| 'submitted' // 투찰
|
||||
| 'failed' // 탈락
|
||||
| 'invalid' // 유찰
|
||||
| 'awarded' // 낙찰
|
||||
| 'hold'; // 보류
|
||||
|
||||
// 입찰 타입
|
||||
export interface Bidding {
|
||||
id: string;
|
||||
biddingCode: string; // 입찰번호
|
||||
|
||||
// 기본 정보
|
||||
partnerId: string; // 거래처 ID
|
||||
partnerName: string; // 거래처명
|
||||
projectName: string; // 현장명
|
||||
|
||||
// 입찰 정보
|
||||
biddingDate: string | null; // 입찰일자
|
||||
totalCount: number; // 총 개소
|
||||
biddingAmount: number; // 입찰금액
|
||||
bidDate: string | null; // 입찰일
|
||||
submissionDate: string | null; // 투찰일
|
||||
confirmDate: string | null; // 확정일
|
||||
|
||||
// 상태 정보
|
||||
status: BiddingStatus;
|
||||
|
||||
// 입찰자
|
||||
bidderId: string;
|
||||
bidderName: string;
|
||||
|
||||
// 비고
|
||||
remarks: string;
|
||||
|
||||
// 메타 정보
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy: string;
|
||||
|
||||
// 연결된 견적 정보 (견적완료 시 자동 연결)
|
||||
estimateId: string;
|
||||
estimateCode: string;
|
||||
}
|
||||
|
||||
// 입찰 통계
|
||||
export interface BiddingStats {
|
||||
total: number; // 전체 입찰
|
||||
waiting: number; // 입찰대기
|
||||
awarded: number; // 낙찰
|
||||
}
|
||||
|
||||
// 입찰 필터
|
||||
export interface BiddingFilter {
|
||||
search?: string;
|
||||
status?: BiddingStatus | 'all';
|
||||
partnerId?: string;
|
||||
bidderId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
sortBy?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// API 응답 타입
|
||||
export interface BiddingListResponse {
|
||||
items: Bidding[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// 상태 옵션
|
||||
export const BIDDING_STATUS_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'waiting', label: '입찰대기' },
|
||||
{ value: 'submitted', label: '투찰' },
|
||||
{ value: 'failed', label: '탈락' },
|
||||
{ value: 'invalid', label: '유찰' },
|
||||
{ value: 'awarded', label: '낙찰' },
|
||||
{ value: 'hold', label: '보류' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
export const BIDDING_SORT_OPTIONS = [
|
||||
{ value: 'biddingDateDesc', label: '최신순 (입찰일)' },
|
||||
{ value: 'biddingDateAsc', label: '등록순 (입찰일)' },
|
||||
{ value: 'submissionDateDesc', label: '투찰일 최신순' },
|
||||
{ value: 'confirmDateDesc', label: '확정일 최신순' },
|
||||
{ value: 'partnerNameAsc', label: '거래처명 오름차순' },
|
||||
{ value: 'partnerNameDesc', label: '거래처명 내림차순' },
|
||||
{ value: 'projectNameAsc', label: '현장명 오름차순' },
|
||||
{ value: 'projectNameDesc', label: '현장명 내림차순' },
|
||||
];
|
||||
|
||||
// 상태별 스타일
|
||||
export const BIDDING_STATUS_STYLES: Record<BiddingStatus, string> = {
|
||||
waiting: 'text-orange-500 font-medium',
|
||||
submitted: 'text-blue-500 font-medium',
|
||||
failed: 'text-red-500 font-medium',
|
||||
invalid: 'text-gray-500 font-medium',
|
||||
awarded: 'text-green-600 font-medium',
|
||||
hold: 'text-gray-400 font-medium',
|
||||
};
|
||||
|
||||
export const BIDDING_STATUS_LABELS: Record<BiddingStatus, string> = {
|
||||
waiting: '입찰대기',
|
||||
submitted: '투찰',
|
||||
failed: '탈락',
|
||||
invalid: '유찰',
|
||||
awarded: '낙찰',
|
||||
hold: '보류',
|
||||
};
|
||||
|
||||
// =====================================================
|
||||
// 입찰 상세 관련 타입
|
||||
// =====================================================
|
||||
|
||||
// 공과 항목 (견적에서 가져옴)
|
||||
export interface ExpenseItem {
|
||||
id: string;
|
||||
name: string; // 공과명
|
||||
amount: number; // 금액
|
||||
}
|
||||
|
||||
// 견적 상세 항목 (견적에서 가져옴 - estimates와 동일한 구조)
|
||||
export interface EstimateDetailItem {
|
||||
id: string;
|
||||
no: number; // 번호
|
||||
name: string; // 명칭
|
||||
material: string; // 제품
|
||||
width: number; // 가로 (M)
|
||||
height: number; // 세로 (M)
|
||||
quantity: number; // 수량
|
||||
|
||||
// 계산값들
|
||||
weight: number; // 무게 = 면적*25
|
||||
area: number; // 면적 = (가로×0.16)*(세로×0.5)
|
||||
steelScreen: number; // 철제,스크린 = 면적*47500
|
||||
caulking: number; // 코킹 = (세로*4)*단가
|
||||
rail: number; // 레일 = (세로×0.2)*단가
|
||||
bottom: number; // 하장 = 가로*단가
|
||||
boxReinforce: number; // 박스+보강 = 가로*단가
|
||||
shaft: number; // 샤프트 = 가로*단가
|
||||
painting: number; // 도장 (셀렉트)
|
||||
motor: number; // 모터 (셀렉트)
|
||||
controller: number; // 제어기 (셀렉트)
|
||||
widthConstruction: number; // 가로시공비
|
||||
heightConstruction: number; // 세로시공비
|
||||
unitPrice: number; // 단가
|
||||
expenseRate: number; // 공과율
|
||||
expense: number; // 공과
|
||||
cost: number; // 원가
|
||||
costExecution: number; // 원가실행
|
||||
marginCost: number; // 마진원가
|
||||
marginCostExecution: number; // 마진원가실행
|
||||
expenseExecution: number; // 공과실행
|
||||
}
|
||||
|
||||
// 입찰 상세 전체 데이터
|
||||
export interface BiddingDetail extends Bidding {
|
||||
// 공사기간
|
||||
constructionStartDate: string;
|
||||
constructionEndDate: string;
|
||||
|
||||
// 부가세
|
||||
vatType: string;
|
||||
|
||||
// 공과 상세 (견적에서 가져옴 - 읽기 전용)
|
||||
expenseItems: ExpenseItem[];
|
||||
|
||||
// 견적 상세 (견적에서 가져옴 - 읽기 전용)
|
||||
estimateDetailItems: EstimateDetailItem[];
|
||||
}
|
||||
|
||||
// 입찰 상세 폼 데이터 (수정용)
|
||||
export interface BiddingDetailFormData {
|
||||
// 입찰 정보
|
||||
biddingCode: string;
|
||||
bidderId: string;
|
||||
bidderName: string;
|
||||
partnerName: string;
|
||||
projectName: string;
|
||||
biddingDate: string;
|
||||
totalCount: number;
|
||||
constructionStartDate: string;
|
||||
constructionEndDate: string;
|
||||
vatType: string;
|
||||
biddingAmount: number;
|
||||
status: BiddingStatus;
|
||||
submissionDate: string;
|
||||
confirmDate: string;
|
||||
remarks: string;
|
||||
|
||||
// 공과 상세 (읽기 전용)
|
||||
expenseItems: ExpenseItem[];
|
||||
|
||||
// 견적 상세 (읽기 전용)
|
||||
estimateDetailItems: EstimateDetailItem[];
|
||||
}
|
||||
|
||||
// 부가세 옵션
|
||||
export const VAT_TYPE_OPTIONS = [
|
||||
{ value: 'included', label: '부가세 포함' },
|
||||
{ value: 'excluded', label: '부가세 별도' },
|
||||
];
|
||||
|
||||
// 빈 폼 데이터 생성
|
||||
export function getEmptyBiddingDetailFormData(): BiddingDetailFormData {
|
||||
return {
|
||||
biddingCode: '',
|
||||
bidderId: '',
|
||||
bidderName: '',
|
||||
partnerName: '',
|
||||
projectName: '',
|
||||
biddingDate: '',
|
||||
totalCount: 0,
|
||||
constructionStartDate: '',
|
||||
constructionEndDate: '',
|
||||
vatType: 'excluded',
|
||||
biddingAmount: 0,
|
||||
status: 'waiting',
|
||||
submissionDate: '',
|
||||
confirmDate: '',
|
||||
remarks: '',
|
||||
expenseItems: [],
|
||||
estimateDetailItems: [],
|
||||
};
|
||||
}
|
||||
|
||||
// BiddingDetail을 FormData로 변환
|
||||
export function biddingDetailToFormData(detail: BiddingDetail): BiddingDetailFormData {
|
||||
return {
|
||||
biddingCode: detail.biddingCode,
|
||||
bidderId: detail.bidderId,
|
||||
bidderName: detail.bidderName,
|
||||
partnerName: detail.partnerName,
|
||||
projectName: detail.projectName,
|
||||
biddingDate: detail.biddingDate || '',
|
||||
totalCount: detail.totalCount,
|
||||
constructionStartDate: detail.constructionStartDate,
|
||||
constructionEndDate: detail.constructionEndDate,
|
||||
vatType: detail.vatType,
|
||||
biddingAmount: detail.biddingAmount,
|
||||
status: detail.status,
|
||||
submissionDate: detail.submissionDate || '',
|
||||
confirmDate: detail.confirmDate || '',
|
||||
remarks: detail.remarks,
|
||||
expenseItems: detail.expenseItems,
|
||||
estimateDetailItems: detail.estimateDetailItems,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import type { CategoryDialogProps } from './types';
|
||||
|
||||
/**
|
||||
* 카테고리 추가/수정 다이얼로그
|
||||
*/
|
||||
export function CategoryDialog({
|
||||
isOpen,
|
||||
onOpenChange,
|
||||
mode,
|
||||
category,
|
||||
onSubmit,
|
||||
isLoading = false,
|
||||
}: CategoryDialogProps) {
|
||||
const [name, setName] = useState('');
|
||||
|
||||
// 다이얼로그 열릴 때 초기값 설정
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
if (mode === 'edit' && category) {
|
||||
setName(category.name);
|
||||
} else {
|
||||
setName('');
|
||||
}
|
||||
}
|
||||
}, [isOpen, mode, category]);
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (name.trim()) {
|
||||
onSubmit(name.trim());
|
||||
setName('');
|
||||
}
|
||||
};
|
||||
|
||||
const title = mode === 'add' ? '카테고리 추가' : '카테고리 수정';
|
||||
const submitText = mode === 'add' ? '등록' : '수정';
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>{title}</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<form onSubmit={handleSubmit}>
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 카테고리명 입력 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category-name">카테고리명</Label>
|
||||
<Input
|
||||
id="category-name"
|
||||
value={name}
|
||||
onChange={(e) => setName(e.target.value)}
|
||||
placeholder="카테고리를 입력해주세요"
|
||||
autoFocus
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
|
||||
취소
|
||||
</Button>
|
||||
<Button type="submit" disabled={!name.trim() || isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : null}
|
||||
{submitText}
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
145
src/components/business/juil/category-management/actions.ts
Normal file
145
src/components/business/juil/category-management/actions.ts
Normal file
@@ -0,0 +1,145 @@
|
||||
'use server';
|
||||
|
||||
import type { Category } from './types';
|
||||
|
||||
// ===== 목데이터 (추후 API 연동 시 교체) =====
|
||||
let mockCategories: Category[] = [
|
||||
{ id: '1', name: '슬라이드 OPEN 사이즈', order: 1, isDefault: true },
|
||||
{ id: '2', name: '모터', order: 2, isDefault: true },
|
||||
{ id: '3', name: '공정자재', order: 3, isDefault: true },
|
||||
{ id: '4', name: '철물', order: 4, isDefault: true },
|
||||
];
|
||||
|
||||
// 다음 ID 생성
|
||||
let nextId = 5;
|
||||
|
||||
// ===== 카테고리 목록 조회 =====
|
||||
export async function getCategories(): Promise<{
|
||||
success: boolean;
|
||||
data?: Category[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 목데이터 반환 (순서대로 정렬)
|
||||
const sortedCategories = [...mockCategories].sort((a, b) => a.order - b.order);
|
||||
return { success: true, data: sortedCategories };
|
||||
} catch (error) {
|
||||
console.error('[getCategories] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 카테고리 생성 =====
|
||||
export async function createCategory(data: {
|
||||
name: string;
|
||||
}): Promise<{
|
||||
success: boolean;
|
||||
data?: Category;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const newCategory: Category = {
|
||||
id: String(nextId++),
|
||||
name: data.name,
|
||||
order: mockCategories.length + 1,
|
||||
isDefault: false,
|
||||
};
|
||||
|
||||
mockCategories.push(newCategory);
|
||||
return { success: true, data: newCategory };
|
||||
} catch (error) {
|
||||
console.error('[createCategory] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 카테고리 수정 =====
|
||||
export async function updateCategory(
|
||||
id: string,
|
||||
data: { name?: string }
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: Category;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const index = mockCategories.findIndex(c => c.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '카테고리를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
mockCategories[index] = {
|
||||
...mockCategories[index],
|
||||
...data,
|
||||
};
|
||||
|
||||
return { success: true, data: mockCategories[index] };
|
||||
} catch (error) {
|
||||
console.error('[updateCategory] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 카테고리 삭제 =====
|
||||
export async function deleteCategory(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
errorType?: 'IN_USE' | 'DEFAULT' | 'GENERAL';
|
||||
}> {
|
||||
try {
|
||||
const category = mockCategories.find(c => c.id === id);
|
||||
|
||||
if (!category) {
|
||||
return { success: false, error: '카테고리를 찾을 수 없습니다.', errorType: 'GENERAL' };
|
||||
}
|
||||
|
||||
// 기본 카테고리는 삭제 불가
|
||||
if (category.isDefault) {
|
||||
return {
|
||||
success: false,
|
||||
error: '기본 카테고리는 삭제가 불가합니다.',
|
||||
errorType: 'DEFAULT'
|
||||
};
|
||||
}
|
||||
|
||||
// TODO: 품목 사용 여부 체크 로직 (추후 API 연동 시)
|
||||
// 현재는 목데이터이므로 사용 중인 품목이 없다고 가정
|
||||
// const itemsUsingCategory = await checkItemsUsingCategory(id);
|
||||
// if (itemsUsingCategory.length > 0) {
|
||||
// return {
|
||||
// success: false,
|
||||
// error: `"${category.name}"을(를) 사용하고 있는 품목이 있습니다. 모두 변경 후 삭제가 가능합니다.`,
|
||||
// errorType: 'IN_USE'
|
||||
// };
|
||||
// }
|
||||
|
||||
mockCategories = mockCategories.filter(c => c.id !== id);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[deleteCategory] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.', errorType: 'GENERAL' };
|
||||
}
|
||||
}
|
||||
|
||||
// ===== 카테고리 순서 변경 =====
|
||||
export async function reorderCategories(
|
||||
items: { id: string; sort_order: number }[]
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
// 순서 업데이트
|
||||
items.forEach(item => {
|
||||
const category = mockCategories.find(c => c.id === item.id);
|
||||
if (category) {
|
||||
category.order = item.sort_order;
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('[reorderCategories] Error:', error);
|
||||
return { success: false, error: '서버 오류가 발생했습니다.' };
|
||||
}
|
||||
}
|
||||
372
src/components/business/juil/category-management/index.tsx
Normal file
372
src/components/business/juil/category-management/index.tsx
Normal file
@@ -0,0 +1,372 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { FolderTree, Plus, GripVertical, Pencil, Trash2, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { CategoryDialog } from './CategoryDialog';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { toast } from 'sonner';
|
||||
import type { Category } from './types';
|
||||
import {
|
||||
getCategories,
|
||||
createCategory,
|
||||
updateCategory,
|
||||
deleteCategory,
|
||||
reorderCategories,
|
||||
} from './actions';
|
||||
|
||||
export function CategoryManagement() {
|
||||
// 카테고리 데이터
|
||||
const [categories, setCategories] = useState<Category[]>([]);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
||||
|
||||
// 입력 필드
|
||||
const [newCategoryName, setNewCategoryName] = useState('');
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [dialogOpen, setDialogOpen] = useState(false);
|
||||
const [dialogMode, setDialogMode] = useState<'add' | 'edit'>('add');
|
||||
const [selectedCategory, setSelectedCategory] = useState<Category | undefined>();
|
||||
|
||||
// 삭제 확인 다이얼로그
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [categoryToDelete, setCategoryToDelete] = useState<Category | null>(null);
|
||||
|
||||
// 드래그 상태
|
||||
const [draggedItem, setDraggedItem] = useState<number | null>(null);
|
||||
|
||||
// 데이터 로드
|
||||
const loadCategories = useCallback(async () => {
|
||||
try {
|
||||
setIsLoading(true);
|
||||
const result = await getCategories();
|
||||
if (result.success && result.data) {
|
||||
setCategories(result.data);
|
||||
} else {
|
||||
toast.error(result.error || '카테고리 목록을 불러오는데 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('카테고리 목록 조회 실패:', error);
|
||||
toast.error('카테고리 목록을 불러오는데 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 초기 데이터 로드
|
||||
useEffect(() => {
|
||||
loadCategories();
|
||||
}, [loadCategories]);
|
||||
|
||||
// 카테고리 추가 (입력 필드에서 직접)
|
||||
const handleQuickAdd = async () => {
|
||||
if (!newCategoryName.trim() || isSubmitting) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const result = await createCategory({ name: newCategoryName.trim() });
|
||||
if (result.success && result.data) {
|
||||
setCategories(prev => [...prev, result.data!]);
|
||||
setNewCategoryName('');
|
||||
toast.success('카테고리가 추가되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '카테고리 추가에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('카테고리 추가 실패:', error);
|
||||
toast.error('카테고리 추가에 실패했습니다.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
// 카테고리 수정 다이얼로그 열기
|
||||
const handleEdit = (category: Category) => {
|
||||
setSelectedCategory(category);
|
||||
setDialogMode('edit');
|
||||
setDialogOpen(true);
|
||||
};
|
||||
|
||||
// 카테고리 삭제 확인
|
||||
const handleDelete = (category: Category) => {
|
||||
setCategoryToDelete(category);
|
||||
setDeleteDialogOpen(true);
|
||||
};
|
||||
|
||||
// 삭제 실행
|
||||
const confirmDelete = async () => {
|
||||
if (!categoryToDelete || isSubmitting) return;
|
||||
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const result = await deleteCategory(categoryToDelete.id);
|
||||
if (result.success) {
|
||||
setCategories(prev => prev.filter(c => c.id !== categoryToDelete.id));
|
||||
toast.success('카테고리가 삭제되었습니다.');
|
||||
} else {
|
||||
// 삭제 실패 유형에 따른 메시지
|
||||
toast.error(result.error || '카테고리 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('카테고리 삭제 실패:', error);
|
||||
toast.error('카테고리 삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setCategoryToDelete(null);
|
||||
}
|
||||
};
|
||||
|
||||
// 다이얼로그 제출
|
||||
const handleDialogSubmit = async (name: string) => {
|
||||
if (dialogMode === 'edit' && selectedCategory) {
|
||||
try {
|
||||
setIsSubmitting(true);
|
||||
const result = await updateCategory(selectedCategory.id, { name });
|
||||
if (result.success) {
|
||||
setCategories(prev => prev.map(c =>
|
||||
c.id === selectedCategory.id ? { ...c, name } : c
|
||||
));
|
||||
toast.success('카테고리가 수정되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '카테고리 수정에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('카테고리 수정 실패:', error);
|
||||
toast.error('카테고리 수정에 실패했습니다.');
|
||||
} finally {
|
||||
setIsSubmitting(false);
|
||||
}
|
||||
}
|
||||
setDialogOpen(false);
|
||||
};
|
||||
|
||||
// 드래그 시작
|
||||
const handleDragStart = (e: React.DragEvent, index: number) => {
|
||||
setDraggedItem(index);
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
};
|
||||
|
||||
// 드래그 종료 - 서버에 순서 저장
|
||||
const handleDragEnd = async () => {
|
||||
if (draggedItem === null) return;
|
||||
|
||||
setDraggedItem(null);
|
||||
|
||||
// 순서 변경 API 호출
|
||||
try {
|
||||
const items = categories.map((category, idx) => ({
|
||||
id: category.id,
|
||||
sort_order: idx + 1,
|
||||
}));
|
||||
const result = await reorderCategories(items);
|
||||
if (result.success) {
|
||||
toast.success('순서가 변경되었습니다.');
|
||||
} else {
|
||||
toast.error(result.error || '순서 변경에 실패했습니다.');
|
||||
loadCategories();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('순서 변경 실패:', error);
|
||||
toast.error('순서 변경에 실패했습니다.');
|
||||
// 실패시 원래 순서로 복구
|
||||
loadCategories();
|
||||
}
|
||||
};
|
||||
|
||||
// 드래그 오버
|
||||
const handleDragOver = (e: React.DragEvent, index: number) => {
|
||||
e.preventDefault();
|
||||
if (draggedItem === null || draggedItem === index) return;
|
||||
|
||||
const newCategories = [...categories];
|
||||
const draggedCategory = newCategories[draggedItem];
|
||||
newCategories.splice(draggedItem, 1);
|
||||
newCategories.splice(index, 0, draggedCategory);
|
||||
|
||||
// 순서 업데이트 (로컬)
|
||||
const reorderedCategories = newCategories.map((category, idx) => ({
|
||||
...category,
|
||||
order: idx + 1
|
||||
}));
|
||||
|
||||
setCategories(reorderedCategories);
|
||||
setDraggedItem(index);
|
||||
};
|
||||
|
||||
// 키보드로 추가 (한글 IME 조합 중에는 무시)
|
||||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
|
||||
handleQuickAdd();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="카테고리관리"
|
||||
description="카테고리를 등록하고 관리합니다. 드래그하여 순서를 변경할 수 있습니다."
|
||||
icon={FolderTree}
|
||||
/>
|
||||
|
||||
<div className="space-y-4">
|
||||
{/* 카테고리 추가 입력 영역 */}
|
||||
<Card>
|
||||
<CardContent className="p-4">
|
||||
<div className="flex gap-2">
|
||||
<Input
|
||||
value={newCategoryName}
|
||||
onChange={(e) => setNewCategoryName(e.target.value)}
|
||||
onKeyDown={handleKeyDown}
|
||||
placeholder="카테고리를 입력해주세요"
|
||||
className="flex-1"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
<Button
|
||||
onClick={handleQuickAdd}
|
||||
disabled={!newCategoryName.trim() || isSubmitting}
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : (
|
||||
<Plus className="h-4 w-4 mr-2" />
|
||||
)}
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 카테고리 목록 */}
|
||||
<Card>
|
||||
<CardContent className="p-0">
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center py-8">
|
||||
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
|
||||
<span className="ml-2 text-muted-foreground">로딩 중...</span>
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{categories.map((category, index) => (
|
||||
<div
|
||||
key={category.id}
|
||||
draggable
|
||||
onDragStart={(e) => handleDragStart(e, index)}
|
||||
onDragEnd={handleDragEnd}
|
||||
onDragOver={(e) => handleDragOver(e, index)}
|
||||
className={`flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors cursor-move ${
|
||||
draggedItem === index ? 'opacity-50 bg-muted' : ''
|
||||
}`}
|
||||
>
|
||||
{/* 드래그 핸들 */}
|
||||
<GripVertical className="h-5 w-5 text-muted-foreground flex-shrink-0" />
|
||||
|
||||
{/* 순서 번호 */}
|
||||
<span className="text-sm text-muted-foreground w-8">
|
||||
{index + 1}
|
||||
</span>
|
||||
|
||||
{/* 카테고리명 */}
|
||||
<span className="flex-1 font-medium">{category.name}</span>
|
||||
|
||||
{/* 액션 버튼 */}
|
||||
<div className="flex gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleEdit(category)}
|
||||
className="h-8 w-8 p-0"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
<span className="sr-only">수정</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDelete(category)}
|
||||
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
|
||||
disabled={isSubmitting}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
<span className="sr-only">삭제</span>
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{categories.length === 0 && (
|
||||
<div className="px-4 py-8 text-center text-muted-foreground">
|
||||
등록된 카테고리가 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 안내 문구 */}
|
||||
<p className="text-sm text-muted-foreground">
|
||||
※ 카테고리 순서는 드래그 앤 드롭으로 변경할 수 있습니다.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 수정 다이얼로그 */}
|
||||
<CategoryDialog
|
||||
isOpen={dialogOpen}
|
||||
onOpenChange={setDialogOpen}
|
||||
mode={dialogMode}
|
||||
category={selectedCategory}
|
||||
onSubmit={handleDialogSubmit}
|
||||
isLoading={isSubmitting}
|
||||
/>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>카테고리 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
"{categoryToDelete?.name}" 카테고리를 삭제하시겠습니까?
|
||||
{categoryToDelete?.isDefault && (
|
||||
<>
|
||||
<br />
|
||||
<span className="text-destructive font-medium">
|
||||
기본 카테고리는 삭제가 불가합니다.
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={isSubmitting}>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={confirmDelete}
|
||||
disabled={isSubmitting || categoryToDelete?.isDefault}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
{isSubmitting ? (
|
||||
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||
) : null}
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
21
src/components/business/juil/category-management/types.ts
Normal file
21
src/components/business/juil/category-management/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* 카테고리 타입 정의
|
||||
*/
|
||||
export interface Category {
|
||||
id: string;
|
||||
name: string;
|
||||
order: number;
|
||||
isDefault?: boolean;
|
||||
isActive?: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
}
|
||||
|
||||
export interface CategoryDialogProps {
|
||||
isOpen: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode: 'add' | 'edit';
|
||||
category?: Category;
|
||||
onSubmit: (name: string) => void;
|
||||
isLoading?: boolean;
|
||||
}
|
||||
6
src/components/business/juil/common/index.ts
Normal file
6
src/components/business/juil/common/index.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
// Types
|
||||
export type { ApprovalPerson, ElectronicApproval } from './types';
|
||||
export { getEmptyElectronicApproval } from './types';
|
||||
|
||||
// Modals
|
||||
export { ElectronicApprovalModal } from './modals';
|
||||
@@ -0,0 +1,298 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogFooter,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { ElectronicApproval, ApprovalPerson } from '../types';
|
||||
|
||||
// 목업 부서 목록
|
||||
const MOCK_DEPARTMENTS = [
|
||||
{ value: 'sales', label: '영업부' },
|
||||
{ value: 'production', label: '생산부' },
|
||||
{ value: 'quality', label: '품질부' },
|
||||
{ value: 'management', label: '경영지원부' },
|
||||
];
|
||||
|
||||
// 목업 직책 목록
|
||||
const MOCK_POSITIONS = [
|
||||
{ value: 'staff', label: '사원' },
|
||||
{ value: 'senior', label: '주임' },
|
||||
{ value: 'assistant_manager', label: '대리' },
|
||||
{ value: 'manager', label: '과장' },
|
||||
{ value: 'deputy_manager', label: '차장' },
|
||||
{ value: 'general_manager', label: '부장' },
|
||||
{ value: 'director', label: '이사' },
|
||||
{ value: 'ceo', label: '대표' },
|
||||
];
|
||||
|
||||
// 목업 사원 목록
|
||||
const MOCK_EMPLOYEES = [
|
||||
{ value: 'hong', label: '홍길동' },
|
||||
{ value: 'kim', label: '김철수' },
|
||||
{ value: 'lee', label: '이영희' },
|
||||
{ value: 'park', label: '박지영' },
|
||||
{ value: 'choi', label: '최민수' },
|
||||
];
|
||||
|
||||
interface ElectronicApprovalModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
approval: ElectronicApproval;
|
||||
onSave: (approval: ElectronicApproval) => void;
|
||||
}
|
||||
|
||||
export function ElectronicApprovalModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
approval,
|
||||
onSave,
|
||||
}: ElectronicApprovalModalProps) {
|
||||
const [localApproval, setLocalApproval] = useState<ElectronicApproval>(approval);
|
||||
|
||||
// 결재자 추가
|
||||
const handleAddApprover = useCallback(() => {
|
||||
const newPerson: ApprovalPerson = {
|
||||
id: String(Date.now()),
|
||||
department: '',
|
||||
position: '',
|
||||
name: '',
|
||||
};
|
||||
setLocalApproval((prev) => ({
|
||||
...prev,
|
||||
approvers: [...prev.approvers, newPerson],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 결재자 삭제
|
||||
const handleRemoveApprover = useCallback((personId: string) => {
|
||||
setLocalApproval((prev) => ({
|
||||
...prev,
|
||||
approvers: prev.approvers.filter((p) => p.id !== personId),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 결재자 변경
|
||||
const handleApproverChange = useCallback(
|
||||
(personId: string, field: keyof ApprovalPerson, value: string) => {
|
||||
setLocalApproval((prev) => ({
|
||||
...prev,
|
||||
approvers: prev.approvers.map((p) =>
|
||||
p.id === personId ? { ...p, [field]: value } : p
|
||||
),
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 참조자 추가
|
||||
const handleAddReference = useCallback(() => {
|
||||
const newPerson: ApprovalPerson = {
|
||||
id: String(Date.now()),
|
||||
department: '',
|
||||
position: '',
|
||||
name: '',
|
||||
};
|
||||
setLocalApproval((prev) => ({
|
||||
...prev,
|
||||
references: [...prev.references, newPerson],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 참조자 삭제
|
||||
const handleRemoveReference = useCallback((personId: string) => {
|
||||
setLocalApproval((prev) => ({
|
||||
...prev,
|
||||
references: prev.references.filter((p) => p.id !== personId),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 참조자 변경
|
||||
const handleReferenceChange = useCallback(
|
||||
(personId: string, field: keyof ApprovalPerson, value: string) => {
|
||||
setLocalApproval((prev) => ({
|
||||
...prev,
|
||||
references: prev.references.map((p) =>
|
||||
p.id === personId ? { ...p, [field]: value } : p
|
||||
),
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 저장
|
||||
const handleSave = useCallback(() => {
|
||||
onSave(localApproval);
|
||||
}, [localApproval, onSave]);
|
||||
|
||||
// 취소
|
||||
const handleCancel = useCallback(() => {
|
||||
setLocalApproval(approval);
|
||||
onClose();
|
||||
}, [approval, onClose]);
|
||||
|
||||
// 사람 선택 행 렌더링
|
||||
const renderPersonRow = (
|
||||
person: ApprovalPerson,
|
||||
index: number,
|
||||
type: 'approver' | 'reference'
|
||||
) => {
|
||||
const onChange =
|
||||
type === 'approver' ? handleApproverChange : handleReferenceChange;
|
||||
const onRemove =
|
||||
type === 'approver' ? handleRemoveApprover : handleRemoveReference;
|
||||
|
||||
return (
|
||||
<div key={person.id} className="flex items-center gap-2">
|
||||
<Select
|
||||
value={person.department || undefined}
|
||||
onValueChange={(val) => onChange(person.id, 'department', val)}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="부서명" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_DEPARTMENTS.map((dept) => (
|
||||
<SelectItem key={dept.value} value={dept.value}>
|
||||
{dept.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-gray-400">/</span>
|
||||
<Select
|
||||
value={person.position || undefined}
|
||||
onValueChange={(val) => onChange(person.id, 'position', val)}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="직책명" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_POSITIONS.map((pos) => (
|
||||
<SelectItem key={pos.value} value={pos.value}>
|
||||
{pos.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-gray-400">/</span>
|
||||
<Select
|
||||
value={person.name || undefined}
|
||||
onValueChange={(val) => onChange(person.id, 'name', val)}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="이름" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_EMPLOYEES.map((emp) => (
|
||||
<SelectItem key={emp.value} value={emp.value}>
|
||||
{emp.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-blue-500 hover:text-blue-600 hover:bg-blue-50"
|
||||
onClick={() => onRemove(person.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
|
||||
<DialogContent className="w-[95vw] max-w-[500px] sm:max-w-[500px]">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-lg font-bold">전자결재</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-6 py-4">
|
||||
{/* 결재선 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">결재선</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">부서 / 직책 / 이름</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddApprover}
|
||||
>
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{localApproval.approvers.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4 border rounded-lg">
|
||||
결재자를 추가해주세요.
|
||||
</p>
|
||||
) : (
|
||||
localApproval.approvers.map((person, index) =>
|
||||
renderPersonRow(person, index, 'approver')
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 참조 */}
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="font-medium">참조</h3>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-500">부서 / 직책 / 이름</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleAddReference}
|
||||
>
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{localApproval.references.length === 0 ? (
|
||||
<p className="text-sm text-gray-500 text-center py-4 border rounded-lg">
|
||||
참조자를 추가해주세요.
|
||||
</p>
|
||||
) : (
|
||||
localApproval.references.map((person, index) =>
|
||||
renderPersonRow(person, index, 'reference')
|
||||
)
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<DialogFooter className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-gray-800 hover:bg-gray-900">
|
||||
저장
|
||||
</Button>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
1
src/components/business/juil/common/modals/index.ts
Normal file
1
src/components/business/juil/common/modals/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ElectronicApprovalModal } from './ElectronicApprovalModal';
|
||||
21
src/components/business/juil/common/types.ts
Normal file
21
src/components/business/juil/common/types.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
// 결재자/참조자 정보
|
||||
export interface ApprovalPerson {
|
||||
id: string;
|
||||
department: string; // 부서
|
||||
position: string; // 직책
|
||||
name: string; // 이름
|
||||
}
|
||||
|
||||
// 전자결재 정보
|
||||
export interface ElectronicApproval {
|
||||
approvers: ApprovalPerson[]; // 결재선
|
||||
references: ApprovalPerson[]; // 참조
|
||||
}
|
||||
|
||||
// 빈 전자결재 데이터 생성
|
||||
export function getEmptyElectronicApproval(): ElectronicApproval {
|
||||
return {
|
||||
approvers: [],
|
||||
references: [],
|
||||
};
|
||||
}
|
||||
712
src/components/business/juil/contract/ContractDetailForm.tsx
Normal file
712
src/components/business/juil/contract/ContractDetailForm.tsx
Normal file
@@ -0,0 +1,712 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, Upload, X, Eye, Download } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { toast } from 'sonner';
|
||||
import type { ContractDetail, ContractFormData, ContractAttachment, ContractStatus } from './types';
|
||||
import {
|
||||
CONTRACT_STATUS_LABELS,
|
||||
VAT_TYPE_OPTIONS,
|
||||
getEmptyContractFormData,
|
||||
contractDetailToFormData,
|
||||
} from './types';
|
||||
import { updateContract, deleteContract } from './actions';
|
||||
import { downloadFileById } from '@/lib/utils/fileDownload';
|
||||
import { ContractDocumentModal } from './modals/ContractDocumentModal';
|
||||
import {
|
||||
ElectronicApprovalModal,
|
||||
type ElectronicApproval,
|
||||
getEmptyElectronicApproval,
|
||||
} from '../common';
|
||||
|
||||
// 금액 포맷팅
|
||||
function formatAmount(amount: number): string {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
}
|
||||
|
||||
// 파일 사이즈 포맷팅
|
||||
function formatFileSize(bytes: number): string {
|
||||
if (bytes < 1024) return bytes + ' B';
|
||||
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
|
||||
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
|
||||
}
|
||||
|
||||
interface ContractDetailFormProps {
|
||||
mode: 'view' | 'edit';
|
||||
contractId: string;
|
||||
initialData?: ContractDetail;
|
||||
}
|
||||
|
||||
export default function ContractDetailForm({
|
||||
mode,
|
||||
contractId,
|
||||
initialData,
|
||||
}: ContractDetailFormProps) {
|
||||
const router = useRouter();
|
||||
const isViewMode = mode === 'view';
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<ContractFormData>(
|
||||
initialData ? contractDetailToFormData(initialData) : getEmptyContractFormData()
|
||||
);
|
||||
|
||||
// 기존 첨부파일 (서버에서 가져온 파일)
|
||||
const [existingAttachments, setExistingAttachments] = useState<ContractAttachment[]>(
|
||||
initialData?.attachments || []
|
||||
);
|
||||
|
||||
// 새로 추가된 파일
|
||||
const [newAttachments, setNewAttachments] = useState<File[]>([]);
|
||||
|
||||
// 기존 계약서 파일 삭제 여부
|
||||
const [isContractFileDeleted, setIsContractFileDeleted] = useState(false);
|
||||
|
||||
// 로딩 상태
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
|
||||
// 다이얼로그 상태
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||
const [showSaveDialog, setShowSaveDialog] = useState(false);
|
||||
|
||||
// 모달 상태
|
||||
const [showDocumentModal, setShowDocumentModal] = useState(false);
|
||||
const [showApprovalModal, setShowApprovalModal] = useState(false);
|
||||
|
||||
// 전자결재 데이터
|
||||
const [approvalData, setApprovalData] = useState<ElectronicApproval>(
|
||||
getEmptyElectronicApproval()
|
||||
);
|
||||
|
||||
// 파일 업로드 ref
|
||||
const contractFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const attachmentInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 드래그 상태
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// 네비게이션 핸들러
|
||||
const handleBack = useCallback(() => {
|
||||
router.push('/ko/juil/project/contract');
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
router.push(`/ko/juil/project/contract/${contractId}/edit`);
|
||||
}, [router, contractId]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
router.push(`/ko/juil/project/contract/${contractId}`);
|
||||
}, [router, contractId]);
|
||||
|
||||
// 폼 필드 변경
|
||||
const handleFieldChange = useCallback(
|
||||
(field: keyof ContractFormData, value: string | number) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = useCallback(() => {
|
||||
setShowSaveDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmSave = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateContract(contractId, formData);
|
||||
if (result.success) {
|
||||
toast.success('수정이 완료되었습니다.');
|
||||
setShowSaveDialog(false);
|
||||
router.push(`/ko/juil/project/contract/${contractId}`);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '저장에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router, contractId, formData]);
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = useCallback(() => {
|
||||
setShowDeleteDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deleteContract(contractId);
|
||||
if (result.success) {
|
||||
toast.success('계약이 삭제되었습니다.');
|
||||
setShowDeleteDialog(false);
|
||||
router.push('/ko/juil/project/contract');
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router, contractId]);
|
||||
|
||||
// 계약서 파일 선택
|
||||
const handleContractFileSelect = useCallback(() => {
|
||||
contractFileInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleContractFileChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
if (file.type !== 'application/pdf') {
|
||||
toast.error('PDF 파일만 업로드 가능합니다.');
|
||||
return;
|
||||
}
|
||||
setFormData((prev) => ({ ...prev, contractFile: file }));
|
||||
}
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 첨부 파일 드래그 앤 드롭
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(true);
|
||||
}, []);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
setIsDragging(false);
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
setNewAttachments((prev) => [...prev, ...files]);
|
||||
}, []);
|
||||
|
||||
const handleAttachmentSelect = useCallback(() => {
|
||||
attachmentInputRef.current?.click();
|
||||
}, []);
|
||||
|
||||
const handleAttachmentChange = useCallback(
|
||||
(e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = Array.from(e.target.files || []);
|
||||
setNewAttachments((prev) => [...prev, ...files]);
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 기존 첨부파일 삭제
|
||||
const handleRemoveExistingAttachment = useCallback((id: string) => {
|
||||
setExistingAttachments((prev) => prev.filter((att) => att.id !== id));
|
||||
}, []);
|
||||
|
||||
// 새 첨부파일 삭제
|
||||
const handleRemoveNewAttachment = useCallback((index: number) => {
|
||||
setNewAttachments((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
// 기존 계약서 파일 삭제
|
||||
const handleRemoveContractFile = useCallback(() => {
|
||||
setIsContractFileDeleted(true);
|
||||
setFormData((prev) => ({ ...prev, contractFile: null }));
|
||||
}, []);
|
||||
|
||||
// 계약서 보기 핸들러
|
||||
const handleViewDocument = useCallback(() => {
|
||||
setShowDocumentModal(true);
|
||||
}, []);
|
||||
|
||||
// 파일 다운로드 핸들러
|
||||
const handleFileDownload = useCallback(async (fileId: string, fileName?: string) => {
|
||||
try {
|
||||
await downloadFileById(parseInt(fileId), fileName);
|
||||
} catch (error) {
|
||||
console.error('[ContractDetailForm] 다운로드 실패:', error);
|
||||
toast.error('파일 다운로드에 실패했습니다.');
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 전자결재 핸들러
|
||||
const handleApproval = useCallback(() => {
|
||||
setShowApprovalModal(true);
|
||||
}, []);
|
||||
|
||||
// 전자결재 저장
|
||||
const handleApprovalSave = useCallback((approval: ElectronicApproval) => {
|
||||
setApprovalData(approval);
|
||||
setShowApprovalModal(false);
|
||||
toast.success('전자결재 정보가 저장되었습니다.');
|
||||
}, []);
|
||||
|
||||
// 헤더 액션 버튼
|
||||
const headerActions = isViewMode ? (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleViewDocument}>
|
||||
<Eye className="h-4 w-4 mr-2" />
|
||||
계약서 보기
|
||||
</Button>
|
||||
<Button variant="outline" onClick={handleApproval}>
|
||||
전자결재
|
||||
</Button>
|
||||
<Button onClick={handleEdit}>수정</Button>
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button variant="destructive" onClick={handleDelete}>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isLoading}>
|
||||
저장
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title="계약 상세"
|
||||
description="계약 정보를 관리합니다"
|
||||
icon={FileText}
|
||||
onBack={handleBack}
|
||||
actions={headerActions}
|
||||
/>
|
||||
|
||||
<div className="space-y-6">
|
||||
{/* 계약 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">계약 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{/* 계약번호 */}
|
||||
<div className="space-y-2">
|
||||
<Label>계약번호</Label>
|
||||
<Input
|
||||
value={formData.contractCode}
|
||||
onChange={(e) => handleFieldChange('contractCode', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 계약담당자 */}
|
||||
<div className="space-y-2">
|
||||
<Label>계약담당자</Label>
|
||||
<Input
|
||||
value={formData.contractManagerName}
|
||||
onChange={(e) => handleFieldChange('contractManagerName', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 거래처명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>거래처명</Label>
|
||||
<Input
|
||||
value={formData.partnerName}
|
||||
onChange={(e) => handleFieldChange('partnerName', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 현장명 */}
|
||||
<div className="space-y-2">
|
||||
<Label>현장명</Label>
|
||||
<Input
|
||||
value={formData.projectName}
|
||||
onChange={(e) => handleFieldChange('projectName', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 계약일자 */}
|
||||
<div className="space-y-2">
|
||||
<Label>계약일자</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.contractDate}
|
||||
onChange={(e) => handleFieldChange('contractDate', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 개소 */}
|
||||
<div className="space-y-2">
|
||||
<Label>개소</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.totalLocations}
|
||||
onChange={(e) => handleFieldChange('totalLocations', parseInt(e.target.value) || 0)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 계약기간 */}
|
||||
<div className="space-y-2">
|
||||
<Label>계약기간</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.contractStartDate}
|
||||
onChange={(e) => handleFieldChange('contractStartDate', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
<span>~</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.contractEndDate}
|
||||
onChange={(e) => handleFieldChange('contractEndDate', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 부가세 */}
|
||||
<div className="space-y-2">
|
||||
<Label>부가세</Label>
|
||||
<Select
|
||||
value={formData.vatType}
|
||||
onValueChange={(value) => handleFieldChange('vatType', value)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VAT_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 계약금액 */}
|
||||
<div className="space-y-2">
|
||||
<Label>계약금액</Label>
|
||||
<Input
|
||||
type="text"
|
||||
value={formatAmount(formData.contractAmount)}
|
||||
onChange={(e) => {
|
||||
const value = e.target.value.replace(/[^0-9]/g, '');
|
||||
handleFieldChange('contractAmount', parseInt(value) || 0);
|
||||
}}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상태 */}
|
||||
<div className="space-y-2">
|
||||
<Label>상태</Label>
|
||||
<RadioGroup
|
||||
value={formData.status}
|
||||
onValueChange={(value) => handleFieldChange('status', value as ContractStatus)}
|
||||
disabled={isViewMode}
|
||||
className="flex gap-4"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="pending" id="pending" />
|
||||
<Label htmlFor="pending" className="font-normal cursor-pointer">
|
||||
{CONTRACT_STATUS_LABELS.pending}
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="completed" id="completed" />
|
||||
<Label htmlFor="completed" className="font-normal cursor-pointer">
|
||||
{CONTRACT_STATUS_LABELS.completed}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
<div className="space-y-2 md:col-span-2">
|
||||
<Label>비고</Label>
|
||||
<Textarea
|
||||
value={formData.remarks}
|
||||
onChange={(e) => handleFieldChange('remarks', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 계약서 관리 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">계약서 관리</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* 파일 선택 버튼 (수정 모드에서만) */}
|
||||
{isEditMode && (
|
||||
<Button variant="outline" onClick={handleContractFileSelect}>
|
||||
찾기
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 새로 선택한 파일 */}
|
||||
{formData.contractFile && (
|
||||
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{formData.contractFile.name}</span>
|
||||
<span className="text-xs text-blue-600">(새 파일)</span>
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setFormData((prev) => ({ ...prev, contractFile: null }))}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 기존 계약서 파일 */}
|
||||
{!isContractFileDeleted && initialData?.contractFile && !formData.contractFile && (
|
||||
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<span className="text-sm font-medium">{initialData.contractFile.fileName}</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleFileDownload(initialData.contractFile!.id, initialData.contractFile!.fileName)}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
다운로드
|
||||
</Button>
|
||||
{isEditMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={handleRemoveContractFile}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일 없음 안내 */}
|
||||
{!formData.contractFile && (isContractFileDeleted || !initialData?.contractFile) && (
|
||||
<span className="text-sm text-muted-foreground">PDF 파일만 업로드 가능합니다</span>
|
||||
)}
|
||||
|
||||
<input
|
||||
ref={contractFileInputRef}
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
className="hidden"
|
||||
onChange={handleContractFileChange}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 계약 첨부 문서 관리 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">계약 첨부 문서 관리</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{/* 드래그 앤 드롭 영역 */}
|
||||
{isEditMode && (
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center mb-4 transition-colors cursor-pointer ${
|
||||
isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
onClick={handleAttachmentSelect}
|
||||
>
|
||||
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
|
||||
<p className="text-muted-foreground">
|
||||
클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일 목록 */}
|
||||
<div className="space-y-2">
|
||||
{/* 기존 첨부파일 */}
|
||||
{existingAttachments.map((att) => (
|
||||
<div
|
||||
key={att.id}
|
||||
className="flex items-center justify-between p-3 bg-muted rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{att.fileName}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(att.fileSize)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleFileDownload(att.id, att.fileName)}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
다운로드
|
||||
</Button>
|
||||
{isEditMode && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveExistingAttachment(att.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 새로 추가된 파일 */}
|
||||
{newAttachments.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-3 bg-muted rounded-lg"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="h-5 w-5 text-muted-foreground" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{file.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => handleRemoveNewAttachment(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<input
|
||||
ref={attachmentInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleAttachmentChange}
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
{/* 저장 확인 다이얼로그 */}
|
||||
<AlertDialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>저장 확인</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
변경사항을 저장하시겠습니까?
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleConfirmSave} disabled={isLoading}>
|
||||
저장
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>계약 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
이 계약을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={handleConfirmDelete}
|
||||
disabled={isLoading}
|
||||
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
|
||||
>
|
||||
삭제
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 계약서 보기 모달 */}
|
||||
{initialData && (
|
||||
<ContractDocumentModal
|
||||
open={showDocumentModal}
|
||||
onOpenChange={setShowDocumentModal}
|
||||
contract={initialData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 전자결재 모달 */}
|
||||
<ElectronicApprovalModal
|
||||
isOpen={showApprovalModal}
|
||||
onClose={() => setShowApprovalModal(false)}
|
||||
approval={approvalData}
|
||||
onSave={handleApprovalSave}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
609
src/components/business/juil/contract/ContractListClient.tsx
Normal file
609
src/components/business/juil/contract/ContractListClient.tsx
Normal file
@@ -0,0 +1,609 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, Clock, CheckCircle, Pencil, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
import { toast } from 'sonner';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
} from '@/components/ui/alert-dialog';
|
||||
import type { Contract, ContractStats } from './types';
|
||||
import {
|
||||
CONTRACT_STATUS_OPTIONS,
|
||||
CONTRACT_SORT_OPTIONS,
|
||||
CONTRACT_STATUS_STYLES,
|
||||
CONTRACT_STATUS_LABELS,
|
||||
} from './types';
|
||||
import { getContractList, getContractStats, deleteContract, deleteContracts } from './actions';
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
// 순서: 체크박스, 번호, 계약번호, 거래처, 현장명, 계약담당자, 공사PM, 총 개소, 계약금액, 계약기간, 상태, 작업
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'contractCode', label: '계약번호', className: 'w-[120px]' },
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
|
||||
{ key: 'projectName', label: '현장명', className: 'min-w-[150px]' },
|
||||
{ key: 'contractManager', label: '계약담당자', className: 'w-[100px] text-center' },
|
||||
{ key: 'constructionPM', label: '공사PM', className: 'w-[80px] text-center' },
|
||||
{ key: 'totalLocations', label: '총 개소', className: 'w-[80px] text-center' },
|
||||
{ key: 'contractAmount', label: '계약금액', className: 'w-[120px] text-right' },
|
||||
{ key: 'contractPeriod', label: '계약기간', className: 'w-[180px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||
];
|
||||
|
||||
// 목업 거래처 목록
|
||||
const MOCK_PARTNERS: MultiSelectOption[] = [
|
||||
{ value: '1', label: '통신공사' },
|
||||
{ value: '2', label: '야사건설' },
|
||||
{ value: '3', label: '여의건설' },
|
||||
];
|
||||
|
||||
// 목업 계약담당자 목록
|
||||
const MOCK_CONTRACT_MANAGERS: MultiSelectOption[] = [
|
||||
{ value: 'hong', label: '홍길동' },
|
||||
{ value: 'kim', label: '김철수' },
|
||||
{ value: 'lee', label: '이영희' },
|
||||
];
|
||||
|
||||
// 목업 공사PM 목록
|
||||
const MOCK_CONSTRUCTION_PMS: MultiSelectOption[] = [
|
||||
{ value: 'kim', label: '김PM' },
|
||||
{ value: 'lee', label: '이PM' },
|
||||
{ value: 'park', label: '박PM' },
|
||||
];
|
||||
|
||||
// 금액 포맷팅
|
||||
function formatAmount(amount: number): string {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
}
|
||||
|
||||
// 날짜 포맷팅
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).replace(/\. /g, '-').replace('.', '');
|
||||
}
|
||||
|
||||
// 계약기간 포맷팅
|
||||
function formatPeriod(startDate: string | null, endDate: string | null): string {
|
||||
const start = formatDate(startDate);
|
||||
const end = formatDate(endDate);
|
||||
if (start === '-' && end === '-') return '-';
|
||||
return `${start} ~ ${end}`;
|
||||
}
|
||||
|
||||
interface ContractListClientProps {
|
||||
initialData?: Contract[];
|
||||
initialStats?: ContractStats;
|
||||
}
|
||||
|
||||
export default function ContractListClient({
|
||||
initialData = [],
|
||||
initialStats,
|
||||
}: ContractListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [contracts, setContracts] = useState<Contract[]>(initialData);
|
||||
const [stats, setStats] = useState<ContractStats | null>(initialStats || null);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||
const [contractManagerFilters, setContractManagerFilters] = useState<string[]>([]);
|
||||
const [constructionPMFilters, setConstructionPMFilters] = useState<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('contractDateDesc');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
const [endDate, setEndDate] = useState<string>('');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getContractList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
getContractStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setContracts(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [startDate, endDate]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredContracts = useMemo(() => {
|
||||
return contracts.filter((contract) => {
|
||||
// 상태 탭 필터
|
||||
if (activeStatTab === 'pending' && contract.status !== 'pending') return false;
|
||||
if (activeStatTab === 'completed' && contract.status !== 'completed') return false;
|
||||
|
||||
// 거래처 필터
|
||||
if (partnerFilters.length > 0) {
|
||||
if (!partnerFilters.includes(contract.partnerId)) return false;
|
||||
}
|
||||
|
||||
// 계약담당자 필터
|
||||
if (contractManagerFilters.length > 0) {
|
||||
if (!contractManagerFilters.includes(contract.contractManagerId)) return false;
|
||||
}
|
||||
|
||||
// 공사PM 필터
|
||||
if (constructionPMFilters.length > 0) {
|
||||
if (!constructionPMFilters.includes(contract.constructionPMId || '')) return false;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && contract.status !== statusFilter) return false;
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
contract.projectName.toLowerCase().includes(search) ||
|
||||
contract.contractCode.toLowerCase().includes(search) ||
|
||||
contract.partnerName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [contracts, activeStatTab, partnerFilters, contractManagerFilters, constructionPMFilters, statusFilter, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedContracts = useMemo(() => {
|
||||
const sorted = [...filteredContracts];
|
||||
switch (sortBy) {
|
||||
case 'contractDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.contractStartDate) return 1;
|
||||
if (!b.contractStartDate) return -1;
|
||||
return new Date(b.contractStartDate).getTime() - new Date(a.contractStartDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'contractDateAsc':
|
||||
sorted.sort((a, b) => {
|
||||
if (!a.contractStartDate) return 1;
|
||||
if (!b.contractStartDate) return -1;
|
||||
return new Date(a.contractStartDate).getTime() - new Date(b.contractStartDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'projectNameAsc':
|
||||
sorted.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
|
||||
break;
|
||||
case 'projectNameDesc':
|
||||
sorted.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
|
||||
break;
|
||||
case 'amountDesc':
|
||||
sorted.sort((a, b) => b.contractAmount - a.contractAmount);
|
||||
break;
|
||||
case 'amountAsc':
|
||||
sorted.sort((a, b) => a.contractAmount - b.contractAmount);
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredContracts, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedContracts.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedContracts.slice(start, start + itemsPerPage);
|
||||
}, [sortedContracts, currentPage, itemsPerPage]);
|
||||
|
||||
// 핸들러
|
||||
const handleSearchChange = useCallback((value: string) => {
|
||||
setSearchValue(value);
|
||||
setCurrentPage(1);
|
||||
}, []);
|
||||
|
||||
const handleToggleSelection = useCallback((id: string) => {
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
if (newSet.has(id)) {
|
||||
newSet.delete(id);
|
||||
} else {
|
||||
newSet.add(id);
|
||||
}
|
||||
return newSet;
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleToggleSelectAll = useCallback(() => {
|
||||
if (selectedItems.size === paginatedData.length) {
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
setSelectedItems(new Set(paginatedData.map((c) => c.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(contract: Contract) => {
|
||||
router.push(`/ko/juil/project/contract/${contract.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, contractId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/juil/project/contract/${contractId}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback((e: React.MouseEvent, contractId: string) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTargetId(contractId);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteTargetId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deleteContract(deleteTargetId);
|
||||
if (result.success) {
|
||||
toast.success('계약이 삭제되었습니다.');
|
||||
setContracts((prev) => prev.filter((c) => c.id !== deleteTargetId));
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(deleteTargetId);
|
||||
return newSet;
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setDeleteDialogOpen(false);
|
||||
setDeleteTargetId(null);
|
||||
}
|
||||
}, [deleteTargetId]);
|
||||
|
||||
const handleBulkDeleteClick = useCallback(() => {
|
||||
if (selectedItems.size === 0) {
|
||||
toast.warning('삭제할 항목을 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
setBulkDeleteDialogOpen(true);
|
||||
}, [selectedItems.size]);
|
||||
|
||||
const handleBulkDeleteConfirm = useCallback(async () => {
|
||||
if (selectedItems.size === 0) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const ids = Array.from(selectedItems);
|
||||
const result = await deleteContracts(ids);
|
||||
if (result.success) {
|
||||
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
|
||||
await loadData();
|
||||
setSelectedItems(new Set());
|
||||
} else {
|
||||
toast.error(result.error || '일괄 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('일괄 삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setBulkDeleteDialogOpen(false);
|
||||
}
|
||||
}, [selectedItems, loadData]);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
// 순서: 체크박스, 번호, 계약번호, 거래처, 현장명, 계약담당자, 공사PM, 총 개소, 계약금액, 계약기간, 상태, 작업
|
||||
const renderTableRow = useCallback(
|
||||
(contract: Contract, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(contract.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={contract.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(contract)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(contract.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{contract.contractCode}</TableCell>
|
||||
<TableCell>{contract.partnerName}</TableCell>
|
||||
<TableCell>{contract.projectName}</TableCell>
|
||||
<TableCell className="text-center">{contract.contractManagerName}</TableCell>
|
||||
<TableCell className="text-center">{contract.constructionPMName || '-'}</TableCell>
|
||||
<TableCell className="text-center">{contract.totalLocations}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(contract.contractAmount)}원</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{formatPeriod(contract.contractStartDate, contract.contractEndDate)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={CONTRACT_STATUS_STYLES[contract.status]}>
|
||||
{CONTRACT_STATUS_LABELS[contract.status]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{isSelected && (
|
||||
<div className="flex items-center justify-center gap-1">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => handleEdit(e, contract.id)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={(e) => handleDeleteClick(e, contract.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
|
||||
);
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(contract: Contract, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
<MobileCard
|
||||
title={contract.projectName}
|
||||
subtitle={contract.contractCode}
|
||||
badge={CONTRACT_STATUS_LABELS[contract.status]}
|
||||
badgeVariant="secondary"
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => handleRowClick(contract)}
|
||||
details={[
|
||||
{ label: '거래처', value: contract.partnerName },
|
||||
{ label: '총 개소', value: `${contract.totalLocations}개` },
|
||||
{ label: '계약금액', value: `${formatAmount(contract.contractAmount)}원` },
|
||||
{ label: '계약담당자', value: contract.contractManagerName },
|
||||
{ label: '공사PM', value: contract.constructionPMName || '-' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
);
|
||||
|
||||
// 헤더 액션 (날짜 필터만)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
);
|
||||
|
||||
// Stats 카드 데이터 (전체 계약, 계약대기, 계약완료)
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '전체 계약',
|
||||
value: stats?.total ?? 0,
|
||||
icon: FileText,
|
||||
iconColor: 'text-blue-600',
|
||||
onClick: () => setActiveStatTab('all'),
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '계약대기',
|
||||
value: stats?.pending ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-orange-500',
|
||||
onClick: () => setActiveStatTab('pending'),
|
||||
isActive: activeStatTab === 'pending',
|
||||
},
|
||||
{
|
||||
label: '계약완료',
|
||||
value: stats?.completed ?? 0,
|
||||
icon: CheckCircle,
|
||||
iconColor: 'text-green-600',
|
||||
onClick: () => setActiveStatTab('completed'),
|
||||
isActive: activeStatTab === 'completed',
|
||||
},
|
||||
];
|
||||
|
||||
// 테이블 헤더 액션 (총 건수 + 필터들)
|
||||
const tableHeaderActions = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {sortedContracts.length}건
|
||||
</span>
|
||||
|
||||
{/* 거래처 필터 */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_PARTNERS}
|
||||
value={partnerFilters}
|
||||
onChange={setPartnerFilters}
|
||||
placeholder="거래처"
|
||||
searchPlaceholder="거래처 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 계약담당자 필터 */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_CONTRACT_MANAGERS}
|
||||
value={contractManagerFilters}
|
||||
onChange={setContractManagerFilters}
|
||||
placeholder="계약담당자"
|
||||
searchPlaceholder="계약담당자 검색..."
|
||||
className="w-[130px]"
|
||||
/>
|
||||
|
||||
{/* 공사PM 필터 */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_CONSTRUCTION_PMS}
|
||||
value={constructionPMFilters}
|
||||
onChange={setConstructionPMFilters}
|
||||
placeholder="공사PM"
|
||||
searchPlaceholder="공사PM 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTRACT_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-[160px]">
|
||||
<SelectValue placeholder="최신순 (계약일)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONTRACT_SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="계약관리"
|
||||
description="계약 정보를 관리합니다"
|
||||
icon={FileText}
|
||||
headerActions={headerActions}
|
||||
stats={statsCardsData}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="계약번호, 거래처, 현장명 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedContracts}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onBulkDelete={handleBulkDeleteClick}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedContracts.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 단일 삭제 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>계약 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 계약을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
{/* 일괄 삭제 다이얼로그 */}
|
||||
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>계약 일괄 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
선택한 {selectedItems.size}개 계약을 삭제하시겠습니까? 이 작업은 되돌릴 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleBulkDeleteConfirm}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
517
src/components/business/juil/contract/actions.ts
Normal file
517
src/components/business/juil/contract/actions.ts
Normal file
@@ -0,0 +1,517 @@
|
||||
'use server';
|
||||
|
||||
import type {
|
||||
Contract,
|
||||
ContractDetail,
|
||||
ContractStats,
|
||||
ContractStageCount,
|
||||
ContractListResponse,
|
||||
ContractFilter,
|
||||
ContractFormData,
|
||||
} from './types';
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_CONTRACTS: Contract[] = [
|
||||
{
|
||||
id: '1',
|
||||
contractCode: 'CT-2025-001',
|
||||
partnerId: '1',
|
||||
partnerName: '통신공사',
|
||||
projectName: '강남역 통신시설 구축',
|
||||
contractManagerId: 'hong',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMId: 'kim',
|
||||
constructionPMName: '김PM',
|
||||
totalLocations: 15,
|
||||
contractAmount: 150000000,
|
||||
contractStartDate: '2025-12-17',
|
||||
contractEndDate: '2026-06-17',
|
||||
status: 'pending',
|
||||
stage: 'estimate_selected',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
createdBy: 'system',
|
||||
biddingId: '1',
|
||||
biddingCode: 'BID-2025-001',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
contractCode: 'CT-2025-002',
|
||||
partnerId: '2',
|
||||
partnerName: '야사건설',
|
||||
projectName: '판교 IT단지 배선공사',
|
||||
contractManagerId: 'hong',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMId: 'lee',
|
||||
constructionPMName: '이PM',
|
||||
totalLocations: 28,
|
||||
contractAmount: 280000000,
|
||||
contractStartDate: '2025-11-01',
|
||||
contractEndDate: '2026-03-31',
|
||||
status: 'pending',
|
||||
stage: 'estimate_progress',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-02',
|
||||
updatedAt: '2025-01-02',
|
||||
createdBy: 'system',
|
||||
biddingId: '2',
|
||||
biddingCode: 'BID-2025-002',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
contractCode: 'CT-2025-003',
|
||||
partnerId: '3',
|
||||
partnerName: '여의건설',
|
||||
projectName: '여의도 오피스빌딩 통신설비',
|
||||
contractManagerId: 'kim',
|
||||
contractManagerName: '김철수',
|
||||
constructionPMId: 'park',
|
||||
constructionPMName: '박PM',
|
||||
totalLocations: 42,
|
||||
contractAmount: 420000000,
|
||||
contractStartDate: '2025-10-15',
|
||||
contractEndDate: '2026-04-15',
|
||||
status: 'pending',
|
||||
stage: 'delivery',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-03',
|
||||
updatedAt: '2025-01-03',
|
||||
createdBy: 'system',
|
||||
biddingId: '3',
|
||||
biddingCode: 'BID-2025-003',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
contractCode: 'CT-2025-004',
|
||||
partnerId: '1',
|
||||
partnerName: '통신공사',
|
||||
projectName: '송파 데이터센터 증설',
|
||||
contractManagerId: 'hong',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMId: 'kim',
|
||||
constructionPMName: '김PM',
|
||||
totalLocations: 58,
|
||||
contractAmount: 580000000,
|
||||
contractStartDate: '2025-09-01',
|
||||
contractEndDate: '2026-02-28',
|
||||
status: 'completed',
|
||||
stage: 'inspection',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-04',
|
||||
updatedAt: '2025-01-04',
|
||||
createdBy: 'system',
|
||||
biddingId: '4',
|
||||
biddingCode: 'BID-2025-004',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
contractCode: 'CT-2025-005',
|
||||
partnerId: '2',
|
||||
partnerName: '야사건설',
|
||||
projectName: '분당 스마트빌딩 LAN공사',
|
||||
contractManagerId: 'lee',
|
||||
contractManagerName: '이영희',
|
||||
constructionPMId: 'lee',
|
||||
constructionPMName: '이PM',
|
||||
totalLocations: 12,
|
||||
contractAmount: 95000000,
|
||||
contractStartDate: '2025-12-01',
|
||||
contractEndDate: '2026-01-31',
|
||||
status: 'pending',
|
||||
stage: 'installation',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-05',
|
||||
updatedAt: '2025-01-05',
|
||||
createdBy: 'system',
|
||||
biddingId: '5',
|
||||
biddingCode: 'BID-2025-005',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
contractCode: 'CT-2025-006',
|
||||
partnerId: '3',
|
||||
partnerName: '여의건설',
|
||||
projectName: '마포 복합시설 CCTV설치',
|
||||
contractManagerId: 'hong',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMId: 'park',
|
||||
constructionPMName: '박PM',
|
||||
totalLocations: 8,
|
||||
contractAmount: 75000000,
|
||||
contractStartDate: '2025-08-01',
|
||||
contractEndDate: '2025-10-31',
|
||||
status: 'completed',
|
||||
stage: 'estimate_selected',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-06',
|
||||
updatedAt: '2025-01-06',
|
||||
createdBy: 'system',
|
||||
biddingId: '6',
|
||||
biddingCode: 'BID-2025-006',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
contractCode: 'CT-2025-007',
|
||||
partnerId: '1',
|
||||
partnerName: '통신공사',
|
||||
projectName: '용산 아파트 인터폰교체',
|
||||
contractManagerId: 'kim',
|
||||
contractManagerName: '김철수',
|
||||
constructionPMId: 'kim',
|
||||
constructionPMName: '김PM',
|
||||
totalLocations: 120,
|
||||
contractAmount: 45000000,
|
||||
contractStartDate: '2025-07-15',
|
||||
contractEndDate: '2025-09-15',
|
||||
status: 'completed',
|
||||
stage: 'estimate_progress',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-07',
|
||||
updatedAt: '2025-01-07',
|
||||
createdBy: 'system',
|
||||
biddingId: '7',
|
||||
biddingCode: 'BID-2025-007',
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
contractCode: 'CT-2025-008',
|
||||
partnerId: '2',
|
||||
partnerName: '야사건설',
|
||||
projectName: '성수동 공장 방범설비',
|
||||
contractManagerId: 'lee',
|
||||
contractManagerName: '이영희',
|
||||
constructionPMId: 'lee',
|
||||
constructionPMName: '이PM',
|
||||
totalLocations: 24,
|
||||
contractAmount: 120000000,
|
||||
contractStartDate: '2025-11-15',
|
||||
contractEndDate: '2026-02-15',
|
||||
status: 'pending',
|
||||
stage: 'other',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-08',
|
||||
updatedAt: '2025-01-08',
|
||||
createdBy: 'system',
|
||||
biddingId: '8',
|
||||
biddingCode: 'BID-2025-008',
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
contractCode: 'CT-2025-009',
|
||||
partnerId: '3',
|
||||
partnerName: '여의건설',
|
||||
projectName: '강서 물류센터 네트워크',
|
||||
contractManagerId: 'hong',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMId: 'park',
|
||||
constructionPMName: '박PM',
|
||||
totalLocations: 35,
|
||||
contractAmount: 320000000,
|
||||
contractStartDate: '2025-06-01',
|
||||
contractEndDate: '2025-11-30',
|
||||
status: 'completed',
|
||||
stage: 'inspection',
|
||||
remarks: '',
|
||||
createdAt: '2025-01-09',
|
||||
updatedAt: '2025-01-09',
|
||||
createdBy: 'system',
|
||||
biddingId: '9',
|
||||
biddingCode: 'BID-2025-009',
|
||||
},
|
||||
];
|
||||
|
||||
// 계약 목록 조회
|
||||
export async function getContractList(filter?: ContractFilter): Promise<{
|
||||
success: boolean;
|
||||
data?: ContractListResponse;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
let filteredData = [...MOCK_CONTRACTS];
|
||||
|
||||
// 검색 필터
|
||||
if (filter?.search) {
|
||||
const search = filter.search.toLowerCase();
|
||||
filteredData = filteredData.filter(
|
||||
(item) =>
|
||||
item.contractCode.toLowerCase().includes(search) ||
|
||||
item.partnerName.toLowerCase().includes(search) ||
|
||||
item.projectName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (filter?.status && filter.status !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.status === filter.status);
|
||||
}
|
||||
|
||||
// 단계 필터
|
||||
if (filter?.stage && filter.stage !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.stage === filter.stage);
|
||||
}
|
||||
|
||||
// 거래처 필터
|
||||
if (filter?.partnerId && filter.partnerId !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.partnerId === filter.partnerId);
|
||||
}
|
||||
|
||||
// 계약담당자 필터
|
||||
if (filter?.contractManagerId && filter.contractManagerId !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.contractManagerId === filter.contractManagerId);
|
||||
}
|
||||
|
||||
// 공사PM 필터
|
||||
if (filter?.constructionPMId && filter.constructionPMId !== 'all') {
|
||||
filteredData = filteredData.filter((item) => item.constructionPMId === filter.constructionPMId);
|
||||
}
|
||||
|
||||
// 날짜 필터
|
||||
if (filter?.startDate) {
|
||||
filteredData = filteredData.filter(
|
||||
(item) => item.contractStartDate && item.contractStartDate >= filter.startDate!
|
||||
);
|
||||
}
|
||||
if (filter?.endDate) {
|
||||
filteredData = filteredData.filter(
|
||||
(item) => item.contractEndDate && item.contractEndDate <= filter.endDate!
|
||||
);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
const sortBy = filter?.sortBy || 'contractDateDesc';
|
||||
switch (sortBy) {
|
||||
case 'contractDateDesc':
|
||||
filteredData.sort((a, b) => {
|
||||
if (!a.contractStartDate) return 1;
|
||||
if (!b.contractStartDate) return -1;
|
||||
return new Date(b.contractStartDate).getTime() - new Date(a.contractStartDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'contractDateAsc':
|
||||
filteredData.sort((a, b) => {
|
||||
if (!a.contractStartDate) return 1;
|
||||
if (!b.contractStartDate) return -1;
|
||||
return new Date(a.contractStartDate).getTime() - new Date(b.contractStartDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
filteredData.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
filteredData.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'projectNameAsc':
|
||||
filteredData.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
|
||||
break;
|
||||
case 'projectNameDesc':
|
||||
filteredData.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
|
||||
break;
|
||||
case 'amountDesc':
|
||||
filteredData.sort((a, b) => b.contractAmount - a.contractAmount);
|
||||
break;
|
||||
case 'amountAsc':
|
||||
filteredData.sort((a, b) => a.contractAmount - b.contractAmount);
|
||||
break;
|
||||
}
|
||||
|
||||
// 페이지네이션
|
||||
const page = filter?.page || 1;
|
||||
const size = filter?.size || 20;
|
||||
const startIndex = (page - 1) * size;
|
||||
const paginatedData = filteredData.slice(startIndex, startIndex + size);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: paginatedData,
|
||||
total: filteredData.length,
|
||||
page,
|
||||
size,
|
||||
totalPages: Math.ceil(filteredData.length / size),
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('getContractList error:', error);
|
||||
return { success: false, error: '계약 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 통계 조회
|
||||
export async function getContractStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: ContractStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const stats: ContractStats = {
|
||||
total: MOCK_CONTRACTS.length,
|
||||
pending: MOCK_CONTRACTS.filter((c) => c.status === 'pending').length,
|
||||
completed: MOCK_CONTRACTS.filter((c) => c.status === 'completed').length,
|
||||
};
|
||||
|
||||
return { success: true, data: stats };
|
||||
} catch (error) {
|
||||
console.error('getContractStats error:', error);
|
||||
return { success: false, error: '통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 단계별 건수 조회
|
||||
export async function getContractStageCounts(): Promise<{
|
||||
success: boolean;
|
||||
data?: ContractStageCount;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const counts: ContractStageCount = {
|
||||
estimateSelected: MOCK_CONTRACTS.filter((c) => c.stage === 'estimate_selected').length,
|
||||
estimateProgress: MOCK_CONTRACTS.filter((c) => c.stage === 'estimate_progress').length,
|
||||
delivery: MOCK_CONTRACTS.filter((c) => c.stage === 'delivery').length,
|
||||
installation: MOCK_CONTRACTS.filter((c) => c.stage === 'installation').length,
|
||||
inspection: MOCK_CONTRACTS.filter((c) => c.stage === 'inspection').length,
|
||||
other: MOCK_CONTRACTS.filter((c) => c.stage === 'other').length,
|
||||
};
|
||||
|
||||
return { success: true, data: counts };
|
||||
} catch (error) {
|
||||
console.error('getContractStageCounts error:', error);
|
||||
return { success: false, error: '단계별 건수를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 단건 조회
|
||||
export async function getContract(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: Contract;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const contract = MOCK_CONTRACTS.find((c) => c.id === id);
|
||||
if (!contract) {
|
||||
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true, data: contract };
|
||||
} catch (error) {
|
||||
console.error('getContract error:', error);
|
||||
return { success: false, error: '계약 정보를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 삭제
|
||||
export async function deleteContract(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const index = MOCK_CONTRACTS.findIndex((c) => c.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('deleteContract error:', error);
|
||||
return { success: false, error: '계약 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 일괄 삭제
|
||||
export async function deleteContracts(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
return { success: true, deletedCount: ids.length };
|
||||
} catch (error) {
|
||||
console.error('deleteContracts error:', error);
|
||||
return { success: false, error: '일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 상세 조회 (첨부파일 포함)
|
||||
export async function getContractDetail(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: ContractDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const contract = MOCK_CONTRACTS.find((c) => c.id === id);
|
||||
if (!contract) {
|
||||
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
// ContractDetail로 변환 (첨부파일 목데이터 포함)
|
||||
const contractDetail: ContractDetail = {
|
||||
...contract,
|
||||
// 계약서 파일 목업 데이터
|
||||
contractFile: {
|
||||
id: '100',
|
||||
fileName: '계약서_CT-2025-001.pdf',
|
||||
fileUrl: '/files/contract.pdf',
|
||||
uploadedAt: contract.createdAt,
|
||||
},
|
||||
attachments: [
|
||||
{
|
||||
id: 'att-1',
|
||||
fileName: '견적서.pdf',
|
||||
fileSize: 1024000,
|
||||
fileUrl: '/files/estimate.pdf',
|
||||
uploadedAt: contract.createdAt,
|
||||
},
|
||||
{
|
||||
id: 'att-2',
|
||||
fileName: '시방서.pdf',
|
||||
fileSize: 2048000,
|
||||
fileUrl: '/files/spec.pdf',
|
||||
uploadedAt: contract.createdAt,
|
||||
},
|
||||
],
|
||||
};
|
||||
|
||||
return { success: true, data: contractDetail };
|
||||
} catch (error) {
|
||||
console.error('getContractDetail error:', error);
|
||||
return { success: false, error: '계약 상세 정보를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 계약 수정
|
||||
export async function updateContract(
|
||||
id: string,
|
||||
_data: Partial<ContractFormData>
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
const index = MOCK_CONTRACTS.findIndex((c) => c.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
// TODO: 실제 API 연동 시 데이터 업데이트 로직
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('updateContract error:', error);
|
||||
return { success: false, error: '계약 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
5
src/components/business/juil/contract/index.ts
Normal file
5
src/components/business/juil/contract/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as ContractListClient } from './ContractListClient';
|
||||
export { default as ContractDetailForm } from './ContractDetailForm';
|
||||
export * from './types';
|
||||
export * from './actions';
|
||||
export * from './modals';
|
||||
@@ -0,0 +1,104 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
VisuallyHidden,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Edit,
|
||||
X as XIcon,
|
||||
Printer,
|
||||
Send,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { printArea } from '@/lib/print-utils';
|
||||
import type { ContractDetail } from '../types';
|
||||
|
||||
interface ContractDocumentModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
contract: ContractDetail;
|
||||
}
|
||||
|
||||
export function ContractDocumentModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
contract,
|
||||
}: ContractDocumentModalProps) {
|
||||
// 수정
|
||||
const handleEdit = () => {
|
||||
toast.info('수정 기능은 준비 중입니다.');
|
||||
};
|
||||
|
||||
// 상신 (전자결재)
|
||||
const handleSubmit = () => {
|
||||
toast.info('전자결재 상신 기능은 준비 중입니다.');
|
||||
};
|
||||
|
||||
// 인쇄
|
||||
const handlePrint = () => {
|
||||
printArea({ title: '계약서 인쇄' });
|
||||
};
|
||||
|
||||
// PDF URL 확인
|
||||
const pdfUrl = contract.contractFile?.fileUrl;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>계약서 상세</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
|
||||
{/* 헤더 영역 - 고정 (인쇄 시 숨김) */}
|
||||
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
|
||||
<h2 className="text-lg font-semibold">계약서 상세</h2>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => onOpenChange(false)}
|
||||
className="h-8 w-8"
|
||||
>
|
||||
<XIcon className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 버튼 영역 - 고정 (인쇄 시 숨김) */}
|
||||
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
|
||||
<Button variant="outline" size="sm" onClick={handleEdit}>
|
||||
<Edit className="h-4 w-4 mr-1" />
|
||||
수정
|
||||
</Button>
|
||||
<Button variant="default" size="sm" onClick={handleSubmit} className="bg-blue-600 hover:bg-blue-700">
|
||||
<Send className="h-4 w-4 mr-1" />
|
||||
상신
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="h-4 w-4 mr-1" />
|
||||
인쇄
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* PDF 뷰어 영역 - 스크롤 (인쇄 시 이 영역만 출력) */}
|
||||
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
|
||||
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg min-h-[297mm]">
|
||||
{pdfUrl ? (
|
||||
<iframe
|
||||
src={pdfUrl}
|
||||
className="w-full h-full min-h-[297mm]"
|
||||
title="계약서 PDF"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex items-center justify-center h-full min-h-[297mm] text-muted-foreground">
|
||||
<p>현재 등록된 계약서가 없습니다.</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
1
src/components/business/juil/contract/modals/index.ts
Normal file
1
src/components/business/juil/contract/modals/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { ContractDocumentModal } from './ContractDocumentModal';
|
||||
242
src/components/business/juil/contract/types.ts
Normal file
242
src/components/business/juil/contract/types.ts
Normal file
@@ -0,0 +1,242 @@
|
||||
/**
|
||||
* 주일 기업 - 계약관리 타입 정의
|
||||
*
|
||||
* 계약 데이터는 낙찰 후 자동 등록됨
|
||||
*/
|
||||
|
||||
// 계약 상태
|
||||
export type ContractStatus =
|
||||
| 'pending' // 계약대기
|
||||
| 'completed'; // 계약완료
|
||||
|
||||
// 계약 단계 (스크린샷의 상단 탭)
|
||||
export type ContractStage =
|
||||
| 'estimate_selected' // 견적선정
|
||||
| 'estimate_progress' // 견적진행
|
||||
| 'delivery' // 납품
|
||||
| 'installation' // 설치중
|
||||
| 'inspection' // 검수
|
||||
| 'other'; // 기타
|
||||
|
||||
// 계약 타입
|
||||
export interface Contract {
|
||||
id: string;
|
||||
contractCode: string; // 계약번호
|
||||
|
||||
// 기본 정보
|
||||
partnerId: string; // 거래처 ID
|
||||
partnerName: string; // 거래처명
|
||||
projectName: string; // 현장명
|
||||
|
||||
// 담당자 정보
|
||||
contractManagerId: string; // 계약담당자 ID
|
||||
contractManagerName: string; // 계약담당자
|
||||
constructionPMId: string; // 공사PM ID
|
||||
constructionPMName: string; // 공사PM
|
||||
|
||||
// 계약 정보
|
||||
totalLocations: number; // 총 개소
|
||||
contractAmount: number; // 계약금액
|
||||
contractStartDate: string | null; // 계약시작일
|
||||
contractEndDate: string | null; // 계약종료일
|
||||
|
||||
// 상태 정보
|
||||
status: ContractStatus; // 계약상태
|
||||
stage: ContractStage; // 계약단계
|
||||
|
||||
// 비고
|
||||
remarks: string;
|
||||
|
||||
// 메타 정보
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
createdBy: string;
|
||||
|
||||
// 연결된 입찰 정보
|
||||
biddingId: string;
|
||||
biddingCode: string;
|
||||
}
|
||||
|
||||
// 계약 통계
|
||||
export interface ContractStats {
|
||||
total: number; // 전체 계약
|
||||
pending: number; // 계약대기
|
||||
completed: number; // 계약완료
|
||||
}
|
||||
|
||||
// 단계별 건수
|
||||
export interface ContractStageCount {
|
||||
estimateSelected: number; // 견적선정
|
||||
estimateProgress: number; // 견적진행
|
||||
delivery: number; // 납품
|
||||
installation: number; // 설치중
|
||||
inspection: number; // 검수
|
||||
other: number; // 기타
|
||||
}
|
||||
|
||||
// 계약 필터
|
||||
export interface ContractFilter {
|
||||
search?: string;
|
||||
status?: ContractStatus | 'all';
|
||||
stage?: ContractStage | 'all';
|
||||
partnerId?: string;
|
||||
contractManagerId?: string;
|
||||
constructionPMId?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
sortBy?: string;
|
||||
page?: number;
|
||||
size?: number;
|
||||
}
|
||||
|
||||
// API 응답 타입
|
||||
export interface ContractListResponse {
|
||||
items: Contract[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
totalPages: number;
|
||||
}
|
||||
|
||||
// 상태 옵션
|
||||
export const CONTRACT_STATUS_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'pending', label: '계약대기' },
|
||||
{ value: 'completed', label: '계약완료' },
|
||||
];
|
||||
|
||||
// 단계 옵션
|
||||
export const CONTRACT_STAGE_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'estimate_selected', label: '견적선정' },
|
||||
{ value: 'estimate_progress', label: '견적진행' },
|
||||
{ value: 'delivery', label: '납품' },
|
||||
{ value: 'installation', label: '설치중' },
|
||||
{ value: 'inspection', label: '검수' },
|
||||
{ value: 'other', label: '기타' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
export const CONTRACT_SORT_OPTIONS = [
|
||||
{ value: 'contractDateDesc', label: '최신순 (계약일)' },
|
||||
{ value: 'contractDateAsc', label: '등록순 (계약일)' },
|
||||
{ value: 'partnerNameAsc', label: '거래처명 오름차순' },
|
||||
{ value: 'partnerNameDesc', label: '거래처명 내림차순' },
|
||||
{ value: 'projectNameAsc', label: '현장명 오름차순' },
|
||||
{ value: 'projectNameDesc', label: '현장명 내림차순' },
|
||||
{ value: 'amountDesc', label: '계약금액 높은순' },
|
||||
{ value: 'amountAsc', label: '계약금액 낮은순' },
|
||||
];
|
||||
|
||||
// 상태별 스타일
|
||||
export const CONTRACT_STATUS_STYLES: Record<ContractStatus, string> = {
|
||||
pending: 'text-orange-500 font-medium',
|
||||
completed: 'text-green-600 font-medium',
|
||||
};
|
||||
|
||||
export const CONTRACT_STATUS_LABELS: Record<ContractStatus, string> = {
|
||||
pending: '계약대기',
|
||||
completed: '계약완료',
|
||||
};
|
||||
|
||||
// 단계별 라벨
|
||||
export const CONTRACT_STAGE_LABELS: Record<ContractStage, string> = {
|
||||
estimate_selected: '견적선정',
|
||||
estimate_progress: '견적진행',
|
||||
delivery: '납품',
|
||||
installation: '설치중',
|
||||
inspection: '검수',
|
||||
other: '기타',
|
||||
};
|
||||
|
||||
// 계약 상세 (상세/수정용 확장 타입)
|
||||
export interface ContractDetail extends Contract {
|
||||
// 계약서 파일
|
||||
contractFile?: {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileUrl: string;
|
||||
uploadedAt: string;
|
||||
} | null;
|
||||
// 첨부 문서 목록
|
||||
attachments: ContractAttachment[];
|
||||
}
|
||||
|
||||
// 첨부 문서 타입
|
||||
export interface ContractAttachment {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileSize: number;
|
||||
fileUrl: string;
|
||||
uploadedAt: string;
|
||||
}
|
||||
|
||||
// 계약 폼 데이터
|
||||
export interface ContractFormData {
|
||||
contractCode: string;
|
||||
contractManagerId: string;
|
||||
contractManagerName: string;
|
||||
partnerId: string;
|
||||
partnerName: string;
|
||||
projectName: string;
|
||||
contractDate: string;
|
||||
totalLocations: number;
|
||||
contractStartDate: string;
|
||||
contractEndDate: string;
|
||||
vatType: string;
|
||||
contractAmount: number;
|
||||
status: ContractStatus;
|
||||
remarks: string;
|
||||
contractFile: File | null;
|
||||
attachments: File[];
|
||||
}
|
||||
|
||||
// 빈 폼 데이터 생성
|
||||
export function getEmptyContractFormData(): ContractFormData {
|
||||
return {
|
||||
contractCode: '',
|
||||
contractManagerId: '',
|
||||
contractManagerName: '',
|
||||
partnerId: '',
|
||||
partnerName: '',
|
||||
projectName: '',
|
||||
contractDate: '',
|
||||
totalLocations: 0,
|
||||
contractStartDate: '',
|
||||
contractEndDate: '',
|
||||
vatType: 'excluded',
|
||||
contractAmount: 0,
|
||||
status: 'pending',
|
||||
remarks: '',
|
||||
contractFile: null,
|
||||
attachments: [],
|
||||
};
|
||||
}
|
||||
|
||||
// ContractDetail을 FormData로 변환
|
||||
export function contractDetailToFormData(detail: ContractDetail): ContractFormData {
|
||||
return {
|
||||
contractCode: detail.contractCode,
|
||||
contractManagerId: detail.contractManagerId,
|
||||
contractManagerName: detail.contractManagerName,
|
||||
partnerId: detail.partnerId,
|
||||
partnerName: detail.partnerName,
|
||||
projectName: detail.projectName,
|
||||
contractDate: detail.createdAt,
|
||||
totalLocations: detail.totalLocations,
|
||||
contractStartDate: detail.contractStartDate || '',
|
||||
contractEndDate: detail.contractEndDate || '',
|
||||
vatType: 'excluded',
|
||||
contractAmount: detail.contractAmount,
|
||||
status: detail.status,
|
||||
remarks: detail.remarks,
|
||||
contractFile: null,
|
||||
attachments: [],
|
||||
};
|
||||
}
|
||||
|
||||
// 부가세 옵션
|
||||
export const VAT_TYPE_OPTIONS = [
|
||||
{ value: 'included', label: '부가세 포함' },
|
||||
{ value: 'excluded', label: '부가세 별도' },
|
||||
];
|
||||
732
src/components/business/juil/estimates/EstimateDetailForm.tsx
Normal file
732
src/components/business/juil/estimates/EstimateDetailForm.tsx
Normal file
@@ -0,0 +1,732 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo, useRef } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, Loader2 } 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-orange-500 hover:bg-orange-600">
|
||||
수정
|
||||
</Button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="flex gap-2">
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
className="text-red-500 border-red-200 hover:bg-red-50"
|
||||
onClick={handleDelete}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleSave} className="bg-orange-500 hover:bg-orange-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-orange-500 hover:bg-orange-600"
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading && <Loader2 className="h-4 w-4 mr-2 animate-spin" />}
|
||||
확인
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, FileTextIcon, FilePenLine, FileCheck, Plus, Pencil, Trash2 } from 'lucide-react';
|
||||
import { FileText, FileTextIcon, Clock, FileCheck, Pencil } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
|
||||
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
|
||||
import { MobileCard } from '@/components/molecules/MobileCard';
|
||||
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
|
||||
@@ -33,8 +34,6 @@ import {
|
||||
ESTIMATE_SORT_OPTIONS,
|
||||
STATUS_STYLES,
|
||||
STATUS_LABELS,
|
||||
AWARD_STATUS_LABELS,
|
||||
AWARD_STATUS_STYLES,
|
||||
} from './types';
|
||||
import { getEstimateList, getEstimateStats, deleteEstimate, deleteEstimates } from './actions';
|
||||
|
||||
@@ -45,26 +44,23 @@ const tableColumns: TableColumn[] = [
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[120px]' },
|
||||
{ key: 'projectName', label: '현장명', className: 'min-w-[150px]' },
|
||||
{ key: 'estimatorName', label: '견적자', className: 'w-[80px] text-center' },
|
||||
{ key: 'itemCount', label: '계', className: 'w-[60px] text-center' },
|
||||
{ key: 'itemCount', label: '총 개소', className: 'w-[80px] text-center' },
|
||||
{ key: 'estimateAmount', label: '견적금액', className: 'w-[120px] text-right' },
|
||||
{ key: 'distributionDate', label: '견적배부일', className: 'w-[110px] text-center' },
|
||||
{ key: 'completedDate', label: '견적완료일', className: 'w-[110px] text-center' },
|
||||
{ key: 'bidDate', label: '입찰일', className: 'w-[110px] text-center' },
|
||||
{ key: 'awardStatus', label: '낙찰', className: 'w-[70px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||
];
|
||||
|
||||
// 목업 거래처 목록
|
||||
const MOCK_PARTNERS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
// 목업 거래처 목록 (다중선택용 - 빈 배열 = 전체)
|
||||
const MOCK_PARTNERS: MultiSelectOption[] = [
|
||||
{ value: '1', label: '회사명' },
|
||||
{ value: '2', label: '야사 대림아파트' },
|
||||
{ value: '3', label: '여의 현장아파트' },
|
||||
];
|
||||
|
||||
// 목업 견적자 목록
|
||||
const MOCK_ESTIMATORS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
// 목업 견적자 목록 (다중선택용 - 빈 배열 = 전체)
|
||||
const MOCK_ESTIMATORS: MultiSelectOption[] = [
|
||||
{ value: 'hong', label: '홍길동' },
|
||||
{ value: 'kim', label: '김철수' },
|
||||
{ value: 'lee', label: '이영희' },
|
||||
@@ -87,8 +83,8 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
const [estimates, setEstimates] = useState<Estimate[]>(initialData);
|
||||
const [stats, setStats] = useState<EstimateStats | null>(initialStats || null);
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [partnerFilter, setPartnerFilter] = useState<string>('all');
|
||||
const [estimatorFilter, setEstimatorFilter] = useState<string>('all');
|
||||
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
|
||||
const [estimatorFilters, setEstimatorFilters] = useState<string[]>([]);
|
||||
const [statusFilter, setStatusFilter] = useState<string>('all');
|
||||
const [sortBy, setSortBy] = useState<string>('latest');
|
||||
const [startDate, setStartDate] = useState<string>('');
|
||||
@@ -99,7 +95,7 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
|
||||
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'drafting' | 'completed'>('all');
|
||||
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
|
||||
const itemsPerPage = 20;
|
||||
|
||||
// 데이터 로드
|
||||
@@ -139,14 +135,18 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
const filteredEstimates = useMemo(() => {
|
||||
return estimates.filter((estimate) => {
|
||||
// 상태 탭 필터
|
||||
if (activeStatTab === 'drafting' && estimate.status !== 'drafting') return false;
|
||||
if (activeStatTab === 'pending' && estimate.status !== 'pending') return false;
|
||||
if (activeStatTab === 'completed' && estimate.status !== 'completed') return false;
|
||||
|
||||
// 거래처 필터
|
||||
if (partnerFilter !== 'all' && estimate.partnerId !== partnerFilter) return false;
|
||||
// 거래처 필터 (다중선택 - 빈 배열 = 전체)
|
||||
if (partnerFilters.length > 0) {
|
||||
if (!partnerFilters.includes(estimate.partnerId)) return false;
|
||||
}
|
||||
|
||||
// 견적자 필터
|
||||
if (estimatorFilter !== 'all' && estimate.estimatorId !== estimatorFilter) return false;
|
||||
// 견적자 필터 (다중선택 - 빈 배열 = 전체)
|
||||
if (estimatorFilters.length > 0) {
|
||||
if (!estimatorFilters.includes(estimate.estimatorId)) return false;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && estimate.status !== statusFilter) return false;
|
||||
@@ -162,23 +162,25 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [estimates, activeStatTab, partnerFilter, estimatorFilter, statusFilter, searchValue]);
|
||||
}, [estimates, activeStatTab, partnerFilters, estimatorFilters, statusFilter, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedEstimates = useMemo(() => {
|
||||
const sorted = [...filteredEstimates];
|
||||
switch (sortBy) {
|
||||
case 'latest':
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
sorted.sort((a, b) => {
|
||||
const dateA = a.completedDate ? new Date(a.completedDate).getTime() : new Date(a.createdAt).getTime();
|
||||
const dateB = b.completedDate ? new Date(b.completedDate).getTime() : new Date(b.createdAt).getTime();
|
||||
return dateB - dateA;
|
||||
});
|
||||
break;
|
||||
case 'oldest':
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
break;
|
||||
case 'amountDesc':
|
||||
sorted.sort((a, b) => b.estimateAmount - a.estimateAmount);
|
||||
break;
|
||||
case 'amountAsc':
|
||||
sorted.sort((a, b) => a.estimateAmount - b.estimateAmount);
|
||||
sorted.sort((a, b) => {
|
||||
const dateA = a.completedDate ? new Date(a.completedDate).getTime() : new Date(a.createdAt).getTime();
|
||||
const dateB = b.completedDate ? new Date(b.completedDate).getTime() : new Date(b.createdAt).getTime();
|
||||
return dateA - dateB;
|
||||
});
|
||||
break;
|
||||
case 'bidDateDesc':
|
||||
sorted.sort((a, b) => {
|
||||
@@ -187,6 +189,18 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
return new Date(b.bidDate).getTime() - new Date(a.bidDate).getTime();
|
||||
});
|
||||
break;
|
||||
case 'partnerNameAsc':
|
||||
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
|
||||
break;
|
||||
case 'partnerNameDesc':
|
||||
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
|
||||
break;
|
||||
case 'projectNameAsc':
|
||||
sorted.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
|
||||
break;
|
||||
case 'projectNameDesc':
|
||||
sorted.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredEstimates, sortBy]);
|
||||
@@ -231,10 +245,6 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
router.push('/ko/juil/project/bidding/estimates/new');
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, estimateId: string) => {
|
||||
e.stopPropagation();
|
||||
@@ -329,13 +339,8 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
<TableCell className="text-center">{estimate.estimatorName}</TableCell>
|
||||
<TableCell className="text-center">{estimate.itemCount}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(estimate.estimateAmount)}</TableCell>
|
||||
<TableCell className="text-center">{estimate.distributionDate || '-'}</TableCell>
|
||||
<TableCell className="text-center">{estimate.completedDate || '-'}</TableCell>
|
||||
<TableCell className="text-center">{estimate.bidDate || '-'}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={AWARD_STATUS_STYLES[estimate.awardStatus]}>
|
||||
{AWARD_STATUS_LABELS[estimate.awardStatus]}
|
||||
</span>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={STATUS_STYLES[estimate.status]}>
|
||||
{STATUS_LABELS[estimate.status]}
|
||||
@@ -352,21 +357,13 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-destructive hover:text-destructive"
|
||||
onClick={(e) => handleDeleteClick(e, estimate.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
@@ -393,26 +390,20 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
[handleRowClick]
|
||||
);
|
||||
|
||||
// 헤더 액션 (등록 버튼 + 날짜 필터)
|
||||
// 헤더 액션 (날짜 필터만 - 견적등록은 현장설명회 참석완료 시 자동 등록)
|
||||
const headerActions = (
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
extraActions={
|
||||
<Button onClick={handleCreate}>
|
||||
<Plus className="mr-2 h-4 w-4" />
|
||||
견적 등록
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
// Stats 카드 데이터 (StatCards 컴포넌트용)
|
||||
const statsCardsData: StatCard[] = [
|
||||
{
|
||||
label: '전체',
|
||||
label: '전체 견적',
|
||||
value: stats?.total ?? 0,
|
||||
icon: FileTextIcon,
|
||||
iconColor: 'text-blue-600',
|
||||
@@ -420,12 +411,12 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
isActive: activeStatTab === 'all',
|
||||
},
|
||||
{
|
||||
label: '견적작성중',
|
||||
value: stats?.drafting ?? 0,
|
||||
icon: FilePenLine,
|
||||
label: '견적대기',
|
||||
value: stats?.pending ?? 0,
|
||||
icon: Clock,
|
||||
iconColor: 'text-orange-500',
|
||||
onClick: () => setActiveStatTab('drafting'),
|
||||
isActive: activeStatTab === 'drafting',
|
||||
onClick: () => setActiveStatTab('pending'),
|
||||
isActive: activeStatTab === 'pending',
|
||||
},
|
||||
{
|
||||
label: '견적완료',
|
||||
@@ -444,33 +435,25 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
총 {sortedEstimates.length}건
|
||||
</span>
|
||||
|
||||
{/* 거래처 필터 */}
|
||||
<Select value={partnerFilter} onValueChange={setPartnerFilter}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue placeholder="거래처" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_PARTNERS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 거래처 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_PARTNERS}
|
||||
value={partnerFilters}
|
||||
onChange={setPartnerFilters}
|
||||
placeholder="거래처"
|
||||
searchPlaceholder="거래처 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 견적자 필터 */}
|
||||
<Select value={estimatorFilter} onValueChange={setEstimatorFilter}>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="견적자" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_ESTIMATORS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
{/* 견적자 필터 (다중선택) */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_ESTIMATORS}
|
||||
value={estimatorFilters}
|
||||
onChange={setEstimatorFilters}
|
||||
placeholder="견적자"
|
||||
searchPlaceholder="견적자 검색..."
|
||||
className="w-[120px]"
|
||||
/>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select value={statusFilter} onValueChange={setStatusFilter}>
|
||||
@@ -523,7 +506,6 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onBulkDelete={handleBulkDeleteClick}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
|
||||
@@ -19,10 +19,9 @@ const mockEstimates: Estimate[] = [
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 8,
|
||||
estimateAmount: 100000000,
|
||||
distributionDate: '2025-12-15',
|
||||
completedDate: null,
|
||||
bidDate: '2025-12-15',
|
||||
status: 'drafting',
|
||||
awardStatus: 'pending',
|
||||
status: 'pending',
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
createdBy: '홍길동',
|
||||
@@ -37,10 +36,9 @@ const mockEstimates: Estimate[] = [
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 8,
|
||||
estimateAmount: 100000000,
|
||||
distributionDate: '2025-12-15',
|
||||
completedDate: null,
|
||||
bidDate: '2025-12-15',
|
||||
status: 'drafting',
|
||||
awardStatus: 'pending',
|
||||
status: 'pending',
|
||||
createdAt: '2025-01-02',
|
||||
updatedAt: '2025-01-02',
|
||||
createdBy: '홍길동',
|
||||
@@ -55,10 +53,9 @@ const mockEstimates: Estimate[] = [
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 21,
|
||||
estimateAmount: 50000000,
|
||||
distributionDate: '2025-12-15',
|
||||
completedDate: null,
|
||||
bidDate: '2025-12-15',
|
||||
status: 'drafting',
|
||||
awardStatus: 'pending',
|
||||
status: 'pending',
|
||||
createdAt: '2025-01-03',
|
||||
updatedAt: '2025-01-03',
|
||||
createdBy: '홍길동',
|
||||
@@ -73,10 +70,9 @@ const mockEstimates: Estimate[] = [
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 0,
|
||||
estimateAmount: 10000000,
|
||||
distributionDate: null,
|
||||
completedDate: '2025-12-10',
|
||||
bidDate: '2025-12-15',
|
||||
status: 'completed',
|
||||
awardStatus: 'pending',
|
||||
createdAt: '2025-01-04',
|
||||
updatedAt: '2025-01-04',
|
||||
createdBy: '홍길동',
|
||||
@@ -91,10 +87,9 @@ const mockEstimates: Estimate[] = [
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 0,
|
||||
estimateAmount: 10000000,
|
||||
distributionDate: null,
|
||||
completedDate: '2025-12-11',
|
||||
bidDate: '2025-12-15',
|
||||
status: 'completed',
|
||||
awardStatus: 'pending',
|
||||
createdAt: '2025-01-05',
|
||||
updatedAt: '2025-01-05',
|
||||
createdBy: '홍길동',
|
||||
@@ -109,10 +104,9 @@ const mockEstimates: Estimate[] = [
|
||||
estimatorName: '홍길동',
|
||||
itemCount: 0,
|
||||
estimateAmount: 10000000,
|
||||
distributionDate: null,
|
||||
completedDate: '2025-12-12',
|
||||
bidDate: '2025-12-15',
|
||||
status: 'completed',
|
||||
awardStatus: 'awarded',
|
||||
createdAt: '2025-01-06',
|
||||
updatedAt: '2025-01-06',
|
||||
createdBy: '홍길동',
|
||||
@@ -127,10 +121,9 @@ const mockEstimates: Estimate[] = [
|
||||
estimatorName: '김철수',
|
||||
itemCount: 15,
|
||||
estimateAmount: 200000000,
|
||||
distributionDate: '2025-12-18',
|
||||
completedDate: null,
|
||||
bidDate: '2025-12-20',
|
||||
status: 'drafting',
|
||||
awardStatus: 'pending',
|
||||
status: 'pending',
|
||||
createdAt: '2025-01-07',
|
||||
updatedAt: '2025-01-07',
|
||||
createdBy: '김철수',
|
||||
@@ -246,17 +239,15 @@ export async function getEstimate(
|
||||
export async function getEstimateStats(): Promise<{ success: boolean; data?: EstimateStats; error?: string }> {
|
||||
try {
|
||||
const total = mockEstimates.length;
|
||||
const drafting = mockEstimates.filter((e) => e.status === 'drafting').length;
|
||||
const pending = mockEstimates.filter((e) => e.status === 'pending').length;
|
||||
const completed = mockEstimates.filter((e) => e.status === 'completed').length;
|
||||
const awarded = mockEstimates.filter((e) => e.awardStatus === 'awarded').length;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total,
|
||||
drafting,
|
||||
pending,
|
||||
completed,
|
||||
awarded,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
|
||||
1
src/components/business/juil/estimates/hooks/index.ts
Normal file
1
src/components/business/juil/estimates/hooks/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * from './useEstimateCalculations';
|
||||
@@ -0,0 +1,502 @@
|
||||
import { useMemo } from 'react';
|
||||
import type { EstimateDetailItem, PriceAdjustmentData } from '../types';
|
||||
|
||||
interface CalculatedValues {
|
||||
area: number;
|
||||
weight: number;
|
||||
steelScreen: number;
|
||||
caulking: number;
|
||||
rail: number;
|
||||
bottom: number;
|
||||
boxReinforce: number;
|
||||
shaft: number;
|
||||
painting: number;
|
||||
motor: number;
|
||||
controller: number;
|
||||
widthConst: number;
|
||||
heightConst: number;
|
||||
unitPrice: number;
|
||||
expenseRate: number;
|
||||
expense: number;
|
||||
quantity: number;
|
||||
cost: number;
|
||||
costExecution: number;
|
||||
marginCost: number;
|
||||
marginCostExecution: number;
|
||||
expenseExecution: number;
|
||||
}
|
||||
|
||||
// appliedPrices 타입 (전체 적용 시 복사된 가격)
|
||||
export interface AppliedPrices {
|
||||
caulking: number;
|
||||
rail: number;
|
||||
bottom: number;
|
||||
boxReinforce: number;
|
||||
shaft: number;
|
||||
painting: number;
|
||||
motor: number;
|
||||
controller: number;
|
||||
}
|
||||
|
||||
// 기본 단가 상수
|
||||
const DEFAULT_PRICES = {
|
||||
caulking: 9500,
|
||||
rail: 85000,
|
||||
bottom: 113000,
|
||||
boxReinforce: 11000,
|
||||
shaft: 14000,
|
||||
painting: 0,
|
||||
motor: 0,
|
||||
controller: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* 기본 계산값 생성 (가로/세로 기반)
|
||||
* 사용자가 수정하지 않았을 때 사용되는 자동 계산 값
|
||||
*/
|
||||
export function getDefaultCalculatedValues(
|
||||
width: number,
|
||||
height: number,
|
||||
priceAdjustmentData: PriceAdjustmentData,
|
||||
useAdjustedPrice: boolean
|
||||
) {
|
||||
const caulkingPrice = useAdjustedPrice ? priceAdjustmentData.caulking.adjustedPrice : DEFAULT_PRICES.caulking;
|
||||
const railPrice = useAdjustedPrice ? priceAdjustmentData.rail.adjustedPrice : DEFAULT_PRICES.rail;
|
||||
const bottomPrice = useAdjustedPrice ? priceAdjustmentData.bottom.adjustedPrice : DEFAULT_PRICES.bottom;
|
||||
const boxReinforcePrice = useAdjustedPrice ? priceAdjustmentData.boxReinforce.adjustedPrice : DEFAULT_PRICES.boxReinforce;
|
||||
const shaftPrice = useAdjustedPrice ? priceAdjustmentData.shaft.adjustedPrice : DEFAULT_PRICES.shaft;
|
||||
|
||||
// 06. 면적: (가로*0.16)*(세로*0.5)
|
||||
const area = (width * 0.16) * (height * 0.5);
|
||||
// 05. 무게: 면적*25
|
||||
const weight = area * 25;
|
||||
// 07. 철제,스크린: 면적*47500
|
||||
const steelScreen = Math.round(area * 47500);
|
||||
// 08. 코킹: (세로*4)*조정단가
|
||||
const caulking = Math.round((height * 4) * caulkingPrice);
|
||||
// 09. 레일: (세로*0.2)*조정단가
|
||||
const rail = Math.round((height * 0.2) * railPrice);
|
||||
// 10. 하장: 가로*조정단가
|
||||
const bottom = Math.round(width * bottomPrice);
|
||||
// 11. 박스+보강: 가로*조정단가
|
||||
const boxReinforce = Math.round(width * boxReinforcePrice);
|
||||
// 12. 샤프트: 가로*조정단가
|
||||
const shaft = Math.round(width * shaftPrice);
|
||||
|
||||
return { area, weight, steelScreen, caulking, rail, bottom, boxReinforce, shaft };
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 견적 상세 항목의 계산값 반환
|
||||
* calcXxx 필드가 있으면 사용자 입력값, 없으면 자동 계산값 사용
|
||||
*/
|
||||
export function calculateItemValues(
|
||||
item: EstimateDetailItem,
|
||||
priceAdjustmentData: PriceAdjustmentData,
|
||||
useAdjustedPrice: boolean
|
||||
): CalculatedValues {
|
||||
// 조정단가 사용 여부에 따른 단가 선택
|
||||
const paintingPrice = useAdjustedPrice ? priceAdjustmentData.painting.adjustedPrice : DEFAULT_PRICES.painting;
|
||||
const motorPrice = useAdjustedPrice ? priceAdjustmentData.motor.adjustedPrice : DEFAULT_PRICES.motor;
|
||||
const controllerPrice = useAdjustedPrice ? priceAdjustmentData.controller.adjustedPrice : DEFAULT_PRICES.controller;
|
||||
|
||||
// 기본 계산값 가져오기
|
||||
const defaultCalc = getDefaultCalculatedValues(
|
||||
item.width,
|
||||
item.height,
|
||||
priceAdjustmentData,
|
||||
useAdjustedPrice
|
||||
);
|
||||
|
||||
// 사용자 입력값이 있으면 사용, 없으면 자동 계산값 사용
|
||||
const area = item.calcArea ?? defaultCalc.area;
|
||||
const weight = item.calcWeight ?? defaultCalc.weight;
|
||||
const steelScreen = item.calcSteelScreen ?? defaultCalc.steelScreen;
|
||||
const caulking = item.calcCaulking ?? defaultCalc.caulking;
|
||||
const rail = item.calcRail ?? defaultCalc.rail;
|
||||
const bottom = item.calcBottom ?? defaultCalc.bottom;
|
||||
const boxReinforce = item.calcBoxReinforce ?? defaultCalc.boxReinforce;
|
||||
const shaft = item.calcShaft ?? defaultCalc.shaft;
|
||||
|
||||
// 13~17: 셀렉트박스 (도장, 모터, 제어기, 가로시공비, 세로시공비)
|
||||
// 조정단가 적용 시 셀렉트 값 대신 조정단가 사용
|
||||
const painting = useAdjustedPrice && paintingPrice > 0 ? paintingPrice : (item.coating || 0);
|
||||
const motor = useAdjustedPrice && motorPrice > 0 ? motorPrice : (item.mounting || 0);
|
||||
const controller = useAdjustedPrice && controllerPrice > 0 ? controllerPrice : (item.controller || 0);
|
||||
const widthConst = item.widthConstruction || 0;
|
||||
const heightConst = item.heightConstruction || 0;
|
||||
|
||||
// 18. 단가: (7)~(17)의 합 - 사용자 입력값이 있으면 사용
|
||||
const calculatedUnitPrice = steelScreen + caulking + rail + bottom + boxReinforce + shaft + painting + motor + controller + widthConst + heightConst;
|
||||
const unitPrice = item.calcUnitPrice ?? calculatedUnitPrice;
|
||||
|
||||
// 19. 공과율: 입력값
|
||||
const expenseRate = item.expense || 0;
|
||||
|
||||
// 20. 공과: 단가*공과율 - 사용자 입력값이 있으면 사용
|
||||
const calculatedExpense = Math.round(unitPrice * expenseRate);
|
||||
const expense = item.calcExpense ?? calculatedExpense;
|
||||
|
||||
// 21. 수량
|
||||
const quantity = item.quantity || 1;
|
||||
// 22. 원가: 단가+공과*수량
|
||||
const cost = Math.round((unitPrice + expense) * quantity);
|
||||
// 23. 원가실행: (단가+공과)*수량
|
||||
const costExecution = Math.round((unitPrice + expense) * quantity);
|
||||
// 24. 마진원가: 단가*공과율*1.03
|
||||
const marginCost = Math.round(unitPrice * expenseRate * 1.03);
|
||||
// 25. 마진원가실행: 마진원가*수량
|
||||
const marginCostExecution = Math.round(marginCost * quantity);
|
||||
// 26. 공과실행: 공과*수량
|
||||
const expenseExecution = Math.round(expense * quantity);
|
||||
|
||||
return {
|
||||
area,
|
||||
weight,
|
||||
steelScreen,
|
||||
caulking,
|
||||
rail,
|
||||
bottom,
|
||||
boxReinforce,
|
||||
shaft,
|
||||
painting,
|
||||
motor,
|
||||
controller,
|
||||
widthConst,
|
||||
heightConst,
|
||||
unitPrice,
|
||||
expenseRate,
|
||||
expense,
|
||||
quantity,
|
||||
cost,
|
||||
costExecution,
|
||||
marginCost,
|
||||
marginCostExecution,
|
||||
expenseExecution,
|
||||
};
|
||||
}
|
||||
|
||||
interface TotalValues {
|
||||
weight: number;
|
||||
area: number;
|
||||
steelScreen: number;
|
||||
caulking: number;
|
||||
rail: number;
|
||||
bottom: number;
|
||||
boxReinforce: number;
|
||||
shaft: number;
|
||||
painting: number;
|
||||
motor: number;
|
||||
controller: number;
|
||||
widthConstruction: number;
|
||||
heightConstruction: number;
|
||||
unitPrice: number;
|
||||
expense: number;
|
||||
quantity: number;
|
||||
cost: number;
|
||||
costExecution: number;
|
||||
marginCost: number;
|
||||
marginCostExecution: number;
|
||||
expenseExecution: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 견적 상세 항목의 합계 계산
|
||||
*/
|
||||
export function calculateTotals(
|
||||
items: EstimateDetailItem[],
|
||||
priceAdjustmentData: PriceAdjustmentData,
|
||||
useAdjustedPrice: boolean
|
||||
): TotalValues {
|
||||
return items.reduce(
|
||||
(acc, item) => {
|
||||
const values = calculateItemValues(item, priceAdjustmentData, useAdjustedPrice);
|
||||
return {
|
||||
weight: acc.weight + values.weight,
|
||||
area: acc.area + values.area,
|
||||
steelScreen: acc.steelScreen + values.steelScreen,
|
||||
caulking: acc.caulking + values.caulking,
|
||||
rail: acc.rail + values.rail,
|
||||
bottom: acc.bottom + values.bottom,
|
||||
boxReinforce: acc.boxReinforce + values.boxReinforce,
|
||||
shaft: acc.shaft + values.shaft,
|
||||
painting: acc.painting + values.painting,
|
||||
motor: acc.motor + values.motor,
|
||||
controller: acc.controller + values.controller,
|
||||
widthConstruction: acc.widthConstruction + values.widthConst,
|
||||
heightConstruction: acc.heightConstruction + values.heightConst,
|
||||
unitPrice: acc.unitPrice + values.unitPrice,
|
||||
expense: acc.expense + values.expense,
|
||||
quantity: acc.quantity + values.quantity,
|
||||
cost: acc.cost + values.cost,
|
||||
costExecution: acc.costExecution + values.costExecution,
|
||||
marginCost: acc.marginCost + values.marginCost,
|
||||
marginCostExecution: acc.marginCostExecution + values.marginCostExecution,
|
||||
expenseExecution: acc.expenseExecution + values.expenseExecution,
|
||||
};
|
||||
},
|
||||
{
|
||||
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,
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 견적 상세 테이블용 계산 훅
|
||||
*/
|
||||
export function useEstimateCalculations(
|
||||
items: EstimateDetailItem[],
|
||||
priceAdjustmentData: PriceAdjustmentData,
|
||||
useAdjustedPrice: boolean
|
||||
) {
|
||||
const totals = useMemo(
|
||||
() => calculateTotals(items, priceAdjustmentData, useAdjustedPrice),
|
||||
[items, priceAdjustmentData, useAdjustedPrice]
|
||||
);
|
||||
|
||||
return { totals, calculateItemValues };
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// appliedPrices 기반 계산 함수들 (priceAdjustmentData와 독립적)
|
||||
// "전체 적용" 버튼 클릭 시점에 복사된 가격만 사용
|
||||
// ============================================================
|
||||
|
||||
/**
|
||||
* 기본 계산값 생성 (개별 항목 조정단가 → appliedPrices → 기본단가 순으로 적용)
|
||||
*
|
||||
* 우선순위:
|
||||
* 1. 개별 항목의 adjustedXxx (선택 적용 버튼으로 설정)
|
||||
* 2. appliedPrices (전체 적용 버튼으로 설정)
|
||||
* 3. DEFAULT_PRICES (기본값)
|
||||
*/
|
||||
export function getDefaultCalculatedValuesWithApplied(
|
||||
width: number,
|
||||
height: number,
|
||||
appliedPrices: AppliedPrices | null,
|
||||
itemAdjusted?: {
|
||||
caulking?: number;
|
||||
rail?: number;
|
||||
bottom?: number;
|
||||
boxReinforce?: number;
|
||||
shaft?: number;
|
||||
}
|
||||
) {
|
||||
// 우선순위: 개별 항목 조정단가 → 전체 적용 단가 → 기본 단가
|
||||
const caulkingPrice = itemAdjusted?.caulking ?? appliedPrices?.caulking ?? DEFAULT_PRICES.caulking;
|
||||
const railPrice = itemAdjusted?.rail ?? appliedPrices?.rail ?? DEFAULT_PRICES.rail;
|
||||
const bottomPrice = itemAdjusted?.bottom ?? appliedPrices?.bottom ?? DEFAULT_PRICES.bottom;
|
||||
const boxReinforcePrice = itemAdjusted?.boxReinforce ?? appliedPrices?.boxReinforce ?? DEFAULT_PRICES.boxReinforce;
|
||||
const shaftPrice = itemAdjusted?.shaft ?? appliedPrices?.shaft ?? DEFAULT_PRICES.shaft;
|
||||
|
||||
// 06. 면적: (가로*0.16)*(세로*0.5)
|
||||
const area = (width * 0.16) * (height * 0.5);
|
||||
// 05. 무게: 면적*25
|
||||
const weight = area * 25;
|
||||
// 07. 철제,스크린: 면적*47500
|
||||
const steelScreen = Math.round(area * 47500);
|
||||
// 08. 코킹: (세로*4)*조정단가
|
||||
const caulking = Math.round((height * 4) * caulkingPrice);
|
||||
// 09. 레일: (세로*0.2)*조정단가
|
||||
const rail = Math.round((height * 0.2) * railPrice);
|
||||
// 10. 하장: 가로*조정단가
|
||||
const bottom = Math.round(width * bottomPrice);
|
||||
// 11. 박스+보강: 가로*조정단가
|
||||
const boxReinforce = Math.round(width * boxReinforcePrice);
|
||||
// 12. 샤프트: 가로*조정단가
|
||||
const shaft = Math.round(width * shaftPrice);
|
||||
|
||||
return { area, weight, steelScreen, caulking, rail, bottom, boxReinforce, shaft };
|
||||
}
|
||||
|
||||
/**
|
||||
* 개별 견적 상세 항목의 계산값 반환 (appliedPrices 기반)
|
||||
*
|
||||
* 우선순위:
|
||||
* 1. 개별 항목의 adjustedXxx (선택 적용 버튼으로 설정)
|
||||
* 2. appliedPrices (전체 적용 버튼으로 설정)
|
||||
* 3. DEFAULT_PRICES (기본값)
|
||||
*/
|
||||
export function calculateItemValuesWithApplied(
|
||||
item: EstimateDetailItem,
|
||||
appliedPrices: AppliedPrices | null
|
||||
): CalculatedValues {
|
||||
// 개별 항목의 조정단가 (선택 적용 시 설정됨)
|
||||
const itemAdjusted = {
|
||||
caulking: item.adjustedCaulking,
|
||||
rail: item.adjustedRail,
|
||||
bottom: item.adjustedBottom,
|
||||
boxReinforce: item.adjustedBoxReinforce,
|
||||
shaft: item.adjustedShaft,
|
||||
};
|
||||
|
||||
// 우선순위: 개별 항목 조정단가 → 전체 적용 단가 → 기본 단가
|
||||
const paintingPrice = item.adjustedPainting ?? appliedPrices?.painting ?? DEFAULT_PRICES.painting;
|
||||
const motorPrice = item.adjustedMotor ?? appliedPrices?.motor ?? DEFAULT_PRICES.motor;
|
||||
const controllerPrice = item.adjustedController ?? appliedPrices?.controller ?? DEFAULT_PRICES.controller;
|
||||
|
||||
// 기본 계산값 가져오기 (개별 조정단가 우선 적용)
|
||||
const defaultCalc = getDefaultCalculatedValuesWithApplied(
|
||||
item.width,
|
||||
item.height,
|
||||
appliedPrices,
|
||||
itemAdjusted
|
||||
);
|
||||
|
||||
// 사용자 입력값이 있으면 사용, 없으면 자동 계산값 사용
|
||||
const area = item.calcArea ?? defaultCalc.area;
|
||||
const weight = item.calcWeight ?? defaultCalc.weight;
|
||||
const steelScreen = item.calcSteelScreen ?? defaultCalc.steelScreen;
|
||||
const caulking = item.calcCaulking ?? defaultCalc.caulking;
|
||||
const rail = item.calcRail ?? defaultCalc.rail;
|
||||
const bottom = item.calcBottom ?? defaultCalc.bottom;
|
||||
const boxReinforce = item.calcBoxReinforce ?? defaultCalc.boxReinforce;
|
||||
const shaft = item.calcShaft ?? defaultCalc.shaft;
|
||||
|
||||
// 13~17: 셀렉트박스 (도장, 모터, 제어기, 가로시공비, 세로시공비)
|
||||
// 우선순위: 개별 조정단가 → 전체 적용 단가 → 셀렉트박스 값
|
||||
const hasItemPainting = item.adjustedPainting !== undefined;
|
||||
const hasAppliedPainting = appliedPrices && appliedPrices.painting > 0;
|
||||
const painting = hasItemPainting ? paintingPrice : (hasAppliedPainting ? paintingPrice : (item.coating || 0));
|
||||
|
||||
const hasItemMotor = item.adjustedMotor !== undefined;
|
||||
const hasAppliedMotor = appliedPrices && appliedPrices.motor > 0;
|
||||
const motor = hasItemMotor ? motorPrice : (hasAppliedMotor ? motorPrice : (item.mounting || 0));
|
||||
|
||||
const hasItemController = item.adjustedController !== undefined;
|
||||
const hasAppliedController = appliedPrices && appliedPrices.controller > 0;
|
||||
const controller = hasItemController ? controllerPrice : (hasAppliedController ? controllerPrice : (item.controller || 0));
|
||||
|
||||
const widthConst = item.widthConstruction || 0;
|
||||
const heightConst = item.heightConstruction || 0;
|
||||
|
||||
// 18. 단가: (7)~(17)의 합 - 사용자 입력값이 있으면 사용
|
||||
const calculatedUnitPrice = steelScreen + caulking + rail + bottom + boxReinforce + shaft + painting + motor + controller + widthConst + heightConst;
|
||||
const unitPrice = item.calcUnitPrice ?? calculatedUnitPrice;
|
||||
|
||||
// 19. 공과율: 입력값
|
||||
const expenseRate = item.expense || 0;
|
||||
|
||||
// 20. 공과: 단가*공과율 - 사용자 입력값이 있으면 사용
|
||||
const calculatedExpense = Math.round(unitPrice * expenseRate);
|
||||
const expense = item.calcExpense ?? calculatedExpense;
|
||||
|
||||
// 21. 수량
|
||||
const quantity = item.quantity || 1;
|
||||
// 22. 원가: 단가+공과*수량
|
||||
const cost = Math.round((unitPrice + expense) * quantity);
|
||||
// 23. 원가실행: (단가+공과)*수량
|
||||
const costExecution = Math.round((unitPrice + expense) * quantity);
|
||||
// 24. 마진원가: 단가*공과율*1.03
|
||||
const marginCost = Math.round(unitPrice * expenseRate * 1.03);
|
||||
// 25. 마진원가실행: 마진원가*수량
|
||||
const marginCostExecution = Math.round(marginCost * quantity);
|
||||
// 26. 공과실행: 공과*수량
|
||||
const expenseExecution = Math.round(expense * quantity);
|
||||
|
||||
return {
|
||||
area,
|
||||
weight,
|
||||
steelScreen,
|
||||
caulking,
|
||||
rail,
|
||||
bottom,
|
||||
boxReinforce,
|
||||
shaft,
|
||||
painting,
|
||||
motor,
|
||||
controller,
|
||||
widthConst,
|
||||
heightConst,
|
||||
unitPrice,
|
||||
expenseRate,
|
||||
expense,
|
||||
quantity,
|
||||
cost,
|
||||
costExecution,
|
||||
marginCost,
|
||||
marginCostExecution,
|
||||
expenseExecution,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 모든 견적 상세 항목의 합계 계산 (appliedPrices 기반)
|
||||
* priceAdjustmentData 변경에 영향받지 않음
|
||||
*/
|
||||
export function calculateTotalsWithApplied(
|
||||
items: EstimateDetailItem[],
|
||||
appliedPrices: AppliedPrices | null
|
||||
): TotalValues {
|
||||
return items.reduce(
|
||||
(acc, item) => {
|
||||
const values = calculateItemValuesWithApplied(item, appliedPrices);
|
||||
return {
|
||||
weight: acc.weight + values.weight,
|
||||
area: acc.area + values.area,
|
||||
steelScreen: acc.steelScreen + values.steelScreen,
|
||||
caulking: acc.caulking + values.caulking,
|
||||
rail: acc.rail + values.rail,
|
||||
bottom: acc.bottom + values.bottom,
|
||||
boxReinforce: acc.boxReinforce + values.boxReinforce,
|
||||
shaft: acc.shaft + values.shaft,
|
||||
painting: acc.painting + values.painting,
|
||||
motor: acc.motor + values.motor,
|
||||
controller: acc.controller + values.controller,
|
||||
widthConstruction: acc.widthConstruction + values.widthConst,
|
||||
heightConstruction: acc.heightConstruction + values.heightConst,
|
||||
unitPrice: acc.unitPrice + values.unitPrice,
|
||||
expense: acc.expense + values.expense,
|
||||
quantity: acc.quantity + values.quantity,
|
||||
cost: acc.cost + values.cost,
|
||||
costExecution: acc.costExecution + values.costExecution,
|
||||
marginCost: acc.marginCost + values.marginCost,
|
||||
marginCostExecution: acc.marginCostExecution + values.marginCostExecution,
|
||||
expenseExecution: acc.expenseExecution + values.expenseExecution,
|
||||
};
|
||||
},
|
||||
{
|
||||
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,
|
||||
}
|
||||
);
|
||||
}
|
||||
@@ -1,3 +1,5 @@
|
||||
export { default as EstimateListClient } from './EstimateListClient';
|
||||
export { default as EstimateDetailForm } from './EstimateDetailForm';
|
||||
export * from './types';
|
||||
export * from './actions';
|
||||
export * from './actions';
|
||||
export * from './modals';
|
||||
@@ -0,0 +1,2 @@
|
||||
// 공통 컴포넌트 re-export
|
||||
export { ElectronicApprovalModal } from '../../common/modals/ElectronicApprovalModal';
|
||||
@@ -0,0 +1,410 @@
|
||||
'use client';
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Printer, Pencil, Send, X as XIcon } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
VisuallyHidden,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { printArea } from '@/lib/print-utils';
|
||||
import type { EstimateDetailFormData } from '../types';
|
||||
|
||||
// 금액 포맷팅
|
||||
function formatAmount(amount: number): string {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
}
|
||||
|
||||
// 금액을 한글로 변환
|
||||
function amountToKorean(amount: number): string {
|
||||
const units = ['', '만', '억', '조'];
|
||||
const smallUnits = ['', '십', '백', '천'];
|
||||
const digits = ['', '일', '이', '삼', '사', '오', '육', '칠', '팔', '구'];
|
||||
|
||||
if (amount === 0) return '영';
|
||||
|
||||
let result = '';
|
||||
let unitIndex = 0;
|
||||
|
||||
while (amount > 0) {
|
||||
const segment = amount % 10000;
|
||||
if (segment > 0) {
|
||||
let segmentStr = '';
|
||||
let segmentNum = segment;
|
||||
for (let i = 0; i < 4 && segmentNum > 0; i++) {
|
||||
const digit = segmentNum % 10;
|
||||
if (digit > 0) {
|
||||
segmentStr = digits[digit] + smallUnits[i] + segmentStr;
|
||||
}
|
||||
segmentNum = Math.floor(segmentNum / 10);
|
||||
}
|
||||
result = segmentStr + units[unitIndex] + result;
|
||||
}
|
||||
amount = Math.floor(amount / 10000);
|
||||
unitIndex++;
|
||||
}
|
||||
|
||||
return '(금)' + result;
|
||||
}
|
||||
|
||||
interface EstimateDocumentModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
formData: EstimateDetailFormData;
|
||||
estimateId?: string;
|
||||
}
|
||||
|
||||
export function EstimateDocumentModal({
|
||||
isOpen,
|
||||
onClose,
|
||||
formData,
|
||||
estimateId,
|
||||
}: EstimateDocumentModalProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 인쇄
|
||||
const handlePrint = useCallback(() => {
|
||||
printArea({ title: '견적서 인쇄' });
|
||||
}, []);
|
||||
|
||||
// 수정 페이지로 이동
|
||||
const handleEdit = useCallback(() => {
|
||||
if (estimateId) {
|
||||
onClose();
|
||||
router.push(`/ko/juil/project/bidding/estimates/${estimateId}/edit`);
|
||||
}
|
||||
}, [estimateId, onClose, router]);
|
||||
|
||||
// 견적서 문서 데이터
|
||||
const documentData = {
|
||||
documentNo: formData.estimateCode || 'ABC123',
|
||||
createdDate: formData.siteBriefing.briefingDate || '2025년 11월 11일',
|
||||
recipient: formData.siteBriefing.partnerName || '',
|
||||
companyName: formData.siteBriefing.companyName || '(주) 주일기업',
|
||||
projectName: formData.bidInfo.projectName || '',
|
||||
address: '주소',
|
||||
amount: formData.summaryItems.reduce((sum, item) => sum + item.totalCost, 0),
|
||||
date: formData.bidInfo.bidDate || '2025년 12월 12일',
|
||||
contact: {
|
||||
hp: '010-3679-2188',
|
||||
tel: '(02) 849-5130',
|
||||
fax: '(02) 6911-6315',
|
||||
},
|
||||
note: '하기와 같이 보내합니다.',
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<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={onClose}
|
||||
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} disabled={!estimateId}>
|
||||
<Pencil className="h-4 w-4 mr-1" />
|
||||
수정
|
||||
</Button>
|
||||
<Button variant="default" size="sm" 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>
|
||||
|
||||
{/* 문서 영역 - 스크롤 (인쇄 시 이 영역만 출력) */}
|
||||
<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 p-8">
|
||||
{/* 상단: 제목 + 결재란 */}
|
||||
<div className="flex justify-between items-start mb-4">
|
||||
{/* 제목 영역 */}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold text-center tracking-[0.3em]">견 적 서</h1>
|
||||
{/* 문서번호 및 작성일자 */}
|
||||
<div className="text-sm mt-4 text-center">
|
||||
<span className="mr-4">문서번호: {documentData.documentNo}</span>
|
||||
<span className="mx-2">|</span>
|
||||
<span className="ml-4">작성일자: {documentData.createdDate}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 결재란 (상단 우측) - 3열 3행 */}
|
||||
<table className="text-xs border-collapse border border-gray-400 ml-4">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-400 w-10"></td>
|
||||
<td className="border border-gray-400 px-4 py-1 text-center whitespace-nowrap">작성</td>
|
||||
<td className="border border-gray-400 px-4 py-1 text-center whitespace-nowrap">승인</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-2 py-3 text-center whitespace-nowrap">결재</td>
|
||||
<td className="border border-gray-400 px-4 py-3 text-center whitespace-nowrap">홍길동</td>
|
||||
<td className="border border-gray-400 px-4 py-3 text-center whitespace-nowrap">이름</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400"></td>
|
||||
<td className="border border-gray-400 px-4 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-4 py-1 text-center whitespace-nowrap">부서명</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 테이블 */}
|
||||
<table className="w-full border-collapse mb-6 text-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-3 py-2 bg-gray-100 w-20 text-center">귀중</td>
|
||||
<td className="border border-gray-400 px-3 py-2 w-1/4">회사명</td>
|
||||
<td className="border border-gray-400 px-3 py-2 bg-gray-100 w-20 text-center">회사명</td>
|
||||
<td className="border border-gray-400 px-3 py-2 w-1/4">{documentData.companyName}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center">현장명</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{documentData.projectName || '현장명'}</td>
|
||||
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center">주소</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{documentData.address || '주소명'}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center">금액</td>
|
||||
<td className="border border-gray-400 px-3 py-2">
|
||||
{amountToKorean(documentData.amount)} 원정
|
||||
</td>
|
||||
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center">일자</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{documentData.date}</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center">연락처</td>
|
||||
<td className="border border-gray-400 px-3 py-2"></td>
|
||||
<td className="border border-gray-400 px-3 py-2 bg-gray-100 text-center">연락처</td>
|
||||
<td className="border border-gray-400 px-3 py-2">
|
||||
<div className="space-y-0.5 text-xs">
|
||||
<div>H . P : {documentData.contact.hp}</div>
|
||||
<div>T E L : {documentData.contact.tel}</div>
|
||||
<div>F A X : {documentData.contact.fax}</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 안내 문구 */}
|
||||
<p className="text-sm mb-6">하기와 같이 見積합니다.</p>
|
||||
|
||||
{/* 견적 요약 테이블 */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-2">
|
||||
<span className="text-sm font-medium">견적 요약</span>
|
||||
</div>
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border border-gray-400 px-3 py-2">명 칭</th>
|
||||
<th className="border border-gray-400 px-3 py-2 w-16">수량</th>
|
||||
<th className="border border-gray-400 px-3 py-2 w-16">단위</th>
|
||||
<th className="border border-gray-400 px-3 py-2 w-24">재 료 비</th>
|
||||
<th className="border border-gray-400 px-3 py-2 w-24">노 무 비</th>
|
||||
<th className="border border-gray-400 px-3 py-2 w-24">합 계</th>
|
||||
<th className="border border-gray-400 px-3 py-2 w-20">비 고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{formData.summaryItems.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={7} className="border border-gray-400 px-3 py-4 text-center text-gray-500">
|
||||
등록된 항목이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
formData.summaryItems.map((item) => (
|
||||
<tr key={item.id}>
|
||||
<td className="border border-gray-400 px-3 py-2">{item.name}</td>
|
||||
<td className="border border-gray-400 px-3 py-2 text-center">
|
||||
{item.quantity}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-3 py-2 text-center">
|
||||
{item.unit}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-3 py-2 text-right">
|
||||
{formatAmount(item.materialCost)}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-3 py-2 text-right">
|
||||
{formatAmount(item.laborCost)}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-3 py-2 text-right font-medium">
|
||||
{formatAmount(item.totalCost)}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-3 py-2">{item.remarks}</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
{/* 합계 행 */}
|
||||
<tr className="font-medium">
|
||||
<td className="border border-gray-400 px-3 py-2 text-center" colSpan={3}>
|
||||
합 계
|
||||
</td>
|
||||
<td className="border border-gray-400 px-3 py-2 text-right">
|
||||
{formatAmount(
|
||||
formData.summaryItems.reduce((sum, item) => sum + item.materialCost, 0)
|
||||
)}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-3 py-2 text-right">
|
||||
{formatAmount(
|
||||
formData.summaryItems.reduce((sum, item) => sum + item.laborCost, 0)
|
||||
)}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-3 py-2 text-right">
|
||||
₩{formatAmount(
|
||||
formData.summaryItems.reduce((sum, item) => sum + item.totalCost, 0)
|
||||
)}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-3 py-2"></td>
|
||||
</tr>
|
||||
{/* 특기사항 행 */}
|
||||
<tr>
|
||||
<td colSpan={7} className="border border-gray-400 px-3 py-2 text-sm">
|
||||
* 특기사항 : 부가세 별도 / 현설조건에 따름
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 견적 상세 테이블 */}
|
||||
<div className="mb-6">
|
||||
<div className="mb-2">
|
||||
<span className="text-sm font-medium">견적 상세</span>
|
||||
</div>
|
||||
<table className="w-full border-collapse text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border border-gray-400 px-2 py-1 w-8" rowSpan={2}>NO</th>
|
||||
<th className="border border-gray-400 px-2 py-1" rowSpan={2}>명칭</th>
|
||||
<th className="border border-gray-400 px-2 py-1" rowSpan={2}>제품</th>
|
||||
<th className="border border-gray-400 px-2 py-1" colSpan={2}>규 격(mm)</th>
|
||||
<th className="border border-gray-400 px-2 py-1" rowSpan={2}>수량</th>
|
||||
<th className="border border-gray-400 px-2 py-1" rowSpan={2}>단위</th>
|
||||
<th className="border border-gray-400 px-2 py-1" colSpan={2}>재 료 비</th>
|
||||
<th className="border border-gray-400 px-2 py-1" colSpan={2}>노 무 비</th>
|
||||
<th className="border border-gray-400 px-2 py-1" colSpan={2}>합 계</th>
|
||||
</tr>
|
||||
<tr className="bg-gray-100">
|
||||
<th className="border border-gray-400 px-2 py-1">가로(W)</th>
|
||||
<th className="border border-gray-400 px-2 py-1">높이(H)</th>
|
||||
<th className="border border-gray-400 px-2 py-1">단가</th>
|
||||
<th className="border border-gray-400 px-2 py-1">금액</th>
|
||||
<th className="border border-gray-400 px-2 py-1">단가</th>
|
||||
<th className="border border-gray-400 px-2 py-1">금액</th>
|
||||
<th className="border border-gray-400 px-2 py-1">단가</th>
|
||||
<th className="border border-gray-400 px-2 py-1">금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{formData.detailItems.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={13} className="border border-gray-400 px-3 py-4 text-center text-gray-500">
|
||||
등록된 항목이 없습니다.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
formData.detailItems.map((item, index) => (
|
||||
<tr key={item.id}>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center">
|
||||
{index + 1}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-2 py-1">{item.name}</td>
|
||||
<td className="border border-gray-400 px-2 py-1">{item.material}</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-right">
|
||||
{formatAmount(item.width)}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-right">
|
||||
{formatAmount(item.height)}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center">
|
||||
{item.quantity}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center">SET</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-right">
|
||||
{formatAmount(item.unitPrice)}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-right">
|
||||
{formatAmount(item.materialCost)}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-right">
|
||||
{formatAmount(item.laborCost)}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-right">
|
||||
{formatAmount(item.laborCost * item.quantity)}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-right">
|
||||
{formatAmount(item.totalPrice)}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-right">
|
||||
{formatAmount(item.totalCost)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
{/* 합계 행 */}
|
||||
<tr className="font-medium">
|
||||
<td className="border border-gray-400 px-2 py-1 text-center" colSpan={5}>
|
||||
합 계
|
||||
</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center">
|
||||
{formData.detailItems.reduce((sum, item) => sum + item.quantity, 0)}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-center">SET</td>
|
||||
<td className="border border-gray-400 px-2 py-1"></td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-right">
|
||||
{formatAmount(
|
||||
formData.detailItems.reduce((sum, item) => sum + item.materialCost, 0)
|
||||
)}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-2 py-1"></td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-right">
|
||||
{formatAmount(
|
||||
formData.detailItems.reduce((sum, item) => sum + item.laborCost * item.quantity, 0)
|
||||
)}
|
||||
</td>
|
||||
<td className="border border-gray-400 px-2 py-1"></td>
|
||||
<td className="border border-gray-400 px-2 py-1 text-right">
|
||||
{formatAmount(
|
||||
formData.detailItems.reduce((sum, item) => sum + item.totalCost, 0)
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
{/* 비고 행 */}
|
||||
<tr>
|
||||
<td colSpan={13} className="border border-gray-400 px-2 py-1 text-sm">
|
||||
* 비고 :
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
2
src/components/business/juil/estimates/modals/index.ts
Normal file
2
src/components/business/juil/estimates/modals/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { ElectronicApprovalModal } from './ElectronicApprovalModal';
|
||||
export { EstimateDocumentModal } from './EstimateDocumentModal';
|
||||
@@ -0,0 +1,601 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { X, HelpCircle } from 'lucide-react';
|
||||
import {
|
||||
Tooltip,
|
||||
TooltipContent,
|
||||
TooltipProvider,
|
||||
TooltipTrigger,
|
||||
} from '@/components/ui/tooltip';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { EstimateDetailItem } from '../types';
|
||||
import { formatAmount, MOCK_MATERIALS } from '../utils';
|
||||
import { calculateItemValuesWithApplied, calculateTotalsWithApplied } from '../hooks/useEstimateCalculations';
|
||||
|
||||
// 계산식 정보
|
||||
const FORMULA_INFO: Record<string, string> = {
|
||||
weight: '면적 × 25',
|
||||
area: '(가로 × 0.16) × (세로 × 0.5)',
|
||||
steelScreen: '면적 × 47,500',
|
||||
caulking: '(세로 × 4) × 조정단가',
|
||||
rail: '(세로 × 0.2) × 조정단가',
|
||||
bottom: '가로 × 조정단가',
|
||||
boxReinforce: '가로 × 조정단가',
|
||||
shaft: '가로 × 조정단가',
|
||||
unitPrice: '철제스크린 + 코킹 + 레일 + 하장 + 박스보강 + 샤프트 + 도장 + 모터 + 제어기 + 가로시공비 + 세로시공비',
|
||||
expense: '단가 × 공과율',
|
||||
cost: '단가 + 공과',
|
||||
costExecution: '원가 × 수량',
|
||||
marginCost: '원가 × 마진율(1.03)',
|
||||
marginCostExecution: '마진원가 × 수량',
|
||||
expenseExecution: '공과 × 수량',
|
||||
};
|
||||
|
||||
// 계산식 툴팁이 있는 헤더 컴포넌트
|
||||
function FormulaHeader({ label, formulaKey, className }: { label: string; formulaKey: string; className?: string }) {
|
||||
const formula = FORMULA_INFO[formulaKey];
|
||||
if (!formula) {
|
||||
return <span>{label}</span>;
|
||||
}
|
||||
return (
|
||||
<TooltipProvider delayDuration={0}>
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<span className={`inline-flex items-center gap-1 cursor-help ${className || ''}`}>
|
||||
{label}
|
||||
<HelpCircle className="h-3.5 w-3.5 text-gray-400 hover:text-gray-600" />
|
||||
</span>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent side="top" className="max-w-xs">
|
||||
<p className="text-xs font-medium text-gray-700">{label} 계산식</p>
|
||||
<p className="text-xs text-gray-500 mt-1">{formula}</p>
|
||||
</TooltipContent>
|
||||
</Tooltip>
|
||||
</TooltipProvider>
|
||||
);
|
||||
}
|
||||
|
||||
// appliedPrices 타입 정의
|
||||
export interface AppliedPrices {
|
||||
caulking: number;
|
||||
rail: number;
|
||||
bottom: number;
|
||||
boxReinforce: number;
|
||||
shaft: number;
|
||||
painting: number;
|
||||
motor: number;
|
||||
controller: number;
|
||||
}
|
||||
|
||||
interface EstimateDetailTableSectionProps {
|
||||
detailItems: EstimateDetailItem[];
|
||||
appliedPrices: AppliedPrices | null;
|
||||
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;
|
||||
}
|
||||
|
||||
export function EstimateDetailTableSection({
|
||||
detailItems,
|
||||
appliedPrices,
|
||||
isViewMode,
|
||||
onAddItems,
|
||||
onRemoveItem,
|
||||
onRemoveSelected,
|
||||
onItemChange,
|
||||
onSelectItem,
|
||||
onSelectAll,
|
||||
onApplyAdjustedPrice,
|
||||
onReset,
|
||||
}: EstimateDetailTableSectionProps) {
|
||||
const selectedCount = detailItems.filter((item) => (item as unknown as { selected?: boolean }).selected).length;
|
||||
const allSelected = detailItems.length > 0 && detailItems.every((item) => (item as unknown as { selected?: boolean }).selected);
|
||||
const totals = calculateTotalsWithApplied(detailItems, appliedPrices);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<CardTitle className="text-lg whitespace-nowrap">견적 상세</CardTitle>
|
||||
{!isViewMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">{selectedCount}건 선택</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="bg-gray-900 hover:bg-gray-800"
|
||||
onClick={onRemoveSelected}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
onClick={onApplyAdjustedPrice}
|
||||
>
|
||||
조정 단가 적용
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isViewMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={1}
|
||||
className="w-16 text-center"
|
||||
id="detail-add-count"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="bg-gray-900 hover:bg-gray-800"
|
||||
onClick={() => {
|
||||
const countInput = document.getElementById('detail-add-count') as HTMLInputElement;
|
||||
const count = Math.max(1, parseInt(countInput?.value || '1', 10));
|
||||
onAddItems(count);
|
||||
}}
|
||||
>
|
||||
추가
|
||||
</Button>
|
||||
{/* TODO: 견적 상세 기획서 수정 후 초기화 버튼 및 테이블 항목/데이터 재작업 필요
|
||||
<Button type="button" variant="outline" size="sm" onClick={onReset}>
|
||||
초기화
|
||||
</Button>
|
||||
*/}
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto max-h-[600px]">
|
||||
<Table>
|
||||
<TableHeader className="sticky top-0 bg-white z-10">
|
||||
<TableRow className="bg-gray-100">
|
||||
{!isViewMode && (
|
||||
<TableHead className="w-[40px] text-center sticky left-0 bg-gray-100 z-20">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
checked={allSelected}
|
||||
onChange={(e) => onSelectAll(e.target.checked)}
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
<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">
|
||||
<FormulaHeader label="무게" formulaKey="weight" />
|
||||
</TableHead>
|
||||
<TableHead className="w-[70px] text-right">
|
||||
<FormulaHeader label="면적" formulaKey="area" />
|
||||
</TableHead>
|
||||
<TableHead className="w-[90px] text-right">
|
||||
<FormulaHeader label="철제,스크린" formulaKey="steelScreen" />
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px] text-right">
|
||||
<FormulaHeader label="코킹" formulaKey="caulking" />
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px] text-right">
|
||||
<FormulaHeader label="레일" formulaKey="rail" />
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px] text-right">
|
||||
<FormulaHeader label="하장" formulaKey="bottom" />
|
||||
</TableHead>
|
||||
<TableHead className="w-[90px] text-right">
|
||||
<FormulaHeader label="박스+보강" formulaKey="boxReinforce" />
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px] text-right">
|
||||
<FormulaHeader label="샤프트" formulaKey="shaft" />
|
||||
</TableHead>
|
||||
<TableHead className="w-[80px] text-center">도장</TableHead>
|
||||
<TableHead className="w-[80px] text-center">모터</TableHead>
|
||||
<TableHead className="w-[80px] text-center">제어기</TableHead>
|
||||
<TableHead className="w-[100px] text-center">가로시공비</TableHead>
|
||||
<TableHead className="w-[100px] text-center">세로시공비</TableHead>
|
||||
<TableHead className="w-[90px] text-right">
|
||||
<FormulaHeader label="단가" formulaKey="unitPrice" />
|
||||
</TableHead>
|
||||
<TableHead className="w-[70px] text-right">공과율</TableHead>
|
||||
<TableHead className="w-[80px] text-right">
|
||||
<FormulaHeader label="공과" formulaKey="expense" />
|
||||
</TableHead>
|
||||
<TableHead className="w-[50px] text-right">수량</TableHead>
|
||||
<TableHead className="w-[90px] text-right">
|
||||
<FormulaHeader label="원가" formulaKey="cost" />
|
||||
</TableHead>
|
||||
<TableHead className="w-[90px] text-right">
|
||||
<FormulaHeader label="원가실행" formulaKey="costExecution" />
|
||||
</TableHead>
|
||||
<TableHead className="w-[90px] text-right">
|
||||
<FormulaHeader label="마진원가" formulaKey="marginCost" />
|
||||
</TableHead>
|
||||
<TableHead className="w-[100px] text-right">
|
||||
<FormulaHeader label="마진원가실행" formulaKey="marginCostExecution" />
|
||||
</TableHead>
|
||||
<TableHead className="w-[90px] text-right">
|
||||
<FormulaHeader label="공과실행" formulaKey="expenseExecution" />
|
||||
</TableHead>
|
||||
{!isViewMode && <TableHead className="w-[50px] text-center">삭제</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{detailItems.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={isViewMode ? 27 : 29}
|
||||
className="text-center text-gray-500 py-8"
|
||||
>
|
||||
등록된 항목이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
<>
|
||||
{detailItems.map((item) => {
|
||||
const values = calculateItemValuesWithApplied(item, appliedPrices);
|
||||
|
||||
return (
|
||||
<TableRow key={item.id}>
|
||||
{!isViewMode && (
|
||||
<TableCell className="text-center sticky left-0 bg-white">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
checked={(item as unknown as { selected?: boolean }).selected || false}
|
||||
onChange={(e) => onSelectItem(item.id, e.target.checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
{/* 01: 명칭 */}
|
||||
<TableCell>
|
||||
<Input
|
||||
value={item.name}
|
||||
onChange={(e) => onItemChange(item.id, 'name', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
className={`w-full min-w-[80px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
{/* 02: 제품 */}
|
||||
<TableCell>
|
||||
<Select
|
||||
value={item.material}
|
||||
onValueChange={(val) => onItemChange(item.id, 'material', val)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger className={`w-full min-w-[70px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_MATERIALS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
{/* 03: 가로 */}
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={item.width}
|
||||
onChange={(e) => onItemChange(item.id, 'width', Number(e.target.value))}
|
||||
disabled={isViewMode}
|
||||
className={`text-right min-w-[60px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
{/* 04: 세로 */}
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={item.height}
|
||||
onChange={(e) => onItemChange(item.id, 'height', Number(e.target.value))}
|
||||
disabled={isViewMode}
|
||||
className={`text-right min-w-[60px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
{/* 05: 무게 (인풋, 계산값 표시 + 수정 가능) */}
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={values.weight.toFixed(2)}
|
||||
onChange={(e) => onItemChange(item.id, 'calcWeight', Number(e.target.value))}
|
||||
disabled={isViewMode}
|
||||
className={`text-right min-w-[60px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
{/* 06: 면적 (인풋, 계산값 표시 + 수정 가능) */}
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={values.area.toFixed(2)}
|
||||
onChange={(e) => onItemChange(item.id, 'calcArea', Number(e.target.value))}
|
||||
disabled={isViewMode}
|
||||
className={`text-right min-w-[60px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
{/* 07: 철제,스크린 (인풋, 계산값 표시 + 수정 가능) */}
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={values.steelScreen}
|
||||
onChange={(e) => onItemChange(item.id, 'calcSteelScreen', Number(e.target.value))}
|
||||
disabled={isViewMode}
|
||||
className={`text-right min-w-[80px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
{/* 08: 코킹 (인풋, 계산값 표시 + 수정 가능) */}
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={values.caulking}
|
||||
onChange={(e) => onItemChange(item.id, 'calcCaulking', Number(e.target.value))}
|
||||
disabled={isViewMode}
|
||||
className={`text-right min-w-[70px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
{/* 09: 레일 (인풋, 계산값 표시 + 수정 가능) */}
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={values.rail}
|
||||
onChange={(e) => onItemChange(item.id, 'calcRail', Number(e.target.value))}
|
||||
disabled={isViewMode}
|
||||
className={`text-right min-w-[70px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
{/* 10: 하장 (인풋, 계산값 표시 + 수정 가능) */}
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={values.bottom}
|
||||
onChange={(e) => onItemChange(item.id, 'calcBottom', Number(e.target.value))}
|
||||
disabled={isViewMode}
|
||||
className={`text-right min-w-[70px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
{/* 11: 박스+보강 (인풋, 계산값 표시 + 수정 가능) */}
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={values.boxReinforce}
|
||||
onChange={(e) => onItemChange(item.id, 'calcBoxReinforce', Number(e.target.value))}
|
||||
disabled={isViewMode}
|
||||
className={`text-right min-w-[80px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
{/* 12: 샤프트 (인풋, 계산값 표시 + 수정 가능) */}
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={values.shaft}
|
||||
onChange={(e) => onItemChange(item.id, 'calcShaft', Number(e.target.value))}
|
||||
disabled={isViewMode}
|
||||
className={`text-right min-w-[70px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
{/* 13: 도장 */}
|
||||
<TableCell>
|
||||
<Select
|
||||
value={String(item.coating || '')}
|
||||
onValueChange={(val) => onItemChange(item.id, 'coating', Number(val))}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger className={`w-full min-w-[70px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="0">직접입력</SelectItem>
|
||||
<SelectItem value="50000">도장A</SelectItem>
|
||||
<SelectItem value="80000">도장B</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
{/* 14: 모터 */}
|
||||
<TableCell>
|
||||
<Select
|
||||
value={String(item.mounting || '300000')}
|
||||
onValueChange={(val) => onItemChange(item.id, 'mounting', Number(val))}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger className={`w-full min-w-[70px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="300000">모터 300,000</SelectItem>
|
||||
<SelectItem value="500000">모터 500,000</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
{/* 15: 제어기 */}
|
||||
<TableCell>
|
||||
<Select
|
||||
value={String(item.controller || '')}
|
||||
onValueChange={(val) => onItemChange(item.id, 'controller', Number(val))}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger className={`w-full min-w-[70px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="150000">제어기 150,000</SelectItem>
|
||||
<SelectItem value="250000">제어기 250,000</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
{/* 16: 가로시공비 */}
|
||||
<TableCell>
|
||||
<Select
|
||||
value={String(item.widthConstruction || '')}
|
||||
onValueChange={(val) => onItemChange(item.id, 'widthConstruction', Number(val))}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger className={`w-full min-w-[90px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="300000">3.01~4.0M</SelectItem>
|
||||
<SelectItem value="400000">4.01~5.0M</SelectItem>
|
||||
<SelectItem value="500000">5.01~6.0M</SelectItem>
|
||||
<SelectItem value="600000">6.01~7.0M</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
{/* 17: 세로시공비 */}
|
||||
<TableCell>
|
||||
<Select
|
||||
value={String(item.heightConstruction || '')}
|
||||
onValueChange={(val) => onItemChange(item.id, 'heightConstruction', Number(val))}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger className={`w-full min-w-[90px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="5000">3.51~4.5M</SelectItem>
|
||||
<SelectItem value="8000">4.51~5.5M</SelectItem>
|
||||
<SelectItem value="10000">5.51~6.5M</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
{/* 18: 단가 (인풋, 계산값 표시 + 수정 가능) */}
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={values.unitPrice}
|
||||
onChange={(e) => onItemChange(item.id, 'calcUnitPrice', Number(e.target.value))}
|
||||
disabled={isViewMode}
|
||||
className={`text-right min-w-[80px] font-medium ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
{/* 19: 공과율 */}
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
step="0.01"
|
||||
value={item.expense}
|
||||
onChange={(e) => onItemChange(item.id, 'expense', Number(e.target.value))}
|
||||
disabled={isViewMode}
|
||||
className={`text-right min-w-[60px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
{/* 20: 공과 (인풋, 계산값 표시 + 수정 가능) */}
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={values.expense}
|
||||
onChange={(e) => onItemChange(item.id, 'calcExpense', Number(e.target.value))}
|
||||
disabled={isViewMode}
|
||||
className={`text-right min-w-[70px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
{/* 21: 수량 */}
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
value={item.quantity}
|
||||
onChange={(e) => onItemChange(item.id, 'quantity', Number(e.target.value))}
|
||||
disabled={isViewMode}
|
||||
className={`text-right min-w-[40px] ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
{/* 22: 원가 */}
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(values.cost)}</TableCell>
|
||||
{/* 23: 원가실행 */}
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(values.costExecution)}</TableCell>
|
||||
{/* 24: 마진원가 */}
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(values.marginCost)}</TableCell>
|
||||
{/* 25: 마진원가실행 */}
|
||||
<TableCell className="text-right bg-gray-50 font-medium">{formatAmount(values.marginCostExecution)}</TableCell>
|
||||
{/* 26: 공과실행 */}
|
||||
<TableCell className="text-right bg-gray-50">{formatAmount(values.expenseExecution)}</TableCell>
|
||||
{!isViewMode && (
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-500 hover:text-red-600"
|
||||
onClick={() => onRemoveItem(item.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
);
|
||||
})}
|
||||
{/* 합계 행 */}
|
||||
{detailItems.length > 0 && (
|
||||
<TableRow className="bg-orange-50 font-medium border-t-2 border-orange-300">
|
||||
{!isViewMode && <TableCell className="sticky left-0 bg-orange-50"></TableCell>}
|
||||
<TableCell colSpan={4} className="text-center font-bold">합계</TableCell>
|
||||
<TableCell className="text-right">{totals.weight.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right">{totals.area.toFixed(2)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(totals.steelScreen)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(totals.caulking)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(totals.rail)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(totals.bottom)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(totals.boxReinforce)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(totals.shaft)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(totals.painting)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(totals.motor)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(totals.controller)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(totals.widthConstruction)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(totals.heightConstruction)}</TableCell>
|
||||
<TableCell className="text-right font-bold">{formatAmount(totals.unitPrice)}</TableCell>
|
||||
<TableCell className="text-right">-</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(totals.expense)}</TableCell>
|
||||
<TableCell className="text-right">{totals.quantity}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(totals.cost)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(totals.costExecution)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(totals.marginCost)}</TableCell>
|
||||
<TableCell className="text-right font-bold">{formatAmount(totals.marginCostExecution)}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(totals.expenseExecution)}</TableCell>
|
||||
{!isViewMode && <TableCell></TableCell>}
|
||||
</TableRow>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,277 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { FileText, X, Upload, Download } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
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 type { EstimateDetailFormData, BidDocument } from '../types';
|
||||
import { STATUS_STYLES, STATUS_LABELS, VAT_TYPE_OPTIONS } from '../types';
|
||||
import { formatAmount } from '../utils';
|
||||
|
||||
interface EstimateInfoSectionProps {
|
||||
formData: EstimateDetailFormData;
|
||||
isViewMode: boolean;
|
||||
isDragging: boolean;
|
||||
documentInputRef: React.RefObject<HTMLInputElement | null>;
|
||||
onFormDataChange: (updates: Partial<EstimateDetailFormData>) => void;
|
||||
onBidInfoChange: (field: string, value: string | number) => void;
|
||||
onDocumentUpload: (e: React.ChangeEvent<HTMLInputElement>) => void;
|
||||
onDocumentRemove: (docId: string) => void;
|
||||
onDragOver: (e: React.DragEvent<HTMLDivElement>) => void;
|
||||
onDragLeave: (e: React.DragEvent<HTMLDivElement>) => void;
|
||||
onDrop: (e: React.DragEvent<HTMLDivElement>) => void;
|
||||
}
|
||||
|
||||
export function EstimateInfoSection({
|
||||
formData,
|
||||
isViewMode,
|
||||
isDragging,
|
||||
documentInputRef,
|
||||
onFormDataChange,
|
||||
onBidInfoChange,
|
||||
onDocumentUpload,
|
||||
onDocumentRemove,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
}: EstimateInfoSectionProps) {
|
||||
return (
|
||||
<>
|
||||
{/* 견적 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">견적 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">견적번호</Label>
|
||||
<Input value={formData.estimateCode} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">견적자</Label>
|
||||
<Input value={formData.estimatorName} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">견적금액</Label>
|
||||
<Input
|
||||
value={formatAmount(formData.estimateAmount)}
|
||||
disabled
|
||||
className="bg-gray-50 text-right"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">상태</Label>
|
||||
<div className="flex items-center h-10 px-3 border rounded-md bg-gray-50">
|
||||
<span className={STATUS_STYLES[formData.status]}>
|
||||
{STATUS_LABELS[formData.status]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 현장설명회 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">현장설명회 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">현설번호</Label>
|
||||
<Input value={formData.siteBriefing.briefingCode} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">거래처명</Label>
|
||||
<Input value={formData.siteBriefing.partnerName} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">현장설명회 일자</Label>
|
||||
<Input value={formData.siteBriefing.briefingDate} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">참석자</Label>
|
||||
<Input value={formData.siteBriefing.attendee} disabled className="bg-gray-50" />
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 입찰 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">입찰 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">현장명</Label>
|
||||
<Input
|
||||
value={formData.bidInfo.projectName}
|
||||
onChange={(e) => onBidInfoChange('projectName', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
className={isViewMode ? 'bg-gray-50' : 'bg-white'}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">입찰일자</Label>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.bidInfo.bidDate}
|
||||
onChange={(e) => onBidInfoChange('bidDate', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
className={isViewMode ? 'bg-gray-50' : 'bg-white'}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">개소</Label>
|
||||
<Input
|
||||
type="number"
|
||||
value={formData.bidInfo.siteCount}
|
||||
onChange={(e) => onBidInfoChange('siteCount', Number(e.target.value))}
|
||||
disabled={isViewMode}
|
||||
className={isViewMode ? 'bg-gray-50' : 'bg-white'}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">공사기간</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.bidInfo.constructionStartDate}
|
||||
onChange={(e) => onBidInfoChange('constructionStartDate', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
className={isViewMode ? 'bg-gray-50' : 'bg-white'}
|
||||
/>
|
||||
<span className="text-muted-foreground">~</span>
|
||||
<Input
|
||||
type="date"
|
||||
value={formData.bidInfo.constructionEndDate}
|
||||
onChange={(e) => onBidInfoChange('constructionEndDate', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
className={isViewMode ? 'bg-gray-50' : 'bg-white'}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">부가세</Label>
|
||||
<Select
|
||||
value={formData.bidInfo.vatType}
|
||||
onValueChange={(val) => onBidInfoChange('vatType', val)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger className={isViewMode ? 'bg-gray-50' : 'bg-white'}>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{VAT_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 업무 보고 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">업무 보고</Label>
|
||||
<Textarea
|
||||
value={formData.bidInfo.workReport}
|
||||
onChange={(e) => onBidInfoChange('workReport', e.target.value)}
|
||||
placeholder="업무 보고 내용"
|
||||
disabled={isViewMode}
|
||||
className={isViewMode ? 'bg-gray-50' : 'bg-white'}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 현장설명회 자료 */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-medium text-gray-700">현장설명회 자료</Label>
|
||||
<input
|
||||
ref={documentInputRef}
|
||||
type="file"
|
||||
onChange={onDocumentUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
{!isViewMode && (
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
||||
isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'hover:border-primary/50 cursor-pointer'
|
||||
}`}
|
||||
onClick={() => documentInputRef.current?.click()}
|
||||
onDragOver={onDragOver}
|
||||
onDragLeave={onDragLeave}
|
||||
onDrop={onDrop}
|
||||
>
|
||||
<Upload
|
||||
className={`w-12 h-12 mx-auto mb-2 ${isDragging ? 'text-primary' : 'text-gray-400'}`}
|
||||
/>
|
||||
<p className="text-sm text-gray-600">
|
||||
{isDragging ? '파일을 여기에 놓으세요' : '클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요.'}
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
{formData.bidInfo.documents.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.bidInfo.documents.map((doc) => (
|
||||
<div
|
||||
key={doc.id}
|
||||
className="flex items-center gap-2 px-3 py-2 bg-gray-50 rounded-lg border"
|
||||
>
|
||||
<FileText className="w-4 h-4 text-primary" />
|
||||
<span className="text-sm">{doc.fileName}</span>
|
||||
{isViewMode ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-6 px-2 text-xs"
|
||||
onClick={() => {
|
||||
const link = document.createElement('a');
|
||||
link.href = doc.fileUrl;
|
||||
link.download = doc.fileName;
|
||||
link.click();
|
||||
toast.success(`${doc.fileName} 다운로드를 시작합니다.`);
|
||||
}}
|
||||
>
|
||||
<Download className="h-3 w-3 mr-1" />
|
||||
다운로드
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-5 w-5"
|
||||
onClick={() => onDocumentRemove(doc.id)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,183 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { EstimateSummaryItem } from '../types';
|
||||
import { formatAmount } from '../utils';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function EstimateSummarySection({
|
||||
summaryItems,
|
||||
summaryMemo,
|
||||
isViewMode,
|
||||
onAddItem,
|
||||
onRemoveItem,
|
||||
onItemChange,
|
||||
onMemoChange,
|
||||
}: EstimateSummarySectionProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-lg">견적 요약 정보</CardTitle>
|
||||
{!isViewMode && (
|
||||
<Button type="button" variant="outline" size="sm" onClick={onAddItem}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[200px]">명칭</TableHead>
|
||||
<TableHead className="w-[80px] text-center">수량</TableHead>
|
||||
<TableHead className="w-[80px] text-center">단위</TableHead>
|
||||
<TableHead className="w-[120px] text-right">재료비</TableHead>
|
||||
<TableHead className="w-[120px] text-right">노무비</TableHead>
|
||||
<TableHead className="w-[120px] text-right">합계</TableHead>
|
||||
<TableHead className="w-[150px]">비고</TableHead>
|
||||
{!isViewMode && <TableHead className="w-[60px] text-center">삭제</TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{summaryItems.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={isViewMode ? 7 : 8} className="text-center text-gray-500 py-8">
|
||||
등록된 항목이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
summaryItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={item.name}
|
||||
onChange={(e) => onItemChange(item.id, 'name', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
className={isViewMode ? 'bg-gray-50' : 'bg-white'}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={item.quantity}
|
||||
onChange={(e) => onItemChange(item.id, 'quantity', Number(e.target.value))}
|
||||
disabled={isViewMode}
|
||||
className={`text-center ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={item.unit}
|
||||
onChange={(e) => onItemChange(item.id, 'unit', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
className={`text-center ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={item.materialCost}
|
||||
onChange={(e) => onItemChange(item.id, 'materialCost', Number(e.target.value))}
|
||||
disabled={isViewMode}
|
||||
className={`text-right ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={item.laborCost}
|
||||
onChange={(e) => onItemChange(item.id, 'laborCost', Number(e.target.value))}
|
||||
disabled={isViewMode}
|
||||
className={`text-right ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-medium">
|
||||
{formatAmount(item.totalCost)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
value={item.remarks}
|
||||
onChange={(e) => onItemChange(item.id, 'remarks', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
className={isViewMode ? 'bg-gray-50' : 'bg-white'}
|
||||
/>
|
||||
</TableCell>
|
||||
{!isViewMode && (
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-red-500 hover:text-red-600"
|
||||
onClick={() => onRemoveItem(item.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
{/* 합계 행 */}
|
||||
{summaryItems.length > 0 && (
|
||||
<TableRow className="bg-gray-50 font-medium">
|
||||
<TableCell colSpan={3} className="text-center">
|
||||
합계
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(summaryItems.reduce((sum, item) => sum + item.materialCost, 0))}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(summaryItems.reduce((sum, item) => sum + item.laborCost, 0))}
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(summaryItems.reduce((sum, item) => sum + item.totalCost, 0))}
|
||||
</TableCell>
|
||||
<TableCell colSpan={isViewMode ? 1 : 2}></TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
|
||||
{/* 메모 입력 */}
|
||||
<div className="mt-4 space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">메모</Label>
|
||||
<Textarea
|
||||
value={summaryMemo}
|
||||
onChange={(e) => onMemoChange(e.target.value)}
|
||||
placeholder="견적 관련 메모를 입력하세요"
|
||||
disabled={isViewMode}
|
||||
className={isViewMode ? 'bg-gray-50' : 'bg-white'}
|
||||
rows={3}
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { ExpenseItem } from '../types';
|
||||
import { formatAmount, MOCK_EXPENSES } from '../utils';
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
export function ExpenseDetailSection({
|
||||
expenseItems,
|
||||
isViewMode,
|
||||
onAddItems,
|
||||
onRemoveSelected,
|
||||
onItemChange,
|
||||
onSelectItem,
|
||||
onSelectAll,
|
||||
}: ExpenseDetailSectionProps) {
|
||||
const selectedCount = expenseItems.filter((item) => item.selected).length;
|
||||
const allSelected = expenseItems.length > 0 && expenseItems.every((item) => item.selected);
|
||||
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
||||
<div className="flex items-center gap-4">
|
||||
<CardTitle className="text-lg">공과 상세</CardTitle>
|
||||
{!isViewMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm text-gray-600">{selectedCount}건 선택</span>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="bg-gray-900 hover:bg-gray-800"
|
||||
onClick={onRemoveSelected}
|
||||
disabled={selectedCount === 0}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{!isViewMode && (
|
||||
<div className="flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={1}
|
||||
defaultValue={1}
|
||||
className="w-16 text-center"
|
||||
id="expense-add-count"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="bg-gray-900 hover:bg-gray-800"
|
||||
onClick={() => {
|
||||
const countInput = document.getElementById('expense-add-count') as HTMLInputElement;
|
||||
const count = Math.max(1, parseInt(countInput?.value || '1', 10));
|
||||
onAddItems(count);
|
||||
}}
|
||||
>
|
||||
추가
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-100">
|
||||
{!isViewMode && (
|
||||
<TableHead className="w-[50px] text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
checked={allSelected}
|
||||
onChange={(e) => onSelectAll(e.target.checked)}
|
||||
/>
|
||||
</TableHead>
|
||||
)}
|
||||
<TableHead className="text-center">공과</TableHead>
|
||||
<TableHead className="text-right">금액</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{expenseItems.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell
|
||||
colSpan={isViewMode ? 2 : 3}
|
||||
className="text-center text-gray-500 py-8"
|
||||
>
|
||||
등록된 공과가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
expenseItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
{!isViewMode && (
|
||||
<TableCell className="text-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 rounded border-gray-300"
|
||||
checked={item.selected || false}
|
||||
onChange={(e) => onSelectItem(item.id, e.target.checked)}
|
||||
/>
|
||||
</TableCell>
|
||||
)}
|
||||
<TableCell>
|
||||
<Select
|
||||
value={item.name}
|
||||
onValueChange={(val) => onItemChange(item.id, 'name', val)}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger className={isViewMode ? 'bg-gray-50' : 'bg-white'}>
|
||||
<SelectValue placeholder="공과 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MOCK_EXPENSES.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Input
|
||||
type="number"
|
||||
value={item.amount}
|
||||
onChange={(e) => onItemChange(item.id, 'amount', Number(e.target.value))}
|
||||
disabled={isViewMode}
|
||||
className={`text-right ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
{/* 합계 행 */}
|
||||
{expenseItems.length > 0 && (
|
||||
<TableRow className="bg-gray-50 font-medium">
|
||||
<TableCell colSpan={isViewMode ? 1 : 2} className="text-center">
|
||||
합계
|
||||
</TableCell>
|
||||
<TableCell className="text-right">
|
||||
{formatAmount(expenseItems.reduce((sum, item) => sum + item.amount, 0))}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,149 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import type { PriceAdjustmentData } from '../types';
|
||||
import { formatAmount } from '../utils';
|
||||
|
||||
interface PriceAdjustmentSectionProps {
|
||||
priceAdjustmentData: PriceAdjustmentData;
|
||||
isViewMode: boolean;
|
||||
onPriceChange: (key: keyof PriceAdjustmentData, value: number) => void;
|
||||
onSave: () => void;
|
||||
onApplyAll: () => void;
|
||||
onReset: () => void;
|
||||
}
|
||||
|
||||
const PRICE_KEYS: (keyof PriceAdjustmentData)[] = [
|
||||
'caulking',
|
||||
'rail',
|
||||
'bottom',
|
||||
'boxReinforce',
|
||||
'shaft',
|
||||
'painting',
|
||||
'motor',
|
||||
'controller',
|
||||
];
|
||||
|
||||
const PRICE_LABELS: Record<keyof PriceAdjustmentData, string> = {
|
||||
caulking: '코킹',
|
||||
rail: '레일',
|
||||
bottom: '하장',
|
||||
boxReinforce: '박스+보강',
|
||||
shaft: '샤프트',
|
||||
painting: '도장',
|
||||
motor: '모터',
|
||||
controller: '제어기',
|
||||
};
|
||||
|
||||
export function PriceAdjustmentSection({
|
||||
priceAdjustmentData,
|
||||
isViewMode,
|
||||
onPriceChange,
|
||||
onSave,
|
||||
onApplyAll,
|
||||
onReset,
|
||||
}: PriceAdjustmentSectionProps) {
|
||||
return (
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between pb-4">
|
||||
<CardTitle className="text-lg">품목 단가 조정</CardTitle>
|
||||
{!isViewMode && (
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="bg-gray-900 hover:bg-gray-800"
|
||||
onClick={onSave}
|
||||
>
|
||||
저장
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
size="sm"
|
||||
className="bg-orange-500 hover:bg-orange-600"
|
||||
onClick={onApplyAll}
|
||||
>
|
||||
전체 적용
|
||||
</Button>
|
||||
<Button type="button" variant="outline" size="sm" onClick={onReset}>
|
||||
초기화
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="overflow-x-auto">
|
||||
<Table className="min-w-[1000px]">
|
||||
<TableHeader>
|
||||
<TableRow className="bg-gray-100">
|
||||
<TableHead className="w-[100px] text-center">단가</TableHead>
|
||||
{PRICE_KEYS.map((key) => (
|
||||
<TableHead key={key} className="w-[100px] text-right">
|
||||
{PRICE_LABELS[key]}
|
||||
</TableHead>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{/* 매입단가 행 */}
|
||||
<TableRow>
|
||||
<TableCell className="font-medium text-center bg-gray-50">매입단가</TableCell>
|
||||
{PRICE_KEYS.map((key) => (
|
||||
<TableCell key={key} className="text-right">
|
||||
{formatAmount(priceAdjustmentData[key].purchasePrice)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
{/* 마진율 행 */}
|
||||
<TableRow>
|
||||
<TableCell className="font-medium text-center bg-gray-50">마진율(%)</TableCell>
|
||||
{PRICE_KEYS.map((key) => (
|
||||
<TableCell key={key} className="text-right">
|
||||
{priceAdjustmentData[key].marginRate.toFixed(1)}%
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
{/* 판매단가 행 */}
|
||||
<TableRow>
|
||||
<TableCell className="font-medium text-center bg-gray-50">판매단가</TableCell>
|
||||
{PRICE_KEYS.map((key) => (
|
||||
<TableCell key={key} className="text-right">
|
||||
{formatAmount(priceAdjustmentData[key].sellingPrice)}
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
{/* 조정단가 행 (입력 가능) */}
|
||||
<TableRow className="bg-orange-50">
|
||||
<TableCell className="font-medium text-center bg-orange-100">조정단가</TableCell>
|
||||
{PRICE_KEYS.map((key) => (
|
||||
<TableCell key={key}>
|
||||
<Input
|
||||
type="number"
|
||||
value={priceAdjustmentData[key].adjustedPrice}
|
||||
onChange={(e) => onPriceChange(key, Number(e.target.value))}
|
||||
disabled={isViewMode}
|
||||
className={`text-right ${isViewMode ? 'bg-gray-50' : 'bg-white'}`}
|
||||
/>
|
||||
</TableCell>
|
||||
))}
|
||||
</TableRow>
|
||||
</TableBody>
|
||||
</Table>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
);
|
||||
}
|
||||
5
src/components/business/juil/estimates/sections/index.ts
Normal file
5
src/components/business/juil/estimates/sections/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export * from './EstimateInfoSection';
|
||||
export * from './EstimateSummarySection';
|
||||
export * from './ExpenseDetailSection';
|
||||
export * from './PriceAdjustmentSection';
|
||||
export * from './EstimateDetailTableSection';
|
||||
@@ -3,10 +3,306 @@
|
||||
*/
|
||||
|
||||
// 견적 상태
|
||||
export type EstimateStatus = 'drafting' | 'completed';
|
||||
export type EstimateStatus = 'pending' | 'approval_waiting' | 'completed' | 'rejected' | 'hold';
|
||||
|
||||
// 낙찰 상태
|
||||
export type AwardStatus = 'pending' | 'awarded' | 'failed';
|
||||
// =====================================================
|
||||
// 견적 상세 관련 타입
|
||||
// =====================================================
|
||||
|
||||
// 견적 요약 항목
|
||||
export interface EstimateSummaryItem {
|
||||
id: string;
|
||||
name: string; // 명칭
|
||||
quantity: number; // 수량
|
||||
unit: string; // 단위
|
||||
materialCost: number; // 재료비
|
||||
laborCost: number; // 노무비
|
||||
totalCost: number; // 합계
|
||||
remarks: string; // 비고
|
||||
}
|
||||
|
||||
// 공과 항목
|
||||
export interface ExpenseItem {
|
||||
id: string;
|
||||
name: string; // 공과명
|
||||
amount: number; // 금액
|
||||
selected?: boolean; // 선택 여부
|
||||
}
|
||||
|
||||
// 품목 단가 조정 - 품목별 단가 정보
|
||||
export interface PriceAdjustmentItemPrice {
|
||||
purchasePrice: number; // 매입단가
|
||||
marginRate: number; // 마진율 (%)
|
||||
sellingPrice: number; // 판매단가
|
||||
adjustedPrice: number; // 조정단가
|
||||
}
|
||||
|
||||
// 품목 단가 조정 전체 구조
|
||||
export interface PriceAdjustmentData {
|
||||
caulking: PriceAdjustmentItemPrice; // 코킹
|
||||
rail: PriceAdjustmentItemPrice; // 레일
|
||||
bottom: PriceAdjustmentItemPrice; // 하장
|
||||
boxReinforce: PriceAdjustmentItemPrice; // 박스+보강
|
||||
shaft: PriceAdjustmentItemPrice; // 샤프트
|
||||
painting: PriceAdjustmentItemPrice; // 도장
|
||||
motor: PriceAdjustmentItemPrice; // 모터
|
||||
controller: PriceAdjustmentItemPrice; // 제어기
|
||||
}
|
||||
|
||||
// 레거시 호환용 (기존 구조)
|
||||
export interface PriceAdjustmentItem {
|
||||
id: string;
|
||||
category: string; // 카테고리 (배합비, 재단비, 판매단가, 조립단가)
|
||||
unitPrice: number; // 단가
|
||||
coating: number; // 코팅
|
||||
batting: number; // 배팅
|
||||
boxReinforce: number; // 박스+보강
|
||||
painting: number; // 도장
|
||||
total: number; // 합계
|
||||
}
|
||||
|
||||
// 견적 상세 항목 (테이블 row)
|
||||
export interface EstimateDetailItem {
|
||||
id: string;
|
||||
no: number; // 번호
|
||||
name: string; // 명칭
|
||||
material: string; // 재료
|
||||
width: number; // 가로 (M, 소수점 둘째자리)
|
||||
height: number; // 세로 (M, 소수점 둘째자리)
|
||||
quantity: number; // 수량
|
||||
box: number; // 박스
|
||||
assembly: number; // 조립
|
||||
coating: number; // 코팅/도장 (셀렉트)
|
||||
batting: number; // 배팅
|
||||
mounting: number; // 모터 (셀렉트)
|
||||
fitting: number; // 착장
|
||||
controller: number; // 제어기 (셀렉트)
|
||||
widthConstruction: number; // 가로 시공비 (수동 셀렉트)
|
||||
heightConstruction: number; // 세로 시공비 (자동 셀렉트)
|
||||
materialCost: number; // 재료비
|
||||
laborCost: number; // 노무비
|
||||
quantityPrice: number; // 수량*단가
|
||||
expenseQuantity: number; // 공과*수량
|
||||
expenseTotal: number; // 공과합
|
||||
totalCost: number; // 합계비
|
||||
otherCost: number; // 기타원가합
|
||||
marginCost: number; // 마진원가
|
||||
totalPrice: number; // 단가합계
|
||||
unitPrice: number; // 단가
|
||||
expense: number; // 공과율 (인풋)
|
||||
marginRate: number; // 마진율
|
||||
unitQuantity: number; // 단가수량
|
||||
expenseResult: number; // 공과 실행
|
||||
marginActual: number; // 마진원가+수량
|
||||
|
||||
// === 계산 필드 (인풋, 계산값 표시 + 수정 가능) ===
|
||||
// 기본값은 자동 계산, 사용자가 수정하면 그 값 유지
|
||||
calcWeight?: number; // 무게 = 면적*25
|
||||
calcArea?: number; // 면적 = (가로×0.16)*(세로×0.5)
|
||||
calcSteelScreen?: number; // 철재,스크린 = 면적*47500
|
||||
calcCaulking?: number; // 코킹 = (세로*4)*단가
|
||||
calcRail?: number; // 레일 = (세로×0.2)*단가
|
||||
calcBottom?: number; // 하장 = 가로*단가
|
||||
calcBoxReinforce?: number; // 박스+보강 = 가로*단가
|
||||
calcShaft?: number; // 샤프트 = 가로*단가
|
||||
calcUnitPrice?: number; // 단가 = (7)~(17)의 합
|
||||
calcExpense?: number; // 공과 = 단가×공과율
|
||||
|
||||
// === 개별 항목 조정단가 (선택 적용 시 사용) ===
|
||||
// "조정 단가 적용" 버튼으로 선택한 항목에만 적용되는 단가
|
||||
adjustedCaulking?: number; // 코킹 조정단가
|
||||
adjustedRail?: number; // 레일 조정단가
|
||||
adjustedBottom?: number; // 하장 조정단가
|
||||
adjustedBoxReinforce?: number; // 박스+보강 조정단가
|
||||
adjustedShaft?: number; // 샤프트 조정단가
|
||||
adjustedPainting?: number; // 도장 조정단가
|
||||
adjustedMotor?: number; // 모터 조정단가
|
||||
adjustedController?: number; // 제어기 조정단가
|
||||
}
|
||||
|
||||
// 결재자 정보 - 공통 타입 re-export
|
||||
export type { ApprovalPerson, ElectronicApproval } from '../common/types';
|
||||
export { getEmptyElectronicApproval } from '../common/types';
|
||||
|
||||
// 현장설명회 정보 (견적 상세용)
|
||||
export interface SiteBriefingInfo {
|
||||
briefingCode: string; // 현설번호
|
||||
partnerName: string; // 거래처명
|
||||
companyName: string; // 회사명
|
||||
briefingDate: string; // 현장설명회 일자
|
||||
attendee: string; // 참석자
|
||||
}
|
||||
|
||||
// 입찰 정보 (견적 상세용)
|
||||
export interface BidInfo {
|
||||
projectName: string; // 현장명
|
||||
bidDate: string; // 입찰일자
|
||||
siteCount: number; // 개소
|
||||
constructionPeriod: string; // 공사기간 (startDate ~ endDate)
|
||||
constructionStartDate: string;
|
||||
constructionEndDate: string;
|
||||
vatType: string; // 부가세
|
||||
workReport: string; // 업무 보고
|
||||
documents: BidDocument[]; // 현장설명회 자료
|
||||
}
|
||||
|
||||
// 입찰 문서
|
||||
export interface BidDocument {
|
||||
id: string;
|
||||
fileName: string;
|
||||
fileUrl: string;
|
||||
fileSize: number;
|
||||
}
|
||||
|
||||
// 견적 상세 전체 데이터
|
||||
export interface EstimateDetail extends Estimate {
|
||||
// 현장설명회 정보
|
||||
siteBriefing: SiteBriefingInfo;
|
||||
|
||||
// 입찰 정보
|
||||
bidInfo: BidInfo;
|
||||
|
||||
// 견적 요약 정보
|
||||
summaryItems: EstimateSummaryItem[];
|
||||
|
||||
// 공과 상세
|
||||
expenseItems: ExpenseItem[];
|
||||
|
||||
// 품목 단가 조정
|
||||
priceAdjustments: PriceAdjustmentItem[];
|
||||
|
||||
// 견적 상세 테이블
|
||||
detailItems: EstimateDetailItem[];
|
||||
|
||||
// 전자결재 정보
|
||||
approval?: ElectronicApproval;
|
||||
}
|
||||
|
||||
// 견적 상세 폼 데이터 (수정용)
|
||||
export interface EstimateDetailFormData {
|
||||
// 견적 정보
|
||||
estimateCode: string;
|
||||
estimatorId: string;
|
||||
estimatorName: string;
|
||||
estimateAmount: number;
|
||||
status: EstimateStatus;
|
||||
|
||||
// 현장설명회 정보
|
||||
siteBriefing: SiteBriefingInfo;
|
||||
|
||||
// 입찰 정보
|
||||
bidInfo: BidInfo;
|
||||
|
||||
// 견적 요약 정보
|
||||
summaryItems: EstimateSummaryItem[];
|
||||
|
||||
// 견적 요약 메모
|
||||
summaryMemo: string;
|
||||
|
||||
// 공과 상세
|
||||
expenseItems: ExpenseItem[];
|
||||
|
||||
// 품목 단가 조정 (레거시)
|
||||
priceAdjustments: PriceAdjustmentItem[];
|
||||
|
||||
// 품목 단가 조정 (신규 구조)
|
||||
priceAdjustmentData: PriceAdjustmentData;
|
||||
|
||||
// 견적 상세 테이블
|
||||
detailItems: EstimateDetailItem[];
|
||||
|
||||
// 전자결재 정보
|
||||
approval: ElectronicApproval;
|
||||
}
|
||||
|
||||
// 부가세 옵션
|
||||
export const VAT_TYPE_OPTIONS = [
|
||||
{ value: 'included', label: '부가세 포함' },
|
||||
{ value: 'excluded', label: '부가세 별도' },
|
||||
];
|
||||
|
||||
// 빈 단가 조정 항목 생성
|
||||
function getEmptyPriceAdjustmentItemPrice(): PriceAdjustmentItemPrice {
|
||||
return {
|
||||
purchasePrice: 10000,
|
||||
marginRate: 3.0,
|
||||
sellingPrice: 10300,
|
||||
adjustedPrice: 10300,
|
||||
};
|
||||
}
|
||||
|
||||
// 빈 단가 조정 데이터 생성
|
||||
export function getEmptyPriceAdjustmentData(): PriceAdjustmentData {
|
||||
return {
|
||||
caulking: getEmptyPriceAdjustmentItemPrice(),
|
||||
rail: getEmptyPriceAdjustmentItemPrice(),
|
||||
bottom: getEmptyPriceAdjustmentItemPrice(),
|
||||
boxReinforce: getEmptyPriceAdjustmentItemPrice(),
|
||||
shaft: getEmptyPriceAdjustmentItemPrice(),
|
||||
painting: getEmptyPriceAdjustmentItemPrice(),
|
||||
motor: getEmptyPriceAdjustmentItemPrice(),
|
||||
controller: getEmptyPriceAdjustmentItemPrice(),
|
||||
};
|
||||
}
|
||||
|
||||
// 빈 폼 데이터 생성
|
||||
export function getEmptyEstimateDetailFormData(): EstimateDetailFormData {
|
||||
return {
|
||||
estimateCode: '',
|
||||
estimatorId: '',
|
||||
estimatorName: '',
|
||||
estimateAmount: 0,
|
||||
status: 'pending',
|
||||
siteBriefing: {
|
||||
briefingCode: '',
|
||||
partnerName: '',
|
||||
companyName: '',
|
||||
briefingDate: '',
|
||||
attendee: '',
|
||||
},
|
||||
bidInfo: {
|
||||
projectName: '',
|
||||
bidDate: '',
|
||||
siteCount: 0,
|
||||
constructionPeriod: '',
|
||||
constructionStartDate: '',
|
||||
constructionEndDate: '',
|
||||
vatType: 'excluded',
|
||||
workReport: '',
|
||||
documents: [],
|
||||
},
|
||||
summaryItems: [],
|
||||
summaryMemo: '',
|
||||
expenseItems: [],
|
||||
priceAdjustments: [],
|
||||
priceAdjustmentData: getEmptyPriceAdjustmentData(),
|
||||
detailItems: [],
|
||||
approval: {
|
||||
approvers: [],
|
||||
references: [],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
// EstimateDetail을 FormData로 변환
|
||||
export function estimateDetailToFormData(detail: EstimateDetail): EstimateDetailFormData {
|
||||
return {
|
||||
estimateCode: detail.estimateCode,
|
||||
estimatorId: detail.estimatorId,
|
||||
estimatorName: detail.estimatorName,
|
||||
estimateAmount: detail.estimateAmount,
|
||||
status: detail.status,
|
||||
siteBriefing: detail.siteBriefing,
|
||||
bidInfo: detail.bidInfo,
|
||||
summaryItems: detail.summaryItems,
|
||||
summaryMemo: '',
|
||||
expenseItems: detail.expenseItems,
|
||||
priceAdjustments: detail.priceAdjustments,
|
||||
priceAdjustmentData: getEmptyPriceAdjustmentData(),
|
||||
detailItems: detail.detailItems,
|
||||
approval: detail.approval || { approvers: [], references: [] },
|
||||
};
|
||||
}
|
||||
|
||||
// 견적 타입
|
||||
export interface Estimate {
|
||||
@@ -21,14 +317,13 @@ export interface Estimate {
|
||||
estimatorName: string; // 견적자명
|
||||
|
||||
// 견적 정보
|
||||
itemCount: number; // 계 (품목 수)
|
||||
itemCount: number; // 총 개소 (품목 수)
|
||||
estimateAmount: number; // 견적금액
|
||||
distributionDate: string | null; // 견적배부일
|
||||
completedDate: string | null; // 견적완료일
|
||||
bidDate: string | null; // 입찰일
|
||||
|
||||
// 상태 정보
|
||||
status: EstimateStatus; // 견적 상태
|
||||
awardStatus: AwardStatus; // 낙찰 상태
|
||||
|
||||
// 메타 정보
|
||||
createdAt: string;
|
||||
@@ -38,10 +333,9 @@ export interface Estimate {
|
||||
|
||||
// 견적 통계
|
||||
export interface EstimateStats {
|
||||
total: number; // 전체
|
||||
drafting: number; // 견적작성중
|
||||
total: number; // 전체 견적
|
||||
pending: number; // 견적대기
|
||||
completed: number; // 견적완료
|
||||
awarded: number; // 낙찰
|
||||
}
|
||||
|
||||
// 견적 필터
|
||||
@@ -69,39 +363,37 @@ export interface EstimateListResponse {
|
||||
// 상태 옵션
|
||||
export const ESTIMATE_STATUS_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'drafting', label: '견적작성중' },
|
||||
{ value: 'pending', label: '견적대기' },
|
||||
{ value: 'approval_waiting', label: '승인대기' },
|
||||
{ value: 'completed', label: '견적완료' },
|
||||
{ value: 'rejected', label: '반려' },
|
||||
{ value: 'hold', label: '보류' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
export const ESTIMATE_SORT_OPTIONS = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
{ value: 'amountDesc', label: '견적금액 높은순' },
|
||||
{ value: 'amountAsc', label: '견적금액 낮은순' },
|
||||
{ value: 'bidDateDesc', label: '입찰일 최신순' },
|
||||
{ value: 'partnerNameAsc', label: '거래처명 오름차순' },
|
||||
{ value: 'partnerNameDesc', label: '거래처명 내림차순' },
|
||||
{ value: 'projectNameAsc', label: '현장명 오름차순' },
|
||||
{ value: 'projectNameDesc', label: '현장명 내림차순' },
|
||||
];
|
||||
|
||||
// 상태별 스타일
|
||||
export const STATUS_STYLES: Record<EstimateStatus, string> = {
|
||||
drafting: 'text-red-500 font-medium',
|
||||
pending: 'text-orange-500 font-medium',
|
||||
approval_waiting: 'text-blue-500 font-medium',
|
||||
completed: 'text-gray-600',
|
||||
rejected: 'text-red-500 font-medium',
|
||||
hold: 'text-gray-400',
|
||||
};
|
||||
|
||||
export const STATUS_LABELS: Record<EstimateStatus, string> = {
|
||||
drafting: '견적작성중',
|
||||
pending: '견적대기',
|
||||
approval_waiting: '승인대기',
|
||||
completed: '견적완료',
|
||||
};
|
||||
|
||||
// 낙찰 상태 라벨
|
||||
export const AWARD_STATUS_LABELS: Record<AwardStatus, string> = {
|
||||
pending: '-',
|
||||
awarded: '낙찰',
|
||||
failed: '유찰',
|
||||
};
|
||||
|
||||
export const AWARD_STATUS_STYLES: Record<AwardStatus, string> = {
|
||||
pending: 'text-gray-400',
|
||||
awarded: 'text-blue-600 font-medium',
|
||||
failed: 'text-red-500',
|
||||
rejected: '반려',
|
||||
hold: '보류',
|
||||
};
|
||||
|
||||
13
src/components/business/juil/estimates/utils/constants.ts
Normal file
13
src/components/business/juil/estimates/utils/constants.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
// 목업 재료 목록
|
||||
export const MOCK_MATERIALS = [
|
||||
{ value: 'screen', label: '스크린' },
|
||||
{ value: 'slat', label: '슬랫' },
|
||||
{ value: 'bending', label: '벤딩' },
|
||||
{ value: 'jointbar', label: '조인트바' },
|
||||
];
|
||||
|
||||
// 목업 공과 목록
|
||||
export const MOCK_EXPENSES = [
|
||||
{ value: 'public_1', label: '공과비 V' },
|
||||
{ value: 'public_2', label: '공과비 A' },
|
||||
];
|
||||
@@ -0,0 +1,4 @@
|
||||
// 금액 포맷팅
|
||||
export function formatAmount(amount: number): string {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
}
|
||||
2
src/components/business/juil/estimates/utils/index.ts
Normal file
2
src/components/business/juil/estimates/utils/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export * from './constants';
|
||||
export * from './formatters';
|
||||
@@ -0,0 +1,784 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, Plus, X, Eye } 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 { Checkbox } from '@/components/ui/checkbox';
|
||||
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 {
|
||||
HandoverReportDetail,
|
||||
HandoverReportFormData,
|
||||
HandoverStatus,
|
||||
ConstructionManager,
|
||||
} from './types';
|
||||
import {
|
||||
HANDOVER_STATUS_LABELS,
|
||||
CONSTRUCTION_PM_OPTIONS,
|
||||
MANAGER_OPTIONS,
|
||||
getEmptyHandoverReportFormData,
|
||||
handoverReportDetailToFormData,
|
||||
} from './types';
|
||||
import { updateHandoverReport, deleteHandoverReport } from './actions';
|
||||
import { HandoverReportDocumentModal } from './modals/HandoverReportDocumentModal';
|
||||
import {
|
||||
ElectronicApprovalModal,
|
||||
type ElectronicApproval,
|
||||
getEmptyElectronicApproval,
|
||||
} from '../common';
|
||||
|
||||
// 금액 포맷팅
|
||||
function formatAmount(amount: number): string {
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
}
|
||||
|
||||
interface HandoverReportDetailFormProps {
|
||||
mode: 'view' | 'edit';
|
||||
reportId: string;
|
||||
initialData?: HandoverReportDetail;
|
||||
}
|
||||
|
||||
export default function HandoverReportDetailForm({
|
||||
mode,
|
||||
reportId,
|
||||
initialData,
|
||||
}: HandoverReportDetailFormProps) {
|
||||
const router = useRouter();
|
||||
const isViewMode = mode === 'view';
|
||||
const isEditMode = mode === 'edit';
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<HandoverReportFormData>(
|
||||
initialData ? handoverReportDetailToFormData(initialData) : getEmptyHandoverReportFormData()
|
||||
);
|
||||
|
||||
// 로딩 상태
|
||||
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()
|
||||
);
|
||||
|
||||
// 네비게이션 핸들러
|
||||
const handleBack = useCallback(() => {
|
||||
router.push('/ko/juil/project/contract/handover-report');
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback(() => {
|
||||
router.push(`/ko/juil/project/contract/handover-report/${reportId}/edit`);
|
||||
}, [router, reportId]);
|
||||
|
||||
const handleCancel = useCallback(() => {
|
||||
router.push(`/ko/juil/project/contract/handover-report/${reportId}`);
|
||||
}, [router, reportId]);
|
||||
|
||||
// 폼 필드 변경
|
||||
const handleFieldChange = useCallback(
|
||||
(field: keyof HandoverReportFormData, value: string | number | boolean) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 저장 핸들러
|
||||
const handleSave = useCallback(() => {
|
||||
setShowSaveDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmSave = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await updateHandoverReport(reportId, formData);
|
||||
if (result.success) {
|
||||
toast.success('수정이 완료되었습니다.');
|
||||
setShowSaveDialog(false);
|
||||
router.push(`/ko/juil/project/contract/handover-report/${reportId}`);
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '저장에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router, reportId, formData]);
|
||||
|
||||
// 삭제 핸들러
|
||||
const handleDelete = useCallback(() => {
|
||||
setShowDeleteDialog(true);
|
||||
}, []);
|
||||
|
||||
const handleConfirmDelete = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deleteHandoverReport(reportId);
|
||||
if (result.success) {
|
||||
toast.success('인수인계보고서가 삭제되었습니다.');
|
||||
setShowDeleteDialog(false);
|
||||
router.push('/ko/juil/project/contract/handover-report');
|
||||
router.refresh();
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch (error) {
|
||||
toast.error(error instanceof Error ? error.message : '삭제에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [router, reportId]);
|
||||
|
||||
// 인수인계보고서 보기 핸들러
|
||||
const handleViewDocument = useCallback(() => {
|
||||
setShowDocumentModal(true);
|
||||
}, []);
|
||||
|
||||
// 전자결재 핸들러
|
||||
const handleApproval = useCallback(() => {
|
||||
setShowApprovalModal(true);
|
||||
}, []);
|
||||
|
||||
// 전자결재 저장
|
||||
const handleApprovalSave = useCallback((approval: ElectronicApproval) => {
|
||||
setApprovalData(approval);
|
||||
setShowApprovalModal(false);
|
||||
toast.success('전자결재 정보가 저장되었습니다.');
|
||||
}, []);
|
||||
|
||||
// 공사담당자 추가
|
||||
const handleAddManager = useCallback(() => {
|
||||
const newManager: ConstructionManager = {
|
||||
id: String(Date.now()),
|
||||
name: '',
|
||||
nonPerformanceReason: '',
|
||||
};
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
constructionManagers: [...prev.constructionManagers, newManager],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 공사담당자 삭제
|
||||
const handleRemoveManager = useCallback((managerId: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
constructionManagers: prev.constructionManagers.filter((m) => m.id !== managerId),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 공사담당자 변경
|
||||
const handleManagerChange = useCallback(
|
||||
(managerId: string, field: keyof ConstructionManager, value: string | boolean) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
constructionManagers: prev.constructionManagers.map((m) =>
|
||||
m.id === managerId ? { ...m, [field]: value } : m
|
||||
),
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 장비 외 실행금액 변경
|
||||
const handleEquipmentCostChange = useCallback(
|
||||
(field: 'shippingCost' | 'highAltitudeWork' | 'publicExpense', value: number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
externalEquipmentCost: {
|
||||
...prev.externalEquipmentCost,
|
||||
[field]: value,
|
||||
},
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 계약 ITEM 비고 변경
|
||||
const handleContractItemRemarkChange = useCallback((itemId: string, remark: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
contractItems: prev.contractItems.map((item) =>
|
||||
item.id === itemId ? { ...item, remark } : item
|
||||
),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 헤더 액션 버튼
|
||||
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.reportNumber}
|
||||
onChange={(e) => handleFieldChange('reportNumber', 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.siteName}
|
||||
onChange={(e) => handleFieldChange('siteName', 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.totalSites}
|
||||
onChange={(e) => handleFieldChange('totalSites', 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>
|
||||
<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>
|
||||
|
||||
{/* 공사PM */}
|
||||
<div className="space-y-2">
|
||||
<Label>공사PM</Label>
|
||||
<Select
|
||||
value={formData.constructionPMId}
|
||||
onValueChange={(value) => {
|
||||
handleFieldChange('constructionPMId', value);
|
||||
const pm = CONSTRUCTION_PM_OPTIONS.find((p) => p.value === value);
|
||||
if (pm) {
|
||||
handleFieldChange('constructionPMName', pm.label);
|
||||
}
|
||||
}}
|
||||
disabled={isViewMode}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CONSTRUCTION_PM_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 상태 */}
|
||||
<div className="space-y-2">
|
||||
<Label>상태</Label>
|
||||
<RadioGroup
|
||||
value={formData.status}
|
||||
onValueChange={(value) => handleFieldChange('status', value as HandoverStatus)}
|
||||
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">
|
||||
{HANDOVER_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">
|
||||
{HANDOVER_STATUS_LABELS.completed}
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 공사담당자 */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-4">
|
||||
<CardTitle className="text-lg">공사담당자</CardTitle>
|
||||
{isEditMode && (
|
||||
<Button variant="outline" size="sm" onClick={handleAddManager}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px] text-center">번호</TableHead>
|
||||
<TableHead className="w-[200px]">공사담당자</TableHead>
|
||||
<TableHead>미이행 사유</TableHead>
|
||||
{isEditMode && <TableHead className="w-[60px]"></TableHead>}
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formData.constructionManagers.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={isEditMode ? 4 : 3} className="text-center text-muted-foreground py-8">
|
||||
등록된 공사담당자가 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
formData.constructionManagers.map((manager, index) => (
|
||||
<TableRow key={manager.id}>
|
||||
<TableCell className="text-center">{index + 1}</TableCell>
|
||||
<TableCell>
|
||||
{isEditMode ? (
|
||||
<Select
|
||||
value={manager.name}
|
||||
onValueChange={(value) => handleManagerChange(manager.id, 'name', value)}
|
||||
>
|
||||
<SelectTrigger className="w-[150px]">
|
||||
<SelectValue placeholder="이름" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{MANAGER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.label}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
) : (
|
||||
manager.name
|
||||
)}
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
value={manager.nonPerformanceReason}
|
||||
onChange={(e) =>
|
||||
handleManagerChange(manager.id, 'nonPerformanceReason', e.target.value)
|
||||
}
|
||||
placeholder="미이행 사유 입력"
|
||||
/>
|
||||
) : (
|
||||
manager.nonPerformanceReason || '-'
|
||||
)}
|
||||
</TableCell>
|
||||
{isEditMode && (
|
||||
<TableCell className="text-center">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => handleRemoveManager(manager.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</TableCell>
|
||||
)}
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 계약 ITEM */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">계약 ITEM</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead className="w-[60px] text-center">번호</TableHead>
|
||||
<TableHead>명칭</TableHead>
|
||||
<TableHead>제품</TableHead>
|
||||
<TableHead className="text-right">수량</TableHead>
|
||||
<TableHead>비고</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{formData.contractItems.length === 0 ? (
|
||||
<TableRow>
|
||||
<TableCell colSpan={5} className="text-center text-muted-foreground py-8">
|
||||
등록된 계약 ITEM이 없습니다.
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
) : (
|
||||
formData.contractItems.map((item) => (
|
||||
<TableRow key={item.id}>
|
||||
<TableCell className="text-center">{item.no}</TableCell>
|
||||
<TableCell>{item.name}</TableCell>
|
||||
<TableCell>{item.product}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(item.quantity)}</TableCell>
|
||||
<TableCell>
|
||||
{isEditMode ? (
|
||||
<Input
|
||||
value={item.remark}
|
||||
onChange={(e) => handleContractItemRemarkChange(item.id, e.target.value)}
|
||||
placeholder="비고 입력"
|
||||
/>
|
||||
) : (
|
||||
item.remark || '-'
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))
|
||||
)}
|
||||
</TableBody>
|
||||
</Table>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 상세 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">상세 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-6">
|
||||
{/* 2차 배관 유무 / 도장 & 코킹 유무 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
{/* 2차 배관 유무 */}
|
||||
<div className="space-y-2">
|
||||
<Label>2차 배관 유무</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<RadioGroup
|
||||
value={formData.hasSecondaryPiping ? 'included' : 'not_included'}
|
||||
onValueChange={(value) => {
|
||||
handleFieldChange('hasSecondaryPiping', value === 'included');
|
||||
if (value !== 'included') {
|
||||
handleFieldChange('secondaryPipingNote', '');
|
||||
}
|
||||
}}
|
||||
disabled={isViewMode}
|
||||
className="flex gap-4 shrink-0"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="not_included" id="piping_not" />
|
||||
<Label htmlFor="piping_not" className="font-normal cursor-pointer">
|
||||
미포함
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="included" id="piping_yes" />
|
||||
<Label htmlFor="piping_yes" className="font-normal cursor-pointer">
|
||||
포함
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<Input
|
||||
value={formData.secondaryPipingNote}
|
||||
onChange={(e) => handleFieldChange('secondaryPipingNote', e.target.value)}
|
||||
disabled={isViewMode || !formData.hasSecondaryPiping}
|
||||
placeholder="2차 배관 내용 입력"
|
||||
className={`flex-1 transition-opacity duration-200 ${
|
||||
formData.hasSecondaryPiping ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 도장 & 코킹 유무 */}
|
||||
<div className="space-y-2">
|
||||
<Label>도장 & 코킹 유무</Label>
|
||||
<div className="flex items-center gap-4">
|
||||
<RadioGroup
|
||||
value={formData.hasCoating ? 'included' : 'not_included'}
|
||||
onValueChange={(value) => {
|
||||
handleFieldChange('hasCoating', value === 'included');
|
||||
if (value !== 'included') {
|
||||
handleFieldChange('coatingNote', '');
|
||||
}
|
||||
}}
|
||||
disabled={isViewMode}
|
||||
className="flex gap-4 shrink-0"
|
||||
>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="not_included" id="coating_not" />
|
||||
<Label htmlFor="coating_not" className="font-normal cursor-pointer">
|
||||
미포함
|
||||
</Label>
|
||||
</div>
|
||||
<div className="flex items-center space-x-2">
|
||||
<RadioGroupItem value="included" id="coating_yes" />
|
||||
<Label htmlFor="coating_yes" className="font-normal cursor-pointer">
|
||||
포함
|
||||
</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
<Input
|
||||
value={formData.coatingNote}
|
||||
onChange={(e) => handleFieldChange('coatingNote', e.target.value)}
|
||||
disabled={isViewMode || !formData.hasCoating}
|
||||
placeholder="도장 & 코킹 내용 입력"
|
||||
className={`flex-1 transition-opacity duration-200 ${
|
||||
formData.hasCoating ? 'opacity-100' : 'opacity-0 pointer-events-none'
|
||||
}`}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 장비 외 실행금액 */}
|
||||
<div className="space-y-2">
|
||||
<Label>장비 외 실행금액</Label>
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={formData.externalEquipmentCost.shippingCost > 0}
|
||||
onCheckedChange={(checked) =>
|
||||
handleEquipmentCostChange('shippingCost', checked ? 1500000 : 0)
|
||||
}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
<Label className="font-normal">운반비</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={formData.externalEquipmentCost.highAltitudeWork > 0}
|
||||
onCheckedChange={(checked) =>
|
||||
handleEquipmentCostChange('highAltitudeWork', checked ? 800000 : 0)
|
||||
}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
<Label className="font-normal">고소작업대</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Checkbox
|
||||
checked={formData.externalEquipmentCost.publicExpense > 0}
|
||||
onCheckedChange={(checked) =>
|
||||
handleEquipmentCostChange('publicExpense', checked ? 10000000 : 0)
|
||||
}
|
||||
disabled={isViewMode}
|
||||
/>
|
||||
<Label className="font-normal">공과잡</Label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 특이사항 */}
|
||||
<div className="space-y-2">
|
||||
<Label>특이사항</Label>
|
||||
<Textarea
|
||||
value={formData.specialNotes}
|
||||
onChange={(e) => handleFieldChange('specialNotes', e.target.value)}
|
||||
disabled={isViewMode}
|
||||
rows={4}
|
||||
placeholder="특이사항을 입력하세요"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 녹음 버튼 */}
|
||||
{isEditMode && (
|
||||
<div className="flex justify-end">
|
||||
<Button variant="outline">녹음</Button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</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 && (
|
||||
<HandoverReportDocumentModal
|
||||
open={showDocumentModal}
|
||||
onOpenChange={setShowDocumentModal}
|
||||
report={initialData}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 전자결재 모달 */}
|
||||
<ElectronicApprovalModal
|
||||
isOpen={showApprovalModal}
|
||||
onClose={() => setShowApprovalModal(false)}
|
||||
approval={approvalData}
|
||||
onSave={handleApprovalSave}
|
||||
/>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,490 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FileText, Clock, CheckCircle, 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 type { HandoverReport, HandoverReportStats } from './types';
|
||||
import {
|
||||
REPORT_STATUS_OPTIONS,
|
||||
REPORT_SORT_OPTIONS,
|
||||
HANDOVER_STATUS_LABELS,
|
||||
HANDOVER_STATUS_STYLES,
|
||||
} from './types';
|
||||
import {
|
||||
getHandoverReportList,
|
||||
getHandoverReportStats,
|
||||
} from './actions';
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'reportNumber', label: '보고서번호', className: 'w-[100px]' },
|
||||
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
|
||||
{ key: 'siteName', label: '현장명', className: 'min-w-[100px]' },
|
||||
{ key: 'contractManager', label: '계약담당자', className: 'w-[100px] text-center' },
|
||||
{ key: 'constructionPM', label: '공사PM', className: 'w-[80px] text-center' },
|
||||
{ key: 'totalSites', label: '총 개소', className: 'w-[80px] text-center' },
|
||||
{ key: 'contractAmount', label: '계약금액(공급가액)', className: 'w-[140px] text-right' },
|
||||
{ key: 'contractPeriod', label: '계약기간', className: 'w-[180px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
|
||||
];
|
||||
|
||||
// 목업 거래처 목록
|
||||
const MOCK_PARTNERS: MultiSelectOption[] = [
|
||||
{ value: 'partner1', label: '주식회사 한빛' },
|
||||
{ value: 'partner2', label: '대성건설' },
|
||||
{ value: 'partner3', label: '삼성물산' },
|
||||
{ value: 'partner4', label: 'LG전자' },
|
||||
];
|
||||
|
||||
// 목업 계약담당자 목록
|
||||
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 HandoverReportListClientProps {
|
||||
initialData?: HandoverReport[];
|
||||
initialStats?: HandoverReportStats;
|
||||
}
|
||||
|
||||
export default function HandoverReportListClient({
|
||||
initialData = [],
|
||||
initialStats,
|
||||
}: HandoverReportListClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 상태
|
||||
const [reports, setReports] = useState<HandoverReport[]>(initialData);
|
||||
const [stats, setStats] = useState<HandoverReportStats | 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>('2025-09-01');
|
||||
const [endDate, setEndDate] = useState<string>('2025-09-03');
|
||||
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = 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([
|
||||
getHandoverReportList({
|
||||
size: 1000,
|
||||
startDate: startDate || undefined,
|
||||
endDate: endDate || undefined,
|
||||
}),
|
||||
getHandoverReportStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setReports(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 filteredReports = useMemo(() => {
|
||||
return reports.filter((report) => {
|
||||
// 상태 탭 필터
|
||||
if (activeStatTab === 'pending' && report.status !== 'pending') return false;
|
||||
if (activeStatTab === 'completed' && report.status !== 'completed') return false;
|
||||
|
||||
// 거래처 필터
|
||||
if (partnerFilters.length > 0) {
|
||||
if (!partnerFilters.includes(report.partnerId)) return false;
|
||||
}
|
||||
|
||||
// 계약담당자 필터
|
||||
if (contractManagerFilters.length > 0) {
|
||||
if (!contractManagerFilters.includes(report.contractManagerId)) return false;
|
||||
}
|
||||
|
||||
// 공사PM 필터
|
||||
if (constructionPMFilters.length > 0) {
|
||||
if (!constructionPMFilters.includes(report.constructionPMId || '')) return false;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && report.status !== statusFilter) return false;
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
report.reportNumber.toLowerCase().includes(search) ||
|
||||
report.partnerName.toLowerCase().includes(search) ||
|
||||
report.siteName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [reports, activeStatTab, partnerFilters, contractManagerFilters, constructionPMFilters, statusFilter, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedReports = useMemo(() => {
|
||||
const sorted = [...filteredReports];
|
||||
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 'siteNameAsc':
|
||||
sorted.sort((a, b) => a.siteName.localeCompare(b.siteName, 'ko'));
|
||||
break;
|
||||
case 'siteNameDesc':
|
||||
sorted.sort((a, b) => b.siteName.localeCompare(a.siteName, 'ko'));
|
||||
break;
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredReports, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedReports.length / itemsPerPage);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * itemsPerPage;
|
||||
return sortedReports.slice(start, start + itemsPerPage);
|
||||
}, [sortedReports, 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((r) => r.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(report: HandoverReport) => {
|
||||
router.push(`/ko/juil/project/contract/handover-report/${report.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, reportId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/juil/project/contract/handover-report/${reportId}/edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(report: HandoverReport, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(report.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={report.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(report)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(report.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell>{report.reportNumber}</TableCell>
|
||||
<TableCell>{report.partnerName}</TableCell>
|
||||
<TableCell>{report.siteName}</TableCell>
|
||||
<TableCell className="text-center">{report.contractManagerName}</TableCell>
|
||||
<TableCell className="text-center">{report.constructionPMName || '-'}</TableCell>
|
||||
<TableCell className="text-center">{report.totalSites}</TableCell>
|
||||
<TableCell className="text-right">{formatAmount(report.contractAmount)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
{formatPeriod(report.contractStartDate, report.contractEndDate)}
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<span className={HANDOVER_STATUS_STYLES[report.status]}>
|
||||
{HANDOVER_STATUS_LABELS[report.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, report.id)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
|
||||
);
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(report: HandoverReport, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
<MobileCard
|
||||
title={report.siteName}
|
||||
subtitle={report.reportNumber}
|
||||
badge={HANDOVER_STATUS_LABELS[report.status]}
|
||||
badgeVariant="secondary"
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => handleRowClick(report)}
|
||||
details={[
|
||||
{ label: '거래처', value: report.partnerName },
|
||||
{ label: '계약금액', value: `${formatAmount(report.contractAmount)}원` },
|
||||
{ label: '계약담당자', value: report.contractManagerName },
|
||||
{ label: '총 개소', value: `${report.totalSites}개소` },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[handleRowClick]
|
||||
);
|
||||
|
||||
// 헤더 액션 (날짜 필터)
|
||||
const headerActions = (
|
||||
<div className="flex flex-col gap-2 w-full">
|
||||
<DateRangeSelector
|
||||
startDate={startDate}
|
||||
endDate={endDate}
|
||||
onStartDateChange={setStartDate}
|
||||
onEndDateChange={setEndDate}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
|
||||
// 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">
|
||||
총 {sortedReports.length}건
|
||||
</span>
|
||||
|
||||
{/* 거래처 필터 */}
|
||||
<MultiSelectCombobox
|
||||
options={MOCK_PARTNERS}
|
||||
value={partnerFilters}
|
||||
onChange={setPartnerFilters}
|
||||
placeholder="거래처"
|
||||
searchPlaceholder="거래처 검색..."
|
||||
className="w-[130px]"
|
||||
/>
|
||||
|
||||
{/* 계약담당자 필터 */}
|
||||
<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-[120px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REPORT_STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="최신순 (계약시작일)" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{REPORT_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={sortedReports}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedReports.length,
|
||||
itemsPerPage,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
400
src/components/business/juil/handover-report/actions.ts
Normal file
400
src/components/business/juil/handover-report/actions.ts
Normal file
@@ -0,0 +1,400 @@
|
||||
'use server';
|
||||
|
||||
import type { HandoverReport, HandoverReportStats, HandoverReportDetail, HandoverReportFormData } from './types';
|
||||
|
||||
// 목업 데이터
|
||||
const MOCK_REPORTS: HandoverReport[] = [
|
||||
{
|
||||
id: '1',
|
||||
reportNumber: '123123',
|
||||
partnerName: '통신공사',
|
||||
siteName: '서울역사 통신공사',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMName: '김PM',
|
||||
totalSites: 21,
|
||||
contractAmount: 105800000,
|
||||
contractStartDate: '2025-12-12',
|
||||
contractEndDate: '2026-12-12',
|
||||
status: 'pending',
|
||||
contractId: '1',
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
reportNumber: '123124',
|
||||
partnerName: '야사건설',
|
||||
siteName: '부산항 건설현장',
|
||||
contractManagerName: '김철수',
|
||||
constructionPMName: '이PM',
|
||||
totalSites: 15,
|
||||
contractAmount: 10500000,
|
||||
contractStartDate: '2025-11-01',
|
||||
contractEndDate: '2026-11-01',
|
||||
status: 'completed',
|
||||
contractId: '2',
|
||||
createdAt: '2025-01-02',
|
||||
updatedAt: '2025-01-02',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
reportNumber: '123125',
|
||||
partnerName: '여의건설',
|
||||
siteName: '인천공항 확장공사',
|
||||
contractManagerName: '이영희',
|
||||
constructionPMName: '박PM',
|
||||
totalSites: 30,
|
||||
contractAmount: 10000000,
|
||||
contractStartDate: '2025-10-15',
|
||||
contractEndDate: '2026-10-15',
|
||||
status: 'pending',
|
||||
contractId: '3',
|
||||
createdAt: '2025-01-03',
|
||||
updatedAt: '2025-01-03',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
reportNumber: '123126',
|
||||
partnerName: '통신공사',
|
||||
siteName: '대전역 리모델링',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMName: '김PM',
|
||||
totalSites: 18,
|
||||
contractAmount: 10000000,
|
||||
contractStartDate: '2025-09-20',
|
||||
contractEndDate: '2026-03-20',
|
||||
status: 'completed',
|
||||
contractId: '4',
|
||||
createdAt: '2025-01-04',
|
||||
updatedAt: '2025-01-04',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
reportNumber: '123127',
|
||||
partnerName: '야사건설',
|
||||
siteName: '광주 신축현장',
|
||||
contractManagerName: '김철수',
|
||||
constructionPMName: '이PM',
|
||||
totalSites: 17,
|
||||
contractAmount: 10500000,
|
||||
contractStartDate: '2025-08-01',
|
||||
contractEndDate: '2026-08-01',
|
||||
status: 'pending',
|
||||
contractId: '5',
|
||||
createdAt: '2025-01-05',
|
||||
updatedAt: '2025-01-05',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
reportNumber: '123128',
|
||||
partnerName: '여의건설',
|
||||
siteName: '세종시 행정타운',
|
||||
contractManagerName: '이영희',
|
||||
constructionPMName: '박PM',
|
||||
totalSites: 25,
|
||||
contractAmount: 100000000,
|
||||
contractStartDate: '2025-07-15',
|
||||
contractEndDate: '2026-07-15',
|
||||
status: 'completed',
|
||||
contractId: '6',
|
||||
createdAt: '2025-01-06',
|
||||
updatedAt: '2025-01-06',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
reportNumber: '123129',
|
||||
partnerName: '통신공사',
|
||||
siteName: '제주 관광단지',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMName: null,
|
||||
totalSites: 12,
|
||||
contractAmount: 105800000,
|
||||
contractStartDate: '2025-06-01',
|
||||
contractEndDate: '2026-06-01',
|
||||
status: 'pending',
|
||||
contractId: '7',
|
||||
createdAt: '2025-01-07',
|
||||
updatedAt: '2025-01-07',
|
||||
},
|
||||
];
|
||||
|
||||
interface GetHandoverReportListParams {
|
||||
size?: number;
|
||||
page?: number;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
interface GetHandoverReportListResult {
|
||||
success: boolean;
|
||||
data?: {
|
||||
items: HandoverReport[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
};
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function getHandoverReportList(
|
||||
params: GetHandoverReportListParams = {}
|
||||
): Promise<GetHandoverReportListResult> {
|
||||
try {
|
||||
// 실제 API 호출 시 여기에 구현
|
||||
// const response = await fetch(`/api/v1/handover-reports?...`);
|
||||
|
||||
// 목업 데이터 반환
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: MOCK_REPORTS,
|
||||
total: MOCK_REPORTS.length,
|
||||
page: params.page || 1,
|
||||
size: params.size || 20,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch handover report list:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '인수인계보고서 목록을 불러오는데 실패했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface GetHandoverReportStatsResult {
|
||||
success: boolean;
|
||||
data?: HandoverReportStats;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function getHandoverReportStats(): Promise<GetHandoverReportStatsResult> {
|
||||
try {
|
||||
// 실제 API 호출 시 여기에 구현
|
||||
|
||||
// 목업 통계 반환
|
||||
const pending = MOCK_REPORTS.filter(r => r.status === 'pending').length;
|
||||
const completed = MOCK_REPORTS.filter(r => r.status === 'completed').length;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
total: MOCK_REPORTS.length,
|
||||
pending,
|
||||
completed,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch handover report stats:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '통계를 불러오는데 실패했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface DeleteHandoverReportResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function deleteHandoverReport(id: string): Promise<DeleteHandoverReportResult> {
|
||||
try {
|
||||
// 실제 API 호출 시 여기에 구현
|
||||
console.log('Deleting handover report:', id);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to delete handover report:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '삭제에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface DeleteHandoverReportsResult {
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function deleteHandoverReports(ids: string[]): Promise<DeleteHandoverReportsResult> {
|
||||
try {
|
||||
// 실제 API 호출 시 여기에 구현
|
||||
console.log('Deleting handover reports:', ids);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
deletedCount: ids.length,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to delete handover reports:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '일괄 삭제에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// 목업 상세 데이터
|
||||
const MOCK_REPORT_DETAILS: Record<string, HandoverReportDetail> = {
|
||||
'1': {
|
||||
id: '1',
|
||||
reportNumber: '123123',
|
||||
partnerName: '통신공사',
|
||||
siteName: '서울역사 통신공사',
|
||||
contractManagerName: '홍길동',
|
||||
constructionPMName: '김PM',
|
||||
constructionPMId: 'pm1',
|
||||
totalSites: 21,
|
||||
contractAmount: 105800000,
|
||||
contractDate: '2025-12-12',
|
||||
contractStartDate: '2026-01-01',
|
||||
contractEndDate: '2026-12-10',
|
||||
status: 'pending',
|
||||
contractId: '1',
|
||||
createdAt: '2025-01-01',
|
||||
updatedAt: '2025-01-01',
|
||||
completionDate: '2026-05-01',
|
||||
constructionManagers: [
|
||||
{ id: 'mgr1', name: '홍길동', isNonPerformanceUsed: false },
|
||||
{ id: 'mgr2', name: '김철수', isNonPerformanceUsed: true },
|
||||
],
|
||||
contractItems: [
|
||||
{ id: 'item1', no: 1, name: '접지방화서터', product: '제품', quantity: 1000, remark: '품질인증적용' },
|
||||
{ id: 'item2', no: 2, name: '스크린방화서터', product: '제품', quantity: 111, remark: '품질인증적용' },
|
||||
],
|
||||
hasSecondaryPiping: true,
|
||||
secondaryPipingAmount: 1200000,
|
||||
hasCoating: true,
|
||||
coatingAmount: 500000,
|
||||
externalEquipmentCost: {
|
||||
shippingCost: 1500000,
|
||||
highAltitudeWork: 800000,
|
||||
publicExpense: 10000000,
|
||||
},
|
||||
specialNotes: '특이사항 내용이 여기에 표시됩니다.',
|
||||
},
|
||||
'2': {
|
||||
id: '2',
|
||||
reportNumber: '123124',
|
||||
partnerName: '야사건설',
|
||||
siteName: '부산항 건설현장',
|
||||
contractManagerName: '김철수',
|
||||
constructionPMName: '이PM',
|
||||
constructionPMId: 'pm2',
|
||||
totalSites: 15,
|
||||
contractAmount: 10500000,
|
||||
contractDate: '2025-11-01',
|
||||
contractStartDate: '2025-11-01',
|
||||
contractEndDate: '2026-11-01',
|
||||
status: 'completed',
|
||||
contractId: '2',
|
||||
createdAt: '2025-01-02',
|
||||
updatedAt: '2025-01-02',
|
||||
completionDate: '2026-04-01',
|
||||
constructionManagers: [
|
||||
{ id: 'mgr3', name: '이영희', isNonPerformanceUsed: false },
|
||||
],
|
||||
contractItems: [
|
||||
{ id: 'item3', no: 1, name: '방화문', product: '제품A', quantity: 500, remark: '' },
|
||||
],
|
||||
hasSecondaryPiping: false,
|
||||
secondaryPipingAmount: 0,
|
||||
hasCoating: false,
|
||||
coatingAmount: 0,
|
||||
externalEquipmentCost: {
|
||||
shippingCost: 500000,
|
||||
highAltitudeWork: 0,
|
||||
publicExpense: 2000000,
|
||||
},
|
||||
specialNotes: '',
|
||||
},
|
||||
};
|
||||
|
||||
interface GetHandoverReportDetailResult {
|
||||
success: boolean;
|
||||
data?: HandoverReportDetail;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function getHandoverReportDetail(id: string): Promise<GetHandoverReportDetailResult> {
|
||||
try {
|
||||
// 실제 API 호출 시 여기에 구현
|
||||
// const response = await fetch(`/api/v1/handover-reports/${id}`);
|
||||
|
||||
const detail = MOCK_REPORT_DETAILS[id];
|
||||
|
||||
if (!detail) {
|
||||
// 목록 데이터에서 기본 상세 생성
|
||||
const report = MOCK_REPORTS.find(r => r.id === id);
|
||||
if (report) {
|
||||
const generatedDetail: HandoverReportDetail = {
|
||||
...report,
|
||||
contractDate: report.contractStartDate,
|
||||
constructionPMId: 'pm1',
|
||||
completionDate: null,
|
||||
constructionManagers: [],
|
||||
contractItems: [],
|
||||
hasSecondaryPiping: false,
|
||||
secondaryPipingAmount: 0,
|
||||
hasCoating: false,
|
||||
coatingAmount: 0,
|
||||
externalEquipmentCost: {
|
||||
shippingCost: 0,
|
||||
highAltitudeWork: 0,
|
||||
publicExpense: 0,
|
||||
},
|
||||
specialNotes: '',
|
||||
};
|
||||
return {
|
||||
success: true,
|
||||
data: generatedDetail,
|
||||
};
|
||||
}
|
||||
return {
|
||||
success: false,
|
||||
error: '인수인계보고서를 찾을 수 없습니다.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: detail,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch handover report detail:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '인수인계보고서 상세 정보를 불러오는데 실패했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
interface UpdateHandoverReportResult {
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}
|
||||
|
||||
export async function updateHandoverReport(
|
||||
id: string,
|
||||
data: HandoverReportFormData
|
||||
): Promise<UpdateHandoverReportResult> {
|
||||
try {
|
||||
// 실제 API 호출 시 여기에 구현
|
||||
console.log('Updating handover report:', id, data);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Failed to update handover report:', error);
|
||||
return {
|
||||
success: false,
|
||||
error: '수정에 실패했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
5
src/components/business/juil/handover-report/index.ts
Normal file
5
src/components/business/juil/handover-report/index.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as HandoverReportListClient } from './HandoverReportListClient';
|
||||
export { default as HandoverReportDetailForm } from './HandoverReportDetailForm';
|
||||
export * from './types';
|
||||
export * from './actions';
|
||||
export * from './modals';
|
||||
@@ -0,0 +1,308 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
VisuallyHidden,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Edit,
|
||||
Trash2,
|
||||
Printer,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { printArea } from '@/lib/print-utils';
|
||||
import type { HandoverReportDetail } from '../types';
|
||||
|
||||
// 금액 포맷팅
|
||||
function formatAmount(amount: number | undefined | null): string {
|
||||
if (amount === undefined || amount === null) return '0';
|
||||
return new Intl.NumberFormat('ko-KR').format(amount);
|
||||
}
|
||||
|
||||
// 날짜 포맷팅 (년월)
|
||||
function formatYearMonth(dateStr: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return `${date.getFullYear()}년 ${date.getMonth() + 1}월`;
|
||||
}
|
||||
|
||||
// 날짜 포맷팅 (전체)
|
||||
function formatDate(dateStr: string | null): string {
|
||||
if (!dateStr) return '-';
|
||||
const date = new Date(dateStr);
|
||||
return date.toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
});
|
||||
}
|
||||
|
||||
interface HandoverReportDocumentModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
report: HandoverReportDetail;
|
||||
}
|
||||
|
||||
export function HandoverReportDocumentModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
report,
|
||||
}: HandoverReportDocumentModalProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 수정
|
||||
const handleEdit = () => {
|
||||
onOpenChange(false);
|
||||
router.push(`/ko/juil/project/contract/handover-report/${report.id}/edit`);
|
||||
};
|
||||
|
||||
// 삭제
|
||||
const handleDelete = () => {
|
||||
toast.info('삭제 기능은 준비 중입니다.');
|
||||
};
|
||||
|
||||
// 인쇄
|
||||
const handlePrint = () => {
|
||||
printArea({ title: '인수인계보고서 인쇄' });
|
||||
};
|
||||
|
||||
// 계약 ITEM 행 수 계산 (최소 1행)
|
||||
const contractItemsCount = Math.max(report.contractItems.length, 1);
|
||||
|
||||
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"
|
||||
>
|
||||
<X 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="outline" size="sm" onClick={handleDelete}>
|
||||
<Trash2 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>
|
||||
|
||||
{/* 문서 영역 - 스크롤 (인쇄 시 이 영역만 출력) */}
|
||||
<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 p-8">
|
||||
{/* 상단: 제목 + 결재란 */}
|
||||
<div className="flex justify-between items-start mb-6">
|
||||
{/* 좌측: 제목 및 문서정보 */}
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold mb-2">인수인계보고서</h1>
|
||||
<div className="text-sm text-gray-600">
|
||||
문서번호: {report.reportNumber} | 작성일자: {formatDate(report.createdAt)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 결재란 */}
|
||||
<table className="border-collapse border border-gray-300 text-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th rowSpan={3} className="border border-gray-300 px-2 py-1 bg-gray-50 text-center w-8 align-middle">
|
||||
<span className="writing-vertical">결<br />재</span>
|
||||
</th>
|
||||
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16">작성</th>
|
||||
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16">승인</th>
|
||||
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16">승인</th>
|
||||
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16">승인</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-300 px-3 py-2 text-center h-10">홍길동</td>
|
||||
<td className="border border-gray-300 px-3 py-2 text-center h-10">이름</td>
|
||||
<td className="border border-gray-300 px-3 py-2 text-center h-10">이름</td>
|
||||
<td className="border border-gray-300 px-3 py-2 text-center h-10">이름</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500">부서명</td>
|
||||
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500">부서명</td>
|
||||
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500">부서명</td>
|
||||
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500">부서명</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 통합 테이블 - 기획서 구조 100% 반영 */}
|
||||
<table className="w-full border-collapse border border-gray-300 text-sm">
|
||||
<tbody>
|
||||
{/* 현장명 */}
|
||||
<tr>
|
||||
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-left w-40 font-medium">현장명</th>
|
||||
<td className="border border-gray-300 px-4 py-3">{report.siteName || '-'}</td>
|
||||
</tr>
|
||||
|
||||
{/* 거래처 */}
|
||||
<tr>
|
||||
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-left font-medium">거래처</th>
|
||||
<td className="border border-gray-300 px-4 py-3">{report.partnerName || '-'}</td>
|
||||
</tr>
|
||||
|
||||
{/* 준공 */}
|
||||
<tr>
|
||||
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-left font-medium">준공</th>
|
||||
<td className="border border-gray-300 px-4 py-3">{formatYearMonth(report.completionDate)}</td>
|
||||
</tr>
|
||||
|
||||
{/* 계약금액 (공급가액) */}
|
||||
<tr>
|
||||
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-left font-medium">계약금액 (공급가액)</th>
|
||||
<td className="border border-gray-300 px-4 py-3">₩ {formatAmount(report.contractAmount)}</td>
|
||||
</tr>
|
||||
|
||||
{/* 계약 ITEM - 기획서: 구분, 수량, 비고 3컬럼 */}
|
||||
<tr>
|
||||
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-left font-medium align-middle">계약 ITEM</th>
|
||||
<td className="border border-gray-300 p-0">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="border-b border-gray-300 px-4 py-2 text-left font-medium">구분</th>
|
||||
<th className="border-b border-l border-gray-300 px-4 py-2 text-center font-medium w-24">수량</th>
|
||||
<th className="border-b border-l border-gray-300 px-4 py-2 text-left font-medium">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.contractItems.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-4 py-3 text-center text-gray-400">-</td>
|
||||
</tr>
|
||||
) : (
|
||||
report.contractItems.map((item, idx) => (
|
||||
<tr key={item.id}>
|
||||
<td className={`px-4 py-2 ${idx > 0 ? 'border-t border-gray-300' : ''}`}>
|
||||
{item.name || '-'}
|
||||
</td>
|
||||
<td className={`px-4 py-2 text-center border-l border-gray-300 ${idx > 0 ? 'border-t' : ''}`}>
|
||||
{formatAmount(item.quantity)}
|
||||
</td>
|
||||
<td className={`px-4 py-2 border-l border-gray-300 ${idx > 0 ? 'border-t' : ''}`}>
|
||||
{item.remark || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* 집행유무 - 기획서: 2차 배관 유무, 도장 & 코킹 유무 + 금액 */}
|
||||
<tr>
|
||||
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-left font-medium align-middle">집행유무</th>
|
||||
<td className="border border-gray-300 p-0">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th className="px-4 py-2 bg-gray-50 text-left font-medium w-40">2차 배관 유무</th>
|
||||
<td className="px-4 py-2">
|
||||
{report.hasSecondaryPiping
|
||||
? `포함 (${formatAmount(report.secondaryPipingAmount)})`
|
||||
: '미포함'}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<th className="border-t border-gray-300 px-4 py-2 bg-gray-50 text-left font-medium">도장 & 코킹 유무</th>
|
||||
<td className="border-t border-gray-300 px-4 py-2">
|
||||
{report.hasCoating
|
||||
? `포함 (${formatAmount(report.coatingAmount)})`
|
||||
: '미포함'}
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* 장비 외 실행금액 - 기획서: 운반비, 양중장비, 공과금 */}
|
||||
<tr>
|
||||
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-left font-medium align-middle">장비 외 실행금액</th>
|
||||
<td className="border border-gray-300 px-4 py-3">
|
||||
<div className="space-y-1">
|
||||
<div>운반비 : {formatAmount(report.externalEquipmentCost?.shippingCost)}</div>
|
||||
<div>양중장비 : {formatAmount(report.externalEquipmentCost?.highAltitudeWork)}</div>
|
||||
<div>공과금 : {formatAmount(report.externalEquipmentCost?.publicExpense)}</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* 특이사항 - 기획서: 내용 단일 필드 */}
|
||||
<tr>
|
||||
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-left font-medium align-middle">특이사항</th>
|
||||
<td className="border border-gray-300 px-4 py-3 whitespace-pre-wrap">
|
||||
{report.specialNotes || '-'}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* 공사 담당자 - 기획서: 성명, 서명, 미이행 사유 3컬럼 */}
|
||||
<tr>
|
||||
<th className="border border-gray-300 px-4 py-3 bg-gray-50 text-left font-medium align-middle">공사 담당자</th>
|
||||
<td className="border border-gray-300 p-0">
|
||||
<table className="w-full border-collapse text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="border-b border-gray-300 px-4 py-2 text-center font-medium w-28">성명</th>
|
||||
<th className="border-b border-l border-gray-300 px-4 py-2 text-center font-medium">서명</th>
|
||||
<th className="border-b border-l border-gray-300 px-4 py-2 text-center font-medium w-32">미이행 사유</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{report.constructionManagers.length === 0 ? (
|
||||
<tr>
|
||||
<td colSpan={3} className="px-4 py-3 text-center text-gray-400">-</td>
|
||||
</tr>
|
||||
) : (
|
||||
report.constructionManagers.map((manager, idx) => (
|
||||
<tr key={manager.id}>
|
||||
<td className={`px-4 py-2 text-center ${idx > 0 ? 'border-t border-gray-300' : ''}`}>
|
||||
{manager.name || '-'}
|
||||
</td>
|
||||
<td className={`px-4 py-2 text-center border-l border-gray-300 ${idx > 0 ? 'border-t' : ''}`}>
|
||||
{manager.signature || ''}
|
||||
</td>
|
||||
<td className={`px-4 py-2 text-center border-l border-gray-300 ${idx > 0 ? 'border-t' : ''}`}>
|
||||
{manager.nonPerformanceReason || ''}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
export { HandoverReportDocumentModal } from './HandoverReportDocumentModal';
|
||||
209
src/components/business/juil/handover-report/types.ts
Normal file
209
src/components/business/juil/handover-report/types.ts
Normal file
@@ -0,0 +1,209 @@
|
||||
// 인수인계보고서 타입 정의
|
||||
|
||||
// 인수인계 상태
|
||||
export type HandoverStatus = 'pending' | 'completed';
|
||||
|
||||
// 인수인계보고서 (목록용)
|
||||
export interface HandoverReport {
|
||||
id: string;
|
||||
reportNumber: string; // 보고서번호
|
||||
partnerName: string; // 거래처
|
||||
siteName: string; // 현장명
|
||||
contractManagerName: string; // 계약담당자
|
||||
constructionPMName: string | null; // 공사PM
|
||||
totalSites: number; // 총 개소
|
||||
contractAmount: number; // 계약금액(공급가액)
|
||||
contractStartDate: string | null; // 계약시작일
|
||||
contractEndDate: string | null; // 계약종료일
|
||||
status: HandoverStatus; // 상태 (인수인계대기/인수인계완료)
|
||||
contractId: string; // 연결된 계약 ID
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 공사담당자
|
||||
export interface ConstructionManager {
|
||||
id: string;
|
||||
name: string;
|
||||
nonPerformanceReason: string; // 미이행 사유
|
||||
signature?: string | null; // 서명 (보고서 보기용)
|
||||
}
|
||||
|
||||
// 계약 ITEM
|
||||
export interface ContractItem {
|
||||
id: string;
|
||||
no: number; // 번호
|
||||
name: string; // 명칭
|
||||
product: string; // 제품
|
||||
quantity: number; // 수량
|
||||
remark: string; // 비고
|
||||
}
|
||||
|
||||
// 장비 외 실행금액
|
||||
export interface ExternalEquipmentCost {
|
||||
shippingCost: number; // 운반비
|
||||
highAltitudeWork: number; // 고소작업대
|
||||
publicExpense: number; // 공과잡
|
||||
}
|
||||
|
||||
// 인수인계보고서 상세
|
||||
export interface HandoverReportDetail extends HandoverReport {
|
||||
contractDate: string | null; // 계약일자
|
||||
constructionPMId: string | null; // 공사PM ID
|
||||
constructionManagers: ConstructionManager[]; // 공사담당자 목록
|
||||
contractItems: ContractItem[]; // 계약 ITEM 목록
|
||||
hasSecondaryPiping: boolean; // 2차 배관 유무
|
||||
secondaryPipingAmount: number; // 2차 배관 금액
|
||||
secondaryPipingNote: string; // 2차 배관 비고 (포함 시 입력)
|
||||
hasCoating: boolean; // 도장 & 코킹 유무
|
||||
coatingAmount: number; // 도장 & 코킹 금액
|
||||
coatingNote: string; // 도장 & 코킹 비고 (포함 시 입력)
|
||||
externalEquipmentCost: ExternalEquipmentCost; // 장비 외 실행금액
|
||||
specialNotes: string; // 특이사항
|
||||
completionDate: string | null; // 준공일
|
||||
}
|
||||
|
||||
// 인수인계보고서 폼 데이터
|
||||
export interface HandoverReportFormData {
|
||||
reportNumber: string;
|
||||
partnerName: string;
|
||||
siteName: string;
|
||||
contractManagerName: string;
|
||||
contractDate: string;
|
||||
totalSites: number;
|
||||
contractStartDate: string;
|
||||
contractEndDate: string;
|
||||
contractAmount: number;
|
||||
constructionPMId: string;
|
||||
constructionPMName: string;
|
||||
status: HandoverStatus;
|
||||
constructionManagers: ConstructionManager[];
|
||||
contractItems: ContractItem[];
|
||||
hasSecondaryPiping: boolean;
|
||||
secondaryPipingNote: string;
|
||||
hasCoating: boolean;
|
||||
coatingNote: string;
|
||||
externalEquipmentCost: ExternalEquipmentCost;
|
||||
specialNotes: string;
|
||||
}
|
||||
|
||||
// 기본 폼 데이터 생성
|
||||
export function getEmptyHandoverReportFormData(): HandoverReportFormData {
|
||||
return {
|
||||
reportNumber: '',
|
||||
partnerName: '',
|
||||
siteName: '',
|
||||
contractManagerName: '',
|
||||
contractDate: '',
|
||||
totalSites: 0,
|
||||
contractStartDate: '',
|
||||
contractEndDate: '',
|
||||
contractAmount: 0,
|
||||
constructionPMId: '',
|
||||
constructionPMName: '',
|
||||
status: 'pending',
|
||||
constructionManagers: [],
|
||||
contractItems: [],
|
||||
hasSecondaryPiping: false,
|
||||
secondaryPipingNote: '',
|
||||
hasCoating: false,
|
||||
coatingNote: '',
|
||||
externalEquipmentCost: {
|
||||
shippingCost: 0,
|
||||
highAltitudeWork: 0,
|
||||
publicExpense: 0,
|
||||
},
|
||||
specialNotes: '',
|
||||
};
|
||||
}
|
||||
|
||||
// 상세 데이터를 폼 데이터로 변환
|
||||
export function handoverReportDetailToFormData(detail: HandoverReportDetail): HandoverReportFormData {
|
||||
return {
|
||||
reportNumber: detail.reportNumber,
|
||||
partnerName: detail.partnerName,
|
||||
siteName: detail.siteName,
|
||||
contractManagerName: detail.contractManagerName,
|
||||
contractDate: detail.contractDate || '',
|
||||
totalSites: detail.totalSites,
|
||||
contractStartDate: detail.contractStartDate || '',
|
||||
contractEndDate: detail.contractEndDate || '',
|
||||
contractAmount: detail.contractAmount,
|
||||
constructionPMId: detail.constructionPMId || '',
|
||||
constructionPMName: detail.constructionPMName || '',
|
||||
status: detail.status,
|
||||
constructionManagers: detail.constructionManagers,
|
||||
contractItems: detail.contractItems,
|
||||
hasSecondaryPiping: detail.hasSecondaryPiping,
|
||||
secondaryPipingNote: detail.secondaryPipingNote || '',
|
||||
hasCoating: detail.hasCoating,
|
||||
coatingNote: detail.coatingNote || '',
|
||||
externalEquipmentCost: detail.externalEquipmentCost,
|
||||
specialNotes: detail.specialNotes,
|
||||
};
|
||||
}
|
||||
|
||||
// 공사PM 목록 (목업)
|
||||
export const CONSTRUCTION_PM_OPTIONS = [
|
||||
{ value: 'pm1', label: '김PM' },
|
||||
{ value: 'pm2', label: '이PM' },
|
||||
{ value: 'pm3', label: '박PM' },
|
||||
{ value: 'pm4', label: '최PM' },
|
||||
];
|
||||
|
||||
// 담당자 목록 (목업)
|
||||
export const MANAGER_OPTIONS = [
|
||||
{ value: 'mgr1', label: '홍길동' },
|
||||
{ value: 'mgr2', label: '김철수' },
|
||||
{ value: 'mgr3', label: '이영희' },
|
||||
{ value: 'mgr4', label: '박민수' },
|
||||
];
|
||||
|
||||
// 통계
|
||||
export interface HandoverReportStats {
|
||||
total: number; // 전체 인수인계보고서
|
||||
pending: number; // 인수인계대기
|
||||
completed: number; // 인수인계완료
|
||||
}
|
||||
|
||||
// 상태 필터 옵션
|
||||
export const REPORT_STATUS_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'pending', label: '인수인계대기' },
|
||||
{ value: 'completed', label: '인수인계완료' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
export const REPORT_SORT_OPTIONS = [
|
||||
{ value: 'contractDateDesc', label: '최신순 (계약시작일)' },
|
||||
{ value: 'contractDateAsc', label: '등록순 (계약종료시작일)' },
|
||||
{ value: 'partnerNameAsc', label: '거래처명 오름차순' },
|
||||
{ value: 'partnerNameDesc', label: '거래처명 내림차순' },
|
||||
{ value: 'siteNameAsc', label: '현장명 오름차순' },
|
||||
{ value: 'siteNameDesc', label: '현장명 내림차순' },
|
||||
];
|
||||
|
||||
// 인수인계 상태 라벨
|
||||
export const HANDOVER_STATUS_LABELS: Record<HandoverStatus, string> = {
|
||||
pending: '인수인계대기',
|
||||
completed: '인수인계완료',
|
||||
};
|
||||
|
||||
// 인수인계 상태 스타일
|
||||
export const HANDOVER_STATUS_STYLES: Record<HandoverStatus, string> = {
|
||||
pending: 'inline-flex items-center justify-center rounded-md border border-orange-500 px-2 py-0.5 text-xs font-medium text-orange-600 bg-orange-50',
|
||||
completed: 'inline-flex items-center justify-center rounded-md border border-green-500 px-2 py-0.5 text-xs font-medium text-green-600 bg-green-50',
|
||||
};
|
||||
|
||||
// 단계 탭 타입
|
||||
export type StageTab = 'all' | 'estimate_selected' | 'estimate_progress' | 'delivery' | 'installation' | 'other';
|
||||
|
||||
// 단계 탭 데이터
|
||||
export const STAGE_TABS: { key: StageTab; label: string }[] = [
|
||||
{ key: 'all', label: '상태단건' },
|
||||
{ key: 'estimate_selected', label: '견적일' },
|
||||
{ key: 'estimate_progress', label: '발일' },
|
||||
{ key: 'delivery', label: '발행' },
|
||||
{ key: 'installation', label: '미체' },
|
||||
{ key: 'other', label: '옵을' },
|
||||
];
|
||||
@@ -0,0 +1,591 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Package, Plus, X } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { toast } from 'sonner';
|
||||
import type { ItemDetail, ItemFormData, ItemType, Specification, OrderType, ItemStatus, OrderItem } from './types';
|
||||
import {
|
||||
ITEM_TYPE_OPTIONS,
|
||||
SPECIFICATION_OPTIONS,
|
||||
ORDER_TYPE_OPTIONS,
|
||||
STATUS_OPTIONS,
|
||||
UNIT_OPTIONS,
|
||||
} from './constants';
|
||||
import { getItem, createItem, updateItem, deleteItem, getCategoryOptions } from './actions';
|
||||
|
||||
interface ItemDetailClientProps {
|
||||
itemId?: string;
|
||||
isEditMode?: boolean;
|
||||
isNewMode?: boolean;
|
||||
}
|
||||
|
||||
const initialFormData: ItemFormData = {
|
||||
itemNumber: '',
|
||||
itemType: '제품',
|
||||
categoryId: '',
|
||||
itemName: '',
|
||||
specification: '인정',
|
||||
unit: 'SET',
|
||||
orderType: '경품발주',
|
||||
status: '사용',
|
||||
note: '',
|
||||
orderItems: [],
|
||||
};
|
||||
|
||||
export default function ItemDetailClient({
|
||||
itemId,
|
||||
isEditMode = false,
|
||||
isNewMode = false,
|
||||
}: ItemDetailClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 모드 상태
|
||||
const [mode, setMode] = useState<'view' | 'edit' | 'new'>(
|
||||
isNewMode ? 'new' : isEditMode ? 'edit' : 'view'
|
||||
);
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<ItemFormData>(initialFormData);
|
||||
const [originalData, setOriginalData] = useState<ItemDetail | null>(null);
|
||||
|
||||
// 카테고리 옵션
|
||||
const [categoryOptions, setCategoryOptions] = useState<{ id: string; name: string }[]>([]);
|
||||
|
||||
// 상태
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
// 카테고리 목록 로드
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
const result = await getCategoryOptions();
|
||||
if (result.success && result.data) {
|
||||
setCategoryOptions(result.data);
|
||||
}
|
||||
};
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
// 품목 데이터 로드
|
||||
useEffect(() => {
|
||||
if (itemId && !isNewMode) {
|
||||
const loadItem = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await getItem(itemId);
|
||||
if (result.success && result.data) {
|
||||
setOriginalData(result.data);
|
||||
setFormData({
|
||||
itemNumber: result.data.itemNumber,
|
||||
itemType: result.data.itemType,
|
||||
categoryId: result.data.categoryId,
|
||||
itemName: result.data.itemName,
|
||||
specification: result.data.specification,
|
||||
unit: result.data.unit,
|
||||
orderType: result.data.orderType,
|
||||
status: result.data.status,
|
||||
note: result.data.note || '',
|
||||
orderItems: result.data.orderItems || [],
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '품목 정보를 불러오는데 실패했습니다.');
|
||||
router.push('/ko/juil/order/base-info/items');
|
||||
}
|
||||
} catch {
|
||||
toast.error('품목 정보를 불러오는데 실패했습니다.');
|
||||
router.push('/ko/juil/order/base-info/items');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadItem();
|
||||
}
|
||||
}, [itemId, isNewMode, router]);
|
||||
|
||||
// 폼 필드 변경
|
||||
const handleFieldChange = useCallback(
|
||||
(field: keyof ItemFormData, value: string | OrderItem[]) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 발주 항목 추가
|
||||
const handleAddOrderItem = useCallback(() => {
|
||||
const newItem: OrderItem = {
|
||||
id: `new-${Date.now()}`,
|
||||
label: '',
|
||||
value: '',
|
||||
};
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
orderItems: [...prev.orderItems, newItem],
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 발주 항목 삭제
|
||||
const handleRemoveOrderItem = useCallback((id: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
orderItems: prev.orderItems.filter((item) => item.id !== id),
|
||||
}));
|
||||
}, []);
|
||||
|
||||
// 발주 항목 변경
|
||||
const handleOrderItemChange = useCallback(
|
||||
(id: string, field: 'label' | 'value', value: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
orderItems: prev.orderItems.map((item) =>
|
||||
item.id === id ? { ...item, [field]: value } : item
|
||||
),
|
||||
}));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 저장
|
||||
const handleSave = useCallback(async () => {
|
||||
// 유효성 검사
|
||||
if (!formData.itemNumber.trim()) {
|
||||
toast.error('품목번호를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!formData.itemName.trim()) {
|
||||
toast.error('품목명을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (mode === 'new') {
|
||||
const result = await createItem(formData);
|
||||
if (result.success && result.data) {
|
||||
toast.success('품목이 등록되었습니다.');
|
||||
router.push(`/ko/juil/order/base-info/items/${result.data.id}`);
|
||||
} else {
|
||||
toast.error(result.error || '품목 등록에 실패했습니다.');
|
||||
}
|
||||
} else if (mode === 'edit' && itemId) {
|
||||
const result = await updateItem(itemId, formData);
|
||||
if (result.success) {
|
||||
toast.success('품목이 수정되었습니다.');
|
||||
setMode('view');
|
||||
// 데이터 다시 로드
|
||||
const reloadResult = await getItem(itemId);
|
||||
if (reloadResult.success && reloadResult.data) {
|
||||
setOriginalData(reloadResult.data);
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '품목 수정에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [mode, formData, itemId, router]);
|
||||
|
||||
// 삭제
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!itemId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deleteItem(itemId);
|
||||
if (result.success) {
|
||||
toast.success('품목이 삭제되었습니다.');
|
||||
router.push('/ko/juil/order/base-info/items');
|
||||
} else {
|
||||
toast.error(result.error || '품목 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
}, [itemId, router]);
|
||||
|
||||
// 수정 모드 전환
|
||||
const handleEditMode = useCallback(() => {
|
||||
setMode('edit');
|
||||
router.replace(`/ko/juil/order/base-info/items/${itemId}?mode=edit`);
|
||||
}, [itemId, router]);
|
||||
|
||||
// 목록으로 이동
|
||||
const handleBack = useCallback(() => {
|
||||
router.push('/ko/juil/order/base-info/items');
|
||||
}, [router]);
|
||||
|
||||
// 취소
|
||||
const handleCancel = useCallback(() => {
|
||||
if (mode === 'new') {
|
||||
router.push('/ko/juil/order/base-info/items');
|
||||
} else {
|
||||
setMode('view');
|
||||
// 원본 데이터로 복원
|
||||
if (originalData) {
|
||||
setFormData({
|
||||
itemNumber: originalData.itemNumber,
|
||||
itemType: originalData.itemType,
|
||||
categoryId: originalData.categoryId,
|
||||
itemName: originalData.itemName,
|
||||
specification: originalData.specification,
|
||||
unit: originalData.unit,
|
||||
orderType: originalData.orderType,
|
||||
status: originalData.status,
|
||||
note: originalData.note || '',
|
||||
orderItems: originalData.orderItems || [],
|
||||
});
|
||||
}
|
||||
router.replace(`/ko/juil/order/base-info/items/${itemId}`);
|
||||
}
|
||||
}, [mode, itemId, originalData, router]);
|
||||
|
||||
// 읽기 전용 여부
|
||||
const isReadOnly = mode === 'view';
|
||||
|
||||
// 페이지 타이틀
|
||||
const pageTitle = mode === 'new' ? '품목 등록' : '품목 상세';
|
||||
|
||||
// 액션 버튼
|
||||
const actionButtons = (
|
||||
<div className="flex items-center gap-2">
|
||||
{mode === 'view' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEditMode}>수정</Button>
|
||||
</>
|
||||
)}
|
||||
{mode === 'edit' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{mode === 'new' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? '등록 중...' : '등록'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLoading && !isNewMode) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={pageTitle}
|
||||
description="품목 정보를 등록하고 관리합니다."
|
||||
icon={Package}
|
||||
/>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={pageTitle}
|
||||
description="품목 정보를 등록하고 관리합니다."
|
||||
icon={Package}
|
||||
actions={actionButtons}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">기본 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Row 1: 품목번호, 품목유형 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="itemNumber">
|
||||
품목번호 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="itemNumber"
|
||||
value={formData.itemNumber}
|
||||
onChange={(e) => handleFieldChange('itemNumber', e.target.value)}
|
||||
placeholder="품목번호를 입력하세요"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="itemType">품목유형</Label>
|
||||
<Select
|
||||
value={formData.itemType}
|
||||
onValueChange={(v) => handleFieldChange('itemType', v as ItemType)}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="품목유형 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ITEM_TYPE_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: 카테고리명 */}
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="categoryId">카테고리명</Label>
|
||||
<Select
|
||||
value={formData.categoryId}
|
||||
onValueChange={(v) => handleFieldChange('categoryId', v)}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="카테고리 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{categoryOptions.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* Row 3: 품목명, 규격 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="itemName">
|
||||
품목명 <span className="text-destructive">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
id="itemName"
|
||||
value={formData.itemName}
|
||||
onChange={(e) => handleFieldChange('itemName', e.target.value)}
|
||||
placeholder="품목명을 입력하세요"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="specification">규격</Label>
|
||||
<Select
|
||||
value={formData.specification}
|
||||
onValueChange={(v) => handleFieldChange('specification', v as Specification)}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="규격 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SPECIFICATION_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 4: 단위, 구분 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="unit">단위</Label>
|
||||
<Select
|
||||
value={formData.unit}
|
||||
onValueChange={(v) => handleFieldChange('unit', v)}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="단위 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{UNIT_OPTIONS.map((unit) => (
|
||||
<SelectItem key={unit} value={unit}>
|
||||
{unit}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="orderType">구분</Label>
|
||||
<Select
|
||||
value={formData.orderType}
|
||||
onValueChange={(v) => handleFieldChange('orderType', v as OrderType)}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="구분 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ORDER_TYPE_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 5: 상태, 비고 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">상태</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(v) => handleFieldChange('status', v as ItemStatus)}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="note">비고</Label>
|
||||
<Input
|
||||
id="note"
|
||||
value={formData.note || ''}
|
||||
onChange={(e) => handleFieldChange('note', e.target.value)}
|
||||
placeholder="비고를 입력하세요"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* 발주 항목 */}
|
||||
<Card>
|
||||
<CardHeader className="flex flex-row items-center justify-between">
|
||||
<CardTitle className="text-base">발주 항목</CardTitle>
|
||||
{!isReadOnly && (
|
||||
<Button variant="outline" size="sm" onClick={handleAddOrderItem}>
|
||||
<Plus className="mr-1 h-4 w-4" />
|
||||
추가
|
||||
</Button>
|
||||
)}
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{formData.orderItems.length === 0 ? (
|
||||
<div className="text-center py-8 text-muted-foreground">
|
||||
발주 항목이 없습니다.
|
||||
{!isReadOnly && ' 추가 버튼을 클릭하여 항목을 추가하세요.'}
|
||||
</div>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="grid grid-cols-[1fr_1fr_40px] gap-2 text-sm font-medium text-muted-foreground">
|
||||
<div>항목명</div>
|
||||
<div>구분 정보</div>
|
||||
<div></div>
|
||||
</div>
|
||||
{formData.orderItems.map((item) => (
|
||||
<div key={item.id} className="grid grid-cols-[1fr_1fr_40px] gap-2 items-center">
|
||||
<Input
|
||||
value={item.label}
|
||||
onChange={(e) => handleOrderItemChange(item.id, 'label', e.target.value)}
|
||||
placeholder="예: 무게"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
<Input
|
||||
value={item.value}
|
||||
onChange={(e) => handleOrderItemChange(item.id, 'value', e.target.value)}
|
||||
placeholder="예: 400KG"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
{!isReadOnly && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-9 w-9"
|
||||
onClick={() => handleRemoveOrderItem(item.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>품목 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
이 품목을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,632 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format, startOfYear, endOfYear } from 'date-fns';
|
||||
import { Package, Plus, Pencil, Trash2, PackageCheck } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { IntegratedListTemplateV2, TableColumn } 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 { Item, ItemStats, ItemType, Specification, OrderType, ItemStatus } from './types';
|
||||
import {
|
||||
ITEM_TYPE_OPTIONS,
|
||||
SPECIFICATION_OPTIONS,
|
||||
ORDER_TYPE_OPTIONS,
|
||||
STATUS_OPTIONS,
|
||||
SORT_OPTIONS,
|
||||
ITEMS_PER_PAGE,
|
||||
} from './constants';
|
||||
import { getItemList, deleteItem, deleteItems, getItemStats, getCategoryOptions } from './actions';
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'itemNumber', label: '품목번호', className: 'w-[100px]' },
|
||||
{ key: 'itemType', label: '품목유형', className: 'w-[90px] text-center' },
|
||||
{ key: 'category', label: '카테고리', className: 'w-[120px]' },
|
||||
{ key: 'itemName', label: '품목명', className: 'min-w-[150px]' },
|
||||
{ key: 'specification', label: '규격', className: 'w-[80px] text-center' },
|
||||
{ key: 'unit', label: '단위', className: 'w-[60px] text-center' },
|
||||
{ key: 'orderType', label: '구분', className: 'w-[100px] text-center' },
|
||||
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[100px] text-center' },
|
||||
];
|
||||
|
||||
interface ItemManagementClientProps {
|
||||
initialData?: Item[];
|
||||
initialStats?: ItemStats;
|
||||
}
|
||||
|
||||
export default function ItemManagementClient({
|
||||
initialData = [],
|
||||
initialStats,
|
||||
}: ItemManagementClientProps) {
|
||||
const router = useRouter();
|
||||
const today = new Date();
|
||||
|
||||
// 날짜 상태 (당해년도 기본값)
|
||||
const [startDate, setStartDate] = useState(format(startOfYear(today), 'yyyy-MM-dd'));
|
||||
const [endDate, setEndDate] = useState(format(endOfYear(today), 'yyyy-MM-dd'));
|
||||
|
||||
// 상태
|
||||
const [items, setItems] = useState<Item[]>(initialData);
|
||||
const [stats, setStats] = useState<ItemStats>(initialStats ?? { total: 0, active: 0 });
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [itemTypeFilter, setItemTypeFilter] = useState<ItemType | 'all'>('all');
|
||||
const [categoryFilter, setCategoryFilter] = useState<string>('all');
|
||||
const [specificationFilter, setSpecificationFilter] = useState<Specification | 'all'>('all');
|
||||
const [orderTypeFilter, setOrderTypeFilter] = useState<OrderType | 'all'>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<ItemStatus | 'all'>('all');
|
||||
const [sortBy, setSortBy] = useState<'latest' | 'oldest'>('latest');
|
||||
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 [categoryOptions, setCategoryOptions] = useState<{ id: string; name: string }[]>([]);
|
||||
|
||||
// 카테고리 목록 로드
|
||||
useEffect(() => {
|
||||
const loadCategories = async () => {
|
||||
const result = await getCategoryOptions();
|
||||
if (result.success && result.data) {
|
||||
setCategoryOptions(result.data);
|
||||
}
|
||||
};
|
||||
loadCategories();
|
||||
}, []);
|
||||
|
||||
// 데이터 로드
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getItemList({
|
||||
size: 1000,
|
||||
itemType: itemTypeFilter,
|
||||
categoryId: categoryFilter,
|
||||
specification: specificationFilter,
|
||||
orderType: orderTypeFilter,
|
||||
status: statusFilter,
|
||||
sortBy,
|
||||
startDate,
|
||||
endDate,
|
||||
}),
|
||||
getItemStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setItems(listResult.data.items);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [itemTypeFilter, categoryFilter, specificationFilter, orderTypeFilter, statusFilter, sortBy, startDate, endDate]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredItems = useMemo(() => {
|
||||
return items.filter((item) => {
|
||||
// 품목유형 필터
|
||||
if (itemTypeFilter !== 'all' && item.itemType !== itemTypeFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 카테고리 필터
|
||||
if (categoryFilter !== 'all' && item.categoryId !== categoryFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 규격 필터
|
||||
if (specificationFilter !== 'all' && item.specification !== specificationFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 구분 필터
|
||||
if (orderTypeFilter !== 'all' && item.orderType !== orderTypeFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && item.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
item.itemNumber.toLowerCase().includes(search) ||
|
||||
item.itemName.toLowerCase().includes(search) ||
|
||||
item.categoryName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [items, itemTypeFilter, categoryFilter, specificationFilter, orderTypeFilter, statusFilter, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedItems = useMemo(() => {
|
||||
const sorted = [...filteredItems];
|
||||
if (sortBy === 'oldest') {
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
} else {
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredItems, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedItems.length / ITEMS_PER_PAGE);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * ITEMS_PER_PAGE;
|
||||
return sortedItems.slice(start, start + ITEMS_PER_PAGE);
|
||||
}, [sortedItems, currentPage]);
|
||||
|
||||
// 핸들러
|
||||
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((item) => item.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(item: Item) => {
|
||||
router.push(`/ko/juil/order/base-info/items/${item.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
router.push('/ko/juil/order/base-info/items/new');
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, itemId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/juil/order/base-info/items/${itemId}?mode=edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback((e: React.MouseEvent, itemId: string) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTargetId(itemId);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteTargetId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deleteItem(deleteTargetId);
|
||||
if (result.success) {
|
||||
toast.success('품목이 삭제되었습니다.');
|
||||
setItems((prev) => prev.filter((item) => item.id !== deleteTargetId));
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(deleteTargetId);
|
||||
return newSet;
|
||||
});
|
||||
// 통계 재조회
|
||||
const statsResult = await getItemStats();
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} 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 deleteItems(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 getStatusBadgeVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case '승인':
|
||||
case '사용':
|
||||
return 'default';
|
||||
case '작업':
|
||||
return 'secondary';
|
||||
case '중지':
|
||||
return 'destructive';
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(item: Item, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(item.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(item.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{item.itemNumber}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="secondary">{item.itemType}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>{item.categoryName}</TableCell>
|
||||
<TableCell className="font-medium">{item.itemName}</TableCell>
|
||||
<TableCell className="text-center">{item.specification}</TableCell>
|
||||
<TableCell className="text-center">{item.unit}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="outline">{item.orderType}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant={getStatusBadgeVariant(item.status)}>{item.status}</Badge>
|
||||
</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, item.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, item.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
|
||||
);
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(item: Item, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
<MobileCard
|
||||
title={item.itemName}
|
||||
subtitle={item.itemNumber}
|
||||
badge={item.status}
|
||||
badgeVariant={getStatusBadgeVariant(item.status) as 'default' | 'secondary' | 'destructive' | 'outline'}
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
details={[
|
||||
{ label: '품목유형', value: item.itemType },
|
||||
{ label: '카테고리', value: item.categoryName },
|
||||
{ label: '규격', value: item.specification },
|
||||
{ label: '단위', value: item.unit },
|
||||
{ label: '구분', value: item.orderType },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
// 테이블 헤더 액션 (6개 필터)
|
||||
const tableHeaderActions = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 총 건수 */}
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {sortedItems.length}건
|
||||
</span>
|
||||
|
||||
{/* 품목유형 필터 */}
|
||||
<Select
|
||||
value={itemTypeFilter}
|
||||
onValueChange={(v) => {
|
||||
setItemTypeFilter(v as ItemType | 'all');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[90px]">
|
||||
<SelectValue placeholder="품목유형" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ITEM_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 카테고리 필터 */}
|
||||
<Select
|
||||
value={categoryFilter}
|
||||
onValueChange={(v) => {
|
||||
setCategoryFilter(v);
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="카테고리" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">전체</SelectItem>
|
||||
{categoryOptions.map((category) => (
|
||||
<SelectItem key={category.id} value={category.id}>
|
||||
{category.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 규격 필터 */}
|
||||
<Select
|
||||
value={specificationFilter}
|
||||
onValueChange={(v) => {
|
||||
setSpecificationFilter(v as Specification | 'all');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[80px]">
|
||||
<SelectValue placeholder="규격" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SPECIFICATION_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 구분 필터 */}
|
||||
<Select
|
||||
value={orderTypeFilter}
|
||||
onValueChange={(v) => {
|
||||
setOrderTypeFilter(v as OrderType | 'all');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="구분" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{ORDER_TYPE_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(v) => {
|
||||
setStatusFilter(v as ItemStatus | 'all');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[80px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={(v) => setSortBy(v as 'latest' | 'oldest')}>
|
||||
<SelectTrigger className="w-[90px]">
|
||||
<SelectValue placeholder="정렬" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="품목관리"
|
||||
description="품목을 등록하여 관리합니다."
|
||||
icon={Package}
|
||||
headerActions={headerActions}
|
||||
stats={[
|
||||
{
|
||||
label: '전체 품목',
|
||||
value: stats.total,
|
||||
icon: Package,
|
||||
iconColor: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: '사용 품목',
|
||||
value: stats.active,
|
||||
icon: PackageCheck,
|
||||
iconColor: 'text-green-500',
|
||||
},
|
||||
]}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="품목명, 품목번호, 카테고리 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedItems}
|
||||
getItemId={(item) => item.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onBulkDelete={handleBulkDeleteClick}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedItems.length,
|
||||
itemsPerPage: ITEMS_PER_PAGE,
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
398
src/components/business/juil/item-management/actions.ts
Normal file
398
src/components/business/juil/item-management/actions.ts
Normal file
@@ -0,0 +1,398 @@
|
||||
'use server';
|
||||
|
||||
import type { Item, ItemStats, ItemListParams, ItemListResponse, ItemDetail, ItemFormData, OrderItem } from './types';
|
||||
|
||||
// 목데이터
|
||||
const mockItems: Item[] = [
|
||||
{
|
||||
id: '1',
|
||||
itemNumber: '123123',
|
||||
itemType: '제품',
|
||||
categoryId: '1',
|
||||
categoryName: '카테고리명',
|
||||
itemName: '품목명',
|
||||
specification: '인정',
|
||||
unit: 'SET',
|
||||
orderType: '외주발주',
|
||||
status: '승인',
|
||||
createdAt: '2026-01-01T10:00:00Z',
|
||||
updatedAt: '2026-01-01T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
itemNumber: '123124',
|
||||
itemType: '부품',
|
||||
categoryId: '2',
|
||||
categoryName: '모터',
|
||||
itemName: '소형 모터 A',
|
||||
specification: '비인정',
|
||||
unit: 'SET',
|
||||
orderType: '외주발주',
|
||||
status: '승인',
|
||||
createdAt: '2026-01-02T11:00:00Z',
|
||||
updatedAt: '2026-01-02T11:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
itemNumber: '123125',
|
||||
itemType: '소모품',
|
||||
categoryId: '3',
|
||||
categoryName: '공정자재',
|
||||
itemName: '절연테이프',
|
||||
specification: '인정',
|
||||
unit: 'SET',
|
||||
orderType: '외주발주',
|
||||
status: '승인',
|
||||
createdAt: '2026-01-03T09:00:00Z',
|
||||
updatedAt: '2026-01-03T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
itemNumber: '123126',
|
||||
itemType: '공과',
|
||||
categoryId: '4',
|
||||
categoryName: '철물',
|
||||
itemName: '볼트 세트',
|
||||
specification: '비인정',
|
||||
unit: 'EA',
|
||||
orderType: '경품발주',
|
||||
status: '작업',
|
||||
createdAt: '2026-01-03T10:00:00Z',
|
||||
updatedAt: '2026-01-03T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
itemNumber: '123127',
|
||||
itemType: '부품',
|
||||
categoryId: '1',
|
||||
categoryName: '슬라이드 OPEN 사이즈',
|
||||
itemName: '슬라이드 레일',
|
||||
specification: '인정',
|
||||
unit: 'EA',
|
||||
orderType: '원자재발주',
|
||||
status: '작업',
|
||||
createdAt: '2026-01-04T08:00:00Z',
|
||||
updatedAt: '2026-01-04T08:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
itemNumber: '123128',
|
||||
itemType: '소모품',
|
||||
categoryId: '3',
|
||||
categoryName: '공정자재',
|
||||
itemName: '윤활유',
|
||||
specification: '비인정',
|
||||
unit: 'L',
|
||||
orderType: '외주발주',
|
||||
status: '사용',
|
||||
createdAt: '2026-01-04T09:00:00Z',
|
||||
updatedAt: '2026-01-04T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
itemNumber: '123129',
|
||||
itemType: '소모품',
|
||||
categoryId: '3',
|
||||
categoryName: '공정자재',
|
||||
itemName: '포장재',
|
||||
specification: '인정',
|
||||
unit: 'BOX',
|
||||
orderType: '경품발주',
|
||||
status: '중지',
|
||||
createdAt: '2026-01-05T10:00:00Z',
|
||||
updatedAt: '2026-01-05T10:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
// 품목 목록 조회
|
||||
export async function getItemList(
|
||||
params: ItemListParams = {}
|
||||
): Promise<{ success: boolean; data?: ItemListResponse; error?: string }> {
|
||||
try {
|
||||
// 시뮬레이션 딜레이
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
let filteredItems = [...mockItems];
|
||||
|
||||
// 물품유형 필터
|
||||
if (params.itemType && params.itemType !== 'all') {
|
||||
filteredItems = filteredItems.filter((item) => item.itemType === params.itemType);
|
||||
}
|
||||
|
||||
// 카테고리 필터
|
||||
if (params.categoryId && params.categoryId !== 'all') {
|
||||
filteredItems = filteredItems.filter((item) => item.categoryId === params.categoryId);
|
||||
}
|
||||
|
||||
// 규격 필터
|
||||
if (params.specification && params.specification !== 'all') {
|
||||
filteredItems = filteredItems.filter((item) => item.specification === params.specification);
|
||||
}
|
||||
|
||||
// 구분 필터
|
||||
if (params.orderType && params.orderType !== 'all') {
|
||||
filteredItems = filteredItems.filter((item) => item.orderType === params.orderType);
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (params.status && params.status !== 'all') {
|
||||
filteredItems = filteredItems.filter((item) => item.status === params.status);
|
||||
}
|
||||
|
||||
// 검색어 필터
|
||||
if (params.search) {
|
||||
const search = params.search.toLowerCase();
|
||||
filteredItems = filteredItems.filter(
|
||||
(item) =>
|
||||
item.itemNumber.toLowerCase().includes(search) ||
|
||||
item.itemName.toLowerCase().includes(search) ||
|
||||
item.categoryName.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
|
||||
// 날짜 필터
|
||||
if (params.startDate) {
|
||||
const startDate = new Date(params.startDate);
|
||||
filteredItems = filteredItems.filter((item) => new Date(item.createdAt) >= startDate);
|
||||
}
|
||||
if (params.endDate) {
|
||||
const endDate = new Date(params.endDate);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
filteredItems = filteredItems.filter((item) => new Date(item.createdAt) <= endDate);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (params.sortBy === 'oldest') {
|
||||
filteredItems.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
} else {
|
||||
// 기본: 최신순
|
||||
filteredItems.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}
|
||||
|
||||
// 페이지네이션
|
||||
const page = params.page || 1;
|
||||
const size = params.size || 20;
|
||||
const start = (page - 1) * size;
|
||||
const paginatedItems = filteredItems.slice(start, start + size);
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: {
|
||||
items: paginatedItems,
|
||||
total: filteredItems.length,
|
||||
page,
|
||||
size,
|
||||
},
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('품목 목록 조회 오류:', error);
|
||||
return { success: false, error: '품목 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 품목 통계 조회
|
||||
export async function getItemStats(): Promise<{ success: boolean; data?: ItemStats; error?: string }> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
const total = mockItems.length;
|
||||
const active = mockItems.filter((item) => item.status === '사용' || item.status === '승인').length;
|
||||
|
||||
return {
|
||||
success: true,
|
||||
data: { total, active },
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('품목 통계 조회 오류:', error);
|
||||
return { success: false, error: '품목 통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 품목 삭제
|
||||
export async function deleteItem(id: string): Promise<{ success: boolean; error?: string }> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// 실제 구현에서는 API 호출
|
||||
const index = mockItems.findIndex((item) => item.id === id);
|
||||
if (index !== -1) {
|
||||
mockItems.splice(index, 1);
|
||||
}
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('품목 삭제 오류:', error);
|
||||
return { success: false, error: '품목 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 품목 일괄 삭제
|
||||
export async function deleteItems(
|
||||
ids: string[]
|
||||
): Promise<{ success: boolean; deletedCount?: number; error?: string }> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 500));
|
||||
|
||||
let deletedCount = 0;
|
||||
ids.forEach((id) => {
|
||||
const index = mockItems.findIndex((item) => item.id === id);
|
||||
if (index !== -1) {
|
||||
mockItems.splice(index, 1);
|
||||
deletedCount++;
|
||||
}
|
||||
});
|
||||
|
||||
return { success: true, deletedCount };
|
||||
} catch (error) {
|
||||
console.error('품목 일괄 삭제 오류:', error);
|
||||
return { success: false, error: '품목 일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 카테고리 목록 조회 (필터용)
|
||||
export async function getCategoryOptions(): Promise<{
|
||||
success: boolean;
|
||||
data?: { id: string; name: string }[];
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 100));
|
||||
|
||||
// 유니크한 카테고리 추출
|
||||
const categories = [...new Map(mockItems.map((item) => [item.categoryId, { id: item.categoryId, name: item.categoryName }])).values()];
|
||||
|
||||
return { success: true, data: categories };
|
||||
} catch (error) {
|
||||
console.error('카테고리 목록 조회 오류:', error);
|
||||
return { success: false, error: '카테고리 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 발주 항목 목데이터
|
||||
const mockOrderItems: Record<string, OrderItem[]> = {
|
||||
'1': [
|
||||
{ id: 'oi1', label: '무게', value: '400KG' },
|
||||
{ id: 'oi2', label: '무게', value: '500KG' },
|
||||
],
|
||||
'2': [
|
||||
{ id: 'oi3', label: '전압', value: '220V' },
|
||||
],
|
||||
'3': [],
|
||||
'4': [
|
||||
{ id: 'oi4', label: '규격', value: 'M10x20' },
|
||||
],
|
||||
'5': [],
|
||||
'6': [],
|
||||
'7': [],
|
||||
};
|
||||
|
||||
// 품목 상세 조회
|
||||
export async function getItem(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: ItemDetail;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 200));
|
||||
|
||||
const item = mockItems.find((i) => i.id === id);
|
||||
if (!item) {
|
||||
return { success: false, error: '품목을 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
const itemDetail: ItemDetail = {
|
||||
...item,
|
||||
note: '',
|
||||
orderItems: mockOrderItems[id] || [],
|
||||
};
|
||||
|
||||
return { success: true, data: itemDetail };
|
||||
} catch (error) {
|
||||
console.error('품목 상세 조회 오류:', error);
|
||||
return { success: false, error: '품목 정보를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 품목 등록
|
||||
export async function createItem(data: ItemFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: { id: string };
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
// 새 ID 생성
|
||||
const newId = String(Math.max(...mockItems.map((i) => parseInt(i.id))) + 1);
|
||||
|
||||
// 카테고리명 찾기
|
||||
const category = mockItems.find((i) => i.categoryId === data.categoryId);
|
||||
const categoryName = category?.categoryName || '기본';
|
||||
|
||||
const newItem: Item = {
|
||||
id: newId,
|
||||
itemNumber: data.itemNumber,
|
||||
itemType: data.itemType,
|
||||
categoryId: data.categoryId,
|
||||
categoryName,
|
||||
itemName: data.itemName,
|
||||
specification: data.specification,
|
||||
unit: data.unit,
|
||||
orderType: data.orderType,
|
||||
status: data.status,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
mockItems.push(newItem);
|
||||
mockOrderItems[newId] = data.orderItems;
|
||||
|
||||
return { success: true, data: { id: newId } };
|
||||
} catch (error) {
|
||||
console.error('품목 등록 오류:', error);
|
||||
return { success: false, error: '품목 등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 품목 수정
|
||||
export async function updateItem(
|
||||
id: string,
|
||||
data: ItemFormData
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
await new Promise((resolve) => setTimeout(resolve, 300));
|
||||
|
||||
const index = mockItems.findIndex((i) => i.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '품목을 찾을 수 없습니다.' };
|
||||
}
|
||||
|
||||
// 카테고리명 찾기
|
||||
const category = mockItems.find((i) => i.categoryId === data.categoryId);
|
||||
const categoryName = category?.categoryName || mockItems[index].categoryName;
|
||||
|
||||
mockItems[index] = {
|
||||
...mockItems[index],
|
||||
itemNumber: data.itemNumber,
|
||||
itemType: data.itemType,
|
||||
categoryId: data.categoryId,
|
||||
categoryName,
|
||||
itemName: data.itemName,
|
||||
specification: data.specification,
|
||||
unit: data.unit,
|
||||
orderType: data.orderType,
|
||||
status: data.status,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
mockOrderItems[id] = data.orderItems;
|
||||
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('품목 수정 오류:', error);
|
||||
return { success: false, error: '품목 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
46
src/components/business/juil/item-management/constants.ts
Normal file
46
src/components/business/juil/item-management/constants.ts
Normal file
@@ -0,0 +1,46 @@
|
||||
// 품목관리 상수 정의
|
||||
|
||||
import type { ItemType, Specification, OrderType, ItemStatus } from './types';
|
||||
|
||||
// 물품유형 옵션
|
||||
export const ITEM_TYPE_OPTIONS: { value: ItemType | 'all'; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '제품', label: '제품' },
|
||||
{ value: '부품', label: '부품' },
|
||||
{ value: '소모품', label: '소모품' },
|
||||
{ value: '공과', label: '공과' },
|
||||
];
|
||||
|
||||
// 규격 옵션
|
||||
export const SPECIFICATION_OPTIONS: { value: Specification | 'all'; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '인정', label: '인정' },
|
||||
{ value: '비인정', label: '비인정' },
|
||||
];
|
||||
|
||||
// 구분(발주유형) 옵션
|
||||
export const ORDER_TYPE_OPTIONS: { value: OrderType | 'all'; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '경품발주', label: '경품발주' },
|
||||
{ value: '원자재발주', label: '원자재발주' },
|
||||
{ value: '외주발주', label: '외주발주' },
|
||||
];
|
||||
|
||||
// 상태 옵션
|
||||
export const STATUS_OPTIONS: { value: ItemStatus | 'all'; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '사용', label: '사용' },
|
||||
{ value: '중지', label: '중지' },
|
||||
];
|
||||
|
||||
// 정렬 옵션
|
||||
export const SORT_OPTIONS: { value: 'latest' | 'oldest'; label: string }[] = [
|
||||
{ value: 'latest', label: '최신순' },
|
||||
{ value: 'oldest', label: '등록순' },
|
||||
];
|
||||
|
||||
// 단위 옵션
|
||||
export const UNIT_OPTIONS = ['SET', 'EA', 'BOX', 'KG', 'M', 'L'];
|
||||
|
||||
// 페이지당 항목 수
|
||||
export const ITEMS_PER_PAGE = 20;
|
||||
5
src/components/business/juil/item-management/index.tsx
Normal file
5
src/components/business/juil/item-management/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as ItemManagementClient } from './ItemManagementClient';
|
||||
export { default as ItemDetailClient } from './ItemDetailClient';
|
||||
export * from './types';
|
||||
export * from './constants';
|
||||
export * from './actions';
|
||||
85
src/components/business/juil/item-management/types.ts
Normal file
85
src/components/business/juil/item-management/types.ts
Normal file
@@ -0,0 +1,85 @@
|
||||
// 품목관리 타입 정의
|
||||
|
||||
// 물품유형
|
||||
export type ItemType = '제품' | '부품' | '소모품' | '공과';
|
||||
|
||||
// 규격
|
||||
export type Specification = '인정' | '비인정';
|
||||
|
||||
// 구분 (발주유형)
|
||||
export type OrderType = '경품발주' | '원자재발주' | '외주발주';
|
||||
|
||||
// 상태
|
||||
export type ItemStatus = '승인' | '작업' | '사용' | '중지';
|
||||
|
||||
// 품목 인터페이스
|
||||
export interface Item {
|
||||
id: string;
|
||||
itemNumber: string; // 품목번호
|
||||
itemType: ItemType; // 물품유형
|
||||
categoryId: string; // 카테고리 ID
|
||||
categoryName: string; // 카테고리명
|
||||
itemName: string; // 품목명
|
||||
specification: Specification; // 규격
|
||||
unit: string; // 단위
|
||||
orderType: OrderType; // 구분
|
||||
status: ItemStatus; // 상태
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 품목 통계
|
||||
export interface ItemStats {
|
||||
total: number; // 전체 품목
|
||||
active: number; // 사용 품목
|
||||
}
|
||||
|
||||
// 품목 목록 조회 파라미터
|
||||
export interface ItemListParams {
|
||||
page?: number;
|
||||
size?: number;
|
||||
itemType?: ItemType | 'all';
|
||||
categoryId?: string;
|
||||
specification?: Specification | 'all';
|
||||
orderType?: OrderType | 'all';
|
||||
status?: ItemStatus | 'all';
|
||||
sortBy?: 'latest' | 'oldest';
|
||||
search?: string;
|
||||
startDate?: string;
|
||||
endDate?: string;
|
||||
}
|
||||
|
||||
// 품목 목록 응답
|
||||
export interface ItemListResponse {
|
||||
items: Item[];
|
||||
total: number;
|
||||
page: number;
|
||||
size: number;
|
||||
}
|
||||
|
||||
// 발주 항목
|
||||
export interface OrderItem {
|
||||
id: string;
|
||||
label: string; // 예: 무게
|
||||
value: string; // 예: 400KG
|
||||
}
|
||||
|
||||
// 품목 상세 (발주 항목 포함)
|
||||
export interface ItemDetail extends Item {
|
||||
note?: string; // 비고
|
||||
orderItems: OrderItem[]; // 발주 항목
|
||||
}
|
||||
|
||||
// 품목 등록/수정 폼 데이터
|
||||
export interface ItemFormData {
|
||||
itemNumber: string;
|
||||
itemType: ItemType;
|
||||
categoryId: string;
|
||||
itemName: string;
|
||||
specification: Specification;
|
||||
unit: string;
|
||||
orderType: OrderType;
|
||||
status: ItemStatus;
|
||||
note?: string;
|
||||
orderItems: OrderItem[];
|
||||
}
|
||||
@@ -0,0 +1,417 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Hammer } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
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 { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { PageLayout } from '@/components/organisms/PageLayout';
|
||||
import { PageHeader } from '@/components/organisms/PageHeader';
|
||||
import { toast } from 'sonner';
|
||||
import type { Labor, LaborFormData, LaborCategory, LaborStatus } from './types';
|
||||
import { CATEGORY_OPTIONS, STATUS_OPTIONS } from './constants';
|
||||
import { getLabor, createLabor, updateLabor, deleteLabor } from './actions';
|
||||
|
||||
interface LaborDetailClientProps {
|
||||
laborId?: string;
|
||||
isEditMode?: boolean;
|
||||
isNewMode?: boolean;
|
||||
}
|
||||
|
||||
const initialFormData: LaborFormData = {
|
||||
laborNumber: '',
|
||||
category: '가로',
|
||||
minM: 0,
|
||||
maxM: 0,
|
||||
laborPrice: null,
|
||||
status: '사용',
|
||||
};
|
||||
|
||||
export default function LaborDetailClient({
|
||||
laborId,
|
||||
isEditMode = false,
|
||||
isNewMode = false,
|
||||
}: LaborDetailClientProps) {
|
||||
const router = useRouter();
|
||||
|
||||
// 모드 상태
|
||||
const [mode, setMode] = useState<'view' | 'edit' | 'new'>(
|
||||
isNewMode ? 'new' : isEditMode ? 'edit' : 'view'
|
||||
);
|
||||
|
||||
// 폼 데이터
|
||||
const [formData, setFormData] = useState<LaborFormData>(initialFormData);
|
||||
const [originalData, setOriginalData] = useState<Labor | null>(null);
|
||||
|
||||
// 상태
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
|
||||
|
||||
// 노임 데이터 로드
|
||||
useEffect(() => {
|
||||
if (laborId && !isNewMode) {
|
||||
const loadLabor = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await getLabor(laborId);
|
||||
if (result.success && result.data) {
|
||||
setOriginalData(result.data);
|
||||
setFormData({
|
||||
laborNumber: result.data.laborNumber,
|
||||
category: result.data.category,
|
||||
minM: result.data.minM,
|
||||
maxM: result.data.maxM,
|
||||
laborPrice: result.data.laborPrice,
|
||||
status: result.data.status,
|
||||
});
|
||||
} else {
|
||||
toast.error(result.error || '노임 정보를 불러오는데 실패했습니다.');
|
||||
router.push('/ko/juil/order/base-info/labor');
|
||||
}
|
||||
} catch {
|
||||
toast.error('노임 정보를 불러오는데 실패했습니다.');
|
||||
router.push('/ko/juil/order/base-info/labor');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
};
|
||||
loadLabor();
|
||||
}
|
||||
}, [laborId, isNewMode, router]);
|
||||
|
||||
// 폼 필드 변경
|
||||
const handleFieldChange = useCallback(
|
||||
(field: keyof LaborFormData, value: string | number | null) => {
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 숫자 입력 (소수점 둘째자리까지)
|
||||
const handleNumberChange = useCallback(
|
||||
(field: 'minM' | 'maxM' | 'laborPrice', value: string) => {
|
||||
if (value === '') {
|
||||
handleFieldChange(field, field === 'laborPrice' ? null : 0);
|
||||
return;
|
||||
}
|
||||
// 소수점 둘째자리까지 허용
|
||||
const regex = /^\d*\.?\d{0,2}$/;
|
||||
if (regex.test(value)) {
|
||||
const numValue = parseFloat(value);
|
||||
if (!isNaN(numValue)) {
|
||||
handleFieldChange(field, numValue);
|
||||
}
|
||||
}
|
||||
},
|
||||
[handleFieldChange]
|
||||
);
|
||||
|
||||
// 저장
|
||||
const handleSave = useCallback(async () => {
|
||||
// 유효성 검사
|
||||
if (!formData.laborNumber.trim()) {
|
||||
toast.error('노임번호를 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
if (mode === 'new') {
|
||||
const result = await createLabor(formData);
|
||||
if (result.success && result.data) {
|
||||
toast.success('노임이 등록되었습니다.');
|
||||
router.push(`/ko/juil/order/base-info/labor/${result.data.id}`);
|
||||
} else {
|
||||
toast.error(result.error || '노임 등록에 실패했습니다.');
|
||||
}
|
||||
} else if (mode === 'edit' && laborId) {
|
||||
const result = await updateLabor(laborId, formData);
|
||||
if (result.success) {
|
||||
toast.success('노임이 수정되었습니다.');
|
||||
setMode('view');
|
||||
// 데이터 다시 로드
|
||||
const reloadResult = await getLabor(laborId);
|
||||
if (reloadResult.success && reloadResult.data) {
|
||||
setOriginalData(reloadResult.data);
|
||||
}
|
||||
} else {
|
||||
toast.error(result.error || '노임 수정에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [mode, formData, laborId, router]);
|
||||
|
||||
// 삭제
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!laborId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deleteLabor(laborId);
|
||||
if (result.success) {
|
||||
toast.success('노임이 삭제되었습니다.');
|
||||
router.push('/ko/juil/order/base-info/labor');
|
||||
} else {
|
||||
toast.error(result.error || '노임 삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
setDeleteDialogOpen(false);
|
||||
}
|
||||
}, [laborId, router]);
|
||||
|
||||
// 수정 모드 전환
|
||||
const handleEditMode = useCallback(() => {
|
||||
setMode('edit');
|
||||
router.replace(`/ko/juil/order/base-info/labor/${laborId}?mode=edit`);
|
||||
}, [laborId, router]);
|
||||
|
||||
// 목록으로 이동
|
||||
const handleBack = useCallback(() => {
|
||||
router.push('/ko/juil/order/base-info/labor');
|
||||
}, [router]);
|
||||
|
||||
// 취소
|
||||
const handleCancel = useCallback(() => {
|
||||
if (mode === 'new') {
|
||||
router.push('/ko/juil/order/base-info/labor');
|
||||
} else {
|
||||
setMode('view');
|
||||
// 원본 데이터로 복원
|
||||
if (originalData) {
|
||||
setFormData({
|
||||
laborNumber: originalData.laborNumber,
|
||||
category: originalData.category,
|
||||
minM: originalData.minM,
|
||||
maxM: originalData.maxM,
|
||||
laborPrice: originalData.laborPrice,
|
||||
status: originalData.status,
|
||||
});
|
||||
}
|
||||
router.replace(`/ko/juil/order/base-info/labor/${laborId}`);
|
||||
}
|
||||
}, [mode, laborId, originalData, router]);
|
||||
|
||||
// 읽기 전용 여부
|
||||
const isReadOnly = mode === 'view';
|
||||
|
||||
// 페이지 타이틀
|
||||
const pageTitle = mode === 'new' ? '노임 등록' : '노임 상세';
|
||||
|
||||
// 액션 버튼
|
||||
const actionButtons = (
|
||||
<div className="flex items-center gap-2">
|
||||
{mode === 'view' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleBack}>
|
||||
목록
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => setDeleteDialogOpen(true)}
|
||||
>
|
||||
삭제
|
||||
</Button>
|
||||
<Button onClick={handleEditMode}>수정</Button>
|
||||
</>
|
||||
)}
|
||||
{mode === 'edit' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? '저장 중...' : '저장'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
{mode === 'new' && (
|
||||
<>
|
||||
<Button variant="outline" onClick={handleCancel}>
|
||||
취소
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isSaving}>
|
||||
{isSaving ? '등록 중...' : '등록'}
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
|
||||
if (isLoading && !isNewMode) {
|
||||
return (
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={pageTitle}
|
||||
description="노임 정보를 등록하고 관리합니다."
|
||||
icon={Hammer}
|
||||
/>
|
||||
<div className="flex items-center justify-center py-12">
|
||||
<div className="text-muted-foreground">로딩 중...</div>
|
||||
</div>
|
||||
</PageLayout>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<PageLayout>
|
||||
<PageHeader
|
||||
title={pageTitle}
|
||||
description="노임 정보를 등록하고 관리합니다."
|
||||
icon={Hammer}
|
||||
actions={actionButtons}
|
||||
/>
|
||||
<div className="space-y-6">
|
||||
{/* 기본 정보 */}
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle className="text-base">
|
||||
기본 정보 <span className="text-destructive">*</span>
|
||||
</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
{/* Row 1: 노임번호, 구분 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="laborNumber">노임번호</Label>
|
||||
<Input
|
||||
id="laborNumber"
|
||||
value={formData.laborNumber}
|
||||
onChange={(e) => handleFieldChange('laborNumber', e.target.value)}
|
||||
placeholder="노임번호를 입력하세요"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="category">구분</Label>
|
||||
<Select
|
||||
value={formData.category}
|
||||
onValueChange={(v) => handleFieldChange('category', v as LaborCategory)}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="구분 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORY_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2: 최소 M, 최대 M */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="minM">최소 M</Label>
|
||||
<Input
|
||||
id="minM"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={formData.minM === 0 ? '' : formData.minM.toString()}
|
||||
onChange={(e) => handleNumberChange('minM', e.target.value)}
|
||||
placeholder="0.00"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="maxM">최대 M</Label>
|
||||
<Input
|
||||
id="maxM"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={formData.maxM === 0 ? '' : formData.maxM.toString()}
|
||||
onChange={(e) => handleNumberChange('maxM', e.target.value)}
|
||||
placeholder="0.00"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3: 노임단가, 상태 */}
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="laborPrice">노임단가</Label>
|
||||
<Input
|
||||
id="laborPrice"
|
||||
type="text"
|
||||
inputMode="decimal"
|
||||
value={formData.laborPrice === null ? '' : formData.laborPrice.toString()}
|
||||
onChange={(e) => handleNumberChange('laborPrice', e.target.value)}
|
||||
placeholder="0"
|
||||
disabled={isReadOnly}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label htmlFor="status">상태</Label>
|
||||
<Select
|
||||
value={formData.status}
|
||||
onValueChange={(v) => handleFieldChange('status', v as LaborStatus)}
|
||||
disabled={isReadOnly}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="상태 선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.filter((o) => o.value !== 'all').map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
</PageLayout>
|
||||
|
||||
{/* 삭제 확인 다이얼로그 */}
|
||||
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
|
||||
<AlertDialogContent>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>노임 삭제</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
이 노임을 삭제하시겠습니까? 이 작업은 취소할 수 없습니다.
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>취소</AlertDialogCancel>
|
||||
<AlertDialogAction onClick={handleDelete}>삭제</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,529 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format, startOfYear, endOfYear } from 'date-fns';
|
||||
import { Hammer, Plus, Pencil, Trash2, HardHat } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { TableCell, TableRow } from '@/components/ui/table';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { IntegratedListTemplateV2, TableColumn } 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 { Labor, LaborStats, LaborCategory, LaborStatus, SortOrder } from './types';
|
||||
import { CATEGORY_OPTIONS, STATUS_OPTIONS, SORT_OPTIONS, DEFAULT_PAGE_SIZE } from './constants';
|
||||
import { getLaborList, deleteLabor, deleteLaborBulk, getLaborStats } from './actions';
|
||||
|
||||
// 테이블 컬럼 정의
|
||||
const tableColumns: TableColumn[] = [
|
||||
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
|
||||
{ key: 'laborNumber', label: '노임번호', className: 'w-[120px]' },
|
||||
{ key: 'category', label: '구분', className: 'w-[100px] text-center' },
|
||||
{ key: 'minM', label: '최소 M', className: 'w-[100px] text-right' },
|
||||
{ key: 'maxM', label: '최대 M', className: 'w-[100px] text-right' },
|
||||
{ key: 'laborPrice', label: '노임단가', className: 'w-[120px] text-right' },
|
||||
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
|
||||
{ key: 'actions', label: '작업', className: 'w-[100px] text-center' },
|
||||
];
|
||||
|
||||
interface LaborManagementClientProps {
|
||||
initialData?: Labor[];
|
||||
initialStats?: LaborStats;
|
||||
}
|
||||
|
||||
export default function LaborManagementClient({
|
||||
initialData = [],
|
||||
initialStats,
|
||||
}: LaborManagementClientProps) {
|
||||
const router = useRouter();
|
||||
const today = new Date();
|
||||
|
||||
// 날짜 상태 (당해년도 기본값)
|
||||
const [startDate, setStartDate] = useState(format(startOfYear(today), 'yyyy-MM-dd'));
|
||||
const [endDate, setEndDate] = useState(format(endOfYear(today), 'yyyy-MM-dd'));
|
||||
|
||||
// 상태
|
||||
const [labors, setLabors] = useState<Labor[]>(initialData);
|
||||
const [stats, setStats] = useState<LaborStats>(initialStats ?? { total: 0, active: 0 });
|
||||
const [searchValue, setSearchValue] = useState('');
|
||||
const [categoryFilter, setCategoryFilter] = useState<LaborCategory | 'all'>('all');
|
||||
const [statusFilter, setStatusFilter] = useState<LaborStatus | 'all'>('all');
|
||||
const [sortBy, setSortBy] = useState<SortOrder>('최신순');
|
||||
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 loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [listResult, statsResult] = await Promise.all([
|
||||
getLaborList({
|
||||
category: categoryFilter,
|
||||
status: statusFilter,
|
||||
sortOrder: sortBy,
|
||||
startDate,
|
||||
endDate,
|
||||
}),
|
||||
getLaborStats(),
|
||||
]);
|
||||
|
||||
if (listResult.success && listResult.data) {
|
||||
setLabors(listResult.data);
|
||||
}
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} catch {
|
||||
toast.error('데이터 로드에 실패했습니다.');
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [categoryFilter, statusFilter, sortBy, startDate, endDate]);
|
||||
|
||||
// 초기 데이터가 없으면 로드
|
||||
useEffect(() => {
|
||||
if (initialData.length === 0) {
|
||||
loadData();
|
||||
}
|
||||
}, [initialData.length, loadData]);
|
||||
|
||||
// 필터링된 데이터
|
||||
const filteredLabors = useMemo(() => {
|
||||
return labors.filter((labor) => {
|
||||
// 구분 필터
|
||||
if (categoryFilter !== 'all' && labor.category !== categoryFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (statusFilter !== 'all' && labor.status !== statusFilter) {
|
||||
return false;
|
||||
}
|
||||
|
||||
// 검색 필터
|
||||
if (searchValue) {
|
||||
const search = searchValue.toLowerCase();
|
||||
return (
|
||||
labor.laborNumber.toLowerCase().includes(search) ||
|
||||
labor.category.toLowerCase().includes(search)
|
||||
);
|
||||
}
|
||||
return true;
|
||||
});
|
||||
}, [labors, categoryFilter, statusFilter, searchValue]);
|
||||
|
||||
// 정렬
|
||||
const sortedLabors = useMemo(() => {
|
||||
const sorted = [...filteredLabors];
|
||||
if (sortBy === '등록순') {
|
||||
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
|
||||
} else {
|
||||
// 기본: 최신순
|
||||
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
|
||||
}
|
||||
return sorted;
|
||||
}, [filteredLabors, sortBy]);
|
||||
|
||||
// 페이지네이션
|
||||
const totalPages = Math.ceil(sortedLabors.length / DEFAULT_PAGE_SIZE);
|
||||
const paginatedData = useMemo(() => {
|
||||
const start = (currentPage - 1) * DEFAULT_PAGE_SIZE;
|
||||
return sortedLabors.slice(start, start + DEFAULT_PAGE_SIZE);
|
||||
}, [sortedLabors, currentPage]);
|
||||
|
||||
// 핸들러
|
||||
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((labor) => labor.id)));
|
||||
}
|
||||
}, [selectedItems.size, paginatedData]);
|
||||
|
||||
const handleRowClick = useCallback(
|
||||
(labor: Labor) => {
|
||||
router.push(`/ko/juil/order/base-info/labor/${labor.id}`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleCreate = useCallback(() => {
|
||||
router.push('/ko/juil/order/base-info/labor/new');
|
||||
}, [router]);
|
||||
|
||||
const handleEdit = useCallback(
|
||||
(e: React.MouseEvent, laborId: string) => {
|
||||
e.stopPropagation();
|
||||
router.push(`/ko/juil/order/base-info/labor/${laborId}?mode=edit`);
|
||||
},
|
||||
[router]
|
||||
);
|
||||
|
||||
const handleDeleteClick = useCallback((e: React.MouseEvent, laborId: string) => {
|
||||
e.stopPropagation();
|
||||
setDeleteTargetId(laborId);
|
||||
setDeleteDialogOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleDeleteConfirm = useCallback(async () => {
|
||||
if (!deleteTargetId) return;
|
||||
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const result = await deleteLabor(deleteTargetId);
|
||||
if (result.success) {
|
||||
toast.success('노임이 삭제되었습니다.');
|
||||
setLabors((prev) => prev.filter((labor) => labor.id !== deleteTargetId));
|
||||
setSelectedItems((prev) => {
|
||||
const newSet = new Set(prev);
|
||||
newSet.delete(deleteTargetId);
|
||||
return newSet;
|
||||
});
|
||||
// 통계 재조회
|
||||
const statsResult = await getLaborStats();
|
||||
if (statsResult.success && statsResult.data) {
|
||||
setStats(statsResult.data);
|
||||
}
|
||||
} 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 deleteLaborBulk(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 getStatusBadgeVariant = (status: string) => {
|
||||
switch (status) {
|
||||
case '사용':
|
||||
return 'default';
|
||||
case '중지':
|
||||
return 'destructive';
|
||||
default:
|
||||
return 'outline';
|
||||
}
|
||||
};
|
||||
|
||||
// 가격 포맷
|
||||
const formatPrice = (price: number | null) => {
|
||||
if (price === null || price === 0) return '-';
|
||||
return price.toLocaleString();
|
||||
};
|
||||
|
||||
// M 값 포맷
|
||||
const formatM = (value: number) => {
|
||||
if (value === 0) return '-';
|
||||
return value.toFixed(2);
|
||||
};
|
||||
|
||||
// 테이블 행 렌더링
|
||||
const renderTableRow = useCallback(
|
||||
(labor: Labor, index: number, globalIndex: number) => {
|
||||
const isSelected = selectedItems.has(labor.id);
|
||||
|
||||
return (
|
||||
<TableRow
|
||||
key={labor.id}
|
||||
className="cursor-pointer hover:bg-muted/50"
|
||||
onClick={() => handleRowClick(labor)}
|
||||
>
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={isSelected}
|
||||
onCheckedChange={() => handleToggleSelection(labor.id)}
|
||||
/>
|
||||
</TableCell>
|
||||
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
|
||||
<TableCell className="font-medium">{labor.laborNumber}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant="secondary">{labor.category}</Badge>
|
||||
</TableCell>
|
||||
<TableCell className="text-right">{formatM(labor.minM)}</TableCell>
|
||||
<TableCell className="text-right">{formatM(labor.maxM)}</TableCell>
|
||||
<TableCell className="text-right font-medium">{formatPrice(labor.laborPrice)}</TableCell>
|
||||
<TableCell className="text-center">
|
||||
<Badge variant={getStatusBadgeVariant(labor.status)}>{labor.status}</Badge>
|
||||
</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, labor.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, labor.id)}
|
||||
>
|
||||
<Trash2 className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
},
|
||||
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
|
||||
);
|
||||
|
||||
// 모바일 카드 렌더링
|
||||
const renderMobileCard = useCallback(
|
||||
(labor: Labor, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
|
||||
return (
|
||||
<MobileCard
|
||||
title={labor.laborNumber}
|
||||
subtitle={labor.category}
|
||||
badge={labor.status}
|
||||
badgeVariant={getStatusBadgeVariant(labor.status) as 'default' | 'secondary' | 'destructive' | 'outline'}
|
||||
isSelected={isSelected}
|
||||
onToggle={onToggle}
|
||||
onClick={() => handleRowClick(labor)}
|
||||
details={[
|
||||
{ label: '최소 M', value: formatM(labor.minM) },
|
||||
{ label: '최대 M', value: formatM(labor.maxM) },
|
||||
{ label: '노임단가', value: formatPrice(labor.laborPrice) },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
[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>
|
||||
}
|
||||
/>
|
||||
);
|
||||
|
||||
// 테이블 헤더 액션 (필터)
|
||||
const tableHeaderActions = (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 총 건수 */}
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {sortedLabors.length}건
|
||||
</span>
|
||||
|
||||
{/* 구분 필터 */}
|
||||
<Select
|
||||
value={categoryFilter}
|
||||
onValueChange={(v) => {
|
||||
setCategoryFilter(v as LaborCategory | 'all');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[100px]">
|
||||
<SelectValue placeholder="구분" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{CATEGORY_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 상태 필터 */}
|
||||
<Select
|
||||
value={statusFilter}
|
||||
onValueChange={(v) => {
|
||||
setStatusFilter(v as LaborStatus | 'all');
|
||||
setCurrentPage(1);
|
||||
}}
|
||||
>
|
||||
<SelectTrigger className="w-[80px]">
|
||||
<SelectValue placeholder="상태" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{STATUS_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortBy} onValueChange={(v) => setSortBy(v as SortOrder)}>
|
||||
<SelectTrigger className="w-[90px]">
|
||||
<SelectValue placeholder="정렬" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<IntegratedListTemplateV2
|
||||
title="노임관리"
|
||||
description="노임을 등록하고 관리합니다."
|
||||
icon={Hammer}
|
||||
headerActions={headerActions}
|
||||
stats={[
|
||||
{
|
||||
label: '전체 노임',
|
||||
value: stats.total,
|
||||
icon: Hammer,
|
||||
iconColor: 'text-blue-500',
|
||||
},
|
||||
{
|
||||
label: '사용 노임',
|
||||
value: stats.active,
|
||||
icon: HardHat,
|
||||
iconColor: 'text-green-500',
|
||||
},
|
||||
]}
|
||||
tableHeaderActions={tableHeaderActions}
|
||||
searchValue={searchValue}
|
||||
onSearchChange={handleSearchChange}
|
||||
searchPlaceholder="노임번호, 구분 검색"
|
||||
tableColumns={tableColumns}
|
||||
data={paginatedData}
|
||||
allData={sortedLabors}
|
||||
getItemId={(labor) => labor.id}
|
||||
renderTableRow={renderTableRow}
|
||||
renderMobileCard={renderMobileCard}
|
||||
selectedItems={selectedItems}
|
||||
onToggleSelection={handleToggleSelection}
|
||||
onToggleSelectAll={handleToggleSelectAll}
|
||||
onBulkDelete={handleBulkDeleteClick}
|
||||
pagination={{
|
||||
currentPage,
|
||||
totalPages,
|
||||
totalItems: sortedLabors.length,
|
||||
itemsPerPage: DEFAULT_PAGE_SIZE,
|
||||
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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
276
src/components/business/juil/labor-management/actions.ts
Normal file
276
src/components/business/juil/labor-management/actions.ts
Normal file
@@ -0,0 +1,276 @@
|
||||
'use server';
|
||||
|
||||
import type { Labor, LaborListParams, LaborFormData, LaborStats } from './types';
|
||||
|
||||
// 목데이터 - 7건
|
||||
const mockLabors: Labor[] = [
|
||||
{
|
||||
id: '1',
|
||||
laborNumber: '123123',
|
||||
category: '가로',
|
||||
minM: 0,
|
||||
maxM: 6.00,
|
||||
laborPrice: 400000,
|
||||
status: '사용',
|
||||
createdAt: '2026-01-03T10:00:00Z',
|
||||
updatedAt: '2026-01-03T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
laborNumber: '123123',
|
||||
category: '세로할증',
|
||||
minM: 3.50,
|
||||
maxM: 3.00,
|
||||
laborPrice: null,
|
||||
status: '중지',
|
||||
createdAt: '2026-01-03T09:00:00Z',
|
||||
updatedAt: '2026-01-03T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
laborNumber: '123123',
|
||||
category: '가로',
|
||||
minM: 6.01,
|
||||
maxM: 7.00,
|
||||
laborPrice: null,
|
||||
status: '사용',
|
||||
createdAt: '2026-01-02T15:00:00Z',
|
||||
updatedAt: '2026-01-02T15:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
laborNumber: '123123',
|
||||
category: '세로할증',
|
||||
minM: 3.51,
|
||||
maxM: 4.50,
|
||||
laborPrice: 50000,
|
||||
status: '사용',
|
||||
createdAt: '2026-01-02T14:00:00Z',
|
||||
updatedAt: '2026-01-02T14:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
laborNumber: '123123',
|
||||
category: '가로',
|
||||
minM: 0,
|
||||
maxM: 6.00,
|
||||
laborPrice: null,
|
||||
status: '사용',
|
||||
createdAt: '2026-01-01T12:00:00Z',
|
||||
updatedAt: '2026-01-01T12:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
laborNumber: '123123',
|
||||
category: '세로할증',
|
||||
minM: 3.50,
|
||||
maxM: 0,
|
||||
laborPrice: 50000,
|
||||
status: '사용',
|
||||
createdAt: '2026-01-01T11:00:00Z',
|
||||
updatedAt: '2026-01-01T11:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
laborNumber: '123123',
|
||||
category: '가로',
|
||||
minM: 0,
|
||||
maxM: 0,
|
||||
laborPrice: null,
|
||||
status: '중지',
|
||||
createdAt: '2026-01-01T10:00:00Z',
|
||||
updatedAt: '2026-01-01T10:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
// 노임 목록 조회
|
||||
export async function getLaborList(params: LaborListParams = {}): Promise<{
|
||||
success: boolean;
|
||||
data?: Labor[];
|
||||
total?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
let filtered = [...mockLabors];
|
||||
|
||||
// 검색어 필터
|
||||
if (params.search) {
|
||||
const searchLower = params.search.toLowerCase();
|
||||
filtered = filtered.filter(
|
||||
(labor) =>
|
||||
labor.laborNumber.toLowerCase().includes(searchLower) ||
|
||||
labor.category.toLowerCase().includes(searchLower)
|
||||
);
|
||||
}
|
||||
|
||||
// 구분 필터
|
||||
if (params.category && params.category !== 'all') {
|
||||
filtered = filtered.filter((labor) => labor.category === params.category);
|
||||
}
|
||||
|
||||
// 상태 필터
|
||||
if (params.status && params.status !== 'all') {
|
||||
filtered = filtered.filter((labor) => labor.status === params.status);
|
||||
}
|
||||
|
||||
// 날짜 필터
|
||||
if (params.startDate) {
|
||||
filtered = filtered.filter(
|
||||
(labor) => new Date(labor.createdAt) >= new Date(params.startDate!)
|
||||
);
|
||||
}
|
||||
if (params.endDate) {
|
||||
const endDate = new Date(params.endDate);
|
||||
endDate.setHours(23, 59, 59, 999);
|
||||
filtered = filtered.filter(
|
||||
(labor) => new Date(labor.createdAt) <= endDate
|
||||
);
|
||||
}
|
||||
|
||||
// 정렬
|
||||
if (params.sortOrder === '등록순') {
|
||||
filtered.sort(
|
||||
(a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
||||
);
|
||||
} else {
|
||||
// 기본: 최신순
|
||||
filtered.sort(
|
||||
(a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()
|
||||
);
|
||||
}
|
||||
|
||||
const total = filtered.length;
|
||||
|
||||
// 페이지네이션
|
||||
if (params.page && params.limit) {
|
||||
const start = (params.page - 1) * params.limit;
|
||||
filtered = filtered.slice(start, start + params.limit);
|
||||
}
|
||||
|
||||
return { success: true, data: filtered, total };
|
||||
} catch (error) {
|
||||
console.error('노임 목록 조회 실패:', error);
|
||||
return { success: false, error: '노임 목록을 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 노임 통계 조회
|
||||
export async function getLaborStats(): Promise<{
|
||||
success: boolean;
|
||||
data?: LaborStats;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const total = mockLabors.length;
|
||||
const active = mockLabors.filter((labor) => labor.status === '사용').length;
|
||||
return { success: true, data: { total, active } };
|
||||
} catch (error) {
|
||||
console.error('노임 통계 조회 실패:', error);
|
||||
return { success: false, error: '노임 통계를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 노임 상세 조회
|
||||
export async function getLabor(id: string): Promise<{
|
||||
success: boolean;
|
||||
data?: Labor;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const labor = mockLabors.find((l) => l.id === id);
|
||||
if (!labor) {
|
||||
return { success: false, error: '노임 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
return { success: true, data: labor };
|
||||
} catch (error) {
|
||||
console.error('노임 상세 조회 실패:', error);
|
||||
return { success: false, error: '노임 정보를 불러오는데 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 노임 등록
|
||||
export async function createLabor(data: LaborFormData): Promise<{
|
||||
success: boolean;
|
||||
data?: Labor;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const newLabor: Labor = {
|
||||
id: String(Date.now()),
|
||||
...data,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
mockLabors.unshift(newLabor);
|
||||
return { success: true, data: newLabor };
|
||||
} catch (error) {
|
||||
console.error('노임 등록 실패:', error);
|
||||
return { success: false, error: '노임 등록에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 노임 수정
|
||||
export async function updateLabor(
|
||||
id: string,
|
||||
data: LaborFormData
|
||||
): Promise<{
|
||||
success: boolean;
|
||||
data?: Labor;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const index = mockLabors.findIndex((l) => l.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '노임 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
mockLabors[index] = {
|
||||
...mockLabors[index],
|
||||
...data,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
return { success: true, data: mockLabors[index] };
|
||||
} catch (error) {
|
||||
console.error('노임 수정 실패:', error);
|
||||
return { success: false, error: '노임 수정에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 노임 삭제
|
||||
export async function deleteLabor(id: string): Promise<{
|
||||
success: boolean;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
const index = mockLabors.findIndex((l) => l.id === id);
|
||||
if (index === -1) {
|
||||
return { success: false, error: '노임 정보를 찾을 수 없습니다.' };
|
||||
}
|
||||
mockLabors.splice(index, 1);
|
||||
return { success: true };
|
||||
} catch (error) {
|
||||
console.error('노임 삭제 실패:', error);
|
||||
return { success: false, error: '노임 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
|
||||
// 노임 일괄 삭제
|
||||
export async function deleteLaborBulk(ids: string[]): Promise<{
|
||||
success: boolean;
|
||||
deletedCount?: number;
|
||||
error?: string;
|
||||
}> {
|
||||
try {
|
||||
let deletedCount = 0;
|
||||
for (const id of ids) {
|
||||
const index = mockLabors.findIndex((l) => l.id === id);
|
||||
if (index !== -1) {
|
||||
mockLabors.splice(index, 1);
|
||||
deletedCount++;
|
||||
}
|
||||
}
|
||||
return { success: true, deletedCount };
|
||||
} catch (error) {
|
||||
console.error('노임 일괄 삭제 실패:', error);
|
||||
return { success: false, error: '노임 일괄 삭제에 실패했습니다.' };
|
||||
}
|
||||
}
|
||||
24
src/components/business/juil/labor-management/constants.ts
Normal file
24
src/components/business/juil/labor-management/constants.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
// 노임관리 상수 정의
|
||||
|
||||
// 구분 옵션
|
||||
export const CATEGORY_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '가로', label: '가로' },
|
||||
{ value: '세로할증', label: '세로할증' },
|
||||
] as const;
|
||||
|
||||
// 상태 옵션
|
||||
export const STATUS_OPTIONS = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: '사용', label: '사용' },
|
||||
{ value: '중지', label: '중지' },
|
||||
] as const;
|
||||
|
||||
// 정렬 옵션
|
||||
export const SORT_OPTIONS = [
|
||||
{ value: '최신순', label: '최신순' },
|
||||
{ value: '등록순', label: '등록순' },
|
||||
] as const;
|
||||
|
||||
// 기본 페이지 사이즈
|
||||
export const DEFAULT_PAGE_SIZE = 20;
|
||||
5
src/components/business/juil/labor-management/index.tsx
Normal file
5
src/components/business/juil/labor-management/index.tsx
Normal file
@@ -0,0 +1,5 @@
|
||||
export { default as LaborManagementClient } from './LaborManagementClient';
|
||||
export { default as LaborDetailClient } from './LaborDetailClient';
|
||||
export * from './types';
|
||||
export * from './constants';
|
||||
export * from './actions';
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user