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:
byeongcheolryu
2026-01-05 18:59:04 +09:00
parent 4b1a3abf05
commit 386cd30bc0
145 changed files with 25782 additions and 254 deletions

View File

@@ -1,5 +1,5 @@
# Juil Enterprise Test URLs
Last Updated: 2025-12-30
Last Updated: 2026-01-05
### 대시보드
| 페이지 | URL | 상태 |
@@ -17,7 +17,29 @@ Last Updated: 2025-12-30
|---|---|---|
| **거래처 관리** | `/ko/juil/project/bidding/partners` | ✅ 완료 |
| **현장설명회관리** | `/ko/juil/project/bidding/site-briefings` | ✅ 완료 |
| **견적관리** | `/ko/juil/project/bidding/estimates` | 🆕 NEW |
| **견적관리** | `/ko/juil/project/bidding/estimates` | ✅ 완료 |
| **입찰관리** | `/ko/juil/project/bidding` | ✅ 완료 |
### 계약관리 (Contract)
| 페이지 | URL | 상태 |
|---|---|---|
| **계약관리** | `/ko/juil/project/contract` | 🆕 NEW |
| **인수인계보고서관리** | `/ko/juil/project/contract/handover-report` | 🆕 NEW |
### 발주관리 (Order)
| 페이지 | URL | 상태 |
|---|---|---|
| **현장관리** | `/ko/juil/order/site-management` | 🆕 NEW |
| **구조검토관리** | `/ko/juil/order/structure-review` | 🆕 NEW |
| **발주관리** | `/ko/juil/order/order-management` | 🆕 NEW |
### 기준정보 (Base Info) - 발주관리 하위
| 페이지 | URL | 상태 |
|---|---|---|
| **카테고리관리** | `/ko/juil/order/base-info/categories` | 🆕 NEW |
| **품목관리** | `/ko/juil/order/base-info/items` | 🆕 NEW |
| **단가관리** | `/ko/juil/order/base-info/pricing` | 🆕 NEW |
| **노임관리** | `/ko/juil/order/base-info/labor` | 🆕 NEW |
## 공사 관리 (Construction)
### 인수인계 / 실측 / 발주 / 시공

View File

@@ -1,6 +1,6 @@
# claudedocs 문서 맵
> 프로젝트 기술 문서 인덱스 (Last Updated: 2025-12-30)
> 프로젝트 기술 문서 인덱스 (Last Updated: 2026-01-02)
## ⭐ 빠른 참조
@@ -154,7 +154,9 @@ claudedocs/
| 파일 | 설명 |
|------|------|
| `[GUIDE-2025-12-29] vercel-deployment.md` | 🔴 **NEW** - Vercel 배포 가이드 (환경변수, CORS, 테스트 체크리스트) |
| `[DESIGN-2026-01-02] document-modal-common-component.md` | 🔴 **NEW** - 문서 모달 공통 컴포넌트 설계 요구사항 (6개 모달 분석, 헤더/결재라인/테이블 조합형) |
| `[GUIDE] print-area-utility.md` | 인쇄 모달 printArea 유틸리티 가이드 (8개 모달 적용, print-utils.ts) |
| `[GUIDE-2025-12-29] vercel-deployment.md` | Vercel 배포 가이드 (환경변수, CORS, 테스트 체크리스트) |
| `[PLAN-2025-12-23] common-component-extraction-plan.md` | 공통 컴포넌트 추출 계획서 (Phase 1-4, 체크리스트 포함, ~1,900줄 절감) |
| `[ANALYSIS-2025-12-23] common-component-extraction-candidates.md` | 📋 공통 컴포넌트 추출 후보 분석 (다이얼로그 102개 중복, ~2,370줄 절감 예상) |
| `[PLAN-2025-12-19] project-health-improvement.md` | ✅ **Phase 1 완료** - 프로젝트 헬스 개선 계획서 (타입에러 0개, API키 보안, SSR 수정) |
@@ -214,6 +216,9 @@ claudedocs/
| 파일 | 설명 |
|------|------|
| `[IMPL-2026-01-05] item-management-checklist.md` | 🔴 **NEW** - 품목관리 구현 체크리스트 (발주관리 > 기준정보 > 품목관리) |
| `[IMPL-2026-01-05] category-management-checklist.md` | 🔴 **NEW** - 카테고리관리 구현 체크리스트 (발주관리 > 기준정보) |
| `[PLAN-2026-01-05] order-management-implementation.md` | 발주관리 페이지 구현 계획서 (달력+리스트, ScheduleCalendar 공통 컴포넌트) |
| `[NEXT-2025-12-30] partner-management-session-context.md` | ⭐ **세션 체크포인트** - 거래처 관리 리스트 완료, 등록/상세/수정 예정 |
| `[REF] juil-project-structure.md` | 주일 프로젝트 구조 가이드 (경로, 컴포넌트, 테스트 URL) |

View File

@@ -0,0 +1,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 용지 설정 자동 적용
- 기존 스타일시트 자동 로드

View File

@@ -0,0 +1,194 @@
# 인쇄 모달 printArea 유틸리티 적용 가이드
> 작성일: 2026-01-02
> 적용 범위: 모든 인쇄 가능한 모달/다이얼로그
## 개요
기존 `window.print()` 방식은 Radix UI Dialog 포털 구조로 인해 CSS `@media print` 제어가 어렵고, 인쇄 시 모달 헤더/버튼이 함께 출력되거나 여러 페이지로 나뉘는 문제가 있었습니다.
이를 해결하기 위해 JavaScript 기반 `printArea()` 유틸리티를 도입하여 `.print-area` 영역만 새 창에서 인쇄하도록 통일했습니다.
## 공통 컴포넌트 변경
### 1. print-utils.ts (신규)
**파일 위치**: `/src/lib/print-utils.ts`
```typescript
interface PrintOptions {
title?: string; // 브라우저 인쇄 다이얼로그에 표시될 제목
styles?: string; // 추가 CSS 스타일
closeAfterPrint?: boolean; // 인쇄 후 창 닫기 (기본: true)
}
// 특정 요소 인쇄
export function printElement(
elementOrSelector: HTMLElement | string,
options?: PrintOptions
): void;
// .print-area 클래스 요소 인쇄 (주로 사용)
export function printArea(options?: PrintOptions): void;
```
**동작 방식**:
1. 새 창 열기
2. 현재 페이지의 스타일시트 복사
3. `.print-area` 요소 내용 복제
4. `.print-hidden` 요소 제거
5. A4 용지에 맞는 인쇄 스타일 적용
6. 자동 인쇄 실행 후 창 닫기
### 2. globals.css 인쇄 스타일 (간소화)
**파일 위치**: `/src/app/globals.css`
```css
@media print {
@page {
size: A4 portrait;
margin: 10mm;
}
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
}
html, body {
background: white !important;
}
.print-hidden {
display: none !important;
}
}
```
## 적용된 모달 목록
| 컴포넌트 | 파일 경로 | 인쇄 제목 |
|---------|----------|----------|
| DocumentDetailModal | `src/components/approval/DocumentDetail/index.tsx` | 문서 타입별 (품의서, 기안서 등) |
| ProcessWorkLogPreviewModal | `src/components/process-management/ProcessWorkLogPreviewModal.tsx` | 작업일지 템플릿명 |
| ReceivingReceiptDialog | `src/components/material/ReceivingManagement/ReceivingReceiptDialog.tsx` | 입고증 인쇄 |
| WorkLogModal | `src/components/production/WorkerScreen/WorkLogModal.tsx` | 작업일지 인쇄 |
| OrderDocumentModal | `src/components/orders/documents/OrderDocumentModal.tsx` | 계약서/거래명세서/발주서 |
| ShipmentDetail | `src/components/outbound/ShipmentManagement/ShipmentDetail.tsx` | 출고증/거래명세서/납품확인서 |
| EstimateDocumentModal | `src/components/business/juil/estimates/modals/EstimateDocumentModal.tsx` | 견적서 인쇄 |
| ContractDocumentModal | `src/components/business/juil/contract/modals/ContractDocumentModal.tsx` | 계약서 인쇄 |
## 사용 방법
### 기본 사용법
```tsx
import { printArea } from '@/lib/print-utils';
// 인쇄 핸들러
const handlePrint = () => {
printArea({ title: '문서 인쇄' });
};
```
### 모달 구조 규칙
인쇄 가능한 모달은 다음 구조를 따라야 합니다:
```tsx
<Dialog>
<DialogContent>
{/* 헤더 영역 - 인쇄에서 제외 */}
<div className="print-hidden">
<h2>문서 제목</h2>
<Button onClick={handlePrint}>인쇄</Button>
<Button onClick={onClose}>닫기</Button>
</div>
{/* 버튼 영역 - 인쇄에서 제외 */}
<div className="print-hidden">
<Button>수정</Button>
<Button>인쇄</Button>
</div>
{/* 문서 영역 - 이 영역만 인쇄됨 */}
<div className="print-area">
{/* 실제 문서 내용 */}
</div>
</DialogContent>
</Dialog>
```
### CSS 클래스 규칙
| 클래스 | 용도 |
|--------|------|
| `.print-area` | 인쇄될 영역 (필수) |
| `.print-hidden` | 인쇄에서 제외할 영역 (헤더, 버튼 등) |
## 이전 방식 vs 새 방식
### 이전 방식 (문제점)
```tsx
const handlePrint = () => {
window.print(); // 전체 페이지 인쇄 시도
};
```
**문제점**:
- Radix UI 포털 구조로 CSS `@media print` 제어 어려움
- `visibility: hidden` 사용 시 빈 공간으로 인해 3-4페이지로 출력
- `display: none` 사용 시 빈 페이지 출력
- 모달 헤더/버튼이 함께 인쇄됨
### 새 방식 (해결)
```tsx
const handlePrint = () => {
printArea({ title: '문서 인쇄' });
};
```
**장점**:
- 새 창에서 `.print-area` 내용만 추출하여 인쇄
- Radix UI 포털 구조 영향 없음
- 항상 1페이지로 깔끔하게 인쇄
- 문서 내용만 인쇄 (헤더/버튼 제외)
## 새 인쇄 모달 추가 시
1. `printArea` import 추가
2. `handlePrint` 함수에서 `printArea()` 호출
3. 모달 구조에 `.print-hidden` / `.print-area` 클래스 적용
```tsx
import { printArea } from '@/lib/print-utils';
export function NewDocumentModal() {
const handlePrint = () => {
printArea({ title: '새 문서 인쇄' });
};
return (
<Dialog>
<DialogContent>
<div className="print-hidden">
{/* 헤더/버튼 */}
</div>
<div className="print-area">
{/* 인쇄될 문서 내용 */}
</div>
</DialogContent>
</Dialog>
);
}
```
## 주의사항
1. **`.print-area` 클래스 필수**: 인쇄 영역에 반드시 `.print-area` 클래스 적용
2. **중첩 `.print-area` 금지**: 하나의 모달에 `.print-area`는 하나만 존재해야 함
3. **스타일 복제**: 인쇄 시 현재 페이지의 스타일시트가 자동으로 복사됨
4. **팝업 차단 주의**: 브라우저 팝업 차단 시 인쇄 창이 열리지 않을 수 있음

View File

@@ -0,0 +1,98 @@
# [IMPL-2026-01-05] 카테고리관리 페이지 구현 체크리스트
## 개요
- **위치**: 발주관리 > 기준정보 > 카테고리관리
- **URL**: `/ko/juil/order/base-info/categories`
- **참조 페이지**: `/ko/settings/ranks` (직급관리)
- **기능**: 동일, 텍스트/라벨만 다름
## 스크린샷 분석
### UI 구성
| 구성요소 | 내용 |
|---------|------|
| 타이틀 | 카테고리관리 |
| 설명 | 카테고리를 등록하고 관리합니다. |
| 입력필드 라벨 | 카테고리 |
| 입력필드 placeholder | 카테고리를 입력해주세요 |
| 테이블 컬럼 | 카테고리, 작업 |
| 기본 데이터 | 슬라이드 OPEN 사이즈, 모터, 공정자재, 철물 |
### Description 영역 (참고용, UI 미구현)
1. 추가 버튼 클릭 시 목록 최하단에 추가
2. 드래그&드롭으로 순서 변경
3. 수정 버튼 → 수정 팝업
4. 삭제 버튼 → 조건별 Alert:
- 품목 사용 중: "(카테고리명)을 사용하고 있는 품목이 있습니다. 모두 변경 후 삭제가 가능합니다."
- 미사용: "정말 삭제하시겠습니까?" → "삭제가 되었습니다."
- 기본 카테고리: "기본 카테고리는 삭제가 불가합니다."
## 구현 체크리스트
### Phase 1: 파일 구조 생성
- [x] `src/app/[locale]/(protected)/juil/order/base-info/categories/page.tsx` 생성
- [x] `src/components/business/juil/category-management/` 디렉토리 생성
### Phase 2: 컴포넌트 구현 (RankManagement 복제 + 수정)
- [x] `index.tsx` - CategoryManagement 메인 컴포넌트
- 타이틀: "카테고리관리"
- 설명: "카테고리를 등록하고 관리합니다. 드래그하여 순서를 변경할 수 있습니다."
- 아이콘: `FolderTree`
- 입력 placeholder: "카테고리를 입력해주세요"
- [x] `types.ts` - Category 타입 정의
- [x] `actions.ts` - Server Actions (목데이터)
- [x] `CategoryDialog.tsx` - 수정 다이얼로그
### Phase 3: 텍스트 변경 사항
| 원본 (ranks) | 변경 (categories) | 상태 |
|-------------|-------------------|------|
| 직급 | 카테고리 | ✅ |
| 직급관리 | 카테고리관리 | ✅ |
| 사원의 직급을 관리합니다 | 카테고리를 등록하고 관리합니다 | ✅ |
| 직급명을 입력하세요 | 카테고리를 입력해주세요 | ✅ |
| 직급이 추가되었습니다 | 카테고리가 추가되었습니다 | ✅ |
| 직급이 수정되었습니다 | 카테고리가 수정되었습니다 | ✅ |
| 직급이 삭제되었습니다 | 카테고리가 삭제되었습니다 | ✅ |
| 등록된 직급이 없습니다 | 등록된 카테고리가 없습니다 | ✅ |
### Phase 4: 삭제 로직 (삭제 조건 처리)
- [x] 기본 카테고리 삭제 불가 로직 추가 (`isDefault` 플래그)
- [x] 조건별 Alert 메시지 분기 (actions.ts의 `errorType` 반환)
- [ ] 품목 사용 여부 체크 로직 추가 (추후 API 연동 시)
### Phase 5: 목데이터 설정
- [x] 기본 카테고리 4개 설정 완료
```typescript
const mockCategories = [
{ id: '1', name: '슬라이드 OPEN 사이즈', order: 1, isDefault: true },
{ id: '2', name: '모터', order: 2, isDefault: true },
{ id: '3', name: '공정자재', order: 3, isDefault: true },
{ id: '4', name: '철물', order: 4, isDefault: true },
];
```
### Phase 6: 테스트 URL 문서 업데이트
- [x] `claudedocs/[REF] juil-pages-test-urls.md` 업데이트
- 발주관리 > 기준정보 섹션 추가
- 카테고리관리 URL 추가
## 파일 구조
```
src/
├── app/[locale]/(protected)/juil/order/
│ └── base-info/
│ └── categories/
│ └── page.tsx
└── components/business/juil/
└── category-management/
├── index.tsx
├── types.ts
├── actions.ts
└── CategoryDialog.tsx
```
## 진행 상태
- 생성일: 2026-01-05
- 상태: ✅ 완료 (목데이터 기반)
- 남은 작업: API 연동 시 품목 사용 여부 체크 로직 추가

View File

@@ -0,0 +1,209 @@
# [IMPL-2026-01-05] 품목관리 페이지 구현 체크리스트
## 개요
- **위치**: 발주관리 > 기준정보 > 품목관리
- **URL**: `/ko/juil/order/base-info/items`
- **참조 템플릿**: IntegratedListTemplateV2 (리스트 페이지 표준)
- **기능**: 품목 CRUD, 필터링, 검색, 정렬
## 스크린샷 분석
### 헤더 영역
| 구성요소 | 내용 |
|---------|------|
| 타이틀 | 품목관리 |
| 설명 | 품목을 등록하여 관리합니다. |
| 날짜 필터 | 날짜 범위 선택 (DateRangePicker) |
| 빠른 날짜 버튼 | 전체년도, 전전월, 전월, 당월, 어제, 오늘 |
| 액션 버튼 | 품목 등록 (빨간색 primary) |
### 통계 카드
| 카드 | 내용 |
|------|------|
| 전체 품목 | 전체 품목 수 표시 |
| 사용 품목 | 사용 중인 품목 수 표시 |
### 검색 및 필터 영역
| 구성요소 | 내용 |
|---------|------|
| 검색 입력 | 품목명 검색 |
| 선택 카운트 | N건 / N건 선택 |
| 삭제 버튼 | 선택된 항목 일괄 삭제 |
### 테이블 컬럼
| 컬럼 | 타입 | 필터 옵션 |
|------|------|----------|
| 체크박스 | checkbox | - |
| 품목번호 | text | - |
| 물품유형 | select filter | 전체, 제품, 부품, 소모품, 공과 |
| 카테고리 | select filter + search | 전체, 기본, (카테고리 목록) |
| 품목명 | text | - |
| 규격 | select filter | 전체, 인정, 비인정 |
| 단위 | text | - |
| 구분 | select filter | 전체, 경품발주, 원자재발주, 외주발주 |
| 상태 | badge | 승인, 작업 |
| 작업 | actions | 수정(연필 아이콘) |
### Description 영역 (참고용, UI 미구현)
1. 품목 등록 버튼 - 클릭 시 품목 상세 등록 화면으로 이동
2. 물품유형 셀렉트 박스 - 전체/제품/부품/소모품/공과 (디폴트: 전체)
3. 카테고리 셀렉트 박스, 검색 - 전체/기본/카테고리 목록 (디폴트: 전체)
4. 규격 셀렉트 박스 - 전체/인정/비인정 (디폴트: 전체)
5. 구분 셀렉트 박스 - 전체/경품발주/원자재발주/외주발주 (디폴트: 전체)
6. 상태 셀렉트 박스 - 전체/사용/중지 (디폴트: 전체)
7. 정렬 셀렉트 박스 - 최신순/등록순 (디폴트: 최신순)
## 구현 체크리스트
### Phase 1: 파일 구조 생성
- [x] `src/app/[locale]/(protected)/juil/order/base-info/items/page.tsx` 생성
- [x] `src/components/business/juil/item-management/` 디렉토리 생성
### Phase 2: 타입 및 상수 정의
- [x] `types.ts` - Item 타입 정의
```typescript
interface Item {
id: string;
itemNumber: string; // 품목번호
itemType: ItemType; // 물품유형
categoryId: string; // 카테고리 ID
categoryName: string; // 카테고리명
itemName: string; // 품목명
specification: string; // 규격 (인쇄/비인쇄)
unit: string; // 단위
orderType: OrderType; // 구분
status: ItemStatus; // 상태
createdAt: string;
updatedAt: string;
}
```
- [x] `constants.ts` - 필터 옵션 상수 정의
```typescript
// 물품유형
const ITEM_TYPES = ['전체', '제품', '부품', '소모품', '공과'];
// 규격
const SPECIFICATIONS = ['전체', '인정', '비인정'];
// 구분
const ORDER_TYPES = ['전체', '경품발주', '원자재발주', '외주발주'];
// 상태
const ITEM_STATUSES = ['전체', '사용', '중지'];
// 정렬
const SORT_OPTIONS = ['최신순', '등록순'];
```
### Phase 3: 메인 컴포넌트 구현
- [x] `index.tsx` - ItemManagement 메인 컴포넌트 (export)
- [x] `ItemManagementClient.tsx` - 클라이언트 컴포넌트
- IntegratedListTemplateV2 사용
- 헤더: 타이틀, 설명, 날짜필터, 품목등록 버튼
- 통계 카드: StatCards 컴포넌트 활용
- 테이블: 컬럼 헤더 필터 포함
- 검색 및 삭제 기능
### Phase 4: 테이블 컬럼 설정
- [x] 테이블 컬럼 정의 (ItemManagementClient.tsx 내 포함)
- 체크박스 컬럼
- 품목번호 컬럼
- 물품유형 컬럼 (헤더 필터 Select)
- 카테고리 컬럼 (헤더 필터 Select + 검색)
- 품목명 컬럼
- 규격 컬럼 (헤더 필터 Select)
- 단위 컬럼
- 구분 컬럼 (헤더 필터 Select)
- 상태 컬럼 (Badge 표시)
- 작업 컬럼 (수정 버튼)
### Phase 5: Server Actions (목데이터)
- [x] `actions.ts` - Server Actions 구현
- `getItemList()` - 품목 목록 조회
- `getItemStats()` - 통계 조회
- `deleteItem()` - 품목 삭제
- `deleteItems()` - 품목 일괄 삭제
- `getCategoryOptions()` - 카테고리 목록 조회
### Phase 6: 목데이터 설정
```typescript
const mockItems: Item[] = [
{ id: '1', itemNumber: '123123', itemType: '제품', categoryName: '카테고리명', itemName: '품목명', specification: '인쇄', unit: 'SET', orderType: '외주발주', status: '승인' },
{ id: '2', itemNumber: '123123', itemType: '부품', categoryName: '카테고리명', itemName: '품목명', specification: '비인쇄', unit: 'SET', orderType: '외주발주', status: '승인' },
{ id: '3', itemNumber: '123123', itemType: '소모품', categoryName: '카테고리명', itemName: '품목명', specification: '인쇄', unit: 'SET', orderType: '외주발주', status: '승인' },
{ id: '4', itemNumber: '123123', itemType: '공과', categoryName: '카테고리명', itemName: '품목명', specification: '비인쇄', unit: 'EA', orderType: '공과', status: '작업' },
{ id: '5', itemNumber: '123123', itemType: '부품', categoryName: '카테고리명', itemName: '품목명', specification: '인쇄', unit: 'EA', orderType: '원자재발주', status: '작업' },
{ id: '6', itemNumber: '123123', itemType: '소모품', categoryName: '카테고리명', itemName: '품목명', specification: '비인쇄', unit: '승인', orderType: '외주발주', status: '작업' },
{ id: '7', itemNumber: '123123', itemType: '소모품', categoryName: '카테고리명', itemName: '품목명', specification: '인쇄', unit: '승인', orderType: '공과', status: '작업' },
];
const mockStats = {
totalItems: 7,
activeItems: 5,
};
```
### Phase 7: 헤더 필터 컴포넌트
- [x] tableHeaderActions 영역에 Select 필터 구현
- 물품유형 필터
- 규격 필터
- 구분 필터
- 정렬 필터
### Phase 8: 등록/상세/수정 페이지 구현
- [x] 품목 등록 버튼 클릭 → `/ko/juil/order/base-info/items/new` 이동
- [x] 수정 버튼 클릭 → `/ko/juil/order/base-info/items/[id]?mode=edit` 이동
- [x] 등록/수정/상세 페이지 구현 (ItemDetailClient.tsx)
- [x] Server Actions (getItem, createItem, updateItem) 구현
- [x] 발주 항목 동적 추가/삭제 기능
### Phase 9: 테스트 URL 문서 업데이트
- [x] `claudedocs/[REF] juil-pages-test-urls.md` 업데이트
- 품목관리 URL 추가
## 파일 구조
```
src/
├── app/[locale]/(protected)/juil/order/
│ └── base-info/
│ └── items/
│ ├── page.tsx
│ ├── new/
│ │ └── page.tsx
│ └── [id]/
│ └── page.tsx
└── components/business/juil/
└── item-management/
├── index.tsx
├── ItemManagementClient.tsx
├── ItemDetailClient.tsx
├── types.ts
├── constants.ts
└── actions.ts
```
## 참조 컴포넌트
- `IntegratedListTemplateV2` - 리스트 템플릿
- `StatCards` - 통계 카드
- `DateRangePicker` - 날짜 범위 선택
- `Select` - 필터 셀렉트박스
- `Badge` - 상태 표시
- `Button` - 버튼
- `Checkbox` - 체크박스
## UI 구현 참고
- 컬럼 헤더 내 필터 Select: 기존 프로젝트 내 유사 구현 검색 필요
- 날짜 빠른 선택 버튼 그룹: 기존 컴포넌트 활용 또는 신규 구현
## 진행 상태
- 생성일: 2026-01-05
- 상태: ✅ 전체 완료 (리스트 + 상세/등록/수정)
## 히스토리
| 날짜 | 작업 내용 | 상태 |
|------|----------|------|
| 2026-01-05 | 체크리스트 작성 | ✅ |
| 2026-01-05 | 리스트 페이지 구현 (Phase 1-7, 9) | ✅ |
| 2026-01-05 | 규격 필터 수정 (인쇄/비인쇄 → 인정/비인정) | ✅ |
| 2026-01-05 | 상세/등록/수정 페이지 구현 (Phase 8) | ✅ |

View File

@@ -0,0 +1,119 @@
# [IMPL-2026-01-05] 단가관리 리스트 페이지 구현 체크리스트
## 개요
- **위치**: 발주관리 > 기준정보 > 단가관리
- **URL**: `/ko/juil/order/base-info/pricing`
- **참조 페이지**: `/ko/juil/order/order-management` (OrderManagementListClient)
- **패턴**: IntegratedListTemplateV2 + StatCards
## 스크린샷 분석
### UI 구성
#### 1. 헤더 영역
| 구성요소 | 내용 |
|---------|------|
| 타이틀 | 단가관리 |
| 설명 | 단가를 등록하고 관리합니다. |
#### 2. 달력 + 액션 버튼 영역
| 구성요소 | 내용 |
|---------|------|
| 날짜 선택 | DateRangeSelector (2025-09-01 ~ 2025-09-03) |
| 액션 버튼들 | 담당단가, 진행단가, 확정, 발행, 이력, 오류, **단가 등록** |
#### 3. StatCards (통계 카드)
| 카드 | 값 | 설명 |
|------|-----|------|
| 미완료 | 9 | 미완료 단가 |
| 확정 | 5 | 확정된 단가 |
| 발행 | 4 | 발행된 단가 |
#### 4. 필터 영역 (테이블 헤더)
| 필터 | 옵션 | 기본값 |
|------|------|--------|
| 품목유형 | 전체, 박스, 부속, 소모품, 공과 | 전체 |
| 카테고리 | 전기, (카테고리 목록) | - |
| 규격 | 전체, 진행, 미진행 | 전체 |
| 구분 | 전체, 금동량, 임의적용가, 미구분 | 전체 |
| 상세 | 전체, 사용, 유지, 미등록 | 전체 |
| 정렬 | 최신순, 등록순 | 최신순 |
#### 5. 테이블 컬럼
| 컬럼 | 설명 |
|------|------|
| 체크박스 | 행 선택 |
| 단가번호 | 단가 고유번호 |
| 품목유형 | 박스/부속/소모품/공과 |
| 카테고리 | 품목 카테고리 |
| 품목 | 품목명 |
| 금액량 | 수량 정보 |
| 정량 | 정량 정보 |
| 단가 | 단가 금액 |
| 구매처 | 구매처 정보 |
| 예상단가 | 예상 단가 |
| 이전단가 | 이전 단가 |
| 판매단가 | 판매 단가 |
| 실적 | 실적 정보 |
## 구현 체크리스트
### Phase 1: 파일 구조 생성
- [x] `src/app/[locale]/(protected)/juil/order/base-info/pricing/page.tsx` 생성
- [x] `src/components/business/juil/pricing-management/` 디렉토리 생성
### Phase 2: 타입 및 상수 정의
- [x] `types.ts` - Pricing 타입, 필터 옵션, 상태 스타일
- Pricing 인터페이스
- PricingStats 인터페이스
- 품목유형 옵션 (ITEM_TYPE_OPTIONS)
- 규격 옵션 (SPEC_OPTIONS)
- 구분 옵션 (DIVISION_OPTIONS)
- 상세 옵션 (DETAIL_OPTIONS)
- 정렬 옵션 (SORT_OPTIONS)
- 상태 스타일 (PRICING_STATUS_STYLES)
### Phase 3: Server Actions (목데이터)
- [x] `actions.ts`
- getPricingList() - 목록 조회
- getPricingStats() - 통계 조회
- deletePricing() - 단일 삭제
- deletePricings() - 일괄 삭제
### Phase 4: 리스트 컴포넌트
- [x] `PricingListClient.tsx`
- IntegratedListTemplateV2 사용
- DateRangeSelector (날짜 범위 선택)
- StatCards (미완료/확정/발행)
- 필터 셀렉트 박스들 (품목유형, 규격, 구분, 상세, 정렬)
- 액션 버튼들 (담당단가, 진행단가, 확정, 발행, 이력, 오류, 단가 등록)
- 테이블 렌더링
- 모바일 카드 렌더링
- 삭제 다이얼로그
### Phase 5: 목데이터 설정
- [x] 7개 목데이터 설정 완료
### Phase 6: 테스트 URL 문서 업데이트
- [x] `claudedocs/[REF] juil-pages-test-urls.md` 업데이트
## 파일 구조
```
src/
├── app/[locale]/(protected)/juil/order/
│ └── base-info/
│ └── pricing/
│ └── page.tsx
└── components/business/juil/
└── pricing-management/
├── index.ts
├── types.ts
├── actions.ts
└── PricingListClient.tsx
```
## 진행 상태
- 생성일: 2026-01-05
- 상태: ✅ 완료 (목데이터 기반)
- 남은 작업: API 연동 시 실제 데이터 연결

View File

@@ -0,0 +1,231 @@
# EstimateDetailForm.tsx 파일 분할 계획서
## 현황 분석
- **파일 위치**: `src/components/business/juil/estimates/EstimateDetailForm.tsx`
- **현재 라인 수**: 2,088줄
- **문제점**: 단일 파일에 모든 섹션, 핸들러, 상태 관리가 집중되어 유지보수 어려움
## 파일 구조 분석
### 현재 구조 (라인 범위)
| 구분 | 라인 | 설명 |
|------|------|------|
| Imports | 1-56 | React, UI 컴포넌트, 타입 |
| 상수/유틸 | 58-75 | MOCK_MATERIALS, MOCK_EXPENSES, formatAmount |
| Props | 77-81 | EstimateDetailFormProps |
| State | 88-127 | formData, 로딩, 다이얼로그, 모달 상태 |
| 핸들러 - 네비게이션 | 130-140 | handleBack, handleEdit, handleCancel |
| 핸들러 - 저장/삭제 | 143-182 | handleSave, handleConfirmSave, handleDelete, handleConfirmDelete |
| 핸들러 - 견적 요약 | 185-227 | handleAddSummaryItem, handleRemoveSummaryItem, handleSummaryItemChange |
| 핸들러 - 공과 상세 | 230-259 | handleAddExpenseItem, handleRemoveExpenseItem, handleExpenseItemChange |
| 핸들러 - 단가 조정 | 262-283 | handlePriceAdjustmentChange |
| 핸들러 - 견적 상세 | 286-343 | handleAddDetailItem, handleRemoveDetailItem, handleDetailItemChange |
| 핸들러 - 파일 업로드 | 346-435 | handleDocumentUpload, handleDocumentRemove, 드래그앤드롭 |
| useMemo | 438-482 | pageTitle, pageDescription, headerActions |
| JSX - 견적 정보 | 496-526 | 견적 정보 Card |
| JSX - 현장설명회 | 528-551 | 현장설명회 정보 Card |
| JSX - 입찰 정보 | 553-736 | 입찰 정보 Card + 파일 업로드 |
| JSX - 견적 요약 | 738-890 | 견적 요약 정보 Table |
| JSX - 공과 상세 | 892-1071 | 공과 상세 Table |
| JSX - 단가 조정 | 1073-1224 | 품목 단가 조정 Table |
| JSX - 견적 상세 | 1226-2017 | 견적 상세 Table (가장 큰 섹션) |
| 모달/다이얼로그 | 2020-2085 | 전자결재, 견적서, 삭제/저장 다이얼로그 |
---
## 분할 계획
### 1단계: 섹션 컴포넌트 분리
```
src/components/business/juil/estimates/
├── EstimateDetailForm.tsx # 메인 컴포넌트 (축소)
├── sections/
│ ├── index.ts # 섹션 export
│ ├── EstimateInfoSection.tsx # 견적 정보 + 현장설명회 + 입찰 정보
│ ├── EstimateSummarySection.tsx # 견적 요약 정보
│ ├── ExpenseDetailSection.tsx # 공과 상세
│ ├── PriceAdjustmentSection.tsx # 품목 단가 조정
│ └── EstimateDetailTableSection.tsx # 견적 상세 테이블
├── hooks/
│ ├── index.ts # hooks export
│ └── useEstimateCalculations.ts # 계산 로직 (면적, 무게, 단가 등)
└── utils/
├── index.ts # utils export
├── constants.ts # MOCK_MATERIALS, MOCK_EXPENSES
└── formatters.ts # formatAmount
```
### 2단계: 각 파일 상세
#### 2.1 constants.ts (~20줄)
```typescript
// MOCK_MATERIALS, MOCK_EXPENSES 이동
export const MOCK_MATERIALS = [...];
export const MOCK_EXPENSES = [...];
```
#### 2.2 formatters.ts (~10줄)
```typescript
// formatAmount 함수 이동
export function formatAmount(amount: number): string { ... }
```
#### 2.3 useEstimateCalculations.ts (~100줄)
```typescript
// 견적 상세 테이블의 계산 로직 분리
// - 면적, 무게, 철제스크린, 코킹, 레일, 하장 등 계산
// - 합계 계산 로직
export function useEstimateCalculations(
item: EstimateDetailItem,
priceAdjustmentData: PriceAdjustmentData,
useAdjustedPrice: boolean
) { ... }
export function calculateTotals(
items: EstimateDetailItem[],
priceAdjustmentData: PriceAdjustmentData,
useAdjustedPrice: boolean
) { ... }
```
#### 2.4 EstimateInfoSection.tsx (~250줄)
```typescript
// 견적 정보 + 현장설명회 + 입찰 정보 Card 3개
// 파일 업로드 영역 포함
interface EstimateInfoSectionProps {
formData: EstimateDetailFormData;
setFormData: React.Dispatch<React.SetStateAction<EstimateDetailFormData>>;
isViewMode: boolean;
documentInputRef: React.RefObject<HTMLInputElement>;
}
```
#### 2.5 EstimateSummarySection.tsx (~200줄)
```typescript
// 견적 요약 정보 테이블
interface EstimateSummarySectionProps {
summaryItems: EstimateSummaryItem[];
summaryMemo: string;
isViewMode: boolean;
onAddItem: () => void;
onRemoveItem: (id: string) => void;
onItemChange: (id: string, field: keyof EstimateSummaryItem, value: string | number) => void;
onMemoChange: (memo: string) => void;
}
```
#### 2.6 ExpenseDetailSection.tsx (~200줄)
```typescript
// 공과 상세 테이블
interface ExpenseDetailSectionProps {
expenseItems: ExpenseItem[];
isViewMode: boolean;
onAddItems: (count: number) => void;
onRemoveSelected: () => void;
onItemChange: (id: string, field: keyof ExpenseItem, value: string | number) => void;
onSelectItem: (id: string, selected: boolean) => void;
onSelectAll: (selected: boolean) => void;
}
```
#### 2.7 PriceAdjustmentSection.tsx (~200줄)
```typescript
// 품목 단가 조정 테이블
interface PriceAdjustmentSectionProps {
priceAdjustmentData: PriceAdjustmentData;
isViewMode: boolean;
onPriceChange: (key: string, value: number) => void;
onSave: () => void;
onApplyAll: () => void;
onReset: () => void;
}
```
#### 2.8 EstimateDetailTableSection.tsx (~600줄)
```typescript
// 견적 상세 테이블 (가장 큰 섹션)
interface EstimateDetailTableSectionProps {
detailItems: EstimateDetailItem[];
priceAdjustmentData: PriceAdjustmentData;
useAdjustedPrice: boolean;
isViewMode: boolean;
onAddItems: (count: number) => void;
onRemoveItem: (id: string) => void;
onRemoveSelected: () => void;
onItemChange: (id: string, field: keyof EstimateDetailItem, value: string | number) => void;
onSelectItem: (id: string, selected: boolean) => void;
onSelectAll: (selected: boolean) => void;
onApplyAdjustedPrice: () => void;
onReset: () => void;
}
```
---
## 분할 후 예상 라인 수
| 파일 | 예상 라인 수 |
|------|-------------|
| EstimateDetailForm.tsx (메인) | ~300줄 |
| EstimateInfoSection.tsx | ~250줄 |
| EstimateSummarySection.tsx | ~200줄 |
| ExpenseDetailSection.tsx | ~200줄 |
| PriceAdjustmentSection.tsx | ~200줄 |
| EstimateDetailTableSection.tsx | ~600줄 |
| useEstimateCalculations.ts | ~100줄 |
| constants.ts | ~20줄 |
| formatters.ts | ~10줄 |
| **총합** | ~1,880줄 (약 10% 감소) |
---
## 실행 순서
### Phase 1: 유틸리티 분리 (5분)
- [ ] `utils/constants.ts` 생성
- [ ] `utils/formatters.ts` 생성
- [ ] `utils/index.ts` 생성
### Phase 2: 계산 로직 분리 (10분)
- [ ] `hooks/useEstimateCalculations.ts` 생성
- [ ] `hooks/index.ts` 생성
### Phase 3: 섹션 컴포넌트 분리 (30분)
- [ ] `sections/EstimateInfoSection.tsx` 생성
- [ ] `sections/EstimateSummarySection.tsx` 생성
- [ ] `sections/ExpenseDetailSection.tsx` 생성
- [ ] `sections/PriceAdjustmentSection.tsx` 생성
- [ ] `sections/EstimateDetailTableSection.tsx` 생성
- [ ] `sections/index.ts` 생성
### Phase 4: 메인 컴포넌트 리팩토링 (10분)
- [ ] EstimateDetailForm.tsx에서 분리된 컴포넌트 import
- [ ] 핸들러 정리 및 props 전달
- [ ] 불필요한 코드 제거
### Phase 5: 검증 (5분)
- [ ] TypeScript 빌드 확인
- [ ] 기능 동작 확인
---
## 주의사항
1. **상태 관리**: formData, setFormData는 메인 컴포넌트에서 관리, 섹션에 props로 전달
2. **타입 일관성**: 기존 types.ts의 타입 그대로 사용
3. **핸들러 위치**: 핸들러는 메인 컴포넌트에 유지, 섹션에 콜백으로 전달
4. **조정단가 상태**: appliedPrices, useAdjustedPrice는 메인 컴포넌트에서 관리
---
## 5가지 수정사항 (분할 후 진행)
| # | 항목 | 수정 위치 (분할 후) |
|---|------|-------------------|
| 2 | 품목 단가 초기화 → 품목 단가만 | PriceAdjustmentSection.tsx |
| 3 | 견적 상세 인풋 필드 추가 | EstimateDetailTableSection.tsx |
| 4 | 견적 상세 초기화 버튼 수정 | EstimateDetailTableSection.tsx |
| 5 | 각 섹션별 초기화 분리 | 각 Section 컴포넌트 |

View File

@@ -0,0 +1,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` - 템플릿 참조

View File

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

View File

@@ -0,0 +1,14 @@
import { ItemDetailClient } from '@/components/business/juil/item-management';
interface ItemDetailPageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ mode?: string }>;
}
export default async function ItemDetailPage({ params, searchParams }: ItemDetailPageProps) {
const { id } = await params;
const { mode } = await searchParams;
const isEditMode = mode === 'edit';
return <ItemDetailClient itemId={id} isEditMode={isEditMode} />;
}

View File

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

View File

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

View File

@@ -0,0 +1,14 @@
import { LaborDetailClient } from '@/components/business/juil/labor-management';
interface LaborDetailPageProps {
params: Promise<{ id: string }>;
searchParams: Promise<{ mode?: string }>;
}
export default async function LaborDetailPage({ params, searchParams }: LaborDetailPageProps) {
const { id } = await params;
const { mode } = await searchParams;
const isEditMode = mode === 'edit';
return <LaborDetailClient laborId={id} isEditMode={isEditMode} />;
}

View File

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

View File

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

View File

@@ -0,0 +1,11 @@
import PricingDetailClient from '@/components/business/juil/pricing-management/PricingDetailClient';
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function PricingEditPage({ params }: PageProps) {
const { id } = await params;
return <PricingDetailClient id={id} mode="edit" />;
}

View File

@@ -0,0 +1,11 @@
import PricingDetailClient from '@/components/business/juil/pricing-management/PricingDetailClient';
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function PricingDetailPage({ params }: PageProps) {
const { id } = await params;
return <PricingDetailClient id={id} mode="view" />;
}

View File

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

View File

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

View File

@@ -0,0 +1,19 @@
import { OrderDetailForm } from '@/components/business/juil/order-management';
import { getOrderDetailFull } from '@/components/business/juil/order-management/actions';
import { notFound } from 'next/navigation';
interface OrderEditPageProps {
params: Promise<{ id: string }>;
}
export default async function OrderEditPage({ params }: OrderEditPageProps) {
const { id } = await params;
const result = await getOrderDetailFull(id);
if (!result.success || !result.data) {
notFound();
}
return <OrderDetailForm mode="edit" orderId={id} initialData={result.data} />;
}

View File

@@ -0,0 +1,19 @@
import { OrderDetailForm } from '@/components/business/juil/order-management';
import { getOrderDetailFull } from '@/components/business/juil/order-management/actions';
import { notFound } from 'next/navigation';
interface OrderDetailPageProps {
params: Promise<{ id: string }>;
}
export default async function OrderDetailPage({ params }: OrderDetailPageProps) {
const { id } = await params;
const result = await getOrderDetailFull(id);
if (!result.success || !result.data) {
notFound();
}
return <OrderDetailForm mode="view" orderId={id} initialData={result.data} />;
}

View File

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

View File

@@ -0,0 +1,27 @@
import SiteDetailForm from '@/components/business/juil/site-management/SiteDetailForm';
// 목업 데이터
const MOCK_SITE = {
id: '1',
siteCode: '123-12-12345',
partnerId: '1',
partnerName: '거래처명',
siteName: '현장명',
address: '',
status: 'active' as const,
createdAt: '2025-09-01T00:00:00Z',
updatedAt: '2025-09-01T00:00:00Z',
};
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function SiteEditPage({ params }: PageProps) {
const { id } = await params;
// TODO: API에서 현장 정보 조회
const site = { ...MOCK_SITE, id };
return <SiteDetailForm site={site} mode="edit" />;
}

View File

@@ -0,0 +1,27 @@
import SiteDetailForm from '@/components/business/juil/site-management/SiteDetailForm';
// 목업 데이터
const MOCK_SITE = {
id: '1',
siteCode: '123-12-12345',
partnerId: '1',
partnerName: '거래처명',
siteName: '현장명',
address: '',
status: 'active' as const,
createdAt: '2025-09-01T00:00:00Z',
updatedAt: '2025-09-01T00:00:00Z',
};
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function SiteDetailPage({ params }: PageProps) {
const { id } = await params;
// TODO: API에서 현장 정보 조회
const site = { ...MOCK_SITE, id };
return <SiteDetailForm site={site} mode="view" />;
}

View File

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

View File

@@ -0,0 +1,32 @@
import StructureReviewDetailForm from '@/components/business/juil/structure-review/StructureReviewDetailForm';
// 목업 데이터
const MOCK_REVIEW = {
id: '1',
reviewNumber: '123123',
partnerId: '1',
partnerName: '거래처명A',
siteId: '1',
siteName: '현장A',
requestDate: '2025-12-12',
reviewCompany: '회사명',
reviewerName: '홍길동',
reviewDate: '2025-12-15',
completionDate: null,
status: 'pending' as const,
createdAt: '2025-12-01T00:00:00Z',
updatedAt: '2025-12-01T00:00:00Z',
};
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function StructureReviewEditPage({ params }: PageProps) {
const { id } = await params;
// TODO: API에서 구조검토 정보 조회
const review = { ...MOCK_REVIEW, id };
return <StructureReviewDetailForm review={review} mode="edit" />;
}

View File

@@ -0,0 +1,32 @@
import StructureReviewDetailForm from '@/components/business/juil/structure-review/StructureReviewDetailForm';
// 목업 데이터
const MOCK_REVIEW = {
id: '1',
reviewNumber: '123123',
partnerId: '1',
partnerName: '거래처명A',
siteId: '1',
siteName: '현장A',
requestDate: '2025-12-12',
reviewCompany: '회사명',
reviewerName: '홍길동',
reviewDate: '2025-12-15',
completionDate: null,
status: 'pending' as const,
createdAt: '2025-12-01T00:00:00Z',
updatedAt: '2025-12-01T00:00:00Z',
};
interface PageProps {
params: Promise<{ id: string }>;
}
export default async function StructureReviewDetailPage({ params }: PageProps) {
const { id } = await params;
// TODO: API에서 구조검토 정보 조회
const review = { ...MOCK_REVIEW, id };
return <StructureReviewDetailForm review={review} mode="view" />;
}

View File

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

View File

@@ -0,0 +1,18 @@
import { BiddingDetailForm, getBiddingDetail } from '@/components/business/juil/bidding';
interface BiddingEditPageProps {
params: Promise<{ id: string }>;
}
export default async function BiddingEditPage({ params }: BiddingEditPageProps) {
const { id } = await params;
const result = await getBiddingDetail(id);
return (
<BiddingDetailForm
mode="edit"
biddingId={id}
initialData={result.data}
/>
);
}

View File

@@ -0,0 +1,18 @@
import { BiddingDetailForm, getBiddingDetail } from '@/components/business/juil/bidding';
interface BiddingDetailPageProps {
params: Promise<{ id: string }>;
}
export default async function BiddingDetailPage({ params }: BiddingDetailPageProps) {
const { id } = await params;
const result = await getBiddingDetail(id);
return (
<BiddingDetailForm
mode="view"
biddingId={id}
initialData={result.data}
/>
);
}

View File

@@ -0,0 +1,201 @@
import { EstimateDetailForm } from '@/components/business/juil/estimates';
import type { EstimateDetail } from '@/components/business/juil/estimates';
interface EstimateEditPageProps {
params: Promise<{ id: string }>;
}
// 목업 데이터 - 추후 API 연동
async function getEstimateDetail(id: string): Promise<EstimateDetail | null> {
// TODO: 실제 API 연동
const mockData: EstimateDetail = {
id,
estimateCode: '123123',
partnerId: '1',
partnerName: '회사명',
projectName: '현장명',
estimatorId: 'hong',
estimatorName: '이름',
itemCount: 21,
estimateAmount: 1420000,
completedDate: null,
bidDate: '2025-12-12',
status: 'pending',
createdAt: '2025-12-01',
updatedAt: '2025-12-01',
createdBy: 'hong',
siteBriefing: {
briefingCode: '123123',
partnerName: '회사명',
companyName: '회사명',
briefingDate: '2025-12-12',
attendee: '이름',
},
bidInfo: {
projectName: '현장명',
bidDate: '2025-12-12',
siteCount: 21,
constructionPeriod: '2026-01-01 ~ 2026-12-10',
constructionStartDate: '2026-01-01',
constructionEndDate: '2026-12-10',
vatType: 'excluded',
workReport: '업무 보고 내용',
documents: [
{
id: '1',
fileName: 'abc.zip',
fileUrl: '#',
fileSize: 1024000,
},
],
},
summaryItems: [
{
id: '1',
name: '서터 심창측공사',
quantity: 1,
unit: '식',
materialCost: 78540000,
laborCost: 15410000,
totalCost: 93950000,
remarks: '',
},
],
expenseItems: [
{
id: '1',
name: 'public_1',
amount: 10000,
},
],
priceAdjustments: [
{
id: '1',
category: '배합비',
unitPrice: 10000,
coating: 10000,
batting: 10000,
boxReinforce: 10500,
painting: 10500,
total: 51000,
},
{
id: '2',
category: '재단비',
unitPrice: 1375,
coating: 0,
batting: 0,
boxReinforce: 0,
painting: 0,
total: 1375,
},
{
id: '3',
category: '판매단가',
unitPrice: 0,
coating: 10000,
batting: 10000,
boxReinforce: 10500,
painting: 10500,
total: 41000,
},
{
id: '4',
category: '조립단가',
unitPrice: 10300,
coating: 10300,
batting: 10300,
boxReinforce: 10500,
painting: 10200,
total: 51600,
},
],
detailItems: [
{
id: '1',
no: 1,
name: 'FS530외/주차',
material: 'screen',
width: 2350,
height: 2500,
quantity: 1,
box: 1,
assembly: 0,
coating: 0,
batting: 0,
mounting: 0,
fitting: 0,
controller: 0,
widthConstruction: 0,
heightConstruction: 0,
materialCost: 1420000,
laborCost: 510000,
quantityPrice: 1930000,
expenseQuantity: 5500,
expenseTotal: 5500,
totalCost: 1930000,
otherCost: 0,
marginCost: 0,
totalPrice: 1930000,
unitPrice: 1420000,
expense: 0,
marginRate: 0,
unitQuantity: 1,
expenseResult: 0,
marginActual: 0,
},
{
id: '2',
no: 2,
name: 'FS530외/주차',
material: 'screen',
width: 7500,
height: 2500,
quantity: 1,
box: 1,
assembly: 0,
coating: 0,
batting: 0,
mounting: 0,
fitting: 0,
controller: 0,
widthConstruction: 0,
heightConstruction: 0,
materialCost: 4720000,
laborCost: 780000,
quantityPrice: 5500000,
expenseQuantity: 5500,
expenseTotal: 5500,
totalCost: 5500000,
otherCost: 0,
marginCost: 0,
totalPrice: 5500000,
unitPrice: 4720000,
expense: 0,
marginRate: 0,
unitQuantity: 1,
expenseResult: 0,
marginActual: 0,
},
],
approval: {
approvers: [],
references: [],
},
};
return mockData;
}
export default async function EstimateEditPage({ params }: EstimateEditPageProps) {
const { id } = await params;
const detail = await getEstimateDetail(id);
return (
<EstimateDetailForm
mode="edit"
estimateId={id}
initialData={detail || undefined}
/>
);
}

View File

@@ -0,0 +1,201 @@
import { EstimateDetailForm } from '@/components/business/juil/estimates';
import type { EstimateDetail } from '@/components/business/juil/estimates';
interface EstimateDetailPageProps {
params: Promise<{ id: string }>;
}
// 목업 데이터 - 추후 API 연동
async function getEstimateDetail(id: string): Promise<EstimateDetail | null> {
// TODO: 실제 API 연동
const mockData: EstimateDetail = {
id,
estimateCode: '123123',
partnerId: '1',
partnerName: '회사명',
projectName: '현장명',
estimatorId: 'hong',
estimatorName: '이름',
itemCount: 21,
estimateAmount: 1420000,
completedDate: null,
bidDate: '2025-12-12',
status: 'pending',
createdAt: '2025-12-01',
updatedAt: '2025-12-01',
createdBy: 'hong',
siteBriefing: {
briefingCode: '123123',
partnerName: '회사명',
companyName: '회사명',
briefingDate: '2025-12-12',
attendee: '이름',
},
bidInfo: {
projectName: '현장명',
bidDate: '2025-12-12',
siteCount: 21,
constructionPeriod: '2026-01-01 ~ 2026-12-10',
constructionStartDate: '2026-01-01',
constructionEndDate: '2026-12-10',
vatType: 'excluded',
workReport: '업무 보고 내용',
documents: [
{
id: '1',
fileName: 'abc.zip',
fileUrl: '#',
fileSize: 1024000,
},
],
},
summaryItems: [
{
id: '1',
name: '서터 심창측공사',
quantity: 1,
unit: '식',
materialCost: 78540000,
laborCost: 15410000,
totalCost: 93950000,
remarks: '',
},
],
expenseItems: [
{
id: '1',
name: 'public_1',
amount: 10000,
},
],
priceAdjustments: [
{
id: '1',
category: '배합비',
unitPrice: 10000,
coating: 10000,
batting: 10000,
boxReinforce: 10500,
painting: 10500,
total: 51000,
},
{
id: '2',
category: '재단비',
unitPrice: 1375,
coating: 0,
batting: 0,
boxReinforce: 0,
painting: 0,
total: 1375,
},
{
id: '3',
category: '판매단가',
unitPrice: 0,
coating: 10000,
batting: 10000,
boxReinforce: 10500,
painting: 10500,
total: 41000,
},
{
id: '4',
category: '조립단가',
unitPrice: 10300,
coating: 10300,
batting: 10300,
boxReinforce: 10500,
painting: 10200,
total: 51600,
},
],
detailItems: [
{
id: '1',
no: 1,
name: 'FS530외/주차',
material: 'screen',
width: 2350,
height: 2500,
quantity: 1,
box: 1,
assembly: 0,
coating: 0,
batting: 0,
mounting: 0,
fitting: 0,
controller: 0,
widthConstruction: 0,
heightConstruction: 0,
materialCost: 1420000,
laborCost: 510000,
quantityPrice: 1930000,
expenseQuantity: 5500,
expenseTotal: 5500,
totalCost: 1930000,
otherCost: 0,
marginCost: 0,
totalPrice: 1930000,
unitPrice: 1420000,
expense: 0,
marginRate: 0,
unitQuantity: 1,
expenseResult: 0,
marginActual: 0,
},
{
id: '2',
no: 2,
name: 'FS530외/주차',
material: 'screen',
width: 7500,
height: 2500,
quantity: 1,
box: 1,
assembly: 0,
coating: 0,
batting: 0,
mounting: 0,
fitting: 0,
controller: 0,
widthConstruction: 0,
heightConstruction: 0,
materialCost: 4720000,
laborCost: 780000,
quantityPrice: 5500000,
expenseQuantity: 5500,
expenseTotal: 5500,
totalCost: 5500000,
otherCost: 0,
marginCost: 0,
totalPrice: 5500000,
unitPrice: 4720000,
expense: 0,
marginRate: 0,
unitQuantity: 1,
expenseResult: 0,
marginActual: 0,
},
],
approval: {
approvers: [],
references: [],
},
};
return mockData;
}
export default async function EstimateDetailPage({ params }: EstimateDetailPageProps) {
const { id } = await params;
const detail = await getEstimateDetail(id);
return (
<EstimateDetailForm
mode="view"
estimateId={id}
initialData={detail || undefined}
/>
);
}

View File

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

View File

@@ -0,0 +1,19 @@
import ContractDetailForm from '@/components/business/juil/contract/ContractDetailForm';
import { getContractDetail } from '@/components/business/juil/contract';
interface ContractEditPageProps {
params: Promise<{ id: string }>;
}
export default async function ContractEditPage({ params }: ContractEditPageProps) {
const { id } = await params;
const result = await getContractDetail(id);
return (
<ContractDetailForm
mode="edit"
contractId={id}
initialData={result.success ? result.data : undefined}
/>
);
}

View File

@@ -0,0 +1,19 @@
import ContractDetailForm from '@/components/business/juil/contract/ContractDetailForm';
import { getContractDetail } from '@/components/business/juil/contract';
interface ContractDetailPageProps {
params: Promise<{ id: string }>;
}
export default async function ContractDetailPage({ params }: ContractDetailPageProps) {
const { id } = await params;
const result = await getContractDetail(id);
return (
<ContractDetailForm
mode="view"
contractId={id}
initialData={result.success ? result.data : undefined}
/>
);
}

View File

@@ -0,0 +1,23 @@
import { HandoverReportDetailForm, getHandoverReportDetail } from '@/components/business/juil/handover-report';
interface HandoverReportEditPageProps {
params: Promise<{
id: string;
locale: string;
}>;
}
export default async function HandoverReportEditPage({ params }: HandoverReportEditPageProps) {
const { id } = await params;
// 서버에서 상세 데이터 조회
const result = await getHandoverReportDetail(id);
return (
<HandoverReportDetailForm
mode="edit"
reportId={id}
initialData={result.data}
/>
);
}

View File

@@ -0,0 +1,23 @@
import { HandoverReportDetailForm, getHandoverReportDetail } from '@/components/business/juil/handover-report';
interface HandoverReportDetailPageProps {
params: Promise<{
id: string;
locale: string;
}>;
}
export default async function HandoverReportDetailPage({ params }: HandoverReportDetailPageProps) {
const { id } = await params;
// 서버에서 상세 데이터 조회
const result = await getHandoverReportDetail(id);
return (
<HandoverReportDetailForm
mode="view"
reportId={id}
initialData={result.data}
/>
);
}

View File

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

View File

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

View File

@@ -453,4 +453,93 @@ html {
[data-slot="sheet-overlay"][data-state="closed"] {
animation: fadeOut 200ms ease-out forwards;
}
/* ==========================================
Print Styles - 인쇄 시 문서만 출력
========================================== */
@media print {
/* A4 용지 설정 */
@page {
size: A4 portrait;
margin: 10mm;
}
/* 배경색 유지 */
* {
-webkit-print-color-adjust: exact !important;
print-color-adjust: exact !important;
color-adjust: exact !important;
}
/* ========================================
인쇄 스타일 (JavaScript printArea 사용 시 기본값)
======================================== */
/* 기본 설정 - printArea 유틸리티가 새 창에서 인쇄하므로 간단하게 유지 */
html, body {
background: white !important;
}
/* print-hidden 클래스 숨김 */
.print-hidden {
display: none !important;
}
/* ========================================
테이블 & 페이지 설정
======================================== */
/* 페이지 나눔 방지 */
table, figure, .page-break-avoid {
page-break-inside: avoid;
}
/* 인쇄용 테이블 스타일 */
.print-area table {
border-collapse: collapse !important;
}
.print-area th,
.print-area td {
border: 1px solid #000 !important;
}
/* print-area 내부 문서 wrapper - transform 제거 */
.print-area > div {
max-width: none !important;
width: 100% !important;
margin: 0 !important;
padding: 0 !important;
box-shadow: none !important;
transform: none !important;
}
/* 실제 문서 컨테이너 - A4에 맞게 조정 */
.print-area > div > div {
width: 100% !important;
max-width: 190mm !important;
min-height: auto !important;
margin: 0 auto !important;
padding: 5mm !important;
box-shadow: none !important;
font-size: 10pt !important;
}
/* 테이블 폰트 크기 축소 */
.print-area table {
font-size: 9pt !important;
}
.print-area .text-xs {
font-size: 8pt !important;
}
.print-area .text-sm {
font-size: 9pt !important;
}
.print-area .text-3xl {
font-size: 18pt !important;
}
}

View File

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

View File

@@ -30,6 +30,7 @@ import {
import { ProposalDocument } from './ProposalDocument';
import { ExpenseReportDocument } from './ExpenseReportDocument';
import { ExpenseEstimateDocument } from './ExpenseEstimateDocument';
import { printArea } from '@/lib/print-utils';
import type {
DocumentType,
DocumentDetailModalProps,
@@ -68,7 +69,7 @@ export function DocumentDetailModal({
};
const handlePrint = () => {
window.print();
printArea({ title: `${getDocumentTitle()} 인쇄` });
};
const handleSharePdf = () => {
@@ -107,8 +108,8 @@ export function DocumentDetailModal({
<DialogTitle>{getDocumentTitle()} </DialogTitle>
</VisuallyHidden>
{/* 헤더 영역 - 고정 */}
<div className="flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
{/* 헤더 영역 - 고정 (인쇄 시 숨김) */}
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
<h2 className="text-lg font-semibold">{getDocumentTitle()} </h2>
<Button
variant="ghost"
@@ -120,8 +121,8 @@ export function DocumentDetailModal({
</Button>
</div>
{/* 버튼 영역 - 고정 */}
<div className="flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
{/* 버튼 영역 - 고정 (인쇄 시 숨김) */}
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
{/* 기안함 모드 + 임시저장 상태: 복제, 상신, 인쇄 */}
{mode === 'draft' && documentStatus === 'draft' && (
<>
@@ -191,8 +192,8 @@ export function DocumentDetailModal({
</DropdownMenu> */}
</div>
{/* 문서 영역 - 스크롤 */}
<div className="flex-1 overflow-y-auto bg-gray-100 p-6">
{/* 문서 영역 - 스크롤 (인쇄 시 이 영역만 출력) */}
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg">
{renderDocument()}
</div>

View File

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

View File

@@ -0,0 +1,574 @@
'use client';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, Clock, Trophy, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { Bidding, BiddingStats } from './types';
import {
BIDDING_STATUS_OPTIONS,
BIDDING_SORT_OPTIONS,
BIDDING_STATUS_STYLES,
BIDDING_STATUS_LABELS,
} from './types';
import { getBiddingList, getBiddingStats, deleteBidding, deleteBiddings } from './actions';
// 테이블 컬럼 정의 (체크박스, 번호, 입찰번호, 거래처, 현장명, 입찰자, 총 개소, 입찰금액, 입찰일, 투찰일, 확정일, 상태, 비고, 작업)
const tableColumns: TableColumn[] = [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'biddingCode', label: '입찰번호', className: 'w-[120px]' },
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
{ key: 'projectName', label: '현장명', className: 'min-w-[120px]' },
{ key: 'bidderName', label: '입찰자', className: 'w-[80px]' },
{ key: 'totalCount', label: '총 개소', className: 'w-[80px] text-center' },
{ key: 'biddingAmount', label: '입찰금액', className: 'w-[120px] text-right' },
{ key: 'bidDate', label: '입찰일', className: 'w-[100px] text-center' },
{ key: 'submissionDate', label: '투찰일', className: 'w-[100px] text-center' },
{ key: 'confirmDate', label: '확정일', className: 'w-[100px] text-center' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'remarks', label: '비고', className: 'w-[120px]' },
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
];
// 목업 거래처 목록 (다중선택용 - 빈 배열 = 전체)
const MOCK_PARTNERS: MultiSelectOption[] = [
{ value: '1', label: '이사대표' },
{ value: '2', label: '야사건설' },
{ value: '3', label: '여의건설' },
];
// 목업 입찰자 목록 (다중선택용 - 빈 배열 = 전체)
const MOCK_BIDDERS: MultiSelectOption[] = [
{ value: 'hong', label: '홍길동' },
{ value: 'kim', label: '김철수' },
{ value: 'lee', label: '이영희' },
];
// 금액 포맷팅
function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
}
// 날짜 포맷팅
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '');
}
interface BiddingListClientProps {
initialData?: Bidding[];
initialStats?: BiddingStats;
}
export default function BiddingListClient({ initialData = [], initialStats }: BiddingListClientProps) {
const router = useRouter();
// 상태
const [biddings, setBiddings] = useState<Bidding[]>(initialData);
const [stats, setStats] = useState<BiddingStats | null>(initialStats || null);
const [searchValue, setSearchValue] = useState('');
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
const [bidderFilters, setBidderFilters] = useState<string[]>([]);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState<string>('biddingDateDesc');
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
const [activeStatTab, setActiveStatTab] = useState<'all' | 'waiting' | 'awarded'>('all');
const itemsPerPage = 20;
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [listResult, statsResult] = await Promise.all([
getBiddingList({
size: 1000,
startDate: startDate || undefined,
endDate: endDate || undefined,
}),
getBiddingStats(),
]);
if (listResult.success && listResult.data) {
setBiddings(listResult.data.items);
}
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
} catch {
toast.error('데이터 로드에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [startDate, endDate]);
// 초기 데이터가 없으면 로드
useEffect(() => {
if (initialData.length === 0) {
loadData();
}
}, [initialData.length, loadData]);
// 필터링된 데이터
const filteredBiddings = useMemo(() => {
return biddings.filter((bidding) => {
// 상태 탭 필터
if (activeStatTab === 'waiting' && bidding.status !== 'waiting') return false;
if (activeStatTab === 'awarded' && bidding.status !== 'awarded') return false;
// 거래처 필터 (다중선택 - 빈 배열 = 전체)
if (partnerFilters.length > 0) {
if (!partnerFilters.includes(bidding.partnerId)) return false;
}
// 입찰자 필터 (다중선택 - 빈 배열 = 전체)
if (bidderFilters.length > 0) {
if (!bidderFilters.includes(bidding.bidderId)) return false;
}
// 상태 필터
if (statusFilter !== 'all' && bidding.status !== statusFilter) return false;
// 검색 필터
if (searchValue) {
const search = searchValue.toLowerCase();
return (
bidding.projectName.toLowerCase().includes(search) ||
bidding.biddingCode.toLowerCase().includes(search) ||
bidding.partnerName.toLowerCase().includes(search)
);
}
return true;
});
}, [biddings, activeStatTab, partnerFilters, bidderFilters, statusFilter, searchValue]);
// 정렬
const sortedBiddings = useMemo(() => {
const sorted = [...filteredBiddings];
switch (sortBy) {
case 'biddingDateDesc':
sorted.sort((a, b) => {
if (!a.biddingDate) return 1;
if (!b.biddingDate) return -1;
return new Date(b.biddingDate).getTime() - new Date(a.biddingDate).getTime();
});
break;
case 'biddingDateAsc':
sorted.sort((a, b) => {
if (!a.biddingDate) return 1;
if (!b.biddingDate) return -1;
return new Date(a.biddingDate).getTime() - new Date(b.biddingDate).getTime();
});
break;
case 'submissionDateDesc':
sorted.sort((a, b) => {
if (!a.submissionDate) return 1;
if (!b.submissionDate) return -1;
return new Date(b.submissionDate).getTime() - new Date(a.submissionDate).getTime();
});
break;
case 'confirmDateDesc':
sorted.sort((a, b) => {
if (!a.confirmDate) return 1;
if (!b.confirmDate) return -1;
return new Date(b.confirmDate).getTime() - new Date(a.confirmDate).getTime();
});
break;
case 'partnerNameAsc':
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
break;
case 'partnerNameDesc':
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
break;
case 'projectNameAsc':
sorted.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
break;
case 'projectNameDesc':
sorted.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
break;
}
return sorted;
}, [filteredBiddings, sortBy]);
// 페이지네이션
const totalPages = Math.ceil(sortedBiddings.length / itemsPerPage);
const paginatedData = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return sortedBiddings.slice(start, start + itemsPerPage);
}, [sortedBiddings, currentPage, itemsPerPage]);
// 핸들러
const handleSearchChange = useCallback((value: string) => {
setSearchValue(value);
setCurrentPage(1);
}, []);
const handleToggleSelection = useCallback((id: string) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const handleToggleSelectAll = useCallback(() => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((b) => b.id)));
}
}, [selectedItems.size, paginatedData]);
const handleRowClick = useCallback(
(bidding: Bidding) => {
router.push(`/ko/juil/project/bidding/${bidding.id}`);
},
[router]
);
const handleEdit = useCallback(
(e: React.MouseEvent, biddingId: string) => {
e.stopPropagation();
router.push(`/ko/juil/project/bidding/${biddingId}/edit`);
},
[router]
);
const handleDeleteClick = useCallback((e: React.MouseEvent, biddingId: string) => {
e.stopPropagation();
setDeleteTargetId(biddingId);
setDeleteDialogOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTargetId) return;
setIsLoading(true);
try {
const result = await deleteBidding(deleteTargetId);
if (result.success) {
toast.success('입찰이 삭제되었습니다.');
setBiddings((prev) => prev.filter((b) => b.id !== deleteTargetId));
setSelectedItems((prev) => {
const newSet = new Set(prev);
newSet.delete(deleteTargetId);
return newSet;
});
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setDeleteDialogOpen(false);
setDeleteTargetId(null);
}
}, [deleteTargetId]);
const handleBulkDeleteClick = useCallback(() => {
if (selectedItems.size === 0) {
toast.warning('삭제할 항목을 선택해주세요.');
return;
}
setBulkDeleteDialogOpen(true);
}, [selectedItems.size]);
const handleBulkDeleteConfirm = useCallback(async () => {
if (selectedItems.size === 0) return;
setIsLoading(true);
try {
const ids = Array.from(selectedItems);
const result = await deleteBiddings(ids);
if (result.success) {
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
await loadData();
setSelectedItems(new Set());
} else {
toast.error(result.error || '일괄 삭제에 실패했습니다.');
}
} catch {
toast.error('일괄 삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setBulkDeleteDialogOpen(false);
}
}, [selectedItems, loadData]);
// 테이블 행 렌더링
const renderTableRow = useCallback(
(bidding: Bidding, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(bidding.id);
return (
<TableRow
key={bidding.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(bidding)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelection(bidding.id)}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell>{bidding.biddingCode}</TableCell>
<TableCell>{bidding.partnerName}</TableCell>
<TableCell>{bidding.projectName}</TableCell>
<TableCell>{bidding.bidderName}</TableCell>
<TableCell className="text-center">{bidding.totalCount}</TableCell>
<TableCell className="text-right">{formatAmount(bidding.biddingAmount)}</TableCell>
<TableCell className="text-center">{formatDate(bidding.bidDate)}</TableCell>
<TableCell className="text-center">{formatDate(bidding.submissionDate)}</TableCell>
<TableCell className="text-center">{formatDate(bidding.confirmDate)}</TableCell>
<TableCell className="text-center">
<span className={BIDDING_STATUS_STYLES[bidding.status]}>
{BIDDING_STATUS_LABELS[bidding.status]}
</span>
</TableCell>
<TableCell className="truncate max-w-[120px]" title={bidding.remarks}>
{bidding.remarks || '-'}
</TableCell>
<TableCell className="text-center">
{isSelected && (
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => handleEdit(e, bidding.id)}
>
<Pencil className="h-4 w-4" />
</Button>
)}
</TableCell>
</TableRow>
);
},
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
);
// 모바일 카드 렌더링
const renderMobileCard = useCallback(
(bidding: Bidding, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
return (
<MobileCard
title={bidding.projectName}
subtitle={bidding.biddingCode}
badge={BIDDING_STATUS_LABELS[bidding.status]}
badgeVariant="secondary"
isSelected={isSelected}
onToggle={onToggle}
onClick={() => handleRowClick(bidding)}
details={[
{ label: '거래처', value: bidding.partnerName },
{ label: '입찰금액', value: `${formatAmount(bidding.biddingAmount)}` },
{ label: '입찰일자', value: formatDate(bidding.biddingDate) },
{ label: '총 개소', value: `${bidding.totalCount}` },
]}
/>
);
},
[handleRowClick]
);
// 헤더 액션 (날짜 필터) - 등록 버튼 없음 (견적완료 시 자동 등록)
const headerActions = (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
);
// Stats 카드 데이터 (전체 입찰, 입찰대기, 낙찰)
const statsCardsData: StatCard[] = [
{
label: '전체 입찰',
value: stats?.total ?? 0,
icon: FileText,
iconColor: 'text-blue-600',
onClick: () => setActiveStatTab('all'),
isActive: activeStatTab === 'all',
},
{
label: '입찰대기',
value: stats?.waiting ?? 0,
icon: Clock,
iconColor: 'text-orange-500',
onClick: () => setActiveStatTab('waiting'),
isActive: activeStatTab === 'waiting',
},
{
label: '낙찰',
value: stats?.awarded ?? 0,
icon: Trophy,
iconColor: 'text-green-600',
onClick: () => setActiveStatTab('awarded'),
isActive: activeStatTab === 'awarded',
},
];
// 테이블 헤더 액션 (총 건수 + 필터 4개: 거래처, 입찰자, 상태, 정렬)
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{sortedBiddings.length}
</span>
{/* 거래처 필터 (다중선택) */}
<MultiSelectCombobox
options={MOCK_PARTNERS}
value={partnerFilters}
onChange={setPartnerFilters}
placeholder="거래처"
searchPlaceholder="거래처 검색..."
className="w-[120px]"
/>
{/* 입찰자 필터 (다중선택) */}
<MultiSelectCombobox
options={MOCK_BIDDERS}
value={bidderFilters}
onChange={setBidderFilters}
placeholder="입찰자"
searchPlaceholder="입찰자 검색..."
className="w-[120px]"
/>
{/* 상태 필터 */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{BIDDING_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="최신순 (입찰일)" />
</SelectTrigger>
<SelectContent>
{BIDDING_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
return (
<>
<IntegratedListTemplateV2
title="입찰관리"
description="입찰을 관리합니다 (견적완료 시 자동 등록)"
icon={FileText}
headerActions={headerActions}
stats={statsCardsData}
tableHeaderActions={tableHeaderActions}
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="입찰번호, 거래처, 현장명 검색"
tableColumns={tableColumns}
data={paginatedData}
allData={sortedBiddings}
getItemId={(item) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
selectedItems={selectedItems}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
onBulkDelete={handleBulkDeleteClick}
pagination={{
currentPage,
totalPages,
totalItems: sortedBiddings.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
{/* 단일 삭제 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 일괄 삭제 다이얼로그 */}
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleBulkDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,574 @@
'use server';
import type {
Bidding,
BiddingStats,
BiddingListResponse,
BiddingFilter,
BiddingDetail,
BiddingDetailFormData,
ExpenseItem,
EstimateDetailItem,
} from './types';
// 목업 데이터
const MOCK_BIDDINGS: Bidding[] = [
{
id: '1',
biddingCode: 'BID-2025-001',
partnerId: '1',
partnerName: '이사대표',
projectName: '광장 아파트',
biddingDate: '2025-01-25',
totalCount: 15,
biddingAmount: 71000000,
bidDate: '2025-01-20',
submissionDate: '2025-01-22',
confirmDate: '2025-01-25',
status: 'awarded',
bidderId: 'hong',
bidderName: '홍길동',
remarks: '',
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
createdBy: 'system',
estimateId: '1',
estimateCode: 'EST-2025-001',
},
{
id: '2',
biddingCode: 'BID-2025-002',
partnerId: '2',
partnerName: '야사건설',
projectName: '대림아파트',
biddingDate: '2025-01-20',
totalCount: 22,
biddingAmount: 100000000,
bidDate: '2025-01-18',
submissionDate: null,
confirmDate: null,
status: 'waiting',
bidderId: 'kim',
bidderName: '김철수',
remarks: '',
createdAt: '2025-01-02',
updatedAt: '2025-01-02',
createdBy: 'system',
estimateId: '2',
estimateCode: 'EST-2025-002',
},
{
id: '3',
biddingCode: 'BID-2025-003',
partnerId: '3',
partnerName: '여의건설',
projectName: '현장아파트',
biddingDate: '2025-01-18',
totalCount: 18,
biddingAmount: 85000000,
bidDate: '2025-01-15',
submissionDate: '2025-01-16',
confirmDate: '2025-01-18',
status: 'awarded',
bidderId: 'hong',
bidderName: '홍길동',
remarks: '',
createdAt: '2025-01-03',
updatedAt: '2025-01-03',
createdBy: 'system',
estimateId: '3',
estimateCode: 'EST-2025-003',
},
{
id: '4',
biddingCode: 'BID-2025-004',
partnerId: '1',
partnerName: '이사대표',
projectName: '송파타워',
biddingDate: '2025-01-15',
totalCount: 30,
biddingAmount: 120000000,
bidDate: '2025-01-12',
submissionDate: '2025-01-13',
confirmDate: '2025-01-15',
status: 'failed',
bidderId: 'lee',
bidderName: '이영희',
remarks: '가격 경쟁력 부족',
createdAt: '2025-01-04',
updatedAt: '2025-01-04',
createdBy: 'system',
estimateId: '4',
estimateCode: 'EST-2025-004',
},
{
id: '5',
biddingCode: 'BID-2025-005',
partnerId: '2',
partnerName: '야사건설',
projectName: '강남센터',
biddingDate: '2025-01-12',
totalCount: 25,
biddingAmount: 95000000,
bidDate: '2025-01-10',
submissionDate: '2025-01-11',
confirmDate: null,
status: 'submitted',
bidderId: 'hong',
bidderName: '홍길동',
remarks: '',
createdAt: '2025-01-05',
updatedAt: '2025-01-05',
createdBy: 'system',
estimateId: '5',
estimateCode: 'EST-2025-005',
},
{
id: '6',
biddingCode: 'BID-2025-006',
partnerId: '3',
partnerName: '여의건설',
projectName: '목동센터',
biddingDate: '2025-01-10',
totalCount: 12,
biddingAmount: 78000000,
bidDate: '2025-01-08',
submissionDate: '2025-01-09',
confirmDate: '2025-01-10',
status: 'invalid',
bidderId: 'kim',
bidderName: '김철수',
remarks: '입찰 조건 미충족',
createdAt: '2025-01-06',
updatedAt: '2025-01-06',
createdBy: 'system',
estimateId: '6',
estimateCode: 'EST-2025-006',
},
{
id: '7',
biddingCode: 'BID-2025-007',
partnerId: '1',
partnerName: '이사대표',
projectName: '서초타워',
biddingDate: '2025-01-08',
totalCount: 35,
biddingAmount: 150000000,
bidDate: '2025-01-05',
submissionDate: null,
confirmDate: null,
status: 'waiting',
bidderId: 'lee',
bidderName: '이영희',
remarks: '',
createdAt: '2025-01-07',
updatedAt: '2025-01-07',
createdBy: 'system',
estimateId: '7',
estimateCode: 'EST-2025-007',
},
{
id: '8',
biddingCode: 'BID-2025-008',
partnerId: '2',
partnerName: '야사건설',
projectName: '청담프로젝트',
biddingDate: '2025-01-05',
totalCount: 40,
biddingAmount: 200000000,
bidDate: '2025-01-03',
submissionDate: '2025-01-04',
confirmDate: '2025-01-05',
status: 'awarded',
bidderId: 'hong',
bidderName: '홍길동',
remarks: '',
createdAt: '2025-01-08',
updatedAt: '2025-01-08',
createdBy: 'system',
estimateId: '8',
estimateCode: 'EST-2025-008',
},
{
id: '9',
biddingCode: 'BID-2025-009',
partnerId: '3',
partnerName: '여의건설',
projectName: '잠실센터',
biddingDate: '2025-01-03',
totalCount: 20,
biddingAmount: 88000000,
bidDate: '2025-01-01',
submissionDate: null,
confirmDate: null,
status: 'hold',
bidderId: 'kim',
bidderName: '김철수',
remarks: '검토 대기 중',
createdAt: '2025-01-09',
updatedAt: '2025-01-09',
createdBy: 'system',
estimateId: '9',
estimateCode: 'EST-2025-009',
},
{
id: '10',
biddingCode: 'BID-2025-010',
partnerId: '1',
partnerName: '이사대표',
projectName: '역삼빌딩',
biddingDate: '2025-01-01',
totalCount: 10,
biddingAmount: 65000000,
bidDate: '2024-12-28',
submissionDate: null,
confirmDate: null,
status: 'waiting',
bidderId: 'lee',
bidderName: '이영희',
remarks: '',
createdAt: '2025-01-10',
updatedAt: '2025-01-10',
createdBy: 'system',
estimateId: '10',
estimateCode: 'EST-2025-010',
},
];
// 입찰 목록 조회
export async function getBiddingList(filter?: BiddingFilter): Promise<{
success: boolean;
data?: BiddingListResponse;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 300));
let filteredData = [...MOCK_BIDDINGS];
// 검색 필터
if (filter?.search) {
const search = filter.search.toLowerCase();
filteredData = filteredData.filter(
(item) =>
item.biddingCode.toLowerCase().includes(search) ||
item.partnerName.toLowerCase().includes(search) ||
item.projectName.toLowerCase().includes(search)
);
}
// 상태 필터
if (filter?.status && filter.status !== 'all') {
filteredData = filteredData.filter((item) => item.status === filter.status);
}
// 거래처 필터
if (filter?.partnerId && filter.partnerId !== 'all') {
filteredData = filteredData.filter((item) => item.partnerId === filter.partnerId);
}
// 입찰자 필터
if (filter?.bidderId && filter.bidderId !== 'all') {
filteredData = filteredData.filter((item) => item.bidderId === filter.bidderId);
}
// 날짜 필터
if (filter?.startDate) {
filteredData = filteredData.filter(
(item) => item.biddingDate && item.biddingDate >= filter.startDate!
);
}
if (filter?.endDate) {
filteredData = filteredData.filter(
(item) => item.biddingDate && item.biddingDate <= filter.endDate!
);
}
// 정렬
const sortBy = filter?.sortBy || 'biddingDateDesc';
switch (sortBy) {
case 'biddingDateDesc':
filteredData.sort((a, b) => {
if (!a.biddingDate) return 1;
if (!b.biddingDate) return -1;
return new Date(b.biddingDate).getTime() - new Date(a.biddingDate).getTime();
});
break;
case 'biddingDateAsc':
filteredData.sort((a, b) => {
if (!a.biddingDate) return 1;
if (!b.biddingDate) return -1;
return new Date(a.biddingDate).getTime() - new Date(b.biddingDate).getTime();
});
break;
case 'submissionDateDesc':
filteredData.sort((a, b) => {
if (!a.submissionDate) return 1;
if (!b.submissionDate) return -1;
return new Date(b.submissionDate).getTime() - new Date(a.submissionDate).getTime();
});
break;
case 'confirmDateDesc':
filteredData.sort((a, b) => {
if (!a.confirmDate) return 1;
if (!b.confirmDate) return -1;
return new Date(b.confirmDate).getTime() - new Date(a.confirmDate).getTime();
});
break;
case 'partnerNameAsc':
filteredData.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
break;
case 'partnerNameDesc':
filteredData.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
break;
case 'projectNameAsc':
filteredData.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
break;
case 'projectNameDesc':
filteredData.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
break;
}
// 페이지네이션
const page = filter?.page || 1;
const size = filter?.size || 20;
const startIndex = (page - 1) * size;
const paginatedData = filteredData.slice(startIndex, startIndex + size);
return {
success: true,
data: {
items: paginatedData,
total: filteredData.length,
page,
size,
totalPages: Math.ceil(filteredData.length / size),
},
};
} catch (error) {
console.error('getBiddingList error:', error);
return { success: false, error: '입찰 목록을 불러오는데 실패했습니다.' };
}
}
// 입찰 통계 조회
export async function getBiddingStats(): Promise<{
success: boolean;
data?: BiddingStats;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 100));
const stats: BiddingStats = {
total: MOCK_BIDDINGS.length,
waiting: MOCK_BIDDINGS.filter((b) => b.status === 'waiting').length,
awarded: MOCK_BIDDINGS.filter((b) => b.status === 'awarded').length,
};
return { success: true, data: stats };
} catch (error) {
console.error('getBiddingStats error:', error);
return { success: false, error: '통계를 불러오는데 실패했습니다.' };
}
}
// 입찰 단건 조회
export async function getBidding(id: string): Promise<{
success: boolean;
data?: Bidding;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 200));
const bidding = MOCK_BIDDINGS.find((b) => b.id === id);
if (!bidding) {
return { success: false, error: '입찰 정보를 찾을 수 없습니다.' };
}
return { success: true, data: bidding };
} catch (error) {
console.error('getBidding error:', error);
return { success: false, error: '입찰 정보를 불러오는데 실패했습니다.' };
}
}
// 입찰 삭제
export async function deleteBidding(id: string): Promise<{
success: boolean;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 300));
const index = MOCK_BIDDINGS.findIndex((b) => b.id === id);
if (index === -1) {
return { success: false, error: '입찰 정보를 찾을 수 없습니다.' };
}
return { success: true };
} catch (error) {
console.error('deleteBidding error:', error);
return { success: false, error: '입찰 삭제에 실패했습니다.' };
}
}
// 입찰 일괄 삭제
export async function deleteBiddings(ids: string[]): Promise<{
success: boolean;
deletedCount?: number;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 500));
return { success: true, deletedCount: ids.length };
} catch (error) {
console.error('deleteBiddings error:', error);
return { success: false, error: '일괄 삭제에 실패했습니다.' };
}
}
// 공과 상세 목업 데이터
const MOCK_EXPENSE_ITEMS: ExpenseItem[] = [
{ id: '1', name: '설계비', amount: 5000000 },
{ id: '2', name: '운반비', amount: 3000000 },
{ id: '3', name: '기타경비', amount: 2000000 },
];
// 견적 상세 목업 데이터
const MOCK_ESTIMATE_DETAIL_ITEMS: EstimateDetailItem[] = [
{
id: '1',
no: 1,
name: '방화문',
material: 'SUS304',
width: 1000,
height: 2100,
quantity: 10,
box: 2,
coating: 1,
batting: 0,
mounting: 1,
shift: 0,
painting: 1,
motor: 0,
controller: 0,
unitPrice: 1500000,
expense: 100000,
expenseQuantity: 10,
totalPrice: 16000000,
marginRate: 15,
marginCost: 2400000,
progressPayment: 8000000,
execution: 13600000,
},
{
id: '2',
no: 2,
name: '자동문',
material: 'AL',
width: 1800,
height: 2400,
quantity: 5,
box: 1,
coating: 1,
batting: 1,
mounting: 1,
shift: 1,
painting: 0,
motor: 1,
controller: 1,
unitPrice: 3500000,
expense: 200000,
expenseQuantity: 5,
totalPrice: 18500000,
marginRate: 18,
marginCost: 3330000,
progressPayment: 9250000,
execution: 15170000,
},
{
id: '3',
no: 3,
name: '셔터',
material: 'STEEL',
width: 3000,
height: 3500,
quantity: 3,
box: 1,
coating: 1,
batting: 0,
mounting: 1,
shift: 0,
painting: 1,
motor: 1,
controller: 1,
unitPrice: 8000000,
expense: 500000,
expenseQuantity: 3,
totalPrice: 25500000,
marginRate: 20,
marginCost: 5100000,
progressPayment: 12750000,
execution: 20400000,
},
];
// 입찰 상세 조회
export async function getBiddingDetail(id: string): Promise<{
success: boolean;
data?: BiddingDetail;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 300));
const bidding = MOCK_BIDDINGS.find((b) => b.id === id);
if (!bidding) {
return { success: false, error: '입찰 정보를 찾을 수 없습니다.' };
}
// 상세 데이터 생성
const biddingDetail: BiddingDetail = {
...bidding,
constructionStartDate: '2025-02-01',
constructionEndDate: '2025-04-30',
vatType: 'excluded',
expenseItems: MOCK_EXPENSE_ITEMS,
estimateDetailItems: MOCK_ESTIMATE_DETAIL_ITEMS,
};
return { success: true, data: biddingDetail };
} catch (error) {
console.error('getBiddingDetail error:', error);
return { success: false, error: '입찰 상세를 불러오는데 실패했습니다.' };
}
}
// 입찰 수정
export async function updateBidding(
id: string,
data: Partial<BiddingDetailFormData>
): Promise<{
success: boolean;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 500));
const index = MOCK_BIDDINGS.findIndex((b) => b.id === id);
if (index === -1) {
return { success: false, error: '입찰 정보를 찾을 수 없습니다.' };
}
// 목업에서는 실제 업데이트하지 않음
console.log('Updating bidding:', id, data);
return { success: true };
} catch (error) {
console.error('updateBidding error:', error);
return { success: false, error: '입찰 수정에 실패했습니다.' };
}
}

View File

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

View File

@@ -0,0 +1,263 @@
/**
* 주일 기업 - 입찰관리 타입 정의
*
* 입찰 데이터는 견적 상세에서 견적완료 시 자동 등록됨
* (별도 등록 기능 없음, 상세/수정만 가능)
*/
// 입찰 상태
export type BiddingStatus =
| 'waiting' // 입찰대기
| 'submitted' // 투찰
| 'failed' // 탈락
| 'invalid' // 유찰
| 'awarded' // 낙찰
| 'hold'; // 보류
// 입찰 타입
export interface Bidding {
id: string;
biddingCode: string; // 입찰번호
// 기본 정보
partnerId: string; // 거래처 ID
partnerName: string; // 거래처명
projectName: string; // 현장명
// 입찰 정보
biddingDate: string | null; // 입찰일자
totalCount: number; // 총 개소
biddingAmount: number; // 입찰금액
bidDate: string | null; // 입찰일
submissionDate: string | null; // 투찰일
confirmDate: string | null; // 확정일
// 상태 정보
status: BiddingStatus;
// 입찰자
bidderId: string;
bidderName: string;
// 비고
remarks: string;
// 메타 정보
createdAt: string;
updatedAt: string;
createdBy: string;
// 연결된 견적 정보 (견적완료 시 자동 연결)
estimateId: string;
estimateCode: string;
}
// 입찰 통계
export interface BiddingStats {
total: number; // 전체 입찰
waiting: number; // 입찰대기
awarded: number; // 낙찰
}
// 입찰 필터
export interface BiddingFilter {
search?: string;
status?: BiddingStatus | 'all';
partnerId?: string;
bidderId?: string;
startDate?: string;
endDate?: string;
sortBy?: string;
page?: number;
size?: number;
}
// API 응답 타입
export interface BiddingListResponse {
items: Bidding[];
total: number;
page: number;
size: number;
totalPages: number;
}
// 상태 옵션
export const BIDDING_STATUS_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'waiting', label: '입찰대기' },
{ value: 'submitted', label: '투찰' },
{ value: 'failed', label: '탈락' },
{ value: 'invalid', label: '유찰' },
{ value: 'awarded', label: '낙찰' },
{ value: 'hold', label: '보류' },
];
// 정렬 옵션
export const BIDDING_SORT_OPTIONS = [
{ value: 'biddingDateDesc', label: '최신순 (입찰일)' },
{ value: 'biddingDateAsc', label: '등록순 (입찰일)' },
{ value: 'submissionDateDesc', label: '투찰일 최신순' },
{ value: 'confirmDateDesc', label: '확정일 최신순' },
{ value: 'partnerNameAsc', label: '거래처명 오름차순' },
{ value: 'partnerNameDesc', label: '거래처명 내림차순' },
{ value: 'projectNameAsc', label: '현장명 오름차순' },
{ value: 'projectNameDesc', label: '현장명 내림차순' },
];
// 상태별 스타일
export const BIDDING_STATUS_STYLES: Record<BiddingStatus, string> = {
waiting: 'text-orange-500 font-medium',
submitted: 'text-blue-500 font-medium',
failed: 'text-red-500 font-medium',
invalid: 'text-gray-500 font-medium',
awarded: 'text-green-600 font-medium',
hold: 'text-gray-400 font-medium',
};
export const BIDDING_STATUS_LABELS: Record<BiddingStatus, string> = {
waiting: '입찰대기',
submitted: '투찰',
failed: '탈락',
invalid: '유찰',
awarded: '낙찰',
hold: '보류',
};
// =====================================================
// 입찰 상세 관련 타입
// =====================================================
// 공과 항목 (견적에서 가져옴)
export interface ExpenseItem {
id: string;
name: string; // 공과명
amount: number; // 금액
}
// 견적 상세 항목 (견적에서 가져옴 - estimates와 동일한 구조)
export interface EstimateDetailItem {
id: string;
no: number; // 번호
name: string; // 명칭
material: string; // 제품
width: number; // 가로 (M)
height: number; // 세로 (M)
quantity: number; // 수량
// 계산값들
weight: number; // 무게 = 면적*25
area: number; // 면적 = (가로×0.16)*(세로×0.5)
steelScreen: number; // 철제,스크린 = 면적*47500
caulking: number; // 코킹 = (세로*4)*단가
rail: number; // 레일 = (세로×0.2)*단가
bottom: number; // 하장 = 가로*단가
boxReinforce: number; // 박스+보강 = 가로*단가
shaft: number; // 샤프트 = 가로*단가
painting: number; // 도장 (셀렉트)
motor: number; // 모터 (셀렉트)
controller: number; // 제어기 (셀렉트)
widthConstruction: number; // 가로시공비
heightConstruction: number; // 세로시공비
unitPrice: number; // 단가
expenseRate: number; // 공과율
expense: number; // 공과
cost: number; // 원가
costExecution: number; // 원가실행
marginCost: number; // 마진원가
marginCostExecution: number; // 마진원가실행
expenseExecution: number; // 공과실행
}
// 입찰 상세 전체 데이터
export interface BiddingDetail extends Bidding {
// 공사기간
constructionStartDate: string;
constructionEndDate: string;
// 부가세
vatType: string;
// 공과 상세 (견적에서 가져옴 - 읽기 전용)
expenseItems: ExpenseItem[];
// 견적 상세 (견적에서 가져옴 - 읽기 전용)
estimateDetailItems: EstimateDetailItem[];
}
// 입찰 상세 폼 데이터 (수정용)
export interface BiddingDetailFormData {
// 입찰 정보
biddingCode: string;
bidderId: string;
bidderName: string;
partnerName: string;
projectName: string;
biddingDate: string;
totalCount: number;
constructionStartDate: string;
constructionEndDate: string;
vatType: string;
biddingAmount: number;
status: BiddingStatus;
submissionDate: string;
confirmDate: string;
remarks: string;
// 공과 상세 (읽기 전용)
expenseItems: ExpenseItem[];
// 견적 상세 (읽기 전용)
estimateDetailItems: EstimateDetailItem[];
}
// 부가세 옵션
export const VAT_TYPE_OPTIONS = [
{ value: 'included', label: '부가세 포함' },
{ value: 'excluded', label: '부가세 별도' },
];
// 빈 폼 데이터 생성
export function getEmptyBiddingDetailFormData(): BiddingDetailFormData {
return {
biddingCode: '',
bidderId: '',
bidderName: '',
partnerName: '',
projectName: '',
biddingDate: '',
totalCount: 0,
constructionStartDate: '',
constructionEndDate: '',
vatType: 'excluded',
biddingAmount: 0,
status: 'waiting',
submissionDate: '',
confirmDate: '',
remarks: '',
expenseItems: [],
estimateDetailItems: [],
};
}
// BiddingDetail을 FormData로 변환
export function biddingDetailToFormData(detail: BiddingDetail): BiddingDetailFormData {
return {
biddingCode: detail.biddingCode,
bidderId: detail.bidderId,
bidderName: detail.bidderName,
partnerName: detail.partnerName,
projectName: detail.projectName,
biddingDate: detail.biddingDate || '',
totalCount: detail.totalCount,
constructionStartDate: detail.constructionStartDate,
constructionEndDate: detail.constructionEndDate,
vatType: detail.vatType,
biddingAmount: detail.biddingAmount,
status: detail.status,
submissionDate: detail.submissionDate || '',
confirmDate: detail.confirmDate || '',
remarks: detail.remarks,
expenseItems: detail.expenseItems,
estimateDetailItems: detail.estimateDetailItems,
};
}

View File

@@ -0,0 +1,89 @@
'use client';
import { useState, useEffect } from 'react';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Button } from '@/components/ui/button';
import { Label } from '@/components/ui/label';
import { Loader2 } from 'lucide-react';
import type { CategoryDialogProps } from './types';
/**
* 카테고리 추가/수정 다이얼로그
*/
export function CategoryDialog({
isOpen,
onOpenChange,
mode,
category,
onSubmit,
isLoading = false,
}: CategoryDialogProps) {
const [name, setName] = useState('');
// 다이얼로그 열릴 때 초기값 설정
useEffect(() => {
if (isOpen) {
if (mode === 'edit' && category) {
setName(category.name);
} else {
setName('');
}
}
}, [isOpen, mode, category]);
const handleSubmit = (e: React.FormEvent) => {
e.preventDefault();
if (name.trim()) {
onSubmit(name.trim());
setName('');
}
};
const title = mode === 'add' ? '카테고리 추가' : '카테고리 수정';
const submitText = mode === 'add' ? '등록' : '수정';
return (
<Dialog open={isOpen} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle>{title}</DialogTitle>
</DialogHeader>
<form onSubmit={handleSubmit}>
<div className="space-y-4 py-4">
{/* 카테고리명 입력 */}
<div className="space-y-2">
<Label htmlFor="category-name"></Label>
<Input
id="category-name"
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="카테고리를 입력해주세요"
autoFocus
/>
</div>
</div>
<DialogFooter>
<Button type="button" variant="outline" onClick={() => onOpenChange(false)} disabled={isLoading}>
</Button>
<Button type="submit" disabled={!name.trim() || isLoading}>
{isLoading ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : null}
{submitText}
</Button>
</DialogFooter>
</form>
</DialogContent>
</Dialog>
);
}

View File

@@ -0,0 +1,145 @@
'use server';
import type { Category } from './types';
// ===== 목데이터 (추후 API 연동 시 교체) =====
let mockCategories: Category[] = [
{ id: '1', name: '슬라이드 OPEN 사이즈', order: 1, isDefault: true },
{ id: '2', name: '모터', order: 2, isDefault: true },
{ id: '3', name: '공정자재', order: 3, isDefault: true },
{ id: '4', name: '철물', order: 4, isDefault: true },
];
// 다음 ID 생성
let nextId = 5;
// ===== 카테고리 목록 조회 =====
export async function getCategories(): Promise<{
success: boolean;
data?: Category[];
error?: string;
}> {
try {
// 목데이터 반환 (순서대로 정렬)
const sortedCategories = [...mockCategories].sort((a, b) => a.order - b.order);
return { success: true, data: sortedCategories };
} catch (error) {
console.error('[getCategories] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 카테고리 생성 =====
export async function createCategory(data: {
name: string;
}): Promise<{
success: boolean;
data?: Category;
error?: string;
}> {
try {
const newCategory: Category = {
id: String(nextId++),
name: data.name,
order: mockCategories.length + 1,
isDefault: false,
};
mockCategories.push(newCategory);
return { success: true, data: newCategory };
} catch (error) {
console.error('[createCategory] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 카테고리 수정 =====
export async function updateCategory(
id: string,
data: { name?: string }
): Promise<{
success: boolean;
data?: Category;
error?: string;
}> {
try {
const index = mockCategories.findIndex(c => c.id === id);
if (index === -1) {
return { success: false, error: '카테고리를 찾을 수 없습니다.' };
}
mockCategories[index] = {
...mockCategories[index],
...data,
};
return { success: true, data: mockCategories[index] };
} catch (error) {
console.error('[updateCategory] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}
// ===== 카테고리 삭제 =====
export async function deleteCategory(id: string): Promise<{
success: boolean;
error?: string;
errorType?: 'IN_USE' | 'DEFAULT' | 'GENERAL';
}> {
try {
const category = mockCategories.find(c => c.id === id);
if (!category) {
return { success: false, error: '카테고리를 찾을 수 없습니다.', errorType: 'GENERAL' };
}
// 기본 카테고리는 삭제 불가
if (category.isDefault) {
return {
success: false,
error: '기본 카테고리는 삭제가 불가합니다.',
errorType: 'DEFAULT'
};
}
// TODO: 품목 사용 여부 체크 로직 (추후 API 연동 시)
// 현재는 목데이터이므로 사용 중인 품목이 없다고 가정
// const itemsUsingCategory = await checkItemsUsingCategory(id);
// if (itemsUsingCategory.length > 0) {
// return {
// success: false,
// error: `"${category.name}"을(를) 사용하고 있는 품목이 있습니다. 모두 변경 후 삭제가 가능합니다.`,
// errorType: 'IN_USE'
// };
// }
mockCategories = mockCategories.filter(c => c.id !== id);
return { success: true };
} catch (error) {
console.error('[deleteCategory] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.', errorType: 'GENERAL' };
}
}
// ===== 카테고리 순서 변경 =====
export async function reorderCategories(
items: { id: string; sort_order: number }[]
): Promise<{
success: boolean;
error?: string;
}> {
try {
// 순서 업데이트
items.forEach(item => {
const category = mockCategories.find(c => c.id === item.id);
if (category) {
category.order = item.sort_order;
}
});
return { success: true };
} catch (error) {
console.error('[reorderCategories] Error:', error);
return { success: false, error: '서버 오류가 발생했습니다.' };
}
}

View File

@@ -0,0 +1,372 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { FolderTree, Plus, GripVertical, Pencil, Trash2, Loader2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Card, CardContent } from '@/components/ui/card';
import { CategoryDialog } from './CategoryDialog';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { toast } from 'sonner';
import type { Category } from './types';
import {
getCategories,
createCategory,
updateCategory,
deleteCategory,
reorderCategories,
} from './actions';
export function CategoryManagement() {
// 카테고리 데이터
const [categories, setCategories] = useState<Category[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [isSubmitting, setIsSubmitting] = useState(false);
// 입력 필드
const [newCategoryName, setNewCategoryName] = useState('');
// 다이얼로그 상태
const [dialogOpen, setDialogOpen] = useState(false);
const [dialogMode, setDialogMode] = useState<'add' | 'edit'>('add');
const [selectedCategory, setSelectedCategory] = useState<Category | undefined>();
// 삭제 확인 다이얼로그
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [categoryToDelete, setCategoryToDelete] = useState<Category | null>(null);
// 드래그 상태
const [draggedItem, setDraggedItem] = useState<number | null>(null);
// 데이터 로드
const loadCategories = useCallback(async () => {
try {
setIsLoading(true);
const result = await getCategories();
if (result.success && result.data) {
setCategories(result.data);
} else {
toast.error(result.error || '카테고리 목록을 불러오는데 실패했습니다.');
}
} catch (error) {
console.error('카테고리 목록 조회 실패:', error);
toast.error('카테고리 목록을 불러오는데 실패했습니다.');
} finally {
setIsLoading(false);
}
}, []);
// 초기 데이터 로드
useEffect(() => {
loadCategories();
}, [loadCategories]);
// 카테고리 추가 (입력 필드에서 직접)
const handleQuickAdd = async () => {
if (!newCategoryName.trim() || isSubmitting) return;
try {
setIsSubmitting(true);
const result = await createCategory({ name: newCategoryName.trim() });
if (result.success && result.data) {
setCategories(prev => [...prev, result.data!]);
setNewCategoryName('');
toast.success('카테고리가 추가되었습니다.');
} else {
toast.error(result.error || '카테고리 추가에 실패했습니다.');
}
} catch (error) {
console.error('카테고리 추가 실패:', error);
toast.error('카테고리 추가에 실패했습니다.');
} finally {
setIsSubmitting(false);
}
};
// 카테고리 수정 다이얼로그 열기
const handleEdit = (category: Category) => {
setSelectedCategory(category);
setDialogMode('edit');
setDialogOpen(true);
};
// 카테고리 삭제 확인
const handleDelete = (category: Category) => {
setCategoryToDelete(category);
setDeleteDialogOpen(true);
};
// 삭제 실행
const confirmDelete = async () => {
if (!categoryToDelete || isSubmitting) return;
try {
setIsSubmitting(true);
const result = await deleteCategory(categoryToDelete.id);
if (result.success) {
setCategories(prev => prev.filter(c => c.id !== categoryToDelete.id));
toast.success('카테고리가 삭제되었습니다.');
} else {
// 삭제 실패 유형에 따른 메시지
toast.error(result.error || '카테고리 삭제에 실패했습니다.');
}
} catch (error) {
console.error('카테고리 삭제 실패:', error);
toast.error('카테고리 삭제에 실패했습니다.');
} finally {
setIsSubmitting(false);
setDeleteDialogOpen(false);
setCategoryToDelete(null);
}
};
// 다이얼로그 제출
const handleDialogSubmit = async (name: string) => {
if (dialogMode === 'edit' && selectedCategory) {
try {
setIsSubmitting(true);
const result = await updateCategory(selectedCategory.id, { name });
if (result.success) {
setCategories(prev => prev.map(c =>
c.id === selectedCategory.id ? { ...c, name } : c
));
toast.success('카테고리가 수정되었습니다.');
} else {
toast.error(result.error || '카테고리 수정에 실패했습니다.');
}
} catch (error) {
console.error('카테고리 수정 실패:', error);
toast.error('카테고리 수정에 실패했습니다.');
} finally {
setIsSubmitting(false);
}
}
setDialogOpen(false);
};
// 드래그 시작
const handleDragStart = (e: React.DragEvent, index: number) => {
setDraggedItem(index);
e.dataTransfer.effectAllowed = 'move';
};
// 드래그 종료 - 서버에 순서 저장
const handleDragEnd = async () => {
if (draggedItem === null) return;
setDraggedItem(null);
// 순서 변경 API 호출
try {
const items = categories.map((category, idx) => ({
id: category.id,
sort_order: idx + 1,
}));
const result = await reorderCategories(items);
if (result.success) {
toast.success('순서가 변경되었습니다.');
} else {
toast.error(result.error || '순서 변경에 실패했습니다.');
loadCategories();
}
} catch (error) {
console.error('순서 변경 실패:', error);
toast.error('순서 변경에 실패했습니다.');
// 실패시 원래 순서로 복구
loadCategories();
}
};
// 드래그 오버
const handleDragOver = (e: React.DragEvent, index: number) => {
e.preventDefault();
if (draggedItem === null || draggedItem === index) return;
const newCategories = [...categories];
const draggedCategory = newCategories[draggedItem];
newCategories.splice(draggedItem, 1);
newCategories.splice(index, 0, draggedCategory);
// 순서 업데이트 (로컬)
const reorderedCategories = newCategories.map((category, idx) => ({
...category,
order: idx + 1
}));
setCategories(reorderedCategories);
setDraggedItem(index);
};
// 키보드로 추가 (한글 IME 조합 중에는 무시)
const handleKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'Enter' && !e.nativeEvent.isComposing) {
handleQuickAdd();
}
};
return (
<PageLayout>
<PageHeader
title="카테고리관리"
description="카테고리를 등록하고 관리합니다. 드래그하여 순서를 변경할 수 있습니다."
icon={FolderTree}
/>
<div className="space-y-4">
{/* 카테고리 추가 입력 영역 */}
<Card>
<CardContent className="p-4">
<div className="flex gap-2">
<Input
value={newCategoryName}
onChange={(e) => setNewCategoryName(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="카테고리를 입력해주세요"
className="flex-1"
disabled={isSubmitting}
/>
<Button
onClick={handleQuickAdd}
disabled={!newCategoryName.trim() || isSubmitting}
>
{isSubmitting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : (
<Plus className="h-4 w-4 mr-2" />
)}
</Button>
</div>
</CardContent>
</Card>
{/* 카테고리 목록 */}
<Card>
<CardContent className="p-0">
{isLoading ? (
<div className="flex items-center justify-center py-8">
<Loader2 className="h-6 w-6 animate-spin text-muted-foreground" />
<span className="ml-2 text-muted-foreground"> ...</span>
</div>
) : (
<div className="divide-y">
{categories.map((category, index) => (
<div
key={category.id}
draggable
onDragStart={(e) => handleDragStart(e, index)}
onDragEnd={handleDragEnd}
onDragOver={(e) => handleDragOver(e, index)}
className={`flex items-center gap-3 px-4 py-3 hover:bg-muted/50 transition-colors cursor-move ${
draggedItem === index ? 'opacity-50 bg-muted' : ''
}`}
>
{/* 드래그 핸들 */}
<GripVertical className="h-5 w-5 text-muted-foreground flex-shrink-0" />
{/* 순서 번호 */}
<span className="text-sm text-muted-foreground w-8">
{index + 1}
</span>
{/* 카테고리명 */}
<span className="flex-1 font-medium">{category.name}</span>
{/* 액션 버튼 */}
<div className="flex gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleEdit(category)}
className="h-8 w-8 p-0"
disabled={isSubmitting}
>
<Pencil className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
<Button
variant="ghost"
size="sm"
onClick={() => handleDelete(category)}
className="h-8 w-8 p-0 text-destructive hover:text-destructive"
disabled={isSubmitting}
>
<Trash2 className="h-4 w-4" />
<span className="sr-only"></span>
</Button>
</div>
</div>
))}
{categories.length === 0 && (
<div className="px-4 py-8 text-center text-muted-foreground">
.
</div>
)}
</div>
)}
</CardContent>
</Card>
{/* 안내 문구 */}
<p className="text-sm text-muted-foreground">
.
</p>
</div>
{/* 수정 다이얼로그 */}
<CategoryDialog
isOpen={dialogOpen}
onOpenChange={setDialogOpen}
mode={dialogMode}
category={selectedCategory}
onSubmit={handleDialogSubmit}
isLoading={isSubmitting}
/>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
&quot;{categoryToDelete?.name}&quot; ?
{categoryToDelete?.isDefault && (
<>
<br />
<span className="text-destructive font-medium">
.
</span>
</>
)}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isSubmitting}></AlertDialogCancel>
<AlertDialogAction
onClick={confirmDelete}
disabled={isSubmitting || categoryToDelete?.isDefault}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
{isSubmitting ? (
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
) : null}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</PageLayout>
);
}

View File

@@ -0,0 +1,21 @@
/**
* 카테고리 타입 정의
*/
export interface Category {
id: string;
name: string;
order: number;
isDefault?: boolean;
isActive?: boolean;
createdAt?: string;
updatedAt?: string;
}
export interface CategoryDialogProps {
isOpen: boolean;
onOpenChange: (open: boolean) => void;
mode: 'add' | 'edit';
category?: Category;
onSubmit: (name: string) => void;
isLoading?: boolean;
}

View File

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

View File

@@ -0,0 +1,298 @@
'use client';
import { useState, useCallback } from 'react';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogFooter,
} from '@/components/ui/dialog';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import type { ElectronicApproval, ApprovalPerson } from '../types';
// 목업 부서 목록
const MOCK_DEPARTMENTS = [
{ value: 'sales', label: '영업부' },
{ value: 'production', label: '생산부' },
{ value: 'quality', label: '품질부' },
{ value: 'management', label: '경영지원부' },
];
// 목업 직책 목록
const MOCK_POSITIONS = [
{ value: 'staff', label: '사원' },
{ value: 'senior', label: '주임' },
{ value: 'assistant_manager', label: '대리' },
{ value: 'manager', label: '과장' },
{ value: 'deputy_manager', label: '차장' },
{ value: 'general_manager', label: '부장' },
{ value: 'director', label: '이사' },
{ value: 'ceo', label: '대표' },
];
// 목업 사원 목록
const MOCK_EMPLOYEES = [
{ value: 'hong', label: '홍길동' },
{ value: 'kim', label: '김철수' },
{ value: 'lee', label: '이영희' },
{ value: 'park', label: '박지영' },
{ value: 'choi', label: '최민수' },
];
interface ElectronicApprovalModalProps {
isOpen: boolean;
onClose: () => void;
approval: ElectronicApproval;
onSave: (approval: ElectronicApproval) => void;
}
export function ElectronicApprovalModal({
isOpen,
onClose,
approval,
onSave,
}: ElectronicApprovalModalProps) {
const [localApproval, setLocalApproval] = useState<ElectronicApproval>(approval);
// 결재자 추가
const handleAddApprover = useCallback(() => {
const newPerson: ApprovalPerson = {
id: String(Date.now()),
department: '',
position: '',
name: '',
};
setLocalApproval((prev) => ({
...prev,
approvers: [...prev.approvers, newPerson],
}));
}, []);
// 결재자 삭제
const handleRemoveApprover = useCallback((personId: string) => {
setLocalApproval((prev) => ({
...prev,
approvers: prev.approvers.filter((p) => p.id !== personId),
}));
}, []);
// 결재자 변경
const handleApproverChange = useCallback(
(personId: string, field: keyof ApprovalPerson, value: string) => {
setLocalApproval((prev) => ({
...prev,
approvers: prev.approvers.map((p) =>
p.id === personId ? { ...p, [field]: value } : p
),
}));
},
[]
);
// 참조자 추가
const handleAddReference = useCallback(() => {
const newPerson: ApprovalPerson = {
id: String(Date.now()),
department: '',
position: '',
name: '',
};
setLocalApproval((prev) => ({
...prev,
references: [...prev.references, newPerson],
}));
}, []);
// 참조자 삭제
const handleRemoveReference = useCallback((personId: string) => {
setLocalApproval((prev) => ({
...prev,
references: prev.references.filter((p) => p.id !== personId),
}));
}, []);
// 참조자 변경
const handleReferenceChange = useCallback(
(personId: string, field: keyof ApprovalPerson, value: string) => {
setLocalApproval((prev) => ({
...prev,
references: prev.references.map((p) =>
p.id === personId ? { ...p, [field]: value } : p
),
}));
},
[]
);
// 저장
const handleSave = useCallback(() => {
onSave(localApproval);
}, [localApproval, onSave]);
// 취소
const handleCancel = useCallback(() => {
setLocalApproval(approval);
onClose();
}, [approval, onClose]);
// 사람 선택 행 렌더링
const renderPersonRow = (
person: ApprovalPerson,
index: number,
type: 'approver' | 'reference'
) => {
const onChange =
type === 'approver' ? handleApproverChange : handleReferenceChange;
const onRemove =
type === 'approver' ? handleRemoveApprover : handleRemoveReference;
return (
<div key={person.id} className="flex items-center gap-2">
<Select
value={person.department || undefined}
onValueChange={(val) => onChange(person.id, 'department', val)}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="부서명" />
</SelectTrigger>
<SelectContent>
{MOCK_DEPARTMENTS.map((dept) => (
<SelectItem key={dept.value} value={dept.value}>
{dept.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-gray-400">/</span>
<Select
value={person.position || undefined}
onValueChange={(val) => onChange(person.id, 'position', val)}
>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="직책명" />
</SelectTrigger>
<SelectContent>
{MOCK_POSITIONS.map((pos) => (
<SelectItem key={pos.value} value={pos.value}>
{pos.label}
</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-gray-400">/</span>
<Select
value={person.name || undefined}
onValueChange={(val) => onChange(person.id, 'name', val)}
>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="이름" />
</SelectTrigger>
<SelectContent>
{MOCK_EMPLOYEES.map((emp) => (
<SelectItem key={emp.value} value={emp.value}>
{emp.label}
</SelectItem>
))}
</SelectContent>
</Select>
<Button
type="button"
variant="ghost"
size="icon"
className="h-8 w-8 text-blue-500 hover:text-blue-600 hover:bg-blue-50"
onClick={() => onRemove(person.id)}
>
<X className="h-4 w-4" />
</Button>
</div>
);
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && handleCancel()}>
<DialogContent className="w-[95vw] max-w-[500px] sm:max-w-[500px]">
<DialogHeader>
<DialogTitle className="text-lg font-bold"></DialogTitle>
</DialogHeader>
<div className="space-y-6 py-4">
{/* 결재선 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium"></h3>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500"> / / </span>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddApprover}
>
</Button>
</div>
</div>
<div className="space-y-2">
{localApproval.approvers.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4 border rounded-lg">
.
</p>
) : (
localApproval.approvers.map((person, index) =>
renderPersonRow(person, index, 'approver')
)
)}
</div>
</div>
{/* 참조 */}
<div className="space-y-3">
<div className="flex items-center justify-between">
<h3 className="font-medium"></h3>
<div className="flex items-center gap-2">
<span className="text-sm text-gray-500"> / / </span>
<Button
type="button"
variant="outline"
size="sm"
onClick={handleAddReference}
>
</Button>
</div>
</div>
<div className="space-y-2">
{localApproval.references.length === 0 ? (
<p className="text-sm text-gray-500 text-center py-4 border rounded-lg">
.
</p>
) : (
localApproval.references.map((person, index) =>
renderPersonRow(person, index, 'reference')
)
)}
</div>
</div>
</div>
<DialogFooter className="flex gap-2">
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button onClick={handleSave} className="bg-gray-800 hover:bg-gray-900">
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View File

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

View File

@@ -0,0 +1,21 @@
// 결재자/참조자 정보
export interface ApprovalPerson {
id: string;
department: string; // 부서
position: string; // 직책
name: string; // 이름
}
// 전자결재 정보
export interface ElectronicApproval {
approvers: ApprovalPerson[]; // 결재선
references: ApprovalPerson[]; // 참조
}
// 빈 전자결재 데이터 생성
export function getEmptyElectronicApproval(): ElectronicApproval {
return {
approvers: [],
references: [],
};
}

View File

@@ -0,0 +1,712 @@
'use client';
import { useState, useCallback, useRef } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, Upload, X, Eye, Download } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Textarea } from '@/components/ui/textarea';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
import { PageLayout } from '@/components/organisms/PageLayout';
import { PageHeader } from '@/components/organisms/PageHeader';
import { toast } from 'sonner';
import type { ContractDetail, ContractFormData, ContractAttachment, ContractStatus } from './types';
import {
CONTRACT_STATUS_LABELS,
VAT_TYPE_OPTIONS,
getEmptyContractFormData,
contractDetailToFormData,
} from './types';
import { updateContract, deleteContract } from './actions';
import { downloadFileById } from '@/lib/utils/fileDownload';
import { ContractDocumentModal } from './modals/ContractDocumentModal';
import {
ElectronicApprovalModal,
type ElectronicApproval,
getEmptyElectronicApproval,
} from '../common';
// 금액 포맷팅
function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
}
// 파일 사이즈 포맷팅
function formatFileSize(bytes: number): string {
if (bytes < 1024) return bytes + ' B';
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
}
interface ContractDetailFormProps {
mode: 'view' | 'edit';
contractId: string;
initialData?: ContractDetail;
}
export default function ContractDetailForm({
mode,
contractId,
initialData,
}: ContractDetailFormProps) {
const router = useRouter();
const isViewMode = mode === 'view';
const isEditMode = mode === 'edit';
// 폼 데이터
const [formData, setFormData] = useState<ContractFormData>(
initialData ? contractDetailToFormData(initialData) : getEmptyContractFormData()
);
// 기존 첨부파일 (서버에서 가져온 파일)
const [existingAttachments, setExistingAttachments] = useState<ContractAttachment[]>(
initialData?.attachments || []
);
// 새로 추가된 파일
const [newAttachments, setNewAttachments] = useState<File[]>([]);
// 기존 계약서 파일 삭제 여부
const [isContractFileDeleted, setIsContractFileDeleted] = useState(false);
// 로딩 상태
const [isLoading, setIsLoading] = useState(false);
// 다이얼로그 상태
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
const [showSaveDialog, setShowSaveDialog] = useState(false);
// 모달 상태
const [showDocumentModal, setShowDocumentModal] = useState(false);
const [showApprovalModal, setShowApprovalModal] = useState(false);
// 전자결재 데이터
const [approvalData, setApprovalData] = useState<ElectronicApproval>(
getEmptyElectronicApproval()
);
// 파일 업로드 ref
const contractFileInputRef = useRef<HTMLInputElement>(null);
const attachmentInputRef = useRef<HTMLInputElement>(null);
// 드래그 상태
const [isDragging, setIsDragging] = useState(false);
// 네비게이션 핸들러
const handleBack = useCallback(() => {
router.push('/ko/juil/project/contract');
}, [router]);
const handleEdit = useCallback(() => {
router.push(`/ko/juil/project/contract/${contractId}/edit`);
}, [router, contractId]);
const handleCancel = useCallback(() => {
router.push(`/ko/juil/project/contract/${contractId}`);
}, [router, contractId]);
// 폼 필드 변경
const handleFieldChange = useCallback(
(field: keyof ContractFormData, value: string | number) => {
setFormData((prev) => ({ ...prev, [field]: value }));
},
[]
);
// 저장 핸들러
const handleSave = useCallback(() => {
setShowSaveDialog(true);
}, []);
const handleConfirmSave = useCallback(async () => {
setIsLoading(true);
try {
const result = await updateContract(contractId, formData);
if (result.success) {
toast.success('수정이 완료되었습니다.');
setShowSaveDialog(false);
router.push(`/ko/juil/project/contract/${contractId}`);
router.refresh();
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} catch (error) {
toast.error(error instanceof Error ? error.message : '저장에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [router, contractId, formData]);
// 삭제 핸들러
const handleDelete = useCallback(() => {
setShowDeleteDialog(true);
}, []);
const handleConfirmDelete = useCallback(async () => {
setIsLoading(true);
try {
const result = await deleteContract(contractId);
if (result.success) {
toast.success('계약이 삭제되었습니다.');
setShowDeleteDialog(false);
router.push('/ko/juil/project/contract');
router.refresh();
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch (error) {
toast.error(error instanceof Error ? error.message : '삭제에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [router, contractId]);
// 계약서 파일 선택
const handleContractFileSelect = useCallback(() => {
contractFileInputRef.current?.click();
}, []);
const handleContractFileChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (file) {
if (file.type !== 'application/pdf') {
toast.error('PDF 파일만 업로드 가능합니다.');
return;
}
setFormData((prev) => ({ ...prev, contractFile: file }));
}
},
[]
);
// 첨부 파일 드래그 앤 드롭
const handleDragOver = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(true);
}, []);
const handleDragLeave = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
}, []);
const handleDrop = useCallback((e: React.DragEvent) => {
e.preventDefault();
setIsDragging(false);
const files = Array.from(e.dataTransfer.files);
setNewAttachments((prev) => [...prev, ...files]);
}, []);
const handleAttachmentSelect = useCallback(() => {
attachmentInputRef.current?.click();
}, []);
const handleAttachmentChange = useCallback(
(e: React.ChangeEvent<HTMLInputElement>) => {
const files = Array.from(e.target.files || []);
setNewAttachments((prev) => [...prev, ...files]);
},
[]
);
// 기존 첨부파일 삭제
const handleRemoveExistingAttachment = useCallback((id: string) => {
setExistingAttachments((prev) => prev.filter((att) => att.id !== id));
}, []);
// 새 첨부파일 삭제
const handleRemoveNewAttachment = useCallback((index: number) => {
setNewAttachments((prev) => prev.filter((_, i) => i !== index));
}, []);
// 기존 계약서 파일 삭제
const handleRemoveContractFile = useCallback(() => {
setIsContractFileDeleted(true);
setFormData((prev) => ({ ...prev, contractFile: null }));
}, []);
// 계약서 보기 핸들러
const handleViewDocument = useCallback(() => {
setShowDocumentModal(true);
}, []);
// 파일 다운로드 핸들러
const handleFileDownload = useCallback(async (fileId: string, fileName?: string) => {
try {
await downloadFileById(parseInt(fileId), fileName);
} catch (error) {
console.error('[ContractDetailForm] 다운로드 실패:', error);
toast.error('파일 다운로드에 실패했습니다.');
}
}, []);
// 전자결재 핸들러
const handleApproval = useCallback(() => {
setShowApprovalModal(true);
}, []);
// 전자결재 저장
const handleApprovalSave = useCallback((approval: ElectronicApproval) => {
setApprovalData(approval);
setShowApprovalModal(false);
toast.success('전자결재 정보가 저장되었습니다.');
}, []);
// 헤더 액션 버튼
const headerActions = isViewMode ? (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleViewDocument}>
<Eye className="h-4 w-4 mr-2" />
</Button>
<Button variant="outline" onClick={handleApproval}>
</Button>
<Button onClick={handleEdit}></Button>
</div>
) : (
<div className="flex items-center gap-2">
<Button variant="outline" onClick={handleCancel}>
</Button>
<Button variant="destructive" onClick={handleDelete}>
</Button>
<Button onClick={handleSave} disabled={isLoading}>
</Button>
</div>
);
return (
<PageLayout>
<PageHeader
title="계약 상세"
description="계약 정보를 관리합니다"
icon={FileText}
onBack={handleBack}
actions={headerActions}
/>
<div className="space-y-6">
{/* 계약 정보 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
{/* 계약번호 */}
<div className="space-y-2">
<Label></Label>
<Input
value={formData.contractCode}
onChange={(e) => handleFieldChange('contractCode', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 계약담당자 */}
<div className="space-y-2">
<Label></Label>
<Input
value={formData.contractManagerName}
onChange={(e) => handleFieldChange('contractManagerName', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 거래처명 */}
<div className="space-y-2">
<Label></Label>
<Input
value={formData.partnerName}
onChange={(e) => handleFieldChange('partnerName', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 현장명 */}
<div className="space-y-2">
<Label></Label>
<Input
value={formData.projectName}
onChange={(e) => handleFieldChange('projectName', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 계약일자 */}
<div className="space-y-2">
<Label></Label>
<Input
type="date"
value={formData.contractDate}
onChange={(e) => handleFieldChange('contractDate', e.target.value)}
disabled={isViewMode}
/>
</div>
{/* 개소 */}
<div className="space-y-2">
<Label></Label>
<Input
type="number"
value={formData.totalLocations}
onChange={(e) => handleFieldChange('totalLocations', parseInt(e.target.value) || 0)}
disabled={isViewMode}
/>
</div>
{/* 계약기간 */}
<div className="space-y-2">
<Label></Label>
<div className="flex items-center gap-2">
<Input
type="date"
value={formData.contractStartDate}
onChange={(e) => handleFieldChange('contractStartDate', e.target.value)}
disabled={isViewMode}
/>
<span>~</span>
<Input
type="date"
value={formData.contractEndDate}
onChange={(e) => handleFieldChange('contractEndDate', e.target.value)}
disabled={isViewMode}
/>
</div>
</div>
{/* 부가세 */}
<div className="space-y-2">
<Label></Label>
<Select
value={formData.vatType}
onValueChange={(value) => handleFieldChange('vatType', value)}
disabled={isViewMode}
>
<SelectTrigger>
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
{VAT_TYPE_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
{/* 계약금액 */}
<div className="space-y-2">
<Label></Label>
<Input
type="text"
value={formatAmount(formData.contractAmount)}
onChange={(e) => {
const value = e.target.value.replace(/[^0-9]/g, '');
handleFieldChange('contractAmount', parseInt(value) || 0);
}}
disabled={isViewMode}
/>
</div>
{/* 상태 */}
<div className="space-y-2">
<Label></Label>
<RadioGroup
value={formData.status}
onValueChange={(value) => handleFieldChange('status', value as ContractStatus)}
disabled={isViewMode}
className="flex gap-4"
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="pending" id="pending" />
<Label htmlFor="pending" className="font-normal cursor-pointer">
{CONTRACT_STATUS_LABELS.pending}
</Label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="completed" id="completed" />
<Label htmlFor="completed" className="font-normal cursor-pointer">
{CONTRACT_STATUS_LABELS.completed}
</Label>
</div>
</RadioGroup>
</div>
{/* 비고 */}
<div className="space-y-2 md:col-span-2">
<Label></Label>
<Textarea
value={formData.remarks}
onChange={(e) => handleFieldChange('remarks', e.target.value)}
disabled={isViewMode}
rows={3}
/>
</div>
</div>
</CardContent>
</Card>
{/* 계약서 관리 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-3">
{/* 파일 선택 버튼 (수정 모드에서만) */}
{isEditMode && (
<Button variant="outline" onClick={handleContractFileSelect}>
</Button>
)}
{/* 새로 선택한 파일 */}
{formData.contractFile && (
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">{formData.contractFile.name}</span>
<span className="text-xs text-blue-600">( )</span>
</div>
{isEditMode && (
<Button
variant="ghost"
size="icon"
onClick={() => setFormData((prev) => ({ ...prev, contractFile: null }))}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
)}
{/* 기존 계약서 파일 */}
{!isContractFileDeleted && initialData?.contractFile && !formData.contractFile && (
<div className="flex items-center justify-between p-3 bg-muted rounded-lg">
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-muted-foreground" />
<span className="text-sm font-medium">{initialData.contractFile.fileName}</span>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleFileDownload(initialData.contractFile!.id, initialData.contractFile!.fileName)}
>
<Download className="h-4 w-4 mr-1" />
</Button>
{isEditMode && (
<Button
variant="ghost"
size="icon"
onClick={handleRemoveContractFile}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
)}
{/* 파일 없음 안내 */}
{!formData.contractFile && (isContractFileDeleted || !initialData?.contractFile) && (
<span className="text-sm text-muted-foreground">PDF </span>
)}
<input
ref={contractFileInputRef}
type="file"
accept=".pdf"
className="hidden"
onChange={handleContractFileChange}
/>
</div>
</CardContent>
</Card>
{/* 계약 첨부 문서 관리 */}
<Card>
<CardHeader>
<CardTitle className="text-lg"> </CardTitle>
</CardHeader>
<CardContent>
{/* 드래그 앤 드롭 영역 */}
{isEditMode && (
<div
className={`border-2 border-dashed rounded-lg p-8 text-center mb-4 transition-colors cursor-pointer ${
isDragging ? 'border-primary bg-primary/5' : 'border-muted-foreground/25'
}`}
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={handleAttachmentSelect}
>
<Upload className="h-8 w-8 mx-auto mb-2 text-muted-foreground" />
<p className="text-muted-foreground">
, .
</p>
</div>
)}
{/* 파일 목록 */}
<div className="space-y-2">
{/* 기존 첨부파일 */}
{existingAttachments.map((att) => (
<div
key={att.id}
className="flex items-center justify-between p-3 bg-muted rounded-lg"
>
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">{att.fileName}</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(att.fileSize)}
</p>
</div>
</div>
<div className="flex items-center gap-1">
<Button
variant="ghost"
size="sm"
onClick={() => handleFileDownload(att.id, att.fileName)}
>
<Download className="h-4 w-4 mr-1" />
</Button>
{isEditMode && (
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveExistingAttachment(att.id)}
>
<X className="h-4 w-4" />
</Button>
)}
</div>
</div>
))}
{/* 새로 추가된 파일 */}
{newAttachments.map((file, index) => (
<div
key={index}
className="flex items-center justify-between p-3 bg-muted rounded-lg"
>
<div className="flex items-center gap-3">
<FileText className="h-5 w-5 text-muted-foreground" />
<div>
<p className="text-sm font-medium">{file.name}</p>
<p className="text-xs text-muted-foreground">
{formatFileSize(file.size)}
</p>
</div>
</div>
<Button
variant="ghost"
size="icon"
onClick={() => handleRemoveNewAttachment(index)}
>
<X className="h-4 w-4" />
</Button>
</div>
))}
</div>
<input
ref={attachmentInputRef}
type="file"
multiple
className="hidden"
onChange={handleAttachmentChange}
/>
</CardContent>
</Card>
</div>
{/* 저장 확인 다이얼로그 */}
<AlertDialog open={showSaveDialog} onOpenChange={setShowSaveDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
?
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleConfirmSave} disabled={isLoading}>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 삭제 확인 다이얼로그 */}
<AlertDialog open={showDeleteDialog} onOpenChange={setShowDeleteDialog}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleConfirmDelete}
disabled={isLoading}
className="bg-destructive text-destructive-foreground hover:bg-destructive/90"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 계약서 보기 모달 */}
{initialData && (
<ContractDocumentModal
open={showDocumentModal}
onOpenChange={setShowDocumentModal}
contract={initialData}
/>
)}
{/* 전자결재 모달 */}
<ElectronicApprovalModal
isOpen={showApprovalModal}
onClose={() => setShowApprovalModal(false)}
approval={approvalData}
onSave={handleApprovalSave}
/>
</PageLayout>
);
}

View File

@@ -0,0 +1,609 @@
'use client';
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, Clock, CheckCircle, Pencil, Trash2 } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
import { toast } from 'sonner';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import type { Contract, ContractStats } from './types';
import {
CONTRACT_STATUS_OPTIONS,
CONTRACT_SORT_OPTIONS,
CONTRACT_STATUS_STYLES,
CONTRACT_STATUS_LABELS,
} from './types';
import { getContractList, getContractStats, deleteContract, deleteContracts } from './actions';
// 테이블 컬럼 정의
// 순서: 체크박스, 번호, 계약번호, 거래처, 현장명, 계약담당자, 공사PM, 총 개소, 계약금액, 계약기간, 상태, 작업
const tableColumns: TableColumn[] = [
{ key: 'no', label: '번호', className: 'w-[60px] text-center' },
{ key: 'contractCode', label: '계약번호', className: 'w-[120px]' },
{ key: 'partnerName', label: '거래처', className: 'w-[100px]' },
{ key: 'projectName', label: '현장명', className: 'min-w-[150px]' },
{ key: 'contractManager', label: '계약담당자', className: 'w-[100px] text-center' },
{ key: 'constructionPM', label: '공사PM', className: 'w-[80px] text-center' },
{ key: 'totalLocations', label: '총 개소', className: 'w-[80px] text-center' },
{ key: 'contractAmount', label: '계약금액', className: 'w-[120px] text-right' },
{ key: 'contractPeriod', label: '계약기간', className: 'w-[180px] text-center' },
{ key: 'status', label: '상태', className: 'w-[80px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
];
// 목업 거래처 목록
const MOCK_PARTNERS: MultiSelectOption[] = [
{ value: '1', label: '통신공사' },
{ value: '2', label: '야사건설' },
{ value: '3', label: '여의건설' },
];
// 목업 계약담당자 목록
const MOCK_CONTRACT_MANAGERS: MultiSelectOption[] = [
{ value: 'hong', label: '홍길동' },
{ value: 'kim', label: '김철수' },
{ value: 'lee', label: '이영희' },
];
// 목업 공사PM 목록
const MOCK_CONSTRUCTION_PMS: MultiSelectOption[] = [
{ value: 'kim', label: '김PM' },
{ value: 'lee', label: '이PM' },
{ value: 'park', label: '박PM' },
];
// 금액 포맷팅
function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
}
// 날짜 포맷팅
function formatDate(dateStr: string | null): string {
if (!dateStr) return '-';
const date = new Date(dateStr);
return date.toLocaleDateString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
}).replace(/\. /g, '-').replace('.', '');
}
// 계약기간 포맷팅
function formatPeriod(startDate: string | null, endDate: string | null): string {
const start = formatDate(startDate);
const end = formatDate(endDate);
if (start === '-' && end === '-') return '-';
return `${start} ~ ${end}`;
}
interface ContractListClientProps {
initialData?: Contract[];
initialStats?: ContractStats;
}
export default function ContractListClient({
initialData = [],
initialStats,
}: ContractListClientProps) {
const router = useRouter();
// 상태
const [contracts, setContracts] = useState<Contract[]>(initialData);
const [stats, setStats] = useState<ContractStats | null>(initialStats || null);
const [searchValue, setSearchValue] = useState('');
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
const [contractManagerFilters, setContractManagerFilters] = useState<string[]>([]);
const [constructionPMFilters, setConstructionPMFilters] = useState<string[]>([]);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState<string>('contractDateDesc');
const [startDate, setStartDate] = useState<string>('');
const [endDate, setEndDate] = useState<string>('');
const [selectedItems, setSelectedItems] = useState<Set<string>>(new Set());
const [currentPage, setCurrentPage] = useState(1);
const [isLoading, setIsLoading] = useState(false);
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
const itemsPerPage = 20;
// 데이터 로드
const loadData = useCallback(async () => {
setIsLoading(true);
try {
const [listResult, statsResult] = await Promise.all([
getContractList({
size: 1000,
startDate: startDate || undefined,
endDate: endDate || undefined,
}),
getContractStats(),
]);
if (listResult.success && listResult.data) {
setContracts(listResult.data.items);
}
if (statsResult.success && statsResult.data) {
setStats(statsResult.data);
}
} catch {
toast.error('데이터 로드에 실패했습니다.');
} finally {
setIsLoading(false);
}
}, [startDate, endDate]);
// 초기 데이터가 없으면 로드
useEffect(() => {
if (initialData.length === 0) {
loadData();
}
}, [initialData.length, loadData]);
// 필터링된 데이터
const filteredContracts = useMemo(() => {
return contracts.filter((contract) => {
// 상태 탭 필터
if (activeStatTab === 'pending' && contract.status !== 'pending') return false;
if (activeStatTab === 'completed' && contract.status !== 'completed') return false;
// 거래처 필터
if (partnerFilters.length > 0) {
if (!partnerFilters.includes(contract.partnerId)) return false;
}
// 계약담당자 필터
if (contractManagerFilters.length > 0) {
if (!contractManagerFilters.includes(contract.contractManagerId)) return false;
}
// 공사PM 필터
if (constructionPMFilters.length > 0) {
if (!constructionPMFilters.includes(contract.constructionPMId || '')) return false;
}
// 상태 필터
if (statusFilter !== 'all' && contract.status !== statusFilter) return false;
// 검색 필터
if (searchValue) {
const search = searchValue.toLowerCase();
return (
contract.projectName.toLowerCase().includes(search) ||
contract.contractCode.toLowerCase().includes(search) ||
contract.partnerName.toLowerCase().includes(search)
);
}
return true;
});
}, [contracts, activeStatTab, partnerFilters, contractManagerFilters, constructionPMFilters, statusFilter, searchValue]);
// 정렬
const sortedContracts = useMemo(() => {
const sorted = [...filteredContracts];
switch (sortBy) {
case 'contractDateDesc':
sorted.sort((a, b) => {
if (!a.contractStartDate) return 1;
if (!b.contractStartDate) return -1;
return new Date(b.contractStartDate).getTime() - new Date(a.contractStartDate).getTime();
});
break;
case 'contractDateAsc':
sorted.sort((a, b) => {
if (!a.contractStartDate) return 1;
if (!b.contractStartDate) return -1;
return new Date(a.contractStartDate).getTime() - new Date(b.contractStartDate).getTime();
});
break;
case 'partnerNameAsc':
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
break;
case 'partnerNameDesc':
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
break;
case 'projectNameAsc':
sorted.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
break;
case 'projectNameDesc':
sorted.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
break;
case 'amountDesc':
sorted.sort((a, b) => b.contractAmount - a.contractAmount);
break;
case 'amountAsc':
sorted.sort((a, b) => a.contractAmount - b.contractAmount);
break;
}
return sorted;
}, [filteredContracts, sortBy]);
// 페이지네이션
const totalPages = Math.ceil(sortedContracts.length / itemsPerPage);
const paginatedData = useMemo(() => {
const start = (currentPage - 1) * itemsPerPage;
return sortedContracts.slice(start, start + itemsPerPage);
}, [sortedContracts, currentPage, itemsPerPage]);
// 핸들러
const handleSearchChange = useCallback((value: string) => {
setSearchValue(value);
setCurrentPage(1);
}, []);
const handleToggleSelection = useCallback((id: string) => {
setSelectedItems((prev) => {
const newSet = new Set(prev);
if (newSet.has(id)) {
newSet.delete(id);
} else {
newSet.add(id);
}
return newSet;
});
}, []);
const handleToggleSelectAll = useCallback(() => {
if (selectedItems.size === paginatedData.length) {
setSelectedItems(new Set());
} else {
setSelectedItems(new Set(paginatedData.map((c) => c.id)));
}
}, [selectedItems.size, paginatedData]);
const handleRowClick = useCallback(
(contract: Contract) => {
router.push(`/ko/juil/project/contract/${contract.id}`);
},
[router]
);
const handleEdit = useCallback(
(e: React.MouseEvent, contractId: string) => {
e.stopPropagation();
router.push(`/ko/juil/project/contract/${contractId}/edit`);
},
[router]
);
const handleDeleteClick = useCallback((e: React.MouseEvent, contractId: string) => {
e.stopPropagation();
setDeleteTargetId(contractId);
setDeleteDialogOpen(true);
}, []);
const handleDeleteConfirm = useCallback(async () => {
if (!deleteTargetId) return;
setIsLoading(true);
try {
const result = await deleteContract(deleteTargetId);
if (result.success) {
toast.success('계약이 삭제되었습니다.');
setContracts((prev) => prev.filter((c) => c.id !== deleteTargetId));
setSelectedItems((prev) => {
const newSet = new Set(prev);
newSet.delete(deleteTargetId);
return newSet;
});
} else {
toast.error(result.error || '삭제에 실패했습니다.');
}
} catch {
toast.error('삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setDeleteDialogOpen(false);
setDeleteTargetId(null);
}
}, [deleteTargetId]);
const handleBulkDeleteClick = useCallback(() => {
if (selectedItems.size === 0) {
toast.warning('삭제할 항목을 선택해주세요.');
return;
}
setBulkDeleteDialogOpen(true);
}, [selectedItems.size]);
const handleBulkDeleteConfirm = useCallback(async () => {
if (selectedItems.size === 0) return;
setIsLoading(true);
try {
const ids = Array.from(selectedItems);
const result = await deleteContracts(ids);
if (result.success) {
toast.success(`${result.deletedCount}개 항목이 삭제되었습니다.`);
await loadData();
setSelectedItems(new Set());
} else {
toast.error(result.error || '일괄 삭제에 실패했습니다.');
}
} catch {
toast.error('일괄 삭제 중 오류가 발생했습니다.');
} finally {
setIsLoading(false);
setBulkDeleteDialogOpen(false);
}
}, [selectedItems, loadData]);
// 테이블 행 렌더링
// 순서: 체크박스, 번호, 계약번호, 거래처, 현장명, 계약담당자, 공사PM, 총 개소, 계약금액, 계약기간, 상태, 작업
const renderTableRow = useCallback(
(contract: Contract, index: number, globalIndex: number) => {
const isSelected = selectedItems.has(contract.id);
return (
<TableRow
key={contract.id}
className="cursor-pointer hover:bg-muted/50"
onClick={() => handleRowClick(contract)}
>
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
<Checkbox
checked={isSelected}
onCheckedChange={() => handleToggleSelection(contract.id)}
/>
</TableCell>
<TableCell className="text-center text-muted-foreground">{globalIndex}</TableCell>
<TableCell>{contract.contractCode}</TableCell>
<TableCell>{contract.partnerName}</TableCell>
<TableCell>{contract.projectName}</TableCell>
<TableCell className="text-center">{contract.contractManagerName}</TableCell>
<TableCell className="text-center">{contract.constructionPMName || '-'}</TableCell>
<TableCell className="text-center">{contract.totalLocations}</TableCell>
<TableCell className="text-right">{formatAmount(contract.contractAmount)}</TableCell>
<TableCell className="text-center">
{formatPeriod(contract.contractStartDate, contract.contractEndDate)}
</TableCell>
<TableCell className="text-center">
<span className={CONTRACT_STATUS_STYLES[contract.status]}>
{CONTRACT_STATUS_LABELS[contract.status]}
</span>
</TableCell>
<TableCell className="text-center">
{isSelected && (
<div className="flex items-center justify-center gap-1">
<Button
variant="ghost"
size="icon"
className="h-8 w-8"
onClick={(e) => handleEdit(e, contract.id)}
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => handleDeleteClick(e, contract.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
);
// 모바일 카드 렌더링
const renderMobileCard = useCallback(
(contract: Contract, index: number, globalIndex: number, isSelected: boolean, onToggle: () => void) => {
return (
<MobileCard
title={contract.projectName}
subtitle={contract.contractCode}
badge={CONTRACT_STATUS_LABELS[contract.status]}
badgeVariant="secondary"
isSelected={isSelected}
onToggle={onToggle}
onClick={() => handleRowClick(contract)}
details={[
{ label: '거래처', value: contract.partnerName },
{ label: '총 개소', value: `${contract.totalLocations}` },
{ label: '계약금액', value: `${formatAmount(contract.contractAmount)}` },
{ label: '계약담당자', value: contract.contractManagerName },
{ label: '공사PM', value: contract.constructionPMName || '-' },
]}
/>
);
},
[handleRowClick]
);
// 헤더 액션 (날짜 필터만)
const headerActions = (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
/>
);
// Stats 카드 데이터 (전체 계약, 계약대기, 계약완료)
const statsCardsData: StatCard[] = [
{
label: '전체 계약',
value: stats?.total ?? 0,
icon: FileText,
iconColor: 'text-blue-600',
onClick: () => setActiveStatTab('all'),
isActive: activeStatTab === 'all',
},
{
label: '계약대기',
value: stats?.pending ?? 0,
icon: Clock,
iconColor: 'text-orange-500',
onClick: () => setActiveStatTab('pending'),
isActive: activeStatTab === 'pending',
},
{
label: '계약완료',
value: stats?.completed ?? 0,
icon: CheckCircle,
iconColor: 'text-green-600',
onClick: () => setActiveStatTab('completed'),
isActive: activeStatTab === 'completed',
},
];
// 테이블 헤더 액션 (총 건수 + 필터들)
const tableHeaderActions = (
<div className="flex items-center gap-2 flex-wrap">
<span className="text-sm text-muted-foreground whitespace-nowrap">
{sortedContracts.length}
</span>
{/* 거래처 필터 */}
<MultiSelectCombobox
options={MOCK_PARTNERS}
value={partnerFilters}
onChange={setPartnerFilters}
placeholder="거래처"
searchPlaceholder="거래처 검색..."
className="w-[120px]"
/>
{/* 계약담당자 필터 */}
<MultiSelectCombobox
options={MOCK_CONTRACT_MANAGERS}
value={contractManagerFilters}
onChange={setContractManagerFilters}
placeholder="계약담당자"
searchPlaceholder="계약담당자 검색..."
className="w-[130px]"
/>
{/* 공사PM 필터 */}
<MultiSelectCombobox
options={MOCK_CONSTRUCTION_PMS}
value={constructionPMFilters}
onChange={setConstructionPMFilters}
placeholder="공사PM"
searchPlaceholder="공사PM 검색..."
className="w-[120px]"
/>
{/* 상태 필터 */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="상태" />
</SelectTrigger>
<SelectContent>
{CONTRACT_STATUS_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 정렬 */}
<Select value={sortBy} onValueChange={setSortBy}>
<SelectTrigger className="w-[160px]">
<SelectValue placeholder="최신순 (계약일)" />
</SelectTrigger>
<SelectContent>
{CONTRACT_SORT_OPTIONS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
</div>
);
return (
<>
<IntegratedListTemplateV2
title="계약관리"
description="계약 정보를 관리합니다"
icon={FileText}
headerActions={headerActions}
stats={statsCardsData}
tableHeaderActions={tableHeaderActions}
searchValue={searchValue}
onSearchChange={handleSearchChange}
searchPlaceholder="계약번호, 거래처, 현장명 검색"
tableColumns={tableColumns}
data={paginatedData}
allData={sortedContracts}
getItemId={(item) => item.id}
renderTableRow={renderTableRow}
renderMobileCard={renderMobileCard}
selectedItems={selectedItems}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
onBulkDelete={handleBulkDeleteClick}
pagination={{
currentPage,
totalPages,
totalItems: sortedContracts.length,
itemsPerPage,
onPageChange: setCurrentPage,
}}
/>
{/* 단일 삭제 다이얼로그 */}
<AlertDialog open={deleteDialogOpen} onOpenChange={setDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
{/* 일괄 삭제 다이얼로그 */}
<AlertDialog open={bulkDeleteDialogOpen} onOpenChange={setBulkDeleteDialogOpen}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
{selectedItems.size} ? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction onClick={handleBulkDeleteConfirm}></AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}

View File

@@ -0,0 +1,517 @@
'use server';
import type {
Contract,
ContractDetail,
ContractStats,
ContractStageCount,
ContractListResponse,
ContractFilter,
ContractFormData,
} from './types';
// 목업 데이터
const MOCK_CONTRACTS: Contract[] = [
{
id: '1',
contractCode: 'CT-2025-001',
partnerId: '1',
partnerName: '통신공사',
projectName: '강남역 통신시설 구축',
contractManagerId: 'hong',
contractManagerName: '홍길동',
constructionPMId: 'kim',
constructionPMName: '김PM',
totalLocations: 15,
contractAmount: 150000000,
contractStartDate: '2025-12-17',
contractEndDate: '2026-06-17',
status: 'pending',
stage: 'estimate_selected',
remarks: '',
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
createdBy: 'system',
biddingId: '1',
biddingCode: 'BID-2025-001',
},
{
id: '2',
contractCode: 'CT-2025-002',
partnerId: '2',
partnerName: '야사건설',
projectName: '판교 IT단지 배선공사',
contractManagerId: 'hong',
contractManagerName: '홍길동',
constructionPMId: 'lee',
constructionPMName: '이PM',
totalLocations: 28,
contractAmount: 280000000,
contractStartDate: '2025-11-01',
contractEndDate: '2026-03-31',
status: 'pending',
stage: 'estimate_progress',
remarks: '',
createdAt: '2025-01-02',
updatedAt: '2025-01-02',
createdBy: 'system',
biddingId: '2',
biddingCode: 'BID-2025-002',
},
{
id: '3',
contractCode: 'CT-2025-003',
partnerId: '3',
partnerName: '여의건설',
projectName: '여의도 오피스빌딩 통신설비',
contractManagerId: 'kim',
contractManagerName: '김철수',
constructionPMId: 'park',
constructionPMName: '박PM',
totalLocations: 42,
contractAmount: 420000000,
contractStartDate: '2025-10-15',
contractEndDate: '2026-04-15',
status: 'pending',
stage: 'delivery',
remarks: '',
createdAt: '2025-01-03',
updatedAt: '2025-01-03',
createdBy: 'system',
biddingId: '3',
biddingCode: 'BID-2025-003',
},
{
id: '4',
contractCode: 'CT-2025-004',
partnerId: '1',
partnerName: '통신공사',
projectName: '송파 데이터센터 증설',
contractManagerId: 'hong',
contractManagerName: '홍길동',
constructionPMId: 'kim',
constructionPMName: '김PM',
totalLocations: 58,
contractAmount: 580000000,
contractStartDate: '2025-09-01',
contractEndDate: '2026-02-28',
status: 'completed',
stage: 'inspection',
remarks: '',
createdAt: '2025-01-04',
updatedAt: '2025-01-04',
createdBy: 'system',
biddingId: '4',
biddingCode: 'BID-2025-004',
},
{
id: '5',
contractCode: 'CT-2025-005',
partnerId: '2',
partnerName: '야사건설',
projectName: '분당 스마트빌딩 LAN공사',
contractManagerId: 'lee',
contractManagerName: '이영희',
constructionPMId: 'lee',
constructionPMName: '이PM',
totalLocations: 12,
contractAmount: 95000000,
contractStartDate: '2025-12-01',
contractEndDate: '2026-01-31',
status: 'pending',
stage: 'installation',
remarks: '',
createdAt: '2025-01-05',
updatedAt: '2025-01-05',
createdBy: 'system',
biddingId: '5',
biddingCode: 'BID-2025-005',
},
{
id: '6',
contractCode: 'CT-2025-006',
partnerId: '3',
partnerName: '여의건설',
projectName: '마포 복합시설 CCTV설치',
contractManagerId: 'hong',
contractManagerName: '홍길동',
constructionPMId: 'park',
constructionPMName: '박PM',
totalLocations: 8,
contractAmount: 75000000,
contractStartDate: '2025-08-01',
contractEndDate: '2025-10-31',
status: 'completed',
stage: 'estimate_selected',
remarks: '',
createdAt: '2025-01-06',
updatedAt: '2025-01-06',
createdBy: 'system',
biddingId: '6',
biddingCode: 'BID-2025-006',
},
{
id: '7',
contractCode: 'CT-2025-007',
partnerId: '1',
partnerName: '통신공사',
projectName: '용산 아파트 인터폰교체',
contractManagerId: 'kim',
contractManagerName: '김철수',
constructionPMId: 'kim',
constructionPMName: '김PM',
totalLocations: 120,
contractAmount: 45000000,
contractStartDate: '2025-07-15',
contractEndDate: '2025-09-15',
status: 'completed',
stage: 'estimate_progress',
remarks: '',
createdAt: '2025-01-07',
updatedAt: '2025-01-07',
createdBy: 'system',
biddingId: '7',
biddingCode: 'BID-2025-007',
},
{
id: '8',
contractCode: 'CT-2025-008',
partnerId: '2',
partnerName: '야사건설',
projectName: '성수동 공장 방범설비',
contractManagerId: 'lee',
contractManagerName: '이영희',
constructionPMId: 'lee',
constructionPMName: '이PM',
totalLocations: 24,
contractAmount: 120000000,
contractStartDate: '2025-11-15',
contractEndDate: '2026-02-15',
status: 'pending',
stage: 'other',
remarks: '',
createdAt: '2025-01-08',
updatedAt: '2025-01-08',
createdBy: 'system',
biddingId: '8',
biddingCode: 'BID-2025-008',
},
{
id: '9',
contractCode: 'CT-2025-009',
partnerId: '3',
partnerName: '여의건설',
projectName: '강서 물류센터 네트워크',
contractManagerId: 'hong',
contractManagerName: '홍길동',
constructionPMId: 'park',
constructionPMName: '박PM',
totalLocations: 35,
contractAmount: 320000000,
contractStartDate: '2025-06-01',
contractEndDate: '2025-11-30',
status: 'completed',
stage: 'inspection',
remarks: '',
createdAt: '2025-01-09',
updatedAt: '2025-01-09',
createdBy: 'system',
biddingId: '9',
biddingCode: 'BID-2025-009',
},
];
// 계약 목록 조회
export async function getContractList(filter?: ContractFilter): Promise<{
success: boolean;
data?: ContractListResponse;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 300));
let filteredData = [...MOCK_CONTRACTS];
// 검색 필터
if (filter?.search) {
const search = filter.search.toLowerCase();
filteredData = filteredData.filter(
(item) =>
item.contractCode.toLowerCase().includes(search) ||
item.partnerName.toLowerCase().includes(search) ||
item.projectName.toLowerCase().includes(search)
);
}
// 상태 필터
if (filter?.status && filter.status !== 'all') {
filteredData = filteredData.filter((item) => item.status === filter.status);
}
// 단계 필터
if (filter?.stage && filter.stage !== 'all') {
filteredData = filteredData.filter((item) => item.stage === filter.stage);
}
// 거래처 필터
if (filter?.partnerId && filter.partnerId !== 'all') {
filteredData = filteredData.filter((item) => item.partnerId === filter.partnerId);
}
// 계약담당자 필터
if (filter?.contractManagerId && filter.contractManagerId !== 'all') {
filteredData = filteredData.filter((item) => item.contractManagerId === filter.contractManagerId);
}
// 공사PM 필터
if (filter?.constructionPMId && filter.constructionPMId !== 'all') {
filteredData = filteredData.filter((item) => item.constructionPMId === filter.constructionPMId);
}
// 날짜 필터
if (filter?.startDate) {
filteredData = filteredData.filter(
(item) => item.contractStartDate && item.contractStartDate >= filter.startDate!
);
}
if (filter?.endDate) {
filteredData = filteredData.filter(
(item) => item.contractEndDate && item.contractEndDate <= filter.endDate!
);
}
// 정렬
const sortBy = filter?.sortBy || 'contractDateDesc';
switch (sortBy) {
case 'contractDateDesc':
filteredData.sort((a, b) => {
if (!a.contractStartDate) return 1;
if (!b.contractStartDate) return -1;
return new Date(b.contractStartDate).getTime() - new Date(a.contractStartDate).getTime();
});
break;
case 'contractDateAsc':
filteredData.sort((a, b) => {
if (!a.contractStartDate) return 1;
if (!b.contractStartDate) return -1;
return new Date(a.contractStartDate).getTime() - new Date(b.contractStartDate).getTime();
});
break;
case 'partnerNameAsc':
filteredData.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
break;
case 'partnerNameDesc':
filteredData.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
break;
case 'projectNameAsc':
filteredData.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
break;
case 'projectNameDesc':
filteredData.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
break;
case 'amountDesc':
filteredData.sort((a, b) => b.contractAmount - a.contractAmount);
break;
case 'amountAsc':
filteredData.sort((a, b) => a.contractAmount - b.contractAmount);
break;
}
// 페이지네이션
const page = filter?.page || 1;
const size = filter?.size || 20;
const startIndex = (page - 1) * size;
const paginatedData = filteredData.slice(startIndex, startIndex + size);
return {
success: true,
data: {
items: paginatedData,
total: filteredData.length,
page,
size,
totalPages: Math.ceil(filteredData.length / size),
},
};
} catch (error) {
console.error('getContractList error:', error);
return { success: false, error: '계약 목록을 불러오는데 실패했습니다.' };
}
}
// 계약 통계 조회
export async function getContractStats(): Promise<{
success: boolean;
data?: ContractStats;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 100));
const stats: ContractStats = {
total: MOCK_CONTRACTS.length,
pending: MOCK_CONTRACTS.filter((c) => c.status === 'pending').length,
completed: MOCK_CONTRACTS.filter((c) => c.status === 'completed').length,
};
return { success: true, data: stats };
} catch (error) {
console.error('getContractStats error:', error);
return { success: false, error: '통계를 불러오는데 실패했습니다.' };
}
}
// 단계별 건수 조회
export async function getContractStageCounts(): Promise<{
success: boolean;
data?: ContractStageCount;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 100));
const counts: ContractStageCount = {
estimateSelected: MOCK_CONTRACTS.filter((c) => c.stage === 'estimate_selected').length,
estimateProgress: MOCK_CONTRACTS.filter((c) => c.stage === 'estimate_progress').length,
delivery: MOCK_CONTRACTS.filter((c) => c.stage === 'delivery').length,
installation: MOCK_CONTRACTS.filter((c) => c.stage === 'installation').length,
inspection: MOCK_CONTRACTS.filter((c) => c.stage === 'inspection').length,
other: MOCK_CONTRACTS.filter((c) => c.stage === 'other').length,
};
return { success: true, data: counts };
} catch (error) {
console.error('getContractStageCounts error:', error);
return { success: false, error: '단계별 건수를 불러오는데 실패했습니다.' };
}
}
// 계약 단건 조회
export async function getContract(id: string): Promise<{
success: boolean;
data?: Contract;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 200));
const contract = MOCK_CONTRACTS.find((c) => c.id === id);
if (!contract) {
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
}
return { success: true, data: contract };
} catch (error) {
console.error('getContract error:', error);
return { success: false, error: '계약 정보를 불러오는데 실패했습니다.' };
}
}
// 계약 삭제
export async function deleteContract(id: string): Promise<{
success: boolean;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 300));
const index = MOCK_CONTRACTS.findIndex((c) => c.id === id);
if (index === -1) {
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
}
return { success: true };
} catch (error) {
console.error('deleteContract error:', error);
return { success: false, error: '계약 삭제에 실패했습니다.' };
}
}
// 계약 일괄 삭제
export async function deleteContracts(ids: string[]): Promise<{
success: boolean;
deletedCount?: number;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 500));
return { success: true, deletedCount: ids.length };
} catch (error) {
console.error('deleteContracts error:', error);
return { success: false, error: '일괄 삭제에 실패했습니다.' };
}
}
// 계약 상세 조회 (첨부파일 포함)
export async function getContractDetail(id: string): Promise<{
success: boolean;
data?: ContractDetail;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 200));
const contract = MOCK_CONTRACTS.find((c) => c.id === id);
if (!contract) {
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
}
// ContractDetail로 변환 (첨부파일 목데이터 포함)
const contractDetail: ContractDetail = {
...contract,
// 계약서 파일 목업 데이터
contractFile: {
id: '100',
fileName: '계약서_CT-2025-001.pdf',
fileUrl: '/files/contract.pdf',
uploadedAt: contract.createdAt,
},
attachments: [
{
id: 'att-1',
fileName: '견적서.pdf',
fileSize: 1024000,
fileUrl: '/files/estimate.pdf',
uploadedAt: contract.createdAt,
},
{
id: 'att-2',
fileName: '시방서.pdf',
fileSize: 2048000,
fileUrl: '/files/spec.pdf',
uploadedAt: contract.createdAt,
},
],
};
return { success: true, data: contractDetail };
} catch (error) {
console.error('getContractDetail error:', error);
return { success: false, error: '계약 상세 정보를 불러오는데 실패했습니다.' };
}
}
// 계약 수정
export async function updateContract(
id: string,
_data: Partial<ContractFormData>
): Promise<{
success: boolean;
error?: string;
}> {
try {
await new Promise((resolve) => setTimeout(resolve, 500));
const index = MOCK_CONTRACTS.findIndex((c) => c.id === id);
if (index === -1) {
return { success: false, error: '계약 정보를 찾을 수 없습니다.' };
}
// TODO: 실제 API 연동 시 데이터 업데이트 로직
return { success: true };
} catch (error) {
console.error('updateContract error:', error);
return { success: false, error: '계약 수정에 실패했습니다.' };
}
}

View File

@@ -0,0 +1,5 @@
export { default as ContractListClient } from './ContractListClient';
export { default as ContractDetailForm } from './ContractDetailForm';
export * from './types';
export * from './actions';
export * from './modals';

View File

@@ -0,0 +1,104 @@
'use client';
import {
Dialog,
DialogContent,
DialogTitle,
VisuallyHidden,
} from '@/components/ui/dialog';
import { Button } from '@/components/ui/button';
import {
Edit,
X as XIcon,
Printer,
Send,
} from 'lucide-react';
import { toast } from 'sonner';
import { printArea } from '@/lib/print-utils';
import type { ContractDetail } from '../types';
interface ContractDocumentModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
contract: ContractDetail;
}
export function ContractDocumentModal({
open,
onOpenChange,
contract,
}: ContractDocumentModalProps) {
// 수정
const handleEdit = () => {
toast.info('수정 기능은 준비 중입니다.');
};
// 상신 (전자결재)
const handleSubmit = () => {
toast.info('전자결재 상신 기능은 준비 중입니다.');
};
// 인쇄
const handlePrint = () => {
printArea({ title: '계약서 인쇄' });
};
// PDF URL 확인
const pdfUrl = contract.contractFile?.fileUrl;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="max-w-[95vw] md:max-w-[800px] lg:max-w-[900px] h-[95vh] p-0 flex flex-col gap-0 overflow-hidden [&>button]:hidden">
<VisuallyHidden>
<DialogTitle> </DialogTitle>
</VisuallyHidden>
{/* 헤더 영역 - 고정 (인쇄 시 숨김) */}
<div className="print-hidden flex-shrink-0 flex items-center justify-between px-6 py-4 border-b bg-white">
<h2 className="text-lg font-semibold"> </h2>
<Button
variant="ghost"
size="icon"
onClick={() => onOpenChange(false)}
className="h-8 w-8"
>
<XIcon className="h-4 w-4" />
</Button>
</div>
{/* 버튼 영역 - 고정 (인쇄 시 숨김) */}
<div className="print-hidden flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
<Button variant="outline" size="sm" onClick={handleEdit}>
<Edit className="h-4 w-4 mr-1" />
</Button>
<Button variant="default" size="sm" onClick={handleSubmit} className="bg-blue-600 hover:bg-blue-700">
<Send className="h-4 w-4 mr-1" />
</Button>
<Button variant="outline" size="sm" onClick={handlePrint}>
<Printer className="h-4 w-4 mr-1" />
</Button>
</div>
{/* PDF 뷰어 영역 - 스크롤 (인쇄 시 이 영역만 출력) */}
<div className="print-area flex-1 overflow-y-auto bg-gray-100 p-6">
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg min-h-[297mm]">
{pdfUrl ? (
<iframe
src={pdfUrl}
className="w-full h-full min-h-[297mm]"
title="계약서 PDF"
/>
) : (
<div className="flex items-center justify-center h-full min-h-[297mm] text-muted-foreground">
<p> .</p>
</div>
)}
</div>
</div>
</DialogContent>
</Dialog>
);
}

View File

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

View File

@@ -0,0 +1,242 @@
/**
* 주일 기업 - 계약관리 타입 정의
*
* 계약 데이터는 낙찰 후 자동 등록됨
*/
// 계약 상태
export type ContractStatus =
| 'pending' // 계약대기
| 'completed'; // 계약완료
// 계약 단계 (스크린샷의 상단 탭)
export type ContractStage =
| 'estimate_selected' // 견적선정
| 'estimate_progress' // 견적진행
| 'delivery' // 납품
| 'installation' // 설치중
| 'inspection' // 검수
| 'other'; // 기타
// 계약 타입
export interface Contract {
id: string;
contractCode: string; // 계약번호
// 기본 정보
partnerId: string; // 거래처 ID
partnerName: string; // 거래처명
projectName: string; // 현장명
// 담당자 정보
contractManagerId: string; // 계약담당자 ID
contractManagerName: string; // 계약담당자
constructionPMId: string; // 공사PM ID
constructionPMName: string; // 공사PM
// 계약 정보
totalLocations: number; // 총 개소
contractAmount: number; // 계약금액
contractStartDate: string | null; // 계약시작일
contractEndDate: string | null; // 계약종료일
// 상태 정보
status: ContractStatus; // 계약상태
stage: ContractStage; // 계약단계
// 비고
remarks: string;
// 메타 정보
createdAt: string;
updatedAt: string;
createdBy: string;
// 연결된 입찰 정보
biddingId: string;
biddingCode: string;
}
// 계약 통계
export interface ContractStats {
total: number; // 전체 계약
pending: number; // 계약대기
completed: number; // 계약완료
}
// 단계별 건수
export interface ContractStageCount {
estimateSelected: number; // 견적선정
estimateProgress: number; // 견적진행
delivery: number; // 납품
installation: number; // 설치중
inspection: number; // 검수
other: number; // 기타
}
// 계약 필터
export interface ContractFilter {
search?: string;
status?: ContractStatus | 'all';
stage?: ContractStage | 'all';
partnerId?: string;
contractManagerId?: string;
constructionPMId?: string;
startDate?: string;
endDate?: string;
sortBy?: string;
page?: number;
size?: number;
}
// API 응답 타입
export interface ContractListResponse {
items: Contract[];
total: number;
page: number;
size: number;
totalPages: number;
}
// 상태 옵션
export const CONTRACT_STATUS_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'pending', label: '계약대기' },
{ value: 'completed', label: '계약완료' },
];
// 단계 옵션
export const CONTRACT_STAGE_OPTIONS = [
{ value: 'all', label: '전체' },
{ value: 'estimate_selected', label: '견적선정' },
{ value: 'estimate_progress', label: '견적진행' },
{ value: 'delivery', label: '납품' },
{ value: 'installation', label: '설치중' },
{ value: 'inspection', label: '검수' },
{ value: 'other', label: '기타' },
];
// 정렬 옵션
export const CONTRACT_SORT_OPTIONS = [
{ value: 'contractDateDesc', label: '최신순 (계약일)' },
{ value: 'contractDateAsc', label: '등록순 (계약일)' },
{ value: 'partnerNameAsc', label: '거래처명 오름차순' },
{ value: 'partnerNameDesc', label: '거래처명 내림차순' },
{ value: 'projectNameAsc', label: '현장명 오름차순' },
{ value: 'projectNameDesc', label: '현장명 내림차순' },
{ value: 'amountDesc', label: '계약금액 높은순' },
{ value: 'amountAsc', label: '계약금액 낮은순' },
];
// 상태별 스타일
export const CONTRACT_STATUS_STYLES: Record<ContractStatus, string> = {
pending: 'text-orange-500 font-medium',
completed: 'text-green-600 font-medium',
};
export const CONTRACT_STATUS_LABELS: Record<ContractStatus, string> = {
pending: '계약대기',
completed: '계약완료',
};
// 단계별 라벨
export const CONTRACT_STAGE_LABELS: Record<ContractStage, string> = {
estimate_selected: '견적선정',
estimate_progress: '견적진행',
delivery: '납품',
installation: '설치중',
inspection: '검수',
other: '기타',
};
// 계약 상세 (상세/수정용 확장 타입)
export interface ContractDetail extends Contract {
// 계약서 파일
contractFile?: {
id: string;
fileName: string;
fileUrl: string;
uploadedAt: string;
} | null;
// 첨부 문서 목록
attachments: ContractAttachment[];
}
// 첨부 문서 타입
export interface ContractAttachment {
id: string;
fileName: string;
fileSize: number;
fileUrl: string;
uploadedAt: string;
}
// 계약 폼 데이터
export interface ContractFormData {
contractCode: string;
contractManagerId: string;
contractManagerName: string;
partnerId: string;
partnerName: string;
projectName: string;
contractDate: string;
totalLocations: number;
contractStartDate: string;
contractEndDate: string;
vatType: string;
contractAmount: number;
status: ContractStatus;
remarks: string;
contractFile: File | null;
attachments: File[];
}
// 빈 폼 데이터 생성
export function getEmptyContractFormData(): ContractFormData {
return {
contractCode: '',
contractManagerId: '',
contractManagerName: '',
partnerId: '',
partnerName: '',
projectName: '',
contractDate: '',
totalLocations: 0,
contractStartDate: '',
contractEndDate: '',
vatType: 'excluded',
contractAmount: 0,
status: 'pending',
remarks: '',
contractFile: null,
attachments: [],
};
}
// ContractDetail을 FormData로 변환
export function contractDetailToFormData(detail: ContractDetail): ContractFormData {
return {
contractCode: detail.contractCode,
contractManagerId: detail.contractManagerId,
contractManagerName: detail.contractManagerName,
partnerId: detail.partnerId,
partnerName: detail.partnerName,
projectName: detail.projectName,
contractDate: detail.createdAt,
totalLocations: detail.totalLocations,
contractStartDate: detail.contractStartDate || '',
contractEndDate: detail.contractEndDate || '',
vatType: 'excluded',
contractAmount: detail.contractAmount,
status: detail.status,
remarks: detail.remarks,
contractFile: null,
attachments: [],
};
}
// 부가세 옵션
export const VAT_TYPE_OPTIONS = [
{ value: 'included', label: '부가세 포함' },
{ value: 'excluded', label: '부가세 별도' },
];

View File

@@ -0,0 +1,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>
);
}

View File

@@ -2,7 +2,7 @@
import { useState, useMemo, useCallback, useEffect } from 'react';
import { useRouter } from 'next/navigation';
import { FileText, FileTextIcon, FilePenLine, FileCheck, Plus, Pencil, Trash2 } from 'lucide-react';
import { FileText, FileTextIcon, Clock, FileCheck, Pencil } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { TableCell, TableRow } from '@/components/ui/table';
import { Checkbox } from '@/components/ui/checkbox';
@@ -13,6 +13,7 @@ import {
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { MultiSelectCombobox, MultiSelectOption } from '@/components/ui/multi-select-combobox';
import { IntegratedListTemplateV2, TableColumn, StatCard } from '@/components/templates/IntegratedListTemplateV2';
import { MobileCard } from '@/components/molecules/MobileCard';
import { DateRangeSelector } from '@/components/molecules/DateRangeSelector';
@@ -33,8 +34,6 @@ import {
ESTIMATE_SORT_OPTIONS,
STATUS_STYLES,
STATUS_LABELS,
AWARD_STATUS_LABELS,
AWARD_STATUS_STYLES,
} from './types';
import { getEstimateList, getEstimateStats, deleteEstimate, deleteEstimates } from './actions';
@@ -45,26 +44,23 @@ const tableColumns: TableColumn[] = [
{ key: 'partnerName', label: '거래처', className: 'w-[120px]' },
{ key: 'projectName', label: '현장명', className: 'min-w-[150px]' },
{ key: 'estimatorName', label: '견적자', className: 'w-[80px] text-center' },
{ key: 'itemCount', label: '', className: 'w-[60px] text-center' },
{ key: 'itemCount', label: '총 개소', className: 'w-[80px] text-center' },
{ key: 'estimateAmount', label: '견적금액', className: 'w-[120px] text-right' },
{ key: 'distributionDate', label: '견적배부일', className: 'w-[110px] text-center' },
{ key: 'completedDate', label: '견적완료일', className: 'w-[110px] text-center' },
{ key: 'bidDate', label: '입찰일', className: 'w-[110px] text-center' },
{ key: 'awardStatus', label: '낙찰', className: 'w-[70px] text-center' },
{ key: 'status', label: '상태', className: 'w-[100px] text-center' },
{ key: 'actions', label: '작업', className: 'w-[80px] text-center' },
];
// 목업 거래처 목록
const MOCK_PARTNERS = [
{ value: 'all', label: '전체' },
// 목업 거래처 목록 (다중선택용 - 빈 배열 = 전체)
const MOCK_PARTNERS: MultiSelectOption[] = [
{ value: '1', label: '회사명' },
{ value: '2', label: '야사 대림아파트' },
{ value: '3', label: '여의 현장아파트' },
];
// 목업 견적자 목록
const MOCK_ESTIMATORS = [
{ value: 'all', label: '전체' },
// 목업 견적자 목록 (다중선택용 - 빈 배열 = 전체)
const MOCK_ESTIMATORS: MultiSelectOption[] = [
{ value: 'hong', label: '홍길동' },
{ value: 'kim', label: '김철수' },
{ value: 'lee', label: '이영희' },
@@ -87,8 +83,8 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
const [estimates, setEstimates] = useState<Estimate[]>(initialData);
const [stats, setStats] = useState<EstimateStats | null>(initialStats || null);
const [searchValue, setSearchValue] = useState('');
const [partnerFilter, setPartnerFilter] = useState<string>('all');
const [estimatorFilter, setEstimatorFilter] = useState<string>('all');
const [partnerFilters, setPartnerFilters] = useState<string[]>([]);
const [estimatorFilters, setEstimatorFilters] = useState<string[]>([]);
const [statusFilter, setStatusFilter] = useState<string>('all');
const [sortBy, setSortBy] = useState<string>('latest');
const [startDate, setStartDate] = useState<string>('');
@@ -99,7 +95,7 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
const [deleteDialogOpen, setDeleteDialogOpen] = useState(false);
const [deleteTargetId, setDeleteTargetId] = useState<string | null>(null);
const [bulkDeleteDialogOpen, setBulkDeleteDialogOpen] = useState(false);
const [activeStatTab, setActiveStatTab] = useState<'all' | 'drafting' | 'completed'>('all');
const [activeStatTab, setActiveStatTab] = useState<'all' | 'pending' | 'completed'>('all');
const itemsPerPage = 20;
// 데이터 로드
@@ -139,14 +135,18 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
const filteredEstimates = useMemo(() => {
return estimates.filter((estimate) => {
// 상태 탭 필터
if (activeStatTab === 'drafting' && estimate.status !== 'drafting') return false;
if (activeStatTab === 'pending' && estimate.status !== 'pending') return false;
if (activeStatTab === 'completed' && estimate.status !== 'completed') return false;
// 거래처 필터
if (partnerFilter !== 'all' && estimate.partnerId !== partnerFilter) return false;
// 거래처 필터 (다중선택 - 빈 배열 = 전체)
if (partnerFilters.length > 0) {
if (!partnerFilters.includes(estimate.partnerId)) return false;
}
// 견적자 필터
if (estimatorFilter !== 'all' && estimate.estimatorId !== estimatorFilter) return false;
// 견적자 필터 (다중선택 - 빈 배열 = 전체)
if (estimatorFilters.length > 0) {
if (!estimatorFilters.includes(estimate.estimatorId)) return false;
}
// 상태 필터
if (statusFilter !== 'all' && estimate.status !== statusFilter) return false;
@@ -162,23 +162,25 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
}
return true;
});
}, [estimates, activeStatTab, partnerFilter, estimatorFilter, statusFilter, searchValue]);
}, [estimates, activeStatTab, partnerFilters, estimatorFilters, statusFilter, searchValue]);
// 정렬
const sortedEstimates = useMemo(() => {
const sorted = [...filteredEstimates];
switch (sortBy) {
case 'latest':
sorted.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
sorted.sort((a, b) => {
const dateA = a.completedDate ? new Date(a.completedDate).getTime() : new Date(a.createdAt).getTime();
const dateB = b.completedDate ? new Date(b.completedDate).getTime() : new Date(b.createdAt).getTime();
return dateB - dateA;
});
break;
case 'oldest':
sorted.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
break;
case 'amountDesc':
sorted.sort((a, b) => b.estimateAmount - a.estimateAmount);
break;
case 'amountAsc':
sorted.sort((a, b) => a.estimateAmount - b.estimateAmount);
sorted.sort((a, b) => {
const dateA = a.completedDate ? new Date(a.completedDate).getTime() : new Date(a.createdAt).getTime();
const dateB = b.completedDate ? new Date(b.completedDate).getTime() : new Date(b.createdAt).getTime();
return dateA - dateB;
});
break;
case 'bidDateDesc':
sorted.sort((a, b) => {
@@ -187,6 +189,18 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
return new Date(b.bidDate).getTime() - new Date(a.bidDate).getTime();
});
break;
case 'partnerNameAsc':
sorted.sort((a, b) => a.partnerName.localeCompare(b.partnerName, 'ko'));
break;
case 'partnerNameDesc':
sorted.sort((a, b) => b.partnerName.localeCompare(a.partnerName, 'ko'));
break;
case 'projectNameAsc':
sorted.sort((a, b) => a.projectName.localeCompare(b.projectName, 'ko'));
break;
case 'projectNameDesc':
sorted.sort((a, b) => b.projectName.localeCompare(a.projectName, 'ko'));
break;
}
return sorted;
}, [filteredEstimates, sortBy]);
@@ -231,10 +245,6 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
[router]
);
const handleCreate = useCallback(() => {
router.push('/ko/juil/project/bidding/estimates/new');
}, [router]);
const handleEdit = useCallback(
(e: React.MouseEvent, estimateId: string) => {
e.stopPropagation();
@@ -329,13 +339,8 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
<TableCell className="text-center">{estimate.estimatorName}</TableCell>
<TableCell className="text-center">{estimate.itemCount}</TableCell>
<TableCell className="text-right">{formatAmount(estimate.estimateAmount)}</TableCell>
<TableCell className="text-center">{estimate.distributionDate || '-'}</TableCell>
<TableCell className="text-center">{estimate.completedDate || '-'}</TableCell>
<TableCell className="text-center">{estimate.bidDate || '-'}</TableCell>
<TableCell className="text-center">
<span className={AWARD_STATUS_STYLES[estimate.awardStatus]}>
{AWARD_STATUS_LABELS[estimate.awardStatus]}
</span>
</TableCell>
<TableCell className="text-center">
<span className={STATUS_STYLES[estimate.status]}>
{STATUS_LABELS[estimate.status]}
@@ -352,21 +357,13 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
>
<Pencil className="h-4 w-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={(e) => handleDeleteClick(e, estimate.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
)}
</TableCell>
</TableRow>
);
},
[selectedItems, handleToggleSelection, handleRowClick, handleEdit, handleDeleteClick]
[selectedItems, handleToggleSelection, handleRowClick, handleEdit]
);
// 모바일 카드 렌더링
@@ -393,26 +390,20 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
[handleRowClick]
);
// 헤더 액션 (등록 버튼 + 날짜 필터)
// 헤더 액션 (날짜 필터만 - 견적등록은 현장설명회 참석완료 시 자동 등록)
const headerActions = (
<DateRangeSelector
startDate={startDate}
endDate={endDate}
onStartDateChange={setStartDate}
onEndDateChange={setEndDate}
extraActions={
<Button onClick={handleCreate}>
<Plus className="mr-2 h-4 w-4" />
</Button>
}
/>
);
// Stats 카드 데이터 (StatCards 컴포넌트용)
const statsCardsData: StatCard[] = [
{
label: '전체',
label: '전체 견적',
value: stats?.total ?? 0,
icon: FileTextIcon,
iconColor: 'text-blue-600',
@@ -420,12 +411,12 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
isActive: activeStatTab === 'all',
},
{
label: '견적작성중',
value: stats?.drafting ?? 0,
icon: FilePenLine,
label: '견적대기',
value: stats?.pending ?? 0,
icon: Clock,
iconColor: 'text-orange-500',
onClick: () => setActiveStatTab('drafting'),
isActive: activeStatTab === 'drafting',
onClick: () => setActiveStatTab('pending'),
isActive: activeStatTab === 'pending',
},
{
label: '견적완료',
@@ -444,33 +435,25 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
{sortedEstimates.length}
</span>
{/* 거래처 필터 */}
<Select value={partnerFilter} onValueChange={setPartnerFilter}>
<SelectTrigger className="w-[130px]">
<SelectValue placeholder="거래처" />
</SelectTrigger>
<SelectContent>
{MOCK_PARTNERS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 거래처 필터 (다중선택) */}
<MultiSelectCombobox
options={MOCK_PARTNERS}
value={partnerFilters}
onChange={setPartnerFilters}
placeholder="거래처"
searchPlaceholder="거래처 검색..."
className="w-[120px]"
/>
{/* 견적자 필터 */}
<Select value={estimatorFilter} onValueChange={setEstimatorFilter}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="견적자" />
</SelectTrigger>
<SelectContent>
{MOCK_ESTIMATORS.map((option) => (
<SelectItem key={option.value} value={option.value}>
{option.label}
</SelectItem>
))}
</SelectContent>
</Select>
{/* 견적자 필터 (다중선택) */}
<MultiSelectCombobox
options={MOCK_ESTIMATORS}
value={estimatorFilters}
onChange={setEstimatorFilters}
placeholder="견적자"
searchPlaceholder="견적자 검색..."
className="w-[120px]"
/>
{/* 상태 필터 */}
<Select value={statusFilter} onValueChange={setStatusFilter}>
@@ -523,7 +506,6 @@ export default function EstimateListClient({ initialData = [], initialStats }: E
selectedItems={selectedItems}
onToggleSelection={handleToggleSelection}
onToggleSelectAll={handleToggleSelectAll}
onBulkDelete={handleBulkDeleteClick}
pagination={{
currentPage,
totalPages,

View File

@@ -19,10 +19,9 @@ const mockEstimates: Estimate[] = [
estimatorName: '홍길동',
itemCount: 8,
estimateAmount: 100000000,
distributionDate: '2025-12-15',
completedDate: null,
bidDate: '2025-12-15',
status: 'drafting',
awardStatus: 'pending',
status: 'pending',
createdAt: '2025-01-01',
updatedAt: '2025-01-01',
createdBy: '홍길동',
@@ -37,10 +36,9 @@ const mockEstimates: Estimate[] = [
estimatorName: '홍길동',
itemCount: 8,
estimateAmount: 100000000,
distributionDate: '2025-12-15',
completedDate: null,
bidDate: '2025-12-15',
status: 'drafting',
awardStatus: 'pending',
status: 'pending',
createdAt: '2025-01-02',
updatedAt: '2025-01-02',
createdBy: '홍길동',
@@ -55,10 +53,9 @@ const mockEstimates: Estimate[] = [
estimatorName: '홍길동',
itemCount: 21,
estimateAmount: 50000000,
distributionDate: '2025-12-15',
completedDate: null,
bidDate: '2025-12-15',
status: 'drafting',
awardStatus: 'pending',
status: 'pending',
createdAt: '2025-01-03',
updatedAt: '2025-01-03',
createdBy: '홍길동',
@@ -73,10 +70,9 @@ const mockEstimates: Estimate[] = [
estimatorName: '홍길동',
itemCount: 0,
estimateAmount: 10000000,
distributionDate: null,
completedDate: '2025-12-10',
bidDate: '2025-12-15',
status: 'completed',
awardStatus: 'pending',
createdAt: '2025-01-04',
updatedAt: '2025-01-04',
createdBy: '홍길동',
@@ -91,10 +87,9 @@ const mockEstimates: Estimate[] = [
estimatorName: '홍길동',
itemCount: 0,
estimateAmount: 10000000,
distributionDate: null,
completedDate: '2025-12-11',
bidDate: '2025-12-15',
status: 'completed',
awardStatus: 'pending',
createdAt: '2025-01-05',
updatedAt: '2025-01-05',
createdBy: '홍길동',
@@ -109,10 +104,9 @@ const mockEstimates: Estimate[] = [
estimatorName: '홍길동',
itemCount: 0,
estimateAmount: 10000000,
distributionDate: null,
completedDate: '2025-12-12',
bidDate: '2025-12-15',
status: 'completed',
awardStatus: 'awarded',
createdAt: '2025-01-06',
updatedAt: '2025-01-06',
createdBy: '홍길동',
@@ -127,10 +121,9 @@ const mockEstimates: Estimate[] = [
estimatorName: '김철수',
itemCount: 15,
estimateAmount: 200000000,
distributionDate: '2025-12-18',
completedDate: null,
bidDate: '2025-12-20',
status: 'drafting',
awardStatus: 'pending',
status: 'pending',
createdAt: '2025-01-07',
updatedAt: '2025-01-07',
createdBy: '김철수',
@@ -246,17 +239,15 @@ export async function getEstimate(
export async function getEstimateStats(): Promise<{ success: boolean; data?: EstimateStats; error?: string }> {
try {
const total = mockEstimates.length;
const drafting = mockEstimates.filter((e) => e.status === 'drafting').length;
const pending = mockEstimates.filter((e) => e.status === 'pending').length;
const completed = mockEstimates.filter((e) => e.status === 'completed').length;
const awarded = mockEstimates.filter((e) => e.awardStatus === 'awarded').length;
return {
success: true,
data: {
total,
drafting,
pending,
completed,
awarded,
},
};
} catch (error) {

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,2 @@
// 공통 컴포넌트 re-export
export { ElectronicApprovalModal } from '../../common/modals/ElectronicApprovalModal';

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
export * from './EstimateInfoSection';
export * from './EstimateSummarySection';
export * from './ExpenseDetailSection';
export * from './PriceAdjustmentSection';
export * from './EstimateDetailTableSection';

View File

@@ -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: '보류',
};

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

View File

@@ -0,0 +1,4 @@
// 금액 포맷팅
export function formatAmount(amount: number): string {
return new Intl.NumberFormat('ko-KR').format(amount);
}

View File

@@ -0,0 +1,2 @@
export * from './constants';
export * from './formatters';

View File

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

View File

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

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

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

View File

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

View File

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

View 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: '옵을' },
];

View File

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

View File

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

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

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

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

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

View File

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

View File

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

View 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: '노임 일괄 삭제에 실패했습니다.' };
}
}

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

View 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