refactor: 모달 Content 컴포넌트 분리 및 파일 입력 UI 공통화
- 모달 컴포넌트에서 Content 분리하여 재사용성 향상 - EstimateDocumentContent, DirectConstructionContent 등 - WorkLogContent, QuotePreviewContent, ReceivingReceiptContent - 파일 입력 공통 UI 컴포넌트 추가 - file-dropzone, file-input, file-list, image-upload - 폼 컴포넌트 코드 정리 및 중복 제거 (-4,056줄) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
298
claudedocs/dev/[REF] page-builder-implementation.md
Normal file
298
claudedocs/dev/[REF] page-builder-implementation.md
Normal file
@@ -0,0 +1,298 @@
|
||||
# 페이지 빌더 (Page Builder) 구현 문서
|
||||
|
||||
> **작성일**: 2026-01-22
|
||||
> **상태**: 개발 중 (테스트 버전)
|
||||
> **경로**: `/dev/page-builder`
|
||||
> **Git 상태**: `.gitignore`에 등록되어 버전 관리 제외 (테스트용)
|
||||
|
||||
---
|
||||
|
||||
## 1. 개요
|
||||
|
||||
### 1.1 목적
|
||||
품목기준관리(Item Master Data Management)의 폼 구조를 **시각적으로(WYSIWYG)** 편집할 수 있는 Framer 스타일의 페이지 빌더입니다.
|
||||
|
||||
### 1.2 핵심 기능
|
||||
- 드래그 앤 드롭으로 섹션/필드 배치
|
||||
- 실시간 미리보기 (데스크탑/태블릿/모바일)
|
||||
- **API 연동**: 품목기준관리 API와 동기화
|
||||
- 조건부 표시 설정 (필드 값에 따른 섹션/필드 표시/숨김)
|
||||
- Undo/Redo 지원
|
||||
- JSON 내보내기/가져오기
|
||||
|
||||
### 1.3 접근 방법
|
||||
```
|
||||
URL: http://localhost:3000/dev/page-builder
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 2. 파일 구조
|
||||
|
||||
```
|
||||
src/app/[locale]/(protected)/dev/page-builder/
|
||||
├── page.tsx # 페이지 진입점
|
||||
├── PageBuilderClient.tsx # 메인 클라이언트 컴포넌트
|
||||
├── PLAN.md # 초기 기획 문서
|
||||
│
|
||||
├── components/
|
||||
│ ├── index.ts # 컴포넌트 배럴 export
|
||||
│ ├── ComponentPalette.tsx # 좌측 컴포넌트 팔레트 (섹션/필드 드래그)
|
||||
│ ├── BuilderCanvas.tsx # 중앙 캔버스 (드롭 영역, 미리보기)
|
||||
│ ├── PropertyPanel.tsx # 우측 속성 패널 (섹션/필드 편집)
|
||||
│ ├── PageSelector.tsx # 페이지 목록 관리
|
||||
│ └── ConditionEditor.tsx # 조건부 표시 설정 UI
|
||||
│
|
||||
├── hooks/
|
||||
│ ├── usePageBuilder.ts # 섹션/필드 CRUD, 선택 상태 관리
|
||||
│ ├── usePageManager.ts # 페이지 CRUD, API 연동, 저장/로드
|
||||
│ ├── useHistory.ts # Undo/Redo 히스토리 관리
|
||||
│ └── useItemMasterSync.ts # (미사용) 초기 API 동기화 시도
|
||||
│
|
||||
├── types/
|
||||
│ ├── index.ts # 타입 배럴 export
|
||||
│ ├── builder.types.ts # BuilderPage, BuilderSection, BuilderField 등
|
||||
│ └── constants.ts # 필드 타입, 섹션 타입 옵션 상수
|
||||
│
|
||||
└── utils/
|
||||
├── index.ts # 유틸 배럴 export
|
||||
└── transformers.ts # API ↔ Builder 타입 변환 함수
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. 핵심 타입 정의
|
||||
|
||||
### 3.1 BuilderPage
|
||||
```typescript
|
||||
interface BuilderPage {
|
||||
id: string;
|
||||
name: string; // 페이지 이름 (예: "소모품 등록")
|
||||
itemType: ItemType; // 품목 유형 (FG, PT, SM, RM, CS)
|
||||
sections: BuilderSection[];
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.2 BuilderSection
|
||||
```typescript
|
||||
interface BuilderSection {
|
||||
id: string;
|
||||
title: string;
|
||||
description?: string;
|
||||
sectionType: SectionType; // BASIC, BOM, CERTIFICATION 등
|
||||
columns: 1 | 2 | 3; // 레이아웃 열 수
|
||||
isCollapsible?: boolean;
|
||||
isDefaultOpen?: boolean;
|
||||
fields: BuilderField[];
|
||||
displayCondition?: DisplayCondition; // 조건부 표시
|
||||
order: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.3 BuilderField
|
||||
```typescript
|
||||
interface BuilderField {
|
||||
id: string;
|
||||
name: string; // 필드 라벨
|
||||
fieldKey: string; // API 키 (예: "item_name")
|
||||
inputType: FieldInputType; // textbox, number, dropdown 등
|
||||
required: boolean;
|
||||
placeholder?: string;
|
||||
defaultValue?: string;
|
||||
options?: DropdownOption[]; // 드롭다운용
|
||||
colSpan?: 1 | 2 | 3;
|
||||
description?: string;
|
||||
displayCondition?: DisplayCondition;
|
||||
validationRules?: ValidationRule[];
|
||||
order: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 3.4 DisplayCondition (조건부 표시)
|
||||
```typescript
|
||||
interface DisplayCondition {
|
||||
enabled: boolean;
|
||||
logic: 'AND' | 'OR';
|
||||
conditions: FieldCondition[];
|
||||
}
|
||||
|
||||
interface FieldCondition {
|
||||
fieldKey: string;
|
||||
operator: 'equals' | 'not_equals' | 'contains' | 'not_contains';
|
||||
expectedValue: string;
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 4. API 연동
|
||||
|
||||
### 4.1 사용하는 API
|
||||
품목기준관리와 **동일한 API** 사용:
|
||||
```typescript
|
||||
// src/lib/api/item-master.ts
|
||||
itemMasterApi.init() // 전체 페이지/섹션/필드 조회
|
||||
itemMasterApi.sections.create(pageId, data) // 섹션 생성
|
||||
itemMasterApi.sections.update(id, data) // 섹션 수정
|
||||
itemMasterApi.sections.delete(id) // 섹션 삭제
|
||||
itemMasterApi.fields.create(sectionId, data)// 필드 생성
|
||||
itemMasterApi.fields.update(id, data) // 필드 수정
|
||||
itemMasterApi.fields.delete(id) // 필드 삭제
|
||||
```
|
||||
|
||||
### 4.2 API 모드 토글
|
||||
- **API 모드 ON**: 백엔드 API에서 데이터 로드/저장
|
||||
- **API 모드 OFF**: localStorage에서 로드/저장 (오프라인 테스트용)
|
||||
- 설정은 localStorage에 저장되어 새로고침 후에도 유지
|
||||
|
||||
### 4.3 동기화 로직 (usePageManager.ts)
|
||||
```typescript
|
||||
const syncPageToAPI = async (page: BuilderPage) => {
|
||||
// 1. 원본 데이터와 현재 데이터 비교
|
||||
// 2. 삭제된 섹션/필드 → API DELETE 호출
|
||||
// 3. 새로 추가된 섹션/필드 → API CREATE 호출
|
||||
// 4. 수정된 섹션/필드 → API UPDATE 호출
|
||||
// 5. 완료 후 API에서 최신 데이터 다시 로드
|
||||
};
|
||||
```
|
||||
|
||||
### 4.4 타입 변환 (transformers.ts)
|
||||
```typescript
|
||||
// API → Builder 변환
|
||||
transformPagesToBuilder(apiData): BuilderPage[]
|
||||
|
||||
// Builder → API 변환
|
||||
transformSectionToAPI(section): APISectionCreateData
|
||||
transformFieldToAPI(field): APIFieldCreateData
|
||||
|
||||
// ID 구분 (API ID는 숫자, 로컬 ID는 문자열)
|
||||
isApiId(id: string): boolean // "123" → true, "section_abc123" → false
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 주요 컴포넌트 설명
|
||||
|
||||
### 5.1 PageBuilderClient.tsx
|
||||
메인 컴포넌트로 전체 레이아웃 구성:
|
||||
- 상단: 툴바 (미리보기 모드, Undo/Redo, 저장 버튼들)
|
||||
- 좌측: PageSelector + ComponentPalette
|
||||
- 중앙: BuilderCanvas
|
||||
- 우측: PropertyPanel
|
||||
|
||||
주요 상태:
|
||||
```typescript
|
||||
const [isAPIMode, setIsAPIMode] = useState(true); // API 모드
|
||||
const [previewMode, setPreviewMode] = useState<'desktop' | 'tablet' | 'mobile'>('desktop');
|
||||
```
|
||||
|
||||
### 5.2 PageSelector.tsx
|
||||
페이지 목록 관리:
|
||||
- 페이지 선택/생성/삭제/복제/이름변경
|
||||
- 호버 시 액션 버튼 표시 (배경색 포함으로 긴 제목도 가리지 않음)
|
||||
|
||||
### 5.3 BuilderCanvas.tsx
|
||||
드래그 앤 드롭 캔버스:
|
||||
- 섹션 드롭 → 새 섹션 추가
|
||||
- 필드 드롭 → 해당 섹션에 필드 추가
|
||||
- 요소 클릭 → 선택 (PropertyPanel에서 편집)
|
||||
|
||||
### 5.4 PropertyPanel.tsx
|
||||
선택된 요소의 속성 편집:
|
||||
- 섹션: 제목, 설명, 타입, 열 수, 접기 설정, 조건부 표시
|
||||
- 필드: 이름, 키, 타입, 필수여부, 옵션, 조건부 표시
|
||||
|
||||
### 5.5 ConditionEditor.tsx
|
||||
조건부 표시 설정 UI:
|
||||
- 다른 필드 값에 따라 현재 섹션/필드 표시/숨김
|
||||
- AND/OR 논리 연산 지원
|
||||
- 여러 조건 추가 가능
|
||||
|
||||
---
|
||||
|
||||
## 6. 사용 방법
|
||||
|
||||
### 6.1 기본 워크플로우
|
||||
1. `/dev/page-builder` 접속
|
||||
2. **API 모드 ON** 확인 (우측 상단 토글)
|
||||
3. 좌측에서 페이지 선택 (소모품, 원자재 등)
|
||||
4. 컴포넌트 팔레트에서 섹션/필드를 캔버스로 드래그
|
||||
5. 캔버스에서 요소 클릭 → 우측 패널에서 속성 편집
|
||||
6. **"API 저장"** 버튼 클릭 → 백엔드에 저장
|
||||
7. 품목기준관리 페이지에서 변경사항 확인
|
||||
|
||||
### 6.2 저장 버튼 종류
|
||||
| 버튼 | 기능 |
|
||||
|------|------|
|
||||
| API 저장 (초록색) | 현재 페이지를 백엔드 API에 저장 |
|
||||
| 현재 페이지 저장 (JSON) | 현재 페이지를 JSON 파일로 다운로드 |
|
||||
| 전체 저장 | 모든 페이지를 JSON 파일로 다운로드 |
|
||||
| 가져오기 | JSON 파일에서 페이지 데이터 로드 |
|
||||
|
||||
### 6.3 Undo/Redo
|
||||
- **실행 취소**: 마지막 작업 취소
|
||||
- **다시 실행**: 취소한 작업 복원
|
||||
- 히스토리는 세션 동안만 유지
|
||||
|
||||
---
|
||||
|
||||
## 7. 테스트 완료 항목
|
||||
|
||||
### 7.1 API 연동 테스트 (2026-01-22)
|
||||
| 테스트 | 결과 |
|
||||
|--------|------|
|
||||
| 페이지 빌더에서 섹션 제목 수정 | ✅ "기본정보" → "기본정보 (빌더테스트)" |
|
||||
| API 저장 버튼 클릭 | ✅ `PUT /api/proxy/item-master/sections/92` (200 OK) |
|
||||
| 품목기준관리 페이지 반영 확인 | ✅ 변경된 제목 표시 확인 |
|
||||
|
||||
### 7.2 UI 수정 (2026-01-22)
|
||||
- 페이지 목록 호버 버튼에 배경색 추가 (긴 제목 가림 방지)
|
||||
|
||||
---
|
||||
|
||||
## 8. 알려진 이슈 / TODO
|
||||
|
||||
### 8.1 현재 이슈
|
||||
- [ ] 새 섹션/필드 생성 후 order 값 자동 계산 필요
|
||||
- [ ] BOM 섹션 타입 특수 처리 (자재명세표)
|
||||
- [ ] 드래그 앤 드롭 순서 변경 시 API order 업데이트
|
||||
|
||||
### 8.2 향후 개선 사항
|
||||
- [ ] 실시간 미리보기에서 실제 폼 렌더링 (DynamicItemForm 연동)
|
||||
- [ ] 필드 유효성 검사 규칙 편집 UI
|
||||
- [ ] 섹션/필드 복사-붙여넣기
|
||||
- [ ] 다중 선택 및 일괄 편집
|
||||
- [ ] 변경사항 diff 뷰어
|
||||
|
||||
### 8.3 Git 관련
|
||||
- 현재 `.gitignore`에 등록되어 있음: `src/app/**/dev/page-builder/`
|
||||
- 정식 배포 시 gitignore에서 제거 필요
|
||||
|
||||
---
|
||||
|
||||
## 9. 관련 파일
|
||||
|
||||
### 9.1 품목기준관리 (연동 대상)
|
||||
```
|
||||
src/app/[locale]/(protected)/master-data/item-master-data-management/page.tsx
|
||||
src/components/items/ItemMasterDataManagement/
|
||||
src/lib/api/item-master.ts
|
||||
```
|
||||
|
||||
### 9.2 동적 품목 폼 (렌더링 대상)
|
||||
```
|
||||
src/components/items/DynamicItemForm/
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 10. 참고 문서
|
||||
|
||||
- 초기 기획: `src/app/[locale]/(protected)/dev/page-builder/PLAN.md`
|
||||
- API 문서: `claudedocs/api/item-master-api.md` (있다면)
|
||||
|
||||
---
|
||||
|
||||
*마지막 업데이트: 2026-01-22*
|
||||
@@ -1,11 +1,12 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { Plus, X, Upload, FileText, ExternalLink } from 'lucide-react';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone';
|
||||
import { FileList, type NewFile, type ExistingFile } from '@/components/ui/file-list';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -30,8 +31,6 @@ interface ExpenseReportFormProps {
|
||||
}
|
||||
|
||||
export function ExpenseReportForm({ data, onChange }: ExpenseReportFormProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleAddItem = () => {
|
||||
const newItem: ExpenseReportItem = {
|
||||
id: `item-${Date.now()}`,
|
||||
@@ -55,11 +54,8 @@ export function ExpenseReportForm({ data, onChange }: ExpenseReportFormProps) {
|
||||
onChange({ ...data, items: newItems, totalAmount });
|
||||
};
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files) {
|
||||
onChange({ ...data, attachments: [...data.attachments, ...Array.from(files)] });
|
||||
}
|
||||
const handleFilesSelect = (files: File[]) => {
|
||||
onChange({ ...data, attachments: [...data.attachments, ...files] });
|
||||
};
|
||||
|
||||
// 기존 업로드 파일 삭제
|
||||
@@ -218,113 +214,31 @@ export function ExpenseReportForm({ data, onChange }: ExpenseReportFormProps) {
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>첨부파일</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
파일 추가
|
||||
</Button>
|
||||
</div>
|
||||
<FileDropzone
|
||||
onFilesSelect={handleFilesSelect}
|
||||
multiple
|
||||
accept="image/*"
|
||||
maxSize={10}
|
||||
compact
|
||||
title="클릭하거나 파일을 드래그하세요"
|
||||
description="이미지 파일만 업로드 가능합니다"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 기존 업로드된 파일 목록 */}
|
||||
{data.uploadedFiles && data.uploadedFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-gray-600">저장된 파일</Label>
|
||||
<div className="space-y-2">
|
||||
{data.uploadedFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between gap-2 p-2 bg-gray-50 rounded-md"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<FileText className="w-4 h-4 text-gray-500 flex-shrink-0" />
|
||||
<span className="text-sm truncate">{file.name}</span>
|
||||
{file.size && (
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||
({Math.round(file.size / 1024)}KB)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{file.url && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(file.url, '_blank')}
|
||||
title="파일 보기"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveUploadedFile(file.id)}
|
||||
title="파일 삭제"
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 새로 추가할 파일 목록 */}
|
||||
{data.attachments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-gray-600">새로 추가할 파일</Label>
|
||||
<div className="space-y-2">
|
||||
{data.attachments.map((file, index) => (
|
||||
<div
|
||||
key={`new-${index}`}
|
||||
className="flex items-center justify-between gap-2 p-2 bg-blue-50 rounded-md"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<FileText className="w-4 h-4 text-blue-500 flex-shrink-0" />
|
||||
<span className="text-sm truncate">{file.name}</span>
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||
({Math.round(file.size / 1024)}KB)
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveAttachment(index)}
|
||||
title="파일 삭제"
|
||||
className="text-red-500 hover:text-red-700 flex-shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일이 없을 때 안내 메시지 */}
|
||||
{(!data.uploadedFiles || data.uploadedFiles.length === 0) && data.attachments.length === 0 && (
|
||||
<div className="text-sm text-gray-500 text-center py-4 border border-dashed rounded-md">
|
||||
첨부된 파일이 없습니다. 위 버튼을 클릭하여 파일을 추가하세요.
|
||||
</div>
|
||||
)}
|
||||
{/* 파일 목록 */}
|
||||
<FileList
|
||||
files={data.attachments.map((file): NewFile => ({ file }))}
|
||||
existingFiles={(data.uploadedFiles || []).map((file): ExistingFile => ({
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
url: file.url,
|
||||
size: file.size,
|
||||
}))}
|
||||
onRemove={handleRemoveAttachment}
|
||||
onRemoveExisting={(id) => handleRemoveUploadedFile(id as number)}
|
||||
emptyMessage="첨부된 파일이 없습니다"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1,12 +1,13 @@
|
||||
'use client';
|
||||
|
||||
import { useRef } from 'react';
|
||||
import { Mic, Upload, X, FileText, ExternalLink } from 'lucide-react';
|
||||
import { Mic } from 'lucide-react';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { Textarea } from '@/components/ui/textarea';
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone';
|
||||
import { FileList, type NewFile, type ExistingFile } from '@/components/ui/file-list';
|
||||
import type { ProposalData, UploadedFile } from './types';
|
||||
|
||||
interface ProposalFormProps {
|
||||
@@ -15,13 +16,8 @@ interface ProposalFormProps {
|
||||
}
|
||||
|
||||
export function ProposalForm({ data, onChange }: ProposalFormProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const handleFileChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files) {
|
||||
onChange({ ...data, attachments: [...data.attachments, ...Array.from(files)] });
|
||||
}
|
||||
const handleFilesSelect = (files: File[]) => {
|
||||
onChange({ ...data, attachments: [...data.attachments, ...files] });
|
||||
};
|
||||
|
||||
// 기존 업로드 파일 삭제
|
||||
@@ -149,113 +145,31 @@ export function ProposalForm({ data, onChange }: ProposalFormProps) {
|
||||
<div className="space-y-4">
|
||||
<div className="space-y-2">
|
||||
<Label>첨부파일</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={handleFileChange}
|
||||
accept="image/*"
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
파일 추가
|
||||
</Button>
|
||||
</div>
|
||||
<FileDropzone
|
||||
onFilesSelect={handleFilesSelect}
|
||||
multiple
|
||||
accept="image/*"
|
||||
maxSize={10}
|
||||
compact
|
||||
title="클릭하거나 파일을 드래그하세요"
|
||||
description="이미지 파일만 업로드 가능합니다"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 기존 업로드된 파일 목록 */}
|
||||
{data.uploadedFiles && data.uploadedFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-gray-600">저장된 파일</Label>
|
||||
<div className="space-y-2">
|
||||
{data.uploadedFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between gap-2 p-2 bg-gray-50 rounded-md"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<FileText className="w-4 h-4 text-gray-500 flex-shrink-0" />
|
||||
<span className="text-sm truncate">{file.name}</span>
|
||||
{file.size && (
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||
({Math.round(file.size / 1024)}KB)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-1 flex-shrink-0">
|
||||
{file.url && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => window.open(file.url, '_blank')}
|
||||
title="파일 보기"
|
||||
>
|
||||
<ExternalLink className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveUploadedFile(file.id)}
|
||||
title="파일 삭제"
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 새로 추가할 파일 목록 */}
|
||||
{data.attachments.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm text-gray-600">새로 추가할 파일</Label>
|
||||
<div className="space-y-2">
|
||||
{data.attachments.map((file, index) => (
|
||||
<div
|
||||
key={`new-${index}`}
|
||||
className="flex items-center justify-between gap-2 p-2 bg-blue-50 rounded-md"
|
||||
>
|
||||
<div className="flex items-center gap-2 flex-1 min-w-0">
|
||||
<FileText className="w-4 h-4 text-blue-500 flex-shrink-0" />
|
||||
<span className="text-sm truncate">{file.name}</span>
|
||||
<span className="text-xs text-gray-400 flex-shrink-0">
|
||||
({Math.round(file.size / 1024)}KB)
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveAttachment(index)}
|
||||
title="파일 삭제"
|
||||
className="text-red-500 hover:text-red-700 flex-shrink-0"
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 파일이 없을 때 안내 메시지 */}
|
||||
{(!data.uploadedFiles || data.uploadedFiles.length === 0) && data.attachments.length === 0 && (
|
||||
<div className="text-sm text-gray-500 text-center py-4 border border-dashed rounded-md">
|
||||
첨부된 파일이 없습니다. 위 버튼을 클릭하여 파일을 추가하세요.
|
||||
</div>
|
||||
)}
|
||||
{/* 파일 목록 */}
|
||||
<FileList
|
||||
files={data.attachments.map((file): NewFile => ({ file }))}
|
||||
existingFiles={(data.uploadedFiles || []).map((file): ExistingFile => ({
|
||||
id: file.id,
|
||||
name: file.name,
|
||||
url: file.url,
|
||||
size: file.size,
|
||||
}))}
|
||||
onRemove={handleRemoveAttachment}
|
||||
onRemoveExisting={(id) => handleRemoveUploadedFile(id as number)}
|
||||
emptyMessage="첨부된 파일이 없습니다"
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -10,11 +10,13 @@
|
||||
* - 필드: 게시판, 상단 노출, 제목, 내용(에디터), 첨부파일, 작성자, 댓글, 등록일시
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useEffect } from 'react';
|
||||
import { useState, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format } from 'date-fns';
|
||||
import { Upload, X, File, Loader2 } from 'lucide-react';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone';
|
||||
import { FileList, type NewFile, type ExistingFile } from '@/components/ui/file-list';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { boardCreateConfig, boardEditConfig } from './boardFormConfig';
|
||||
import { Button } from '@/components/ui/button';
|
||||
@@ -70,7 +72,6 @@ const MAX_PINNED_COUNT = 5;
|
||||
|
||||
export function BoardForm({ mode, initialData }: BoardFormProps) {
|
||||
const router = useRouter();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ===== 폼 상태 =====
|
||||
const [boardCode, setBoardCode] = useState(initialData?.boardCode || '');
|
||||
@@ -78,9 +79,14 @@ export function BoardForm({ mode, initialData }: BoardFormProps) {
|
||||
const [title, setTitle] = useState(initialData?.title || '');
|
||||
const [content, setContent] = useState(initialData?.content || '');
|
||||
const [allowComments, setAllowComments] = useState(initialData?.allowComments ? 'true' : 'false');
|
||||
const [attachments, setAttachments] = useState<File[]>([]);
|
||||
const [existingAttachments, setExistingAttachments] = useState<Attachment[]>(
|
||||
initialData?.attachments || []
|
||||
const [attachments, setAttachments] = useState<NewFile[]>([]);
|
||||
const [existingAttachments, setExistingAttachments] = useState<ExistingFile[]>(
|
||||
(initialData?.attachments || []).map(a => ({
|
||||
id: a.id,
|
||||
name: a.fileName,
|
||||
url: a.fileUrl,
|
||||
size: a.fileSize,
|
||||
}))
|
||||
);
|
||||
|
||||
// 상단 노출 초과 Alert
|
||||
@@ -128,22 +134,16 @@ export function BoardForm({ mode, initialData }: BoardFormProps) {
|
||||
}, []);
|
||||
|
||||
// ===== 파일 업로드 핸들러 =====
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files) {
|
||||
setAttachments((prev) => [...prev, ...Array.from(files)]);
|
||||
}
|
||||
// 파일 input 초기화
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
const handleFilesSelect = useCallback((files: File[]) => {
|
||||
const newFiles = files.map(file => ({ file }));
|
||||
setAttachments((prev) => [...prev, ...newFiles]);
|
||||
}, []);
|
||||
|
||||
const handleRemoveFile = useCallback((index: number) => {
|
||||
setAttachments((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
const handleRemoveExistingFile = useCallback((id: string) => {
|
||||
const handleRemoveExistingFile = useCallback((id: string | number) => {
|
||||
setExistingAttachments((prev) => prev.filter((a) => a.id !== id));
|
||||
}, []);
|
||||
|
||||
@@ -206,13 +206,6 @@ export function BoardForm({ mode, initialData }: BoardFormProps) {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
// 파일 크기 포맷
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
// ===== 폼 콘텐츠 렌더링 =====
|
||||
const renderFormContent = useCallback(() => (
|
||||
<>
|
||||
@@ -314,80 +307,21 @@ export function BoardForm({ mode, initialData }: BoardFormProps) {
|
||||
{/* 첨부파일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>첨부파일</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
찾기
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 기존 파일 목록 */}
|
||||
{existingAttachments.length > 0 && (
|
||||
<div className="space-y-2 mt-3">
|
||||
{existingAttachments.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between p-2 bg-gray-50 rounded-md"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<File className="h-4 w-4 text-gray-500" />
|
||||
<span className="text-sm">{file.fileName}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
({formatFileSize(file.fileSize)})
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveExistingFile(file.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 새로 추가된 파일 목록 */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="space-y-2 mt-3">
|
||||
{attachments.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-2 bg-blue-50 rounded-md"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<File className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm">{file.name}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
({formatFileSize(file.size)})
|
||||
</span>
|
||||
<span className="text-xs text-blue-500">(새 파일)</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveFile(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<FileDropzone
|
||||
onFilesSelect={handleFilesSelect}
|
||||
multiple
|
||||
maxSize={10}
|
||||
compact
|
||||
title="클릭하거나 파일을 드래그하세요"
|
||||
description="최대 10MB"
|
||||
/>
|
||||
<FileList
|
||||
files={attachments}
|
||||
existingFiles={existingAttachments}
|
||||
onRemove={handleRemoveFile}
|
||||
onRemoveExisting={handleRemoveExistingFile}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 작성자 (읽기 전용) */}
|
||||
@@ -471,7 +405,7 @@ export function BoardForm({ mode, initialData }: BoardFormProps) {
|
||||
), [
|
||||
boardCode, isPinned, title, content, allowComments, errors, boards,
|
||||
isBoardsLoading, mode, initialData, attachments, existingAttachments,
|
||||
showPinnedAlert, formatFileSize, handlePinnedChange, handleFileSelect,
|
||||
showPinnedAlert, handlePinnedChange, handleFilesSelect,
|
||||
handleRemoveFile, handleRemoveExistingFile,
|
||||
]);
|
||||
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Upload, X, Eye, Download, FileText } from 'lucide-react';
|
||||
import { Eye, Download, FileText, X } from 'lucide-react';
|
||||
import { FileInput } from '@/components/ui/file-input';
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone';
|
||||
import { FileList, type NewFile, type ExistingFile } from '@/components/ui/file-list';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -43,13 +46,6 @@ 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' | 'create';
|
||||
contractId: string;
|
||||
@@ -96,12 +92,6 @@ export default function ContractDetailForm({
|
||||
getEmptyElectronicApproval()
|
||||
);
|
||||
|
||||
// 파일 업로드 ref
|
||||
const contractFileInputRef = useRef<HTMLInputElement>(null);
|
||||
const attachmentInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 드래그 상태
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// 변경 계약서 생성 핸들러
|
||||
const handleCreateChangeContract = useCallback(() => {
|
||||
@@ -164,55 +154,19 @@ export default function ContractDetailForm({
|
||||
}, [router, contractId]);
|
||||
|
||||
// 계약서 파일 선택
|
||||
const handleContractFileSelect = useCallback(() => {
|
||||
contractFileInputRef.current?.click();
|
||||
const handleContractFileSelect = useCallback((file: File) => {
|
||||
if (file.type !== 'application/pdf') {
|
||||
toast.error('PDF 파일만 업로드 가능합니다.');
|
||||
return;
|
||||
}
|
||||
setFormData((prev) => ({ ...prev, contractFile: file }));
|
||||
}, []);
|
||||
|
||||
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);
|
||||
// 첨부 파일 선택 (다중)
|
||||
const handleAttachmentsSelect = useCallback((files: File[]) => {
|
||||
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));
|
||||
@@ -462,30 +416,26 @@ export default function ContractDetailForm({
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<div className="space-y-3">
|
||||
{/* 파일 선택 버튼 (수정/생성 모드에서만) */}
|
||||
{/* 파일 선택 (수정/생성 모드에서만) */}
|
||||
{(isEditMode || isCreateMode) && (
|
||||
<Button variant="outline" onClick={handleContractFileSelect}>
|
||||
찾기
|
||||
</Button>
|
||||
<FileInput
|
||||
value={formData.contractFile}
|
||||
onFileSelect={handleContractFileSelect}
|
||||
onFileRemove={() => setFormData((prev) => ({ ...prev, contractFile: null }))}
|
||||
accept=".pdf"
|
||||
buttonText="찾기"
|
||||
placeholder="PDF 파일만 업로드 가능합니다"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 새로 선택한 파일 */}
|
||||
{formData.contractFile && (
|
||||
{/* 새로 선택한 파일 (view 모드에서 표시) */}
|
||||
{isViewMode && 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 || isCreateMode) && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() => setFormData((prev) => ({ ...prev, contractFile: null }))}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
@@ -517,19 +467,6 @@ export default function ContractDetailForm({
|
||||
</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>
|
||||
@@ -539,96 +476,32 @@ export default function ContractDetailForm({
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg">계약 첨부 문서 관리</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
<CardContent className="space-y-4">
|
||||
{/* 드래그 앤 드롭 영역 */}
|
||||
{(isEditMode || isCreateMode) && (
|
||||
<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>
|
||||
<FileDropzone
|
||||
onFilesSelect={handleAttachmentsSelect}
|
||||
multiple
|
||||
title="클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요."
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 파일 목록 */}
|
||||
<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 || isCreateMode) && (
|
||||
<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}
|
||||
<FileList
|
||||
files={newAttachments.map((file): NewFile => ({ file }))}
|
||||
existingFiles={existingAttachments.map((att): ExistingFile => ({
|
||||
id: att.id,
|
||||
name: att.fileName,
|
||||
size: att.fileSize,
|
||||
}))}
|
||||
onRemove={handleRemoveNewAttachment}
|
||||
onRemoveExisting={handleRemoveExistingAttachment}
|
||||
onDownload={(id) => {
|
||||
const att = existingAttachments.find((a) => a.id === id);
|
||||
if (att) handleFileDownload(att.id, att.fileName);
|
||||
}}
|
||||
showRemove={isEditMode || isCreateMode}
|
||||
emptyMessage="첨부된 파일이 없습니다"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { getExpenseItemOptions, updateEstimate, type ExpenseItemOption } from './actions';
|
||||
@@ -74,12 +74,6 @@ export default function EstimateDetailForm({
|
||||
const [showApprovalModal, setShowApprovalModal] = useState(false);
|
||||
const [showDocumentModal, setShowDocumentModal] = useState(false);
|
||||
|
||||
// 파일 업로드 ref
|
||||
const documentInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 드래그 상태
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// 공과 품목 옵션 (Items API에서 조회)
|
||||
const [expenseOptions, setExpenseOptions] = useState<ExpenseItemOption[]>([]);
|
||||
|
||||
@@ -525,33 +519,23 @@ export default function EstimateDetailForm({
|
||||
}, []);
|
||||
|
||||
// ===== 파일 업로드 핸들러 =====
|
||||
const handleDocumentUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
const handleFilesSelect = useCallback((files: File[]) => {
|
||||
files.forEach((file) => {
|
||||
const doc: BidDocument = {
|
||||
id: String(Date.now() + Math.random()),
|
||||
fileName: file.name,
|
||||
fileUrl: URL.createObjectURL(file),
|
||||
fileSize: file.size,
|
||||
};
|
||||
|
||||
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 = '';
|
||||
}
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
bidInfo: {
|
||||
...prev.bidInfo,
|
||||
documents: [...prev.bidInfo.documents, doc],
|
||||
},
|
||||
}));
|
||||
});
|
||||
}, []);
|
||||
|
||||
const handleDocumentRemove = useCallback((docId: string) => {
|
||||
@@ -564,57 +548,6 @@ export default function EstimateDetailForm({
|
||||
}));
|
||||
}, []);
|
||||
|
||||
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 renderHeaderActions = useCallback(() => {
|
||||
if (isViewMode) {
|
||||
@@ -657,15 +590,10 @@ export default function EstimateDetailForm({
|
||||
<EstimateInfoSection
|
||||
formData={formData}
|
||||
isViewMode={isViewMode}
|
||||
isDragging={isDragging}
|
||||
documentInputRef={documentInputRef}
|
||||
onFormDataChange={(updates) => setFormData((prev) => ({ ...prev, ...updates }))}
|
||||
onBidInfoChange={handleBidInfoChange}
|
||||
onDocumentUpload={handleDocumentUpload}
|
||||
onFilesSelect={handleFilesSelect}
|
||||
onDocumentRemove={handleDocumentRemove}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
/>
|
||||
|
||||
{/* 견적 요약 정보 */}
|
||||
@@ -720,16 +648,11 @@ export default function EstimateDetailForm({
|
||||
}, [
|
||||
formData,
|
||||
isViewMode,
|
||||
isDragging,
|
||||
documentInputRef,
|
||||
expenseOptions,
|
||||
appliedPrices,
|
||||
handleBidInfoChange,
|
||||
handleDocumentUpload,
|
||||
handleFilesSelect,
|
||||
handleDocumentRemove,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
handleAddSummaryItem,
|
||||
handleRemoveSummaryItem,
|
||||
handleSummaryItemChange,
|
||||
|
||||
@@ -0,0 +1,289 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 견적서 문서 콘텐츠
|
||||
*
|
||||
* DocumentViewer와 함께 사용하는 문서 본문 컴포넌트
|
||||
*
|
||||
* 공통 컴포넌트 사용:
|
||||
* - DocumentHeader: centered 레이아웃 + 3col 결재란
|
||||
*/
|
||||
|
||||
import type { EstimateDetailFormData } from '../types';
|
||||
import type { CompanyInfo } from '../actions';
|
||||
import { DocumentHeader } from '@/components/document-system';
|
||||
|
||||
// 금액 포맷팅
|
||||
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 EstimateDocumentContentProps {
|
||||
data: {
|
||||
formData: EstimateDetailFormData;
|
||||
companyInfo: CompanyInfo | null;
|
||||
};
|
||||
}
|
||||
|
||||
export function EstimateDocumentContent({ data }: EstimateDocumentContentProps) {
|
||||
const { formData, companyInfo } = data;
|
||||
|
||||
// 견적서 문서 데이터
|
||||
const documentData = {
|
||||
documentNo: formData.estimateCode || '',
|
||||
createdDate: formData.siteBriefing.briefingDate || '',
|
||||
recipient: formData.siteBriefing.partnerName || '',
|
||||
companyName: companyInfo?.companyName || formData.siteBriefing.companyName || '',
|
||||
projectName: formData.bidInfo.projectName || '',
|
||||
address: companyInfo?.address || '',
|
||||
amount: formData.summaryItems.reduce((sum, item) => sum + item.totalCost, 0),
|
||||
date: formData.bidInfo.bidDate || '',
|
||||
manager: companyInfo?.managerName || formData.estimateCompanyManager || '',
|
||||
managerContact: companyInfo?.managerPhone || formData.estimateCompanyManagerContact || '',
|
||||
contact: {
|
||||
hp: companyInfo?.managerPhone || '',
|
||||
tel: companyInfo?.phone || '',
|
||||
fax: companyInfo?.fax || '',
|
||||
},
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8">
|
||||
{/* 상단: 제목 + 결재란 (공통 컴포넌트) */}
|
||||
<DocumentHeader
|
||||
title="견 적 서"
|
||||
subtitle={`문서번호: ${documentData.documentNo} | 작성일자: ${documentData.createdDate}`}
|
||||
layout="centered"
|
||||
approval={{
|
||||
type: '3col',
|
||||
writer: { name: formData.estimatorName || '' },
|
||||
showDepartment: false,
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 기본 정보 테이블 */}
|
||||
<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" rowSpan={2}>연락처</td>
|
||||
<td className="border border-gray-400 px-3 py-2" rowSpan={2}>
|
||||
<div className="space-y-0.5 text-xs">
|
||||
<div>담당자 : {documentData.manager}</div>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -1,55 +1,21 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 견적서 문서 모달
|
||||
*
|
||||
* document-system 통합 버전 (2026-01-22)
|
||||
* - DocumentViewer 사용
|
||||
* - EstimateDocumentContent로 문서 본문 분리
|
||||
*/
|
||||
|
||||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Printer, Pencil, Send, X as XIcon } from 'lucide-react';
|
||||
import { Pencil, Send } 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 { DocumentViewer } from '@/components/document-system';
|
||||
import type { EstimateDetailFormData } from '../types';
|
||||
import { getCompanyInfo, type CompanyInfo } from '../actions';
|
||||
|
||||
// 금액 포맷팅
|
||||
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;
|
||||
}
|
||||
import { EstimateDocumentContent } from './EstimateDocumentContent';
|
||||
|
||||
interface EstimateDocumentModalProps {
|
||||
isOpen: boolean;
|
||||
@@ -80,11 +46,6 @@ export function EstimateDocumentModal({
|
||||
}
|
||||
}, [isOpen]);
|
||||
|
||||
// 인쇄
|
||||
const handlePrint = useCallback(() => {
|
||||
printArea({ title: '견적서 인쇄' });
|
||||
}, []);
|
||||
|
||||
// 수정 페이지로 이동
|
||||
const handleEdit = useCallback(() => {
|
||||
if (estimateId) {
|
||||
@@ -93,340 +54,34 @@ export function EstimateDocumentModal({
|
||||
}
|
||||
}, [estimateId, onClose, router]);
|
||||
|
||||
// 견적서 문서 데이터 (회사 정보는 API에서 로드)
|
||||
const documentData = {
|
||||
documentNo: formData.estimateCode || '',
|
||||
createdDate: formData.siteBriefing.briefingDate || '',
|
||||
recipient: formData.siteBriefing.partnerName || '',
|
||||
companyName: companyInfo?.companyName || formData.siteBriefing.companyName || '',
|
||||
projectName: formData.bidInfo.projectName || '',
|
||||
address: companyInfo?.address || '',
|
||||
amount: formData.summaryItems.reduce((sum, item) => sum + item.totalCost, 0),
|
||||
date: formData.bidInfo.bidDate || '',
|
||||
manager: companyInfo?.managerName || formData.estimateCompanyManager || '',
|
||||
managerContact: companyInfo?.managerPhone || formData.estimateCompanyManagerContact || '',
|
||||
contact: {
|
||||
hp: companyInfo?.managerPhone || '',
|
||||
tel: companyInfo?.phone || '',
|
||||
fax: companyInfo?.fax || '',
|
||||
},
|
||||
note: '하기와 같이 보내합니다.',
|
||||
};
|
||||
// 툴바 확장 버튼들
|
||||
const toolbarExtra = (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
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">{formData.estimatorName || ''}</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" rowSpan={2}>연락처</td>
|
||||
<td className="border border-gray-400 px-3 py-2" rowSpan={2}>
|
||||
<div className="space-y-0.5 text-xs">
|
||||
<div>담당자 : {documentData.manager}</div>
|
||||
<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>
|
||||
<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>
|
||||
</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>
|
||||
<DocumentViewer
|
||||
title="견적서 상세"
|
||||
preset="inspection"
|
||||
open={isOpen}
|
||||
onOpenChange={(open) => !open && onClose()}
|
||||
toolbarExtra={toolbarExtra}
|
||||
>
|
||||
<EstimateDocumentContent
|
||||
data={{
|
||||
formData,
|
||||
companyInfo,
|
||||
}}
|
||||
/>
|
||||
</DocumentViewer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,9 +1,6 @@
|
||||
'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';
|
||||
@@ -16,36 +13,28 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import type { EstimateDetailFormData, BidDocument } from '../types';
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone';
|
||||
import { FileList, type ExistingFile } from '@/components/ui/file-list';
|
||||
import type { EstimateDetailFormData } 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;
|
||||
onFilesSelect: (files: File[]) => 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,
|
||||
onFilesSelect,
|
||||
onDocumentRemove,
|
||||
onDragOver,
|
||||
onDragLeave,
|
||||
onDrop,
|
||||
}: EstimateInfoSectionProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -219,73 +208,26 @@ export function EstimateInfoSection({
|
||||
{/* 현장설명회 자료 */}
|
||||
<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>
|
||||
<FileDropzone
|
||||
onFilesSelect={onFilesSelect}
|
||||
multiple
|
||||
maxSize={10}
|
||||
title="클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요."
|
||||
/>
|
||||
)}
|
||||
<FileList
|
||||
files={[]}
|
||||
existingFiles={formData.bidInfo.documents.map((doc): ExistingFile => ({
|
||||
id: doc.id,
|
||||
name: doc.fileName,
|
||||
size: doc.fileSize,
|
||||
url: doc.fileUrl,
|
||||
}))}
|
||||
onRemoveExisting={onDocumentRemove}
|
||||
showRemove={!isViewMode}
|
||||
emptyMessage="업로드된 파일이 없습니다"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -1,8 +1,11 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { useState, useCallback, useMemo, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, X, Upload, FileText, Image as ImageIcon, Download } from 'lucide-react';
|
||||
import { Plus, X } from 'lucide-react';
|
||||
import { ImageUpload } from '@/components/ui/image-upload';
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone';
|
||||
import { FileList, type ExistingFile } from '@/components/ui/file-list';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -78,13 +81,6 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
|
||||
// 새 메모 입력
|
||||
const [newMemo, setNewMemo] = useState('');
|
||||
|
||||
// 파일 업로드 ref
|
||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||
const documentInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 드래그 상태
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
|
||||
// 상세/수정 모드에서 목데이터 초기화
|
||||
useEffect(() => {
|
||||
if (initialData) {
|
||||
@@ -190,22 +186,7 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
|
||||
}, []);
|
||||
|
||||
// 로고 업로드 핸들러
|
||||
const handleLogoUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// 파일 크기 검증 (10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error('파일 크기는 10MB 이하여야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 타입 검증
|
||||
if (!['image/png', 'image/jpeg', 'image/gif'].includes(file.type)) {
|
||||
toast.error('PNG, JPEG, GIF 파일만 업로드 가능합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const handleLogoUpload = useCallback((file: File) => {
|
||||
// BLOB으로 변환
|
||||
const reader = new FileReader();
|
||||
reader.onload = () => {
|
||||
@@ -225,93 +206,32 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
|
||||
logoBlob: null,
|
||||
logoUrl: null,
|
||||
}));
|
||||
if (logoInputRef.current) {
|
||||
logoInputRef.current.value = '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 문서 업로드 핸들러
|
||||
const handleDocumentUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// 파일 크기 검증 (10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error('파일 크기는 10MB 이하여야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const doc: PartnerDocument = {
|
||||
id: String(Date.now()),
|
||||
const handleDocumentsSelect = useCallback((files: File[]) => {
|
||||
const newDocs = files.map((file) => ({
|
||||
id: String(Date.now() + Math.random()),
|
||||
fileName: file.name,
|
||||
fileUrl: URL.createObjectURL(file),
|
||||
fileSize: file.size,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
};
|
||||
}));
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
documents: [...prev.documents, doc],
|
||||
documents: [...prev.documents, ...newDocs],
|
||||
}));
|
||||
|
||||
if (documentInputRef.current) {
|
||||
documentInputRef.current.value = '';
|
||||
}
|
||||
}, []);
|
||||
|
||||
// 문서 삭제 핸들러
|
||||
const handleDocumentRemove = useCallback((docId: string) => {
|
||||
const handleDocumentRemove = useCallback((docId: string | number) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
documents: prev.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) => {
|
||||
// 파일 크기 검증 (10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error(`${file.name}: 파일 크기는 10MB 이하여야 합니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const doc: PartnerDocument = {
|
||||
id: String(Date.now() + Math.random()),
|
||||
fileName: file.name,
|
||||
fileUrl: URL.createObjectURL(file),
|
||||
fileSize: file.size,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
documents: [...prev.documents, doc],
|
||||
}));
|
||||
});
|
||||
}, [isViewMode]);
|
||||
|
||||
// 동적 Config (모드별 타이틀/설명)
|
||||
const dynamicConfig = useMemo(() => {
|
||||
if (isNewMode) {
|
||||
@@ -533,52 +453,16 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
|
||||
{/* 회사 로고 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="text-sm font-medium text-gray-700">회사 로고</Label>
|
||||
<input
|
||||
ref={logoInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif"
|
||||
<ImageUpload
|
||||
value={formData.logoBlob || formData.logoUrl}
|
||||
onChange={handleLogoUpload}
|
||||
className="hidden"
|
||||
onRemove={handleLogoRemove}
|
||||
disabled={isViewMode}
|
||||
aspectRatio="wide"
|
||||
size="lg"
|
||||
maxSize={10}
|
||||
hint="750 X 250px, 10MB 이하의 PNG, JPEG, GIF"
|
||||
/>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
||||
isViewMode ? 'bg-gray-50' : 'hover:border-primary/50 cursor-pointer'
|
||||
}`}
|
||||
onClick={() => !isViewMode && logoInputRef.current?.click()}
|
||||
>
|
||||
{formData.logoBlob || formData.logoUrl ? (
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<img
|
||||
src={formData.logoBlob || formData.logoUrl || ''}
|
||||
alt="회사 로고"
|
||||
className="max-h-[100px] max-w-[300px] object-contain"
|
||||
/>
|
||||
{!isViewMode && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleLogoRemove();
|
||||
}}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<ImageIcon className="w-12 h-12 text-gray-400 mx-auto mb-2" />
|
||||
<p className="text-sm text-gray-500">750 X 250px, 10MB 이하의 PNG, JPEG, GIF</p>
|
||||
{!isViewMode && (
|
||||
<Button type="button" variant="outline" className="mt-2">
|
||||
이미지 업로드
|
||||
</Button>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
{renderSelectField(
|
||||
@@ -717,74 +601,25 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor
|
||||
<CardTitle className="text-lg">필요 서류</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<input
|
||||
ref={documentInputRef}
|
||||
type="file"
|
||||
onChange={handleDocumentUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
||||
isViewMode
|
||||
? 'bg-gray-50'
|
||||
: isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'hover:border-primary/50 cursor-pointer'
|
||||
}`}
|
||||
onClick={() => !isViewMode && documentInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<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.documents.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{formData.documents.map((doc) => (
|
||||
<div key={doc.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-8 h-8 text-primary" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{doc.fileName}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{(doc.fileSize / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{isViewMode ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const link = document.createElement('a');
|
||||
link.href = doc.fileUrl;
|
||||
link.download = doc.fileName;
|
||||
link.click();
|
||||
toast.success(`${doc.fileName} 다운로드를 시작합니다.`);
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
다운로드
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDocumentRemove(doc.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!isViewMode && (
|
||||
<FileDropzone
|
||||
onFilesSelect={handleDocumentsSelect}
|
||||
multiple
|
||||
maxSize={10}
|
||||
title="클릭하여 파일을 첨부하거나, 마우스로 파일을 끌어오세요"
|
||||
/>
|
||||
)}
|
||||
<FileList
|
||||
existingFiles={formData.documents.map((doc): ExistingFile => ({
|
||||
id: doc.id,
|
||||
name: doc.fileName,
|
||||
url: doc.fileUrl,
|
||||
size: doc.fileSize,
|
||||
}))}
|
||||
onRemoveExisting={isViewMode ? undefined : handleDocumentRemove}
|
||||
readOnly={isViewMode}
|
||||
emptyMessage="등록된 서류가 없습니다"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,156 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 직접 공사 내역서 문서 콘텐츠
|
||||
*
|
||||
* 공통 컴포넌트 사용:
|
||||
* - DocumentHeader: simple 레이아웃 + 3col 결재란
|
||||
*/
|
||||
|
||||
import type { ProgressBillingDetailFormData } from '../types';
|
||||
import { DocumentHeader } from '@/components/document-system';
|
||||
|
||||
function formatNumber(num: number | undefined): string {
|
||||
if (num === undefined || num === null) return '-';
|
||||
return num.toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
interface DirectConstructionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
product: string;
|
||||
width: number;
|
||||
height: number;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
contractUnitPrice: number;
|
||||
contractAmount: number;
|
||||
prevQuantity: number;
|
||||
prevAmount: number;
|
||||
currentQuantity: number;
|
||||
currentAmount: number;
|
||||
cumulativeQuantity: number;
|
||||
cumulativeAmount: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
function generateMockItems(billingItems: ProgressBillingDetailFormData['billingItems']): DirectConstructionItem[] {
|
||||
return billingItems.map((item, index) => ({
|
||||
id: item.id,
|
||||
name: item.name || '명칭',
|
||||
product: item.product || '제품명',
|
||||
width: item.width || 2500,
|
||||
height: item.height || 3200,
|
||||
quantity: 1,
|
||||
unit: 'EA',
|
||||
contractUnitPrice: 2500000,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: index < 4 ? 0 : 0.8,
|
||||
prevAmount: index < 4 ? 0 : 1900000,
|
||||
currentQuantity: 0.8,
|
||||
currentAmount: 1900000,
|
||||
cumulativeQuantity: 0.8,
|
||||
cumulativeAmount: 1900000,
|
||||
remark: '',
|
||||
}));
|
||||
}
|
||||
|
||||
interface DirectConstructionContentProps {
|
||||
data: ProgressBillingDetailFormData;
|
||||
}
|
||||
|
||||
export function DirectConstructionContent({ data }: DirectConstructionContentProps) {
|
||||
const items = generateMockItems(data.billingItems);
|
||||
const totalContractAmount = items.reduce((sum, item) => sum + item.contractAmount, 0);
|
||||
const totalCurrentAmount = items.reduce((sum, item) => sum + item.currentAmount, 0);
|
||||
const totalCumulativeAmount = items.reduce((sum, item) => sum + item.cumulativeAmount, 0);
|
||||
|
||||
return (
|
||||
<div className="max-w-[297mm] mx-auto bg-white shadow-lg rounded-lg p-8">
|
||||
{/* 문서 헤더 (공통 컴포넌트) */}
|
||||
<DocumentHeader
|
||||
title="직접 공사 내역서"
|
||||
subtitle={`문서번호: ${data.billingNumber || 'ABC123'} | 작성일자: 2025년 11월 11일`}
|
||||
layout="centered"
|
||||
approval={{
|
||||
type: '3col',
|
||||
writer: { name: '홍길동' },
|
||||
approver: { name: '이름' },
|
||||
showDepartment: true,
|
||||
departmentLabels: { writer: '부서명', approver: '부서명' },
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="text-center font-bold text-lg mb-4">
|
||||
기성내역 제{data.billingRound || 1}회 ({data.billingYearMonth || '2025년 11월'})
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<span className="font-bold">■ 현장: {data.siteName || '현장명'}</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse border border-gray-400 text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-10">No.</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[80px]">명칭</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[60px]">제품</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">규격 mm</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-12">수량</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-12">단위</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">계약금액</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">전회기성</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">금회기성</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">누계기성</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-16">비고</th>
|
||||
</tr>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-14">가로</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-14">세로</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-16">단가</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, index) => (
|
||||
<tr key={item.id}>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{index + 1}</td>
|
||||
<td className="border border-gray-400 px-2 py-2">{item.name}</td>
|
||||
<td className="border border-gray-400 px-2 py-2">{item.product}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.width)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.height)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.quantity}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.unit}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.contractUnitPrice)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.contractAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.prevQuantity || '-'}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{item.prevAmount ? formatNumber(item.prevAmount) : '-'}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.currentQuantity}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.currentAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.cumulativeQuantity}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.cumulativeAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2">{item.remark || ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="bg-gray-50 font-bold">
|
||||
<td colSpan={8} className="border border-gray-400 px-2 py-2 text-center">합계</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalContractAmount)}</td>
|
||||
<td colSpan={2} className="border border-gray-400 px-2 py-2"></td>
|
||||
<td className="border border-gray-400 px-2 py-2"></td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCurrentAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2"></td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCumulativeAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,17 @@
|
||||
'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 { printArea } from '@/lib/print-utils';
|
||||
import type { ProgressBillingDetailFormData } from '../types';
|
||||
/**
|
||||
* 직접 공사 내역서 모달
|
||||
*
|
||||
* document-system 통합 버전 (2026-01-22)
|
||||
*/
|
||||
|
||||
// 숫자 포맷팅 (천단위 콤마)
|
||||
function formatNumber(num: number | undefined): string {
|
||||
if (num === undefined || num === null) return '-';
|
||||
return num.toLocaleString('ko-KR');
|
||||
}
|
||||
import { Edit, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import type { ProgressBillingDetailFormData } from '../types';
|
||||
import { DirectConstructionContent } from './DirectConstructionContent';
|
||||
|
||||
interface DirectConstructionModalProps {
|
||||
open: boolean;
|
||||
@@ -24,54 +19,11 @@ interface DirectConstructionModalProps {
|
||||
data: ProgressBillingDetailFormData;
|
||||
}
|
||||
|
||||
// 직접 공사 내역 아이템 타입
|
||||
interface DirectConstructionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
product: string;
|
||||
width: number;
|
||||
height: number;
|
||||
quantity: number;
|
||||
unit: string;
|
||||
contractUnitPrice: number;
|
||||
contractAmount: number;
|
||||
prevQuantity: number;
|
||||
prevAmount: number;
|
||||
currentQuantity: number;
|
||||
currentAmount: number;
|
||||
cumulativeQuantity: number;
|
||||
cumulativeAmount: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
// 목업 데이터 생성
|
||||
function generateMockItems(billingItems: ProgressBillingDetailFormData['billingItems']): DirectConstructionItem[] {
|
||||
return billingItems.map((item, index) => ({
|
||||
id: item.id,
|
||||
name: item.name || '명칭',
|
||||
product: item.product || '제품명',
|
||||
width: item.width || 2500,
|
||||
height: item.height || 3200,
|
||||
quantity: 1,
|
||||
unit: 'EA',
|
||||
contractUnitPrice: 2500000,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: index < 4 ? 0 : 0.8,
|
||||
prevAmount: index < 4 ? 0 : 1900000,
|
||||
currentQuantity: 0.8,
|
||||
currentAmount: 1900000,
|
||||
cumulativeQuantity: 0.8,
|
||||
cumulativeAmount: 1900000,
|
||||
remark: '',
|
||||
}));
|
||||
}
|
||||
|
||||
export function DirectConstructionModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
data,
|
||||
}: DirectConstructionModalProps) {
|
||||
// 핸들러
|
||||
const handleEdit = () => {
|
||||
toast.info('수정 기능은 준비 중입니다.');
|
||||
};
|
||||
@@ -80,189 +32,28 @@ export function DirectConstructionModal({
|
||||
toast.info('삭제 기능은 준비 중입니다.');
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
printArea({ title: '직접 공사 내역서 인쇄' });
|
||||
};
|
||||
|
||||
// 목업 데이터
|
||||
const items = generateMockItems(data.billingItems);
|
||||
|
||||
// 합계 계산
|
||||
const totalContractAmount = items.reduce((sum, item) => sum + item.contractAmount, 0);
|
||||
const totalCurrentAmount = items.reduce((sum, item) => sum + item.currentAmount, 0);
|
||||
const totalCumulativeAmount = items.reduce((sum, item) => sum + item.cumulativeAmount, 0);
|
||||
const toolbarExtra = (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-[1000px] lg:max-w-[1200px] 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-[297mm] mx-auto bg-white shadow-lg rounded-lg p-8">
|
||||
{/* 상단: 제목 + 결재란 */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
{/* 좌측: 제목 및 문서 정보 */}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold mb-2">직접 공사 내역서</h1>
|
||||
<div className="text-sm text-gray-600">
|
||||
문서번호: {data.billingNumber || 'ABC123'} | 작성일자: 2025년 11월 11일
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 결재란 */}
|
||||
<table className="border-collapse border border-gray-400 text-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th rowSpan={3} className="border border-gray-400 px-3 py-1 bg-gray-50 text-center align-middle w-12">
|
||||
결<br />재
|
||||
</th>
|
||||
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap">작성</th>
|
||||
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap">승인</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap">홍길동</td>
|
||||
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap">이름</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap">부서명</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 기성내역 제목 */}
|
||||
<div className="text-center font-bold text-lg mb-4">
|
||||
기성내역 제{data.billingRound || 1}회 ({data.billingYearMonth || '2025년 11월'})
|
||||
</div>
|
||||
|
||||
{/* 현장 정보 */}
|
||||
<div className="mb-4">
|
||||
<span className="font-bold">■ 현장: {data.siteName || '현장명'}</span>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse border border-gray-400 text-xs">
|
||||
<thead>
|
||||
{/* 1행: 상위 헤더 */}
|
||||
<tr className="bg-gray-50">
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-10">No.</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[80px]">명칭</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[60px]">제품</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
규격 mm
|
||||
</span>
|
||||
</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-12">수량</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-12">단위</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
계약금액
|
||||
</span>
|
||||
</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
전회기성
|
||||
</span>
|
||||
</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
금회기성
|
||||
</span>
|
||||
</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||
<span className="inline-flex items-center gap-1">
|
||||
누계기성
|
||||
</span>
|
||||
</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-16">비고</th>
|
||||
</tr>
|
||||
{/* 2행: 하위 헤더 */}
|
||||
<tr className="bg-gray-50">
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-14">가로</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-14">세로</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-16">단가</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, index) => (
|
||||
<tr key={item.id}>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{index + 1}</td>
|
||||
<td className="border border-gray-400 px-2 py-2">{item.name}</td>
|
||||
<td className="border border-gray-400 px-2 py-2">{item.product}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.width)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.height)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.quantity}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.unit}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.contractUnitPrice)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.contractAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.prevQuantity || '-'}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{item.prevAmount ? formatNumber(item.prevAmount) : '-'}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.currentQuantity}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.currentAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.cumulativeQuantity}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.cumulativeAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2">{item.remark || ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
{/* 합계 행 */}
|
||||
<tr className="bg-gray-50 font-bold">
|
||||
<td colSpan={8} className="border border-gray-400 px-2 py-2 text-center">합계</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalContractAmount)}</td>
|
||||
<td colSpan={2} className="border border-gray-400 px-2 py-2"></td>
|
||||
<td className="border border-gray-400 px-2 py-2"></td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCurrentAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2"></td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCumulativeAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DocumentViewer
|
||||
title="직접 공사 내역서"
|
||||
preset="inspection"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
toolbarExtra={toolbarExtra}
|
||||
>
|
||||
<DirectConstructionContent data={data} />
|
||||
</DocumentViewer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,142 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 간접 공사 내역서 문서 콘텐츠
|
||||
*
|
||||
* 공통 컴포넌트 사용:
|
||||
* - DocumentHeader: simple 레이아웃 + 3col 결재란
|
||||
*/
|
||||
|
||||
import type { ProgressBillingDetailFormData } from '../types';
|
||||
import { DocumentHeader } from '@/components/document-system';
|
||||
|
||||
// 숫자 포맷팅 (천단위 콤마)
|
||||
function formatNumber(num: number | undefined): string {
|
||||
if (num === undefined || num === null) return '-';
|
||||
return num.toLocaleString('ko-KR');
|
||||
}
|
||||
|
||||
// 간접 공사 내역 아이템 타입
|
||||
interface IndirectConstructionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
contractQuantity: number;
|
||||
contractAmount: number;
|
||||
prevQuantity: number;
|
||||
prevAmount: number;
|
||||
currentQuantity: number;
|
||||
currentAmount: number;
|
||||
cumulativeQuantity: number;
|
||||
cumulativeAmount: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
// 목업 데이터 생성
|
||||
function generateMockItems(): IndirectConstructionItem[] {
|
||||
return [
|
||||
{ id: '1', name: '국민연금', spec: '직접노무비 × 4.50%', unit: '식', contractQuantity: 1, contractAmount: 2500000, prevQuantity: 0, prevAmount: 0, currentQuantity: 0, currentAmount: 2500000, cumulativeQuantity: 0, cumulativeAmount: 2500000 },
|
||||
{ id: '2', name: '건강보험', spec: '직접노무비 × 3.545%', unit: '식', contractQuantity: 1, contractAmount: 2500000, prevQuantity: 0, prevAmount: 0, currentQuantity: 0, currentAmount: 2500000, cumulativeQuantity: 0, cumulativeAmount: 2500000 },
|
||||
{ id: '3', name: '노인장기요양보험료', spec: '건강보험료 × 12.81%', unit: '식', contractQuantity: 1, contractAmount: 2500000, prevQuantity: 0, prevAmount: 0, currentQuantity: 0, currentAmount: 2500000, cumulativeQuantity: 0, cumulativeAmount: 2500000 },
|
||||
{ id: '4', name: '고용보험', spec: '직접공사비 × 30% × 1.57%', unit: '식', contractQuantity: 1, contractAmount: 2500000, prevQuantity: 0, prevAmount: 0, currentQuantity: 0, currentAmount: 2500000, cumulativeQuantity: 0, cumulativeAmount: 2500000 },
|
||||
{ id: '5', name: '일반관리비', spec: '1) 직접공사비 × 업체요율\n2) 공과물비+작업비 시공비 포함', unit: '식', contractQuantity: 1, contractAmount: 2500000, prevQuantity: 0, prevAmount: 0, currentQuantity: 0, currentAmount: 2500000, cumulativeQuantity: 0, cumulativeAmount: 2500000 },
|
||||
{ id: '6', name: '안전관리비', spec: '직접공사비 × 0.3%(일반건산)', unit: '식', contractQuantity: 1, contractAmount: 2500000, prevQuantity: 0, prevAmount: 0, currentQuantity: 0, currentAmount: 2500000, cumulativeQuantity: 0, cumulativeAmount: 2500000 },
|
||||
{ id: '7', name: '안전검사자', spec: '실투입 × 양정실시', unit: 'M/D', contractQuantity: 1, contractAmount: 2500000, prevQuantity: 0, prevAmount: 0, currentQuantity: 0, currentAmount: 2500000, cumulativeQuantity: 0, cumulativeAmount: 2500000 },
|
||||
{ id: '8', name: '신호수 및 위기감시자', spec: '실투입 × 양정실시', unit: 'M/D', contractQuantity: 1, contractAmount: 2500000, prevQuantity: 0, prevAmount: 0, currentQuantity: 0, currentAmount: 2500000, cumulativeQuantity: 0, cumulativeAmount: 2500000 },
|
||||
{ id: '9', name: '퇴직공제부금', spec: '직접노무비 × 2.3%', unit: '식', contractQuantity: 1, contractAmount: 2500000, prevQuantity: 0, prevAmount: 0, currentQuantity: 0, currentAmount: 2500000, cumulativeQuantity: 0, cumulativeAmount: 2500000 },
|
||||
{ id: '10', name: '폐기물처리비', spec: '직접공사비 × 요제요율이상', unit: '식', contractQuantity: 1, contractAmount: 2500000, prevQuantity: 0, prevAmount: 0, currentQuantity: 0, currentAmount: 2500000, cumulativeQuantity: 0, cumulativeAmount: 2500000 },
|
||||
{ id: '11', name: '건설기계대여자금보증료', spec: '(직접비+간접공사비) × 0.07%', unit: '식', contractQuantity: 1, contractAmount: 2500000, prevQuantity: 0, prevAmount: 0, currentQuantity: 0, currentAmount: 2500000, cumulativeQuantity: 0, cumulativeAmount: 2500000 },
|
||||
];
|
||||
}
|
||||
|
||||
interface IndirectConstructionContentProps {
|
||||
data: ProgressBillingDetailFormData;
|
||||
}
|
||||
|
||||
export function IndirectConstructionContent({ data }: IndirectConstructionContentProps) {
|
||||
const items = generateMockItems();
|
||||
const totalContractAmount = items.reduce((sum, item) => sum + item.contractAmount, 0);
|
||||
const totalCurrentAmount = items.reduce((sum, item) => sum + item.currentAmount, 0);
|
||||
const totalCumulativeAmount = items.reduce((sum, item) => sum + item.cumulativeAmount, 0);
|
||||
|
||||
return (
|
||||
<div className="max-w-[297mm] mx-auto bg-white shadow-lg rounded-lg p-8">
|
||||
{/* 문서 헤더 (공통 컴포넌트) */}
|
||||
<DocumentHeader
|
||||
title="간접 공사 내역서"
|
||||
subtitle={`문서번호: ${data.billingNumber || 'ABC123'} | 작성일자: 2025년 11월 11일`}
|
||||
layout="centered"
|
||||
approval={{
|
||||
type: '3col',
|
||||
writer: { name: '홍길동' },
|
||||
approver: { name: '이름' },
|
||||
showDepartment: true,
|
||||
departmentLabels: { writer: '부서명', approver: '부서명' },
|
||||
}}
|
||||
/>
|
||||
|
||||
<div className="text-center font-bold text-lg mb-4">
|
||||
기성내역 제{data.billingRound || 1}회 ({data.billingYearMonth || '2025년 11월'})
|
||||
</div>
|
||||
<div className="mb-4">
|
||||
<span className="font-bold">■ 현장: {data.siteName || '현장명'}</span>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse border border-gray-400 text-xs">
|
||||
<thead>
|
||||
<tr className="bg-gray-50">
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-10">No.</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[100px]">품명</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[180px]">규격</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-14">단위</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">계약금액</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">전회기성</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">금회기성</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">누계기성</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-16">비고</th>
|
||||
</tr>
|
||||
<tr className="bg-gray-50">
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, index) => (
|
||||
<tr key={item.id}>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{index + 1}</td>
|
||||
<td className="border border-gray-400 px-2 py-2">{item.name}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 whitespace-pre-line text-xs">{item.spec}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.unit}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.contractQuantity}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.contractAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.prevQuantity || '-'}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{item.prevAmount ? formatNumber(item.prevAmount) : '-'}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.currentQuantity || '-'}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.currentAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.cumulativeQuantity || '-'}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.cumulativeAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2">{item.remark || ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
<tr className="bg-gray-50 font-bold">
|
||||
<td colSpan={5} className="border border-gray-400 px-2 py-2 text-center">합계</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalContractAmount)}</td>
|
||||
<td colSpan={2} className="border border-gray-400 px-2 py-2"></td>
|
||||
<td colSpan={2} className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCurrentAmount)}</td>
|
||||
<td colSpan={2} className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCumulativeAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,22 +1,17 @@
|
||||
'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 { printArea } from '@/lib/print-utils';
|
||||
import type { ProgressBillingDetailFormData } from '../types';
|
||||
/**
|
||||
* 간접 공사 내역서 모달
|
||||
*
|
||||
* document-system 통합 버전 (2026-01-22)
|
||||
*/
|
||||
|
||||
// 숫자 포맷팅 (천단위 콤마)
|
||||
function formatNumber(num: number | undefined): string {
|
||||
if (num === undefined || num === null) return '-';
|
||||
return num.toLocaleString('ko-KR');
|
||||
}
|
||||
import { Edit, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { toast } from 'sonner';
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import type { ProgressBillingDetailFormData } from '../types';
|
||||
import { IndirectConstructionContent } from './IndirectConstructionContent';
|
||||
|
||||
interface IndirectConstructionModalProps {
|
||||
open: boolean;
|
||||
@@ -24,189 +19,11 @@ interface IndirectConstructionModalProps {
|
||||
data: ProgressBillingDetailFormData;
|
||||
}
|
||||
|
||||
// 간접 공사 내역 아이템 타입
|
||||
interface IndirectConstructionItem {
|
||||
id: string;
|
||||
name: string;
|
||||
spec: string;
|
||||
unit: string;
|
||||
contractQuantity: number;
|
||||
contractAmount: number;
|
||||
prevQuantity: number;
|
||||
prevAmount: number;
|
||||
currentQuantity: number;
|
||||
currentAmount: number;
|
||||
cumulativeQuantity: number;
|
||||
cumulativeAmount: number;
|
||||
remark?: string;
|
||||
}
|
||||
|
||||
// 목업 데이터 생성
|
||||
function generateMockItems(): IndirectConstructionItem[] {
|
||||
return [
|
||||
{
|
||||
id: '1',
|
||||
name: '국민연금',
|
||||
spec: '직접노무비 × 4.50%',
|
||||
unit: '식',
|
||||
contractQuantity: 1,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: 0,
|
||||
prevAmount: 0,
|
||||
currentQuantity: 0,
|
||||
currentAmount: 2500000,
|
||||
cumulativeQuantity: 0,
|
||||
cumulativeAmount: 2500000,
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
name: '건강보험',
|
||||
spec: '직접노무비 × 3.545%',
|
||||
unit: '식',
|
||||
contractQuantity: 1,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: 0,
|
||||
prevAmount: 0,
|
||||
currentQuantity: 0,
|
||||
currentAmount: 2500000,
|
||||
cumulativeQuantity: 0,
|
||||
cumulativeAmount: 2500000,
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
name: '노인장기요양보험료',
|
||||
spec: '건강보험료 × 12.81%',
|
||||
unit: '식',
|
||||
contractQuantity: 1,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: 0,
|
||||
prevAmount: 0,
|
||||
currentQuantity: 0,
|
||||
currentAmount: 2500000,
|
||||
cumulativeQuantity: 0,
|
||||
cumulativeAmount: 2500000,
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
name: '고용보험',
|
||||
spec: '직접공사비 × 30% × 1.57%',
|
||||
unit: '식',
|
||||
contractQuantity: 1,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: 0,
|
||||
prevAmount: 0,
|
||||
currentQuantity: 0,
|
||||
currentAmount: 2500000,
|
||||
cumulativeQuantity: 0,
|
||||
cumulativeAmount: 2500000,
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
name: '일반관리비',
|
||||
spec: '1) 직접공사비 × 업체요율\n2) 공과물비+작업비 시공비 포함',
|
||||
unit: '식',
|
||||
contractQuantity: 1,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: 0,
|
||||
prevAmount: 0,
|
||||
currentQuantity: 0,
|
||||
currentAmount: 2500000,
|
||||
cumulativeQuantity: 0,
|
||||
cumulativeAmount: 2500000,
|
||||
},
|
||||
{
|
||||
id: '6',
|
||||
name: '안전관리비',
|
||||
spec: '직접공사비 × 0.3%(일반건산)',
|
||||
unit: '식',
|
||||
contractQuantity: 1,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: 0,
|
||||
prevAmount: 0,
|
||||
currentQuantity: 0,
|
||||
currentAmount: 2500000,
|
||||
cumulativeQuantity: 0,
|
||||
cumulativeAmount: 2500000,
|
||||
},
|
||||
{
|
||||
id: '7',
|
||||
name: '안전검사자',
|
||||
spec: '실투입 × 양정실시',
|
||||
unit: 'M/D',
|
||||
contractQuantity: 1,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: 0,
|
||||
prevAmount: 0,
|
||||
currentQuantity: 0,
|
||||
currentAmount: 2500000,
|
||||
cumulativeQuantity: 0,
|
||||
cumulativeAmount: 2500000,
|
||||
},
|
||||
{
|
||||
id: '8',
|
||||
name: '신호수 및 위기감시자',
|
||||
spec: '실투입 × 양정실시',
|
||||
unit: 'M/D',
|
||||
contractQuantity: 1,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: 0,
|
||||
prevAmount: 0,
|
||||
currentQuantity: 0,
|
||||
currentAmount: 2500000,
|
||||
cumulativeQuantity: 0,
|
||||
cumulativeAmount: 2500000,
|
||||
},
|
||||
{
|
||||
id: '9',
|
||||
name: '퇴직공제부금',
|
||||
spec: '직접노무비 × 2.3%',
|
||||
unit: '식',
|
||||
contractQuantity: 1,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: 0,
|
||||
prevAmount: 0,
|
||||
currentQuantity: 0,
|
||||
currentAmount: 2500000,
|
||||
cumulativeQuantity: 0,
|
||||
cumulativeAmount: 2500000,
|
||||
},
|
||||
{
|
||||
id: '10',
|
||||
name: '폐기물처리비',
|
||||
spec: '직접공사비 × 요제요율이상',
|
||||
unit: '식',
|
||||
contractQuantity: 1,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: 0,
|
||||
prevAmount: 0,
|
||||
currentQuantity: 0,
|
||||
currentAmount: 2500000,
|
||||
cumulativeQuantity: 0,
|
||||
cumulativeAmount: 2500000,
|
||||
},
|
||||
{
|
||||
id: '11',
|
||||
name: '건설기계대여자금보증료',
|
||||
spec: '(직접비+간접공사비) × 0.07%',
|
||||
unit: '식',
|
||||
contractQuantity: 1,
|
||||
contractAmount: 2500000,
|
||||
prevQuantity: 0,
|
||||
prevAmount: 0,
|
||||
currentQuantity: 0,
|
||||
currentAmount: 2500000,
|
||||
cumulativeQuantity: 0,
|
||||
cumulativeAmount: 2500000,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export function IndirectConstructionModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
data,
|
||||
}: IndirectConstructionModalProps) {
|
||||
// 핸들러
|
||||
const handleEdit = () => {
|
||||
toast.info('수정 기능은 준비 중입니다.');
|
||||
};
|
||||
@@ -215,168 +32,28 @@ export function IndirectConstructionModal({
|
||||
toast.info('삭제 기능은 준비 중입니다.');
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
printArea({ title: '간접 공사 내역서 인쇄' });
|
||||
};
|
||||
|
||||
// 목업 데이터
|
||||
const items = generateMockItems();
|
||||
|
||||
// 합계 계산
|
||||
const totalContractAmount = items.reduce((sum, item) => sum + item.contractAmount, 0);
|
||||
const totalCurrentAmount = items.reduce((sum, item) => sum + item.currentAmount, 0);
|
||||
const totalCumulativeAmount = items.reduce((sum, item) => sum + item.cumulativeAmount, 0);
|
||||
const toolbarExtra = (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-[1000px] lg:max-w-[1200px] 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-[297mm] mx-auto bg-white shadow-lg rounded-lg p-8">
|
||||
{/* 상단: 제목 + 결재란 */}
|
||||
<div className="flex items-start justify-between mb-6">
|
||||
{/* 좌측: 제목 및 문서 정보 */}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold mb-2">간접 공사 내역서</h1>
|
||||
<div className="text-sm text-gray-600">
|
||||
문서번호: {data.billingNumber || 'ABC123'} | 작성일자: 2025년 11월 11일
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 결재란 */}
|
||||
<table className="border-collapse border border-gray-400 text-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th rowSpan={3} className="border border-gray-400 px-3 py-1 bg-gray-50 text-center align-middle w-12">
|
||||
결<br />재
|
||||
</th>
|
||||
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap">작성</th>
|
||||
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap">승인</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap">홍길동</td>
|
||||
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap">이름</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap">부서명</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 기성내역 제목 */}
|
||||
<div className="text-center font-bold text-lg mb-4">
|
||||
기성내역 제{data.billingRound || 1}회 ({data.billingYearMonth || '2025년 11월'})
|
||||
</div>
|
||||
|
||||
{/* 현장 정보 */}
|
||||
<div className="mb-4">
|
||||
<span className="font-bold">■ 현장: {data.siteName || '현장명'}</span>
|
||||
</div>
|
||||
|
||||
{/* 테이블 */}
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full border-collapse border border-gray-400 text-xs">
|
||||
<thead>
|
||||
{/* 1행: 상위 헤더 */}
|
||||
<tr className="bg-gray-50">
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-10">No.</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[100px]">품명</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center min-w-[180px]">규격</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-14">단위</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||
계약금액
|
||||
</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||
전회기성
|
||||
</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||
금회기성
|
||||
</th>
|
||||
<th colSpan={2} className="border border-gray-400 px-2 py-1 text-center bg-red-50">
|
||||
누계기성
|
||||
</th>
|
||||
<th rowSpan={2} className="border border-gray-400 px-2 py-2 text-center w-16">비고</th>
|
||||
</tr>
|
||||
{/* 2행: 하위 헤더 */}
|
||||
<tr className="bg-gray-50">
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-12">수량</th>
|
||||
<th className="border border-gray-400 px-2 py-1 text-center w-20">금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{items.map((item, index) => (
|
||||
<tr key={item.id}>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{index + 1}</td>
|
||||
<td className="border border-gray-400 px-2 py-2">{item.name}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 whitespace-pre-line text-xs">{item.spec}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.unit}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.contractQuantity}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.contractAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.prevQuantity || '-'}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{item.prevAmount ? formatNumber(item.prevAmount) : '-'}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.currentQuantity || '-'}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.currentAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-center">{item.cumulativeQuantity || '-'}</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(item.cumulativeAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2">{item.remark || ''}</td>
|
||||
</tr>
|
||||
))}
|
||||
{/* 합계 행 */}
|
||||
<tr className="bg-gray-50 font-bold">
|
||||
<td colSpan={5} className="border border-gray-400 px-2 py-2 text-center">합계</td>
|
||||
<td className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalContractAmount)}</td>
|
||||
<td colSpan={2} className="border border-gray-400 px-2 py-2"></td>
|
||||
<td colSpan={2} className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCurrentAmount)}</td>
|
||||
<td colSpan={2} className="border border-gray-400 px-2 py-2 text-right">{formatNumber(totalCumulativeAmount)}</td>
|
||||
<td className="border border-gray-400 px-2 py-2"></td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DocumentViewer
|
||||
title="간접 공사 내역서"
|
||||
preset="inspection"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
toolbarExtra={toolbarExtra}
|
||||
>
|
||||
<IndirectConstructionContent data={data} />
|
||||
</DocumentViewer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,129 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 사진대지 문서 콘텐츠
|
||||
*
|
||||
* 공통 컴포넌트 사용:
|
||||
* - DocumentHeader: centered 레이아웃 + 3col 결재란
|
||||
*/
|
||||
|
||||
import type { ProgressBillingDetailFormData } from '../types';
|
||||
import { DocumentHeader } from '@/components/document-system';
|
||||
|
||||
// 사진대지 아이템 타입
|
||||
interface PhotoDocumentItem {
|
||||
id: string;
|
||||
imageUrl: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 목업 데이터 생성
|
||||
function generateMockPhotos(photoItems: ProgressBillingDetailFormData['photoItems']): PhotoDocumentItem[] {
|
||||
const photos: PhotoDocumentItem[] = [];
|
||||
|
||||
photoItems.forEach((item) => {
|
||||
if (item.photos && item.photos.length > 0) {
|
||||
const selectedIndex = item.selectedPhotoIndex ?? 0;
|
||||
photos.push({
|
||||
id: item.id,
|
||||
imageUrl: item.photos[selectedIndex] || item.photos[0],
|
||||
name: item.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 최소 6개 항목 채우기 (2열 × 3행)
|
||||
while (photos.length < 6) {
|
||||
photos.push({
|
||||
id: `mock-${photos.length}`,
|
||||
imageUrl: '',
|
||||
name: '명칭',
|
||||
});
|
||||
}
|
||||
|
||||
return photos;
|
||||
}
|
||||
|
||||
interface PhotoDocumentContentProps {
|
||||
data: ProgressBillingDetailFormData;
|
||||
}
|
||||
|
||||
export function PhotoDocumentContent({ data }: PhotoDocumentContentProps) {
|
||||
const photos = generateMockPhotos(data.photoItems);
|
||||
|
||||
// 2열로 그룹화
|
||||
const photoRows: PhotoDocumentItem[][] = [];
|
||||
for (let i = 0; i < photos.length; i += 2) {
|
||||
photoRows.push(photos.slice(i, i + 2));
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8">
|
||||
{/* 문서 헤더 (공통 컴포넌트) */}
|
||||
<DocumentHeader
|
||||
title="사진대지"
|
||||
subtitle={`문서번호: ${data.billingNumber || 'ABC123'} | 작성일자: 2025년 11월 11일`}
|
||||
layout="centered"
|
||||
approval={{
|
||||
type: '3col',
|
||||
writer: { name: '홍길동' },
|
||||
approver: { name: '이름' },
|
||||
showDepartment: true,
|
||||
departmentLabels: { writer: '부서명', approver: '부서명' },
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 기성신청 사진대지 제목 */}
|
||||
<div className="text-center font-bold text-lg mb-4">
|
||||
기성신청 사진대지 제{data.billingRound || 1}회 ({data.billingYearMonth || '2025년 11월'})
|
||||
</div>
|
||||
|
||||
{/* 현장 정보 */}
|
||||
<div className="mb-6">
|
||||
<span className="font-bold">■ 현장: {data.siteName || '현장명'}</span>
|
||||
</div>
|
||||
|
||||
{/* 사진 그리드 */}
|
||||
<div className="border border-gray-400">
|
||||
{photoRows.map((row, rowIndex) => (
|
||||
<div key={rowIndex} className="grid grid-cols-2">
|
||||
{row.map((photo, colIndex) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className={`border border-gray-400 ${colIndex === 0 ? 'border-l-0' : ''} ${rowIndex === 0 ? 'border-t-0' : ''}`}
|
||||
>
|
||||
{/* 이미지 영역 */}
|
||||
<div className="aspect-[4/3] bg-gray-100 flex items-center justify-center overflow-hidden">
|
||||
{photo.imageUrl ? (
|
||||
<img
|
||||
src={photo.imageUrl}
|
||||
alt={photo.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-gray-400 text-lg">IMG</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 명칭 라벨 */}
|
||||
<div className="border-t border-gray-400 px-4 py-3 text-center bg-white">
|
||||
<span className="text-sm font-medium">{photo.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* 홀수 개일 때 빈 셀 채우기 */}
|
||||
{row.length === 1 && (
|
||||
<div className="border border-gray-400 border-t-0">
|
||||
<div className="aspect-[4/3] bg-gray-100 flex items-center justify-center">
|
||||
<span className="text-gray-400 text-lg">IMG</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-400 px-4 py-3 text-center bg-white">
|
||||
<span className="text-sm font-medium">명칭</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,16 +1,17 @@
|
||||
'use client';
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
VisuallyHidden,
|
||||
} from '@/components/ui/dialog';
|
||||
/**
|
||||
* 사진대지 모달
|
||||
*
|
||||
* document-system 통합 버전 (2026-01-22)
|
||||
*/
|
||||
|
||||
import { Edit, Trash2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Edit, Trash2, Printer, X } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { printArea } from '@/lib/print-utils';
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import type { ProgressBillingDetailFormData } from '../types';
|
||||
import { PhotoDocumentContent } from './PhotoDocumentContent';
|
||||
|
||||
interface PhotoDocumentModalProps {
|
||||
open: boolean;
|
||||
@@ -18,47 +19,11 @@ interface PhotoDocumentModalProps {
|
||||
data: ProgressBillingDetailFormData;
|
||||
}
|
||||
|
||||
// 사진대지 아이템 타입
|
||||
interface PhotoDocumentItem {
|
||||
id: string;
|
||||
imageUrl: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
// 목업 데이터 생성
|
||||
function generateMockPhotos(photoItems: ProgressBillingDetailFormData['photoItems']): PhotoDocumentItem[] {
|
||||
// 기존 photoItems에서 선택된 사진들을 가져오거나 목업 생성
|
||||
const photos: PhotoDocumentItem[] = [];
|
||||
|
||||
photoItems.forEach((item) => {
|
||||
if (item.photos && item.photos.length > 0) {
|
||||
const selectedIndex = item.selectedPhotoIndex ?? 0;
|
||||
photos.push({
|
||||
id: item.id,
|
||||
imageUrl: item.photos[selectedIndex] || item.photos[0],
|
||||
name: item.name,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// 최소 6개 항목 채우기 (2열 × 3행)
|
||||
while (photos.length < 6) {
|
||||
photos.push({
|
||||
id: `mock-${photos.length}`,
|
||||
imageUrl: '',
|
||||
name: '명칭',
|
||||
});
|
||||
}
|
||||
|
||||
return photos;
|
||||
}
|
||||
|
||||
export function PhotoDocumentModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
data,
|
||||
}: PhotoDocumentModalProps) {
|
||||
// 핸들러
|
||||
const handleEdit = () => {
|
||||
toast.info('수정 기능은 준비 중입니다.');
|
||||
};
|
||||
@@ -67,144 +32,28 @@ export function PhotoDocumentModal({
|
||||
toast.info('삭제 기능은 준비 중입니다.');
|
||||
};
|
||||
|
||||
const handlePrint = () => {
|
||||
printArea({ title: '사진대지 인쇄' });
|
||||
};
|
||||
|
||||
// 목업 데이터
|
||||
const photos = generateMockPhotos(data.photoItems);
|
||||
|
||||
// 2열로 그룹화
|
||||
const photoRows: PhotoDocumentItem[][] = [];
|
||||
for (let i = 0; i < photos.length; i += 2) {
|
||||
photoRows.push(photos.slice(i, i + 2));
|
||||
}
|
||||
const toolbarExtra = (
|
||||
<>
|
||||
<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>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="max-w-[95vw] md:max-w-[900px] lg:max-w-[1000px] 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 items-start justify-between mb-6">
|
||||
{/* 좌측: 제목 및 문서 정보 */}
|
||||
<div className="flex-1">
|
||||
<h1 className="text-2xl font-bold mb-2">사진대지</h1>
|
||||
<div className="text-sm text-gray-600">
|
||||
문서번호: {data.billingNumber || 'ABC123'} | 작성일자: 2025년 11월 11일
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 우측: 결재란 */}
|
||||
<table className="border-collapse border border-gray-400 text-sm">
|
||||
<tbody>
|
||||
<tr>
|
||||
<th rowSpan={3} className="border border-gray-400 px-3 py-1 bg-gray-50 text-center align-middle w-12">
|
||||
결<br />재
|
||||
</th>
|
||||
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap">작성</th>
|
||||
<th className="border border-gray-400 px-6 py-1 bg-gray-50 text-center whitespace-nowrap">승인</th>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap">홍길동</td>
|
||||
<td className="border border-gray-400 px-6 py-2 text-center h-10 whitespace-nowrap">이름</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap">부서명</td>
|
||||
<td className="border border-gray-400 px-6 py-1 text-center text-xs text-gray-500 whitespace-nowrap">부서명</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 기성신청 사진대지 제목 */}
|
||||
<div className="text-center font-bold text-lg mb-4">
|
||||
기성신청 사진대지 제{data.billingRound || 1}회 ({data.billingYearMonth || '2025년 11월'})
|
||||
</div>
|
||||
|
||||
{/* 현장 정보 */}
|
||||
<div className="mb-6">
|
||||
<span className="font-bold">■ 현장: {data.siteName || '현장명'}</span>
|
||||
</div>
|
||||
|
||||
{/* 사진 그리드 */}
|
||||
<div className="border border-gray-400">
|
||||
{photoRows.map((row, rowIndex) => (
|
||||
<div key={rowIndex} className="grid grid-cols-2">
|
||||
{row.map((photo, colIndex) => (
|
||||
<div
|
||||
key={photo.id}
|
||||
className={`border border-gray-400 ${colIndex === 0 ? 'border-l-0' : ''} ${rowIndex === 0 ? 'border-t-0' : ''}`}
|
||||
>
|
||||
{/* 이미지 영역 */}
|
||||
<div className="aspect-[4/3] bg-gray-100 flex items-center justify-center overflow-hidden">
|
||||
{photo.imageUrl ? (
|
||||
<img
|
||||
src={photo.imageUrl}
|
||||
alt={photo.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-gray-400 text-lg">IMG</span>
|
||||
)}
|
||||
</div>
|
||||
{/* 명칭 라벨 */}
|
||||
<div className="border-t border-gray-400 px-4 py-3 text-center bg-white">
|
||||
<span className="text-sm font-medium">{photo.name}</span>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
{/* 홀수 개일 때 빈 셀 채우기 */}
|
||||
{row.length === 1 && (
|
||||
<div className="border border-gray-400 border-t-0">
|
||||
<div className="aspect-[4/3] bg-gray-100 flex items-center justify-center">
|
||||
<span className="text-gray-400 text-lg">IMG</span>
|
||||
</div>
|
||||
<div className="border-t border-gray-400 px-4 py-3 text-center bg-white">
|
||||
<span className="text-sm font-medium">명칭</span>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DocumentViewer
|
||||
title="사진대지"
|
||||
preset="inspection"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
toolbarExtra={toolbarExtra}
|
||||
>
|
||||
<PhotoDocumentContent data={data} />
|
||||
</DocumentViewer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,7 +2,9 @@
|
||||
|
||||
import { useState, useCallback, useMemo, useRef, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Plus, X, Loader2, Upload, FileText, Mic, Download, Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { Plus, X, Loader2, Mic, Check, ChevronsUpDown } from 'lucide-react';
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone';
|
||||
import { FileList, type NewFile, type ExistingFile } from '@/components/ui/file-list';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -113,11 +115,8 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
|
||||
// 다이얼로그 상태 (현장 신규 등록은 별도로 관리)
|
||||
|
||||
// 파일 업로드 ref
|
||||
const documentInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 드래그 상태
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
// 새 파일 상태
|
||||
const [newDocumentFiles, setNewDocumentFiles] = useState<File[]>([]);
|
||||
|
||||
// 거래처 목록
|
||||
const [partners, setPartners] = useState<Partner[]>([]);
|
||||
@@ -377,94 +376,24 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
[]
|
||||
);
|
||||
|
||||
// 문서 업로드 핸들러
|
||||
const handleDocumentUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// 파일 크기 검증 (10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error('파일 크기는 10MB 이하여야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const doc: BriefingDocument = {
|
||||
id: String(Date.now()),
|
||||
fileName: file.name,
|
||||
fileUrl: URL.createObjectURL(file),
|
||||
fileSize: file.size,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
documents: [...prev.documents, doc],
|
||||
}));
|
||||
|
||||
if (documentInputRef.current) {
|
||||
documentInputRef.current.value = '';
|
||||
}
|
||||
// 파일 추가 핸들러
|
||||
const handleFilesSelect = useCallback((files: File[]) => {
|
||||
setNewDocumentFiles((prev) => [...prev, ...files]);
|
||||
}, []);
|
||||
|
||||
// 문서 삭제 핸들러
|
||||
const handleDocumentRemove = useCallback((docId: string) => {
|
||||
// 새 파일 삭제 핸들러
|
||||
const handleNewFileRemove = useCallback((index: number) => {
|
||||
setNewDocumentFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
// 기존 문서 삭제 핸들러
|
||||
const handleExistingDocumentRemove = useCallback((docId: string) => {
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
documents: prev.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) => {
|
||||
// 파일 크기 검증 (10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error(`${file.name}: 파일 크기는 10MB 이하여야 합니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const doc: BriefingDocument = {
|
||||
id: String(Date.now() + Math.random()),
|
||||
fileName: file.name,
|
||||
fileUrl: URL.createObjectURL(file),
|
||||
fileSize: file.size,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setFormData((prev) => ({
|
||||
...prev,
|
||||
documents: [...prev.documents, doc],
|
||||
}));
|
||||
});
|
||||
},
|
||||
[isViewMode]
|
||||
);
|
||||
|
||||
// 동적 config (모드에 따른 title 변경)
|
||||
const dynamicConfig = useMemo(() => {
|
||||
if (isNewMode) {
|
||||
@@ -872,72 +801,27 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site
|
||||
{/* 현장설명회 자료 */}
|
||||
<div className="space-y-4">
|
||||
<Label className="text-sm font-medium text-gray-700">현장설명회 자료</Label>
|
||||
<input
|
||||
ref={documentInputRef}
|
||||
type="file"
|
||||
onChange={handleDocumentUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-6 text-center transition-colors ${
|
||||
isViewMode
|
||||
? 'bg-gray-50'
|
||||
: isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'hover:border-primary/50 cursor-pointer'
|
||||
}`}
|
||||
onClick={() => !isViewMode && documentInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<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.documents.length > 0 && (
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{formData.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={() => handleDocumentRemove(doc.id)}
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!isViewMode && (
|
||||
<FileDropzone
|
||||
onFilesSelect={handleFilesSelect}
|
||||
multiple
|
||||
maxSize={10}
|
||||
title="클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요."
|
||||
/>
|
||||
)}
|
||||
<FileList
|
||||
files={newDocumentFiles.map((file): NewFile => ({ file }))}
|
||||
existingFiles={formData.documents.map((doc): ExistingFile => ({
|
||||
id: doc.id,
|
||||
name: doc.fileName,
|
||||
size: doc.fileSize,
|
||||
url: doc.fileUrl,
|
||||
}))}
|
||||
onRemove={handleNewFileRemove}
|
||||
onRemoveExisting={handleExistingDocumentRemove}
|
||||
showRemove={!isViewMode}
|
||||
emptyMessage="업로드된 파일이 없습니다"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -5,9 +5,11 @@
|
||||
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Upload, Mic, X, FileText, Download } from 'lucide-react';
|
||||
import { Mic } from 'lucide-react';
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone';
|
||||
import { FileList, type NewFile, type ExistingFile } from '@/components/ui/file-list';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -45,11 +47,8 @@ export default function SiteDetailForm({ site, mode = 'view' }: SiteDetailFormPr
|
||||
const router = useRouter();
|
||||
const isViewMode = mode === 'view';
|
||||
|
||||
// 파일 업로드 ref
|
||||
const drawingInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 드래그 상태
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
// 새 파일 상태
|
||||
const [newFiles, setNewFiles] = useState<File[]>([]);
|
||||
|
||||
// 목데이터: 기존 도면 파일
|
||||
const MOCK_DRAWINGS: DrawingDocument[] = site ? [
|
||||
@@ -118,79 +117,21 @@ export default function SiteDetailForm({ site, mode = 'view' }: SiteDetailFormPr
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// 도면 파일 업로드 핸들러
|
||||
const handleDrawingUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// 파일 크기 검증 (10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error('파일 크기는 10MB 이하여야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const doc: DrawingDocument = {
|
||||
id: String(Date.now()),
|
||||
fileName: file.name,
|
||||
fileUrl: URL.createObjectURL(file),
|
||||
fileSize: file.size,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setDrawings((prev) => [...prev, doc]);
|
||||
|
||||
if (drawingInputRef.current) {
|
||||
drawingInputRef.current.value = '';
|
||||
}
|
||||
// 파일 추가 핸들러
|
||||
const handleFilesSelect = useCallback((files: File[]) => {
|
||||
setNewFiles((prev) => [...prev, ...files]);
|
||||
}, []);
|
||||
|
||||
// 도면 파일 삭제 핸들러
|
||||
const handleDrawingRemove = useCallback((docId: string) => {
|
||||
// 새 파일 삭제 핸들러
|
||||
const handleNewFileRemove = useCallback((index: number) => {
|
||||
setNewFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
// 기존 파일 삭제 핸들러
|
||||
const handleExistingFileRemove = useCallback((docId: string) => {
|
||||
setDrawings((prev) => prev.filter((d) => d.id !== docId));
|
||||
}, []);
|
||||
|
||||
// 드래그 핸들러
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isViewMode) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
}, [isViewMode]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
if (isViewMode) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
files.forEach((file) => {
|
||||
// 파일 크기 검증 (10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error(`${file.name}: 파일 크기는 10MB 이하여야 합니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const doc: DrawingDocument = {
|
||||
id: String(Date.now() + Math.random()),
|
||||
fileName: file.name,
|
||||
fileUrl: URL.createObjectURL(file),
|
||||
fileSize: file.size,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setDrawings((prev) => [...prev, doc]);
|
||||
});
|
||||
}, [isViewMode]);
|
||||
|
||||
// 동적 config (mode에 따라 title 변경)
|
||||
const dynamicConfig = useMemo(() => {
|
||||
const titleMap: Record<string, string> = {
|
||||
@@ -359,74 +300,27 @@ export default function SiteDetailForm({ site, mode = 'view' }: SiteDetailFormPr
|
||||
<CardTitle className="text-base">도면 정보</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<input
|
||||
ref={drawingInputRef}
|
||||
type="file"
|
||||
onChange={handleDrawingUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
||||
isViewMode
|
||||
? 'bg-gray-50'
|
||||
: isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'hover:border-primary/50 cursor-pointer'
|
||||
}`}
|
||||
onClick={() => !isViewMode && drawingInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Upload className={`mx-auto h-8 w-8 mb-2 ${isDragging ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isDragging ? '파일을 여기에 놓으세요' : '클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요.'}
|
||||
</p>
|
||||
</div>
|
||||
{/* 업로드된 파일 목록 */}
|
||||
{drawings.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{drawings.map((doc) => (
|
||||
<div key={doc.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-8 h-8 text-primary" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{doc.fileName}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{(doc.fileSize / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
{!isViewMode ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleDrawingRemove(doc.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
const link = document.createElement('a');
|
||||
link.href = doc.fileUrl;
|
||||
link.download = doc.fileName;
|
||||
link.click();
|
||||
toast.success(`${doc.fileName} 다운로드를 시작합니다.`);
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
다운로드
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!isViewMode && (
|
||||
<FileDropzone
|
||||
onFilesSelect={handleFilesSelect}
|
||||
multiple
|
||||
maxSize={10}
|
||||
title="클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요."
|
||||
/>
|
||||
)}
|
||||
<FileList
|
||||
files={newFiles.map((file): NewFile => ({ file }))}
|
||||
existingFiles={drawings.map((doc): ExistingFile => ({
|
||||
id: doc.id,
|
||||
name: doc.fileName,
|
||||
size: doc.fileSize,
|
||||
url: doc.fileUrl,
|
||||
}))}
|
||||
onRemove={handleNewFileRemove}
|
||||
onRemoveExisting={handleExistingFileRemove}
|
||||
showRemove={!isViewMode}
|
||||
emptyMessage="업로드된 파일이 없습니다"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
@@ -467,16 +361,14 @@ export default function SiteDetailForm({ site, mode = 'view' }: SiteDetailFormPr
|
||||
formData,
|
||||
isViewMode,
|
||||
drawings,
|
||||
isDragging,
|
||||
newFiles,
|
||||
isScriptLoaded,
|
||||
handleInputChange,
|
||||
handleSelectChange,
|
||||
openPostcode,
|
||||
handleDrawingUpload,
|
||||
handleDrawingRemove,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
handleFilesSelect,
|
||||
handleNewFileRemove,
|
||||
handleExistingFileRemove,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -5,9 +5,10 @@
|
||||
* IntegratedDetailTemplate 마이그레이션 완료 (2026-01-20)
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef, useMemo } from 'react';
|
||||
import { useState, useCallback, useMemo } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Upload, X, FileText, Download } from 'lucide-react';
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone';
|
||||
import { FileList, type NewFile, type ExistingFile } from '@/components/ui/file-list';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
@@ -62,11 +63,8 @@ export default function StructureReviewDetailForm({
|
||||
const isViewMode = mode === 'view';
|
||||
const isNewMode = mode === 'new';
|
||||
|
||||
// 파일 업로드 ref
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 드래그 상태
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
// 새 파일 상태
|
||||
const [newFiles, setNewFiles] = useState<File[]>([]);
|
||||
|
||||
// 목데이터: 기존 구조검토 파일
|
||||
const MOCK_REVIEW_FILES: ReviewFile[] = review ? [
|
||||
@@ -115,79 +113,21 @@ export default function StructureReviewDetailForm({
|
||||
setFormData((prev) => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
// 파일 업로드 핸들러
|
||||
const handleFileUpload = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// 파일 크기 검증 (10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error('파일 크기는 10MB 이하여야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const doc: ReviewFile = {
|
||||
id: String(Date.now()),
|
||||
fileName: file.name,
|
||||
fileUrl: URL.createObjectURL(file),
|
||||
fileSize: file.size,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setReviewFiles((prev) => [...prev, doc]);
|
||||
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
// 파일 추가 핸들러
|
||||
const handleFilesSelect = useCallback((files: File[]) => {
|
||||
setNewFiles((prev) => [...prev, ...files]);
|
||||
}, []);
|
||||
|
||||
// 파일 삭제 핸들러
|
||||
const handleFileRemove = useCallback((docId: string) => {
|
||||
// 새 파일 삭제 핸들러
|
||||
const handleNewFileRemove = useCallback((index: number) => {
|
||||
setNewFiles((prev) => prev.filter((_, i) => i !== index));
|
||||
}, []);
|
||||
|
||||
// 기존 파일 삭제 핸들러
|
||||
const handleExistingFileRemove = useCallback((docId: string) => {
|
||||
setReviewFiles((prev) => prev.filter((d) => d.id !== docId));
|
||||
}, []);
|
||||
|
||||
// 드래그 핸들러
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!isViewMode) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
}, [isViewMode]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
if (isViewMode) return;
|
||||
|
||||
const files = Array.from(e.dataTransfer.files);
|
||||
files.forEach((file) => {
|
||||
// 파일 크기 검증 (10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error(`${file.name}: 파일 크기는 10MB 이하여야 합니다.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const doc: ReviewFile = {
|
||||
id: String(Date.now() + Math.random()),
|
||||
fileName: file.name,
|
||||
fileUrl: URL.createObjectURL(file),
|
||||
fileSize: file.size,
|
||||
uploadedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
setReviewFiles((prev) => [...prev, doc]);
|
||||
});
|
||||
}, [isViewMode]);
|
||||
|
||||
// 동적 config (mode에 따라 title 변경)
|
||||
const dynamicConfig = useMemo(() => {
|
||||
const titleMap: Record<string, string> = {
|
||||
@@ -404,74 +344,27 @@ export default function StructureReviewDetailForm({
|
||||
<CardTitle className="text-base">구조검토 파일</CardTitle>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
onChange={handleFileUpload}
|
||||
className="hidden"
|
||||
/>
|
||||
<div
|
||||
className={`border-2 border-dashed rounded-lg p-8 text-center transition-colors ${
|
||||
isViewMode
|
||||
? 'bg-gray-50'
|
||||
: isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'hover:border-primary/50 cursor-pointer'
|
||||
}`}
|
||||
onClick={() => !isViewMode && fileInputRef.current?.click()}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
<Upload className={`mx-auto h-8 w-8 mb-2 ${isDragging ? 'text-primary' : 'text-muted-foreground'}`} />
|
||||
<p className="text-sm text-muted-foreground">
|
||||
{isDragging ? '파일을 여기에 놓으세요' : '클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요.'}
|
||||
</p>
|
||||
</div>
|
||||
{/* 업로드된 파일 목록 */}
|
||||
{reviewFiles.length > 0 && (
|
||||
<div className="space-y-2">
|
||||
{reviewFiles.map((doc) => (
|
||||
<div key={doc.id} className="flex items-center justify-between p-3 bg-gray-50 rounded-lg">
|
||||
<div className="flex items-center gap-3">
|
||||
<FileText className="w-8 h-8 text-primary" />
|
||||
<div>
|
||||
<p className="text-sm font-medium">{doc.fileName}</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
{(doc.fileSize / 1024).toFixed(1)} KB
|
||||
</p>
|
||||
</div>
|
||||
</div>{isViewMode ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// 실제로는 doc.fileUrl로 다운로드
|
||||
const link = document.createElement('a');
|
||||
link.href = doc.fileUrl;
|
||||
link.download = doc.fileName;
|
||||
link.click();
|
||||
toast.success(`${doc.fileName} 다운로드를 시작합니다.`);
|
||||
}}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
다운로드
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleFileRemove(doc.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
{!isViewMode && (
|
||||
<FileDropzone
|
||||
onFilesSelect={handleFilesSelect}
|
||||
multiple
|
||||
maxSize={10}
|
||||
title="클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요."
|
||||
/>
|
||||
)}
|
||||
<FileList
|
||||
files={newFiles.map((file): NewFile => ({ file }))}
|
||||
existingFiles={reviewFiles.map((doc): ExistingFile => ({
|
||||
id: doc.id,
|
||||
name: doc.fileName,
|
||||
size: doc.fileSize,
|
||||
url: doc.fileUrl,
|
||||
}))}
|
||||
onRemove={handleNewFileRemove}
|
||||
onRemoveExisting={handleExistingFileRemove}
|
||||
showRemove={!isViewMode}
|
||||
emptyMessage="업로드된 파일이 없습니다"
|
||||
/>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
@@ -479,15 +372,12 @@ export default function StructureReviewDetailForm({
|
||||
formData,
|
||||
isViewMode,
|
||||
reviewFiles,
|
||||
isDragging,
|
||||
newFiles,
|
||||
handleInputChange,
|
||||
handleSelectChange,
|
||||
handleFileUpload,
|
||||
handleFileRemove,
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handleDrop,
|
||||
fileInputRef,
|
||||
handleFilesSelect,
|
||||
handleNewFileRemove,
|
||||
handleExistingFileRemove,
|
||||
]);
|
||||
|
||||
return (
|
||||
|
||||
@@ -10,14 +10,14 @@
|
||||
* - 필드: 상담분류, 제목, 내용(에디터), 첨부파일
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { Upload, X, File } from 'lucide-react';
|
||||
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
|
||||
import { inquiryCreateConfig, inquiryEditConfig } from './inquiryConfig';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone';
|
||||
import { FileList, type NewFile, type ExistingFile } from '@/components/ui/file-list';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -44,7 +44,6 @@ interface InquiryFormProps {
|
||||
|
||||
export function InquiryForm({ mode, initialData }: InquiryFormProps) {
|
||||
const router = useRouter();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ===== 폼 상태 =====
|
||||
const [category, setCategory] = useState<InquiryCategory>(initialData?.category || 'inquiry');
|
||||
@@ -59,15 +58,8 @@ export function InquiryForm({ mode, initialData }: InquiryFormProps) {
|
||||
const [errors, setErrors] = useState<Record<string, string>>({});
|
||||
|
||||
// ===== 파일 업로드 핸들러 =====
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files) {
|
||||
setAttachments((prev) => [...prev, ...Array.from(files)]);
|
||||
}
|
||||
// 파일 input 초기화
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
const handleFilesSelect = useCallback((files: File[]) => {
|
||||
setAttachments((prev) => [...prev, ...files]);
|
||||
}, []);
|
||||
|
||||
const handleRemoveFile = useCallback((index: number) => {
|
||||
@@ -128,13 +120,6 @@ export function InquiryForm({ mode, initialData }: InquiryFormProps) {
|
||||
router.back();
|
||||
}, [router]);
|
||||
|
||||
// 파일 크기 포맷
|
||||
const formatFileSize = (bytes: number) => {
|
||||
if (bytes < 1024) return `${bytes} B`;
|
||||
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
|
||||
return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
|
||||
};
|
||||
|
||||
// ===== 폼 콘텐츠 렌더링 =====
|
||||
const renderFormContent = useCallback(() => (
|
||||
<Card>
|
||||
@@ -207,84 +192,30 @@ export function InquiryForm({ mode, initialData }: InquiryFormProps) {
|
||||
{/* 첨부파일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>첨부파일</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
찾기
|
||||
</Button>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 기존 파일 목록 */}
|
||||
{existingAttachments.length > 0 && (
|
||||
<div className="space-y-2 mt-3">
|
||||
{existingAttachments.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className="flex items-center justify-between p-2 bg-gray-50 rounded-md"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<File className="h-4 w-4 text-gray-500" />
|
||||
<span className="text-sm">{file.fileName}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
({formatFileSize(file.fileSize)})
|
||||
</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveExistingFile(file.id)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 새로 추가된 파일 목록 */}
|
||||
{attachments.length > 0 && (
|
||||
<div className="space-y-2 mt-3">
|
||||
{attachments.map((file, index) => (
|
||||
<div
|
||||
key={index}
|
||||
className="flex items-center justify-between p-2 bg-blue-50 rounded-md"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<File className="h-4 w-4 text-blue-500" />
|
||||
<span className="text-sm">{file.name}</span>
|
||||
<span className="text-xs text-gray-400">
|
||||
({formatFileSize(file.size)})
|
||||
</span>
|
||||
<span className="text-xs text-blue-500">(새 파일)</span>
|
||||
</div>
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => handleRemoveFile(index)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<FileDropzone
|
||||
onFilesSelect={handleFilesSelect}
|
||||
multiple
|
||||
maxSize={10}
|
||||
compact
|
||||
title="클릭하거나 파일을 드래그하세요"
|
||||
description="최대 10MB"
|
||||
/>
|
||||
<FileList
|
||||
files={attachments.map((file): NewFile => ({ file }))}
|
||||
existingFiles={existingAttachments.map((file): ExistingFile => ({
|
||||
id: file.id,
|
||||
name: file.fileName,
|
||||
url: file.fileUrl,
|
||||
size: file.fileSize,
|
||||
}))}
|
||||
onRemove={handleRemoveFile}
|
||||
onRemoveExisting={handleRemoveExistingFile}
|
||||
compact
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
), [category, title, content, errors, attachments, existingAttachments, formatFileSize, handleFileSelect, handleRemoveFile, handleRemoveExistingFile]);
|
||||
), [category, title, content, errors, attachments, existingAttachments, handleFilesSelect, handleRemoveFile, handleRemoveExistingFile]);
|
||||
|
||||
// Config 선택 (create/edit)
|
||||
const config = mode === 'create' ? inquiryCreateConfig : inquiryEditConfig;
|
||||
|
||||
154
src/components/document-system/components/ApprovalLine.tsx
Normal file
154
src/components/document-system/components/ApprovalLine.tsx
Normal file
@@ -0,0 +1,154 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 결재란 공통 컴포넌트
|
||||
*
|
||||
* @example
|
||||
* // 3열 결재란 (작성/승인)
|
||||
* <ApprovalLine type="3col" writer="홍길동" writerDate="01/22" />
|
||||
*
|
||||
* // 4열 결재란 (작성/검토/승인)
|
||||
* <ApprovalLine type="4col" writer="홍길동" reviewer="김검토" />
|
||||
*
|
||||
* // 외부 송부 시 숨김
|
||||
* <ApprovalLine visible={false} />
|
||||
*/
|
||||
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface ApprovalPerson {
|
||||
name?: string;
|
||||
date?: string;
|
||||
department?: string;
|
||||
}
|
||||
|
||||
export interface ApprovalLineProps {
|
||||
/** 결재란 유형: 3열(작성/승인) 또는 4열(작성/검토/승인) */
|
||||
type?: '3col' | '4col';
|
||||
/** 외부 송부 시 숨김 */
|
||||
visible?: boolean;
|
||||
/** 문서 모드: internal(내부), external(외부송부) */
|
||||
mode?: 'internal' | 'external';
|
||||
/** 작성자 정보 */
|
||||
writer?: ApprovalPerson;
|
||||
/** 검토자 정보 (4col에서만 사용) */
|
||||
reviewer?: ApprovalPerson;
|
||||
/** 승인자 정보 */
|
||||
approver?: ApprovalPerson;
|
||||
/** 부서명 표시 여부 */
|
||||
showDepartment?: boolean;
|
||||
/** 부서 라벨 (기본값: 작성/검토/승인에 따라 다름) */
|
||||
departmentLabels?: {
|
||||
writer?: string;
|
||||
reviewer?: string;
|
||||
approver?: string;
|
||||
};
|
||||
/** 추가 className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function ApprovalLine({
|
||||
type = '3col',
|
||||
visible = true,
|
||||
mode = 'internal',
|
||||
writer,
|
||||
reviewer,
|
||||
approver,
|
||||
showDepartment = true,
|
||||
departmentLabels = {
|
||||
writer: '판매/전진',
|
||||
reviewer: '생산',
|
||||
approver: '품질',
|
||||
},
|
||||
className,
|
||||
}: ApprovalLineProps) {
|
||||
// 외부 송부 모드이거나 visible이 false면 렌더링 안함
|
||||
if (!visible || mode === 'external') {
|
||||
return null;
|
||||
}
|
||||
|
||||
const is4Col = type === '4col';
|
||||
|
||||
return (
|
||||
<table className={cn('border-collapse text-xs', className)}>
|
||||
<tbody>
|
||||
{/* 헤더 행: 결재 + 작성/검토/승인 */}
|
||||
<tr>
|
||||
<td
|
||||
rowSpan={showDepartment ? 3 : 2}
|
||||
className="w-8 text-center font-medium bg-gray-100 border border-gray-300 align-middle"
|
||||
>
|
||||
<div className="flex flex-col items-center px-1">
|
||||
<span>결</span>
|
||||
<span>재</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="w-16 p-2 text-center font-medium bg-gray-100 border border-gray-300">
|
||||
작성
|
||||
</td>
|
||||
{is4Col && (
|
||||
<td className="w-16 p-2 text-center font-medium bg-gray-100 border border-gray-300">
|
||||
검토
|
||||
</td>
|
||||
)}
|
||||
<td className="w-16 p-2 text-center font-medium bg-gray-100 border border-gray-300">
|
||||
승인
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* 서명 행: 이름 + 날짜 */}
|
||||
<tr>
|
||||
<td className="w-16 p-2 text-center border border-gray-300 h-10">
|
||||
{writer?.name && (
|
||||
<>
|
||||
<div>{writer.name}</div>
|
||||
{writer.date && (
|
||||
<div className="text-[10px] text-gray-500">{writer.date}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
{is4Col && (
|
||||
<td className="w-16 p-2 text-center border border-gray-300 h-10">
|
||||
{reviewer?.name && (
|
||||
<>
|
||||
<div>{reviewer.name}</div>
|
||||
{reviewer.date && (
|
||||
<div className="text-[10px] text-gray-500">{reviewer.date}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
)}
|
||||
<td className="w-16 p-2 text-center border border-gray-300 h-10">
|
||||
{approver?.name && (
|
||||
<>
|
||||
<div>{approver.name}</div>
|
||||
{approver.date && (
|
||||
<div className="text-[10px] text-gray-500">{approver.date}</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
|
||||
{/* 부서 행 (선택적) */}
|
||||
{showDepartment && (
|
||||
<tr>
|
||||
<td className="w-16 p-2 text-center bg-gray-50 border border-gray-300 text-[10px]">
|
||||
{departmentLabels.writer}
|
||||
</td>
|
||||
{is4Col && (
|
||||
<td className="w-16 p-2 text-center bg-gray-50 border border-gray-300 text-[10px]">
|
||||
{departmentLabels.reviewer}
|
||||
</td>
|
||||
)}
|
||||
<td className="w-16 p-2 text-center bg-gray-50 border border-gray-300 text-[10px]">
|
||||
{departmentLabels.approver}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
);
|
||||
}
|
||||
160
src/components/document-system/components/DocumentHeader.tsx
Normal file
160
src/components/document-system/components/DocumentHeader.tsx
Normal file
@@ -0,0 +1,160 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 문서 헤더 공통 컴포넌트
|
||||
*
|
||||
* @example
|
||||
* // 기본 사용
|
||||
* <DocumentHeader
|
||||
* title="작업일지"
|
||||
* documentCode="WL-SCR"
|
||||
* subtitle="스크린 생산부서"
|
||||
* approval={{ type: '4col', writer: { name: '홍길동', date: '01/22' } }}
|
||||
* />
|
||||
*
|
||||
* // 로고 포함
|
||||
* <DocumentHeader
|
||||
* logo={{ text: 'KD', subtext: '정동기업' }}
|
||||
* title="작업일지"
|
||||
* approval={{ type: '4col' }}
|
||||
* />
|
||||
*
|
||||
* // 결재선 위에 추가 정보
|
||||
* <DocumentHeader
|
||||
* title="견적서"
|
||||
* topInfo={<div>LOT: KD-WO-260122</div>}
|
||||
* approval={{ type: '3col' }}
|
||||
* />
|
||||
*
|
||||
* // 외부 송부 (결재선 숨김)
|
||||
* <DocumentHeader title="견적서" mode="external" />
|
||||
*/
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
import { ApprovalLine, ApprovalLineProps } from './ApprovalLine';
|
||||
|
||||
export interface DocumentHeaderLogo {
|
||||
/** 로고 텍스트 (예: 'KD') */
|
||||
text: string;
|
||||
/** 로고 서브텍스트 (예: '정동기업') */
|
||||
subtext?: string;
|
||||
/** 로고 이미지 URL (text 대신 사용) */
|
||||
imageUrl?: string;
|
||||
}
|
||||
|
||||
export interface DocumentHeaderProps {
|
||||
/** 문서 제목 */
|
||||
title: string;
|
||||
/** 문서 코드 (예: 'WL-SCR') */
|
||||
documentCode?: string;
|
||||
/** 부제목 (예: '스크린 생산부서') */
|
||||
subtitle?: string;
|
||||
/** 로고 설정 */
|
||||
logo?: DocumentHeaderLogo;
|
||||
/** 결재선 위에 표시할 추가 정보 */
|
||||
topInfo?: ReactNode;
|
||||
/** 결재란 설정 (null이면 숨김) */
|
||||
approval?: Omit<ApprovalLineProps, 'mode'> | null;
|
||||
/** 문서 모드: internal(내부), external(외부송부) */
|
||||
mode?: 'internal' | 'external';
|
||||
/** 레이아웃 유형 */
|
||||
layout?: 'default' | 'centered' | 'simple';
|
||||
/** 추가 className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function DocumentHeader({
|
||||
title,
|
||||
documentCode,
|
||||
subtitle,
|
||||
logo,
|
||||
topInfo,
|
||||
approval,
|
||||
mode = 'internal',
|
||||
layout = 'default',
|
||||
className,
|
||||
}: DocumentHeaderProps) {
|
||||
const isExternal = mode === 'external';
|
||||
const showApproval = approval !== null && !isExternal;
|
||||
|
||||
// 간단한 레이아웃 (제목만)
|
||||
if (layout === 'simple') {
|
||||
return (
|
||||
<div className={cn('mb-6', className)}>
|
||||
<h1 className="text-2xl font-bold text-center tracking-widest">{title}</h1>
|
||||
{documentCode && (
|
||||
<p className="text-sm text-gray-500 text-center mt-1">{documentCode}</p>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-sm font-medium text-center mt-1">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 중앙 정렬 레이아웃 (견적서 스타일)
|
||||
if (layout === 'centered') {
|
||||
return (
|
||||
<div className={cn('flex justify-between items-start mb-6', className)}>
|
||||
<div className="flex-1">
|
||||
<h1 className="text-3xl font-bold text-center tracking-[0.3em]">{title}</h1>
|
||||
{(documentCode || subtitle) && (
|
||||
<div className="text-sm mt-4 text-center">
|
||||
{documentCode && <span>문서번호: {documentCode}</span>}
|
||||
{documentCode && subtitle && <span className="mx-2">|</span>}
|
||||
{subtitle && <span>{subtitle}</span>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{showApproval && approval && (
|
||||
<ApprovalLine {...approval} mode={mode} className="ml-4" />
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 기본 레이아웃 (로고 + 제목 + 결재란)
|
||||
return (
|
||||
<div className={cn('flex border border-gray-300 mb-6', className)}>
|
||||
{/* 좌측: 로고 영역 */}
|
||||
{logo && (
|
||||
<div className="w-24 border-r border-gray-300 flex flex-col items-center justify-center p-3 shrink-0">
|
||||
{logo.imageUrl ? (
|
||||
<img src={logo.imageUrl} alt={logo.text} className="h-8" />
|
||||
) : (
|
||||
<span className="text-2xl font-bold">{logo.text}</span>
|
||||
)}
|
||||
{logo.subtext && (
|
||||
<span className="text-xs text-gray-500">{logo.subtext}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 중앙: 문서 제목 */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-3 border-r border-gray-300">
|
||||
<h1 className="text-xl font-bold tracking-widest mb-1">{title}</h1>
|
||||
{documentCode && (
|
||||
<p className="text-xs text-gray-500">{documentCode}</p>
|
||||
)}
|
||||
{subtitle && (
|
||||
<p className="text-sm font-medium mt-1">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측: 결재선 위 정보 + 결재란 */}
|
||||
{(showApproval || topInfo) && (
|
||||
<div className="shrink-0 flex flex-col">
|
||||
{topInfo && (
|
||||
<div className="p-2 text-xs border-b border-gray-300">
|
||||
{topInfo}
|
||||
</div>
|
||||
)}
|
||||
{showApproval && approval && (
|
||||
<ApprovalLine {...approval} mode={mode} />
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
94
src/components/document-system/components/InfoTable.tsx
Normal file
94
src/components/document-system/components/InfoTable.tsx
Normal file
@@ -0,0 +1,94 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 정보 테이블 공통 컴포넌트 (라벨-값 구조)
|
||||
*
|
||||
* @example
|
||||
* <InfoTable
|
||||
* rows={[
|
||||
* [{ label: '발주처', value: '(주)현대건설' }, { label: '현장명', value: '판교 테크노밸리' }],
|
||||
* [{ label: '작업일자', value: '2026-01-22' }, { label: 'LOT NO.', value: 'KD-WO-260122' }],
|
||||
* ]}
|
||||
* />
|
||||
*
|
||||
* // 단일 열
|
||||
* <InfoTable
|
||||
* columns={1}
|
||||
* rows={[
|
||||
* [{ label: '비고', value: '특이사항 없음' }],
|
||||
* ]}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface InfoTableCell {
|
||||
/** 라벨 */
|
||||
label: string;
|
||||
/** 값 (문자열 또는 ReactNode) */
|
||||
value: ReactNode;
|
||||
/** 라벨 너비 (기본: w-24) */
|
||||
labelWidth?: string;
|
||||
/** 값 colSpan */
|
||||
colSpan?: number;
|
||||
}
|
||||
|
||||
export interface InfoTableProps {
|
||||
/** 행 데이터 (2차원 배열: 행 > 셀) */
|
||||
rows: InfoTableCell[][];
|
||||
/** 열 개수 (기본: 2) */
|
||||
columns?: 1 | 2 | 3 | 4;
|
||||
/** 라벨 너비 (기본: w-24) */
|
||||
labelWidth?: string;
|
||||
/** 추가 className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function InfoTable({
|
||||
rows,
|
||||
columns = 2,
|
||||
labelWidth = 'w-24',
|
||||
className,
|
||||
}: InfoTableProps) {
|
||||
return (
|
||||
<div className={cn('border border-gray-300', className)}>
|
||||
{rows.map((row, rowIndex) => (
|
||||
<div
|
||||
key={rowIndex}
|
||||
className={cn(
|
||||
'grid',
|
||||
columns === 1 && 'grid-cols-1',
|
||||
columns === 2 && 'grid-cols-2',
|
||||
columns === 3 && 'grid-cols-3',
|
||||
columns === 4 && 'grid-cols-4',
|
||||
rowIndex < rows.length - 1 && 'border-b border-gray-300'
|
||||
)}
|
||||
>
|
||||
{row.map((cell, cellIndex) => (
|
||||
<div
|
||||
key={cellIndex}
|
||||
className={cn(
|
||||
'flex',
|
||||
cellIndex < row.length - 1 && 'border-r border-gray-300'
|
||||
)}
|
||||
style={cell.colSpan ? { gridColumn: `span ${cell.colSpan}` } : undefined}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
'bg-gray-100 p-3 text-sm font-medium border-r border-gray-300 flex items-center shrink-0',
|
||||
cell.labelWidth || labelWidth
|
||||
)}
|
||||
>
|
||||
{cell.label}
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm flex items-center">
|
||||
{cell.value}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
45
src/components/document-system/components/SectionHeader.tsx
Normal file
45
src/components/document-system/components/SectionHeader.tsx
Normal file
@@ -0,0 +1,45 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 섹션 헤더 공통 컴포넌트
|
||||
*
|
||||
* @example
|
||||
* <SectionHeader>작업내역</SectionHeader>
|
||||
* <SectionHeader variant="light">특이사항</SectionHeader>
|
||||
*/
|
||||
|
||||
import { ReactNode } from 'react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface SectionHeaderProps {
|
||||
/** 섹션 제목 */
|
||||
children: ReactNode;
|
||||
/** 스타일 변형: dark(검정), light(회색), primary(파랑) */
|
||||
variant?: 'dark' | 'light' | 'primary';
|
||||
/** 추가 className */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
const variantStyles = {
|
||||
dark: 'bg-gray-800 text-white',
|
||||
light: 'bg-gray-100 text-gray-800 border-b border-gray-300',
|
||||
primary: 'bg-blue-600 text-white',
|
||||
};
|
||||
|
||||
export function SectionHeader({
|
||||
children,
|
||||
variant = 'dark',
|
||||
className,
|
||||
}: SectionHeaderProps) {
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
'p-2.5 text-sm font-medium text-center',
|
||||
variantStyles[variant],
|
||||
className
|
||||
)}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
11
src/components/document-system/components/index.ts
Normal file
11
src/components/document-system/components/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// 문서 공통 컴포넌트
|
||||
export { ApprovalLine } from './ApprovalLine';
|
||||
export { DocumentHeader } from './DocumentHeader';
|
||||
export { SectionHeader } from './SectionHeader';
|
||||
export { InfoTable } from './InfoTable';
|
||||
|
||||
// Types
|
||||
export type { ApprovalPerson, ApprovalLineProps } from './ApprovalLine';
|
||||
export type { DocumentHeaderLogo, DocumentHeaderProps } from './DocumentHeader';
|
||||
export type { SectionHeaderProps } from './SectionHeader';
|
||||
export type { InfoTableCell, InfoTableProps } from './InfoTable';
|
||||
@@ -1,6 +1,14 @@
|
||||
// Main Component
|
||||
export { DocumentViewer } from './viewer';
|
||||
|
||||
// Document Components (공통 문서 요소)
|
||||
export {
|
||||
ApprovalLine,
|
||||
DocumentHeader,
|
||||
SectionHeader,
|
||||
InfoTable,
|
||||
} from './components';
|
||||
|
||||
// Hooks
|
||||
export { useZoom, useDrag } from './viewer/hooks';
|
||||
|
||||
@@ -8,6 +16,17 @@ export { useZoom, useDrag } from './viewer/hooks';
|
||||
export { DOCUMENT_PRESETS, getPreset, mergeWithPreset } from './presets';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
// Document Component Types
|
||||
ApprovalPerson,
|
||||
ApprovalLineProps,
|
||||
DocumentHeaderLogo,
|
||||
DocumentHeaderProps,
|
||||
SectionHeaderProps,
|
||||
InfoTableCell,
|
||||
InfoTableProps,
|
||||
} from './components';
|
||||
|
||||
export type {
|
||||
DocumentConfig,
|
||||
DocumentViewerProps,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { useState, useCallback } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
@@ -20,7 +19,8 @@ import {
|
||||
TableRow,
|
||||
} from '@/components/ui/table';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Upload, FileSpreadsheet, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { FileSpreadsheet, AlertCircle, CheckCircle } from 'lucide-react';
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone';
|
||||
import type { Employee, CSVEmployeeRow, CSVValidationResult } from './types';
|
||||
|
||||
interface CSVUploadDialogProps {
|
||||
@@ -37,11 +37,10 @@ export function CSVUploadDialog({
|
||||
const [file, setFile] = useState<File | null>(null);
|
||||
const [validationResults, setValidationResults] = useState<CSVValidationResult[]>([]);
|
||||
const [isProcessing, setIsProcessing] = useState(false);
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 파일 선택
|
||||
const handleFileSelect = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const selectedFile = e.target.files?.[0];
|
||||
const handleFilesSelect = useCallback((files: File[]) => {
|
||||
const selectedFile = files[0];
|
||||
if (selectedFile && selectedFile.type === 'text/csv') {
|
||||
setFile(selectedFile);
|
||||
processCSV(selectedFile);
|
||||
@@ -135,9 +134,6 @@ export function CSVUploadDialog({
|
||||
const handleReset = () => {
|
||||
setFile(null);
|
||||
setValidationResults([]);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const validCount = validationResults.filter(r => r.isValid).length;
|
||||
@@ -156,35 +152,13 @@ export function CSVUploadDialog({
|
||||
<div className="space-y-4 py-4">
|
||||
{/* 파일 업로드 영역 */}
|
||||
{!file && (
|
||||
<Card className="border-dashed">
|
||||
<CardContent className="p-8">
|
||||
<div className="flex flex-col items-center justify-center space-y-4">
|
||||
<FileSpreadsheet className="w-12 h-12 text-muted-foreground" />
|
||||
<div className="text-center">
|
||||
<p className="text-sm text-muted-foreground mb-2">
|
||||
CSV 파일을 드래그하거나 클릭하여 업로드
|
||||
</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
필수 컬럼: 이름 | 선택 컬럼: 휴대폰, 이메일, 부서, 직책, 입사일, 상태
|
||||
</p>
|
||||
</div>
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept=".csv"
|
||||
onChange={handleFileSelect}
|
||||
className="hidden"
|
||||
id="csv-upload"
|
||||
/>
|
||||
<Button variant="outline" asChild>
|
||||
<label htmlFor="csv-upload" className="cursor-pointer">
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
파일 선택
|
||||
</label>
|
||||
</Button>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
<FileDropzone
|
||||
onFilesSelect={handleFilesSelect}
|
||||
accept=".csv"
|
||||
maxSize={10}
|
||||
title="CSV 파일을 드래그하거나 클릭하여 업로드"
|
||||
description="필수 컬럼: 이름 | 선택 컬럼: 휴대폰, 이메일, 부서, 직책, 입사일, 상태"
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* 파일 정보 및 미리보기 */}
|
||||
|
||||
@@ -18,6 +18,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { CurrencyInput } from '@/components/ui/currency-input';
|
||||
import { PhoneInput } from '@/components/ui/phone-input';
|
||||
import { PersonalNumberInput } from '@/components/ui/personal-number-input';
|
||||
import { ImageUpload } from '@/components/ui/image-upload';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -25,7 +26,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { Plus, Trash2, Settings, Camera } from 'lucide-react';
|
||||
import { Plus, Trash2, Settings } from 'lucide-react';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import { FieldSettingsDialog } from './FieldSettingsDialog';
|
||||
import type {
|
||||
@@ -492,48 +493,26 @@ export function EmployeeForm({
|
||||
{fieldSettings.showProfileImage && (
|
||||
<div className="space-y-2 flex-shrink-0">
|
||||
<Label>프로필 사진</Label>
|
||||
<div className={`w-32 h-32 border border-dashed border-gray-300 rounded-md flex flex-col items-center justify-center bg-gray-50 relative ${isViewMode ? '' : 'cursor-pointer hover:bg-gray-100'}`}>
|
||||
{formData.profileImage ? (
|
||||
<img
|
||||
src={formData.profileImage}
|
||||
alt="프로필"
|
||||
className="w-full h-full object-cover rounded-md"
|
||||
/>
|
||||
) : (
|
||||
<>
|
||||
<span className="text-sm text-gray-400 mb-1">IMG</span>
|
||||
<div className="w-8 h-8 rounded-full bg-gray-200 flex items-center justify-center">
|
||||
<Camera className="w-4 h-4 text-gray-500" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
{!isViewMode && (
|
||||
<input
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif"
|
||||
className="absolute inset-0 opacity-0 cursor-pointer"
|
||||
onChange={async (e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
// 미리보기 즉시 표시
|
||||
handleChange('profileImage', URL.createObjectURL(file));
|
||||
// 서버에 업로드 (FormData로 감싸서 전송)
|
||||
const formData = new FormData();
|
||||
formData.append('file', file);
|
||||
const result = await uploadProfileImage(formData);
|
||||
if (result.success && result.data?.url) {
|
||||
handleChange('profileImage', result.data.url);
|
||||
}
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
{!isViewMode && (
|
||||
<p className="text-xs text-gray-500">
|
||||
1 250 X 250px, 10MB 이하의<br />PNG, JPEG, GIF
|
||||
</p>
|
||||
)}
|
||||
<ImageUpload
|
||||
value={formData.profileImage}
|
||||
onChange={async (file) => {
|
||||
// 미리보기 즉시 표시
|
||||
handleChange('profileImage', URL.createObjectURL(file));
|
||||
// 서버에 업로드 (FormData로 감싸서 전송)
|
||||
const uploadFormData = new FormData();
|
||||
uploadFormData.append('file', file);
|
||||
const result = await uploadProfileImage(uploadFormData);
|
||||
if (result.success && result.data?.url) {
|
||||
handleChange('profileImage', result.data.url);
|
||||
}
|
||||
}}
|
||||
onRemove={() => handleChange('profileImage', '')}
|
||||
disabled={isViewMode}
|
||||
size="lg"
|
||||
rounded
|
||||
maxSize={10}
|
||||
hint={isViewMode ? undefined : '250 X 250px, 10MB 이하의 PNG, JPEG, GIF'}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
@@ -247,12 +247,6 @@ export function SalaryManagement() {
|
||||
toast.info('지급항목 추가 기능은 준비 중입니다.');
|
||||
}, []);
|
||||
|
||||
// ===== 탭 (단일 탭) =====
|
||||
const [activeTab, setActiveTab] = useState('all');
|
||||
const tabs: TabOption[] = useMemo(() => [
|
||||
{ value: 'all', label: '전체', count: totalCount, color: 'blue' },
|
||||
], [totalCount]);
|
||||
|
||||
// ===== 통계 카드 (총 실지급액, 총 기본급, 총 수당, 초과근무, 상여, 총공제) =====
|
||||
const statCards: StatCard[] = useMemo(() => {
|
||||
const totalNetPayment = salaryData.reduce((sum, s) => sum + s.netPayment, 0);
|
||||
|
||||
@@ -4,8 +4,8 @@
|
||||
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { FileDropzone } from '@/components/ui/file-dropzone';
|
||||
import { NumberInput } from '@/components/ui/number-input';
|
||||
import { FileImage, Plus, Trash2, X, Download, Loader2 } from 'lucide-react';
|
||||
import type { BendingDetail } from '@/types/item';
|
||||
@@ -215,11 +215,9 @@ export default function BendingDiagramSection({
|
||||
<div>
|
||||
<Label>파일 선택</Label>
|
||||
<div className="mt-2 space-y-3">
|
||||
<Input
|
||||
type="file"
|
||||
accept="image/*,.pdf"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
<FileDropzone
|
||||
onFilesSelect={(files) => {
|
||||
const file = files[0];
|
||||
if (file && typeof window !== 'undefined') {
|
||||
setBendingDiagramFile(file);
|
||||
const reader = new window.FileReader();
|
||||
@@ -229,13 +227,14 @@ export default function BendingDiagramSection({
|
||||
reader.readAsDataURL(file);
|
||||
}
|
||||
}}
|
||||
accept="image/*,.pdf"
|
||||
maxSize={10}
|
||||
disabled={isSubmitting}
|
||||
title="클릭하거나 파일을 드래그하세요"
|
||||
description={selectedPartType === 'ASSEMBLY'
|
||||
? '조립품 전개도 이미지를 업로드하세요(JPG, PNG, PDF 등)'
|
||||
: '절곡품 전개도 이미지를 업로드하세요(JPG, PNG, PDF 등)'}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
* {selectedPartType === 'ASSEMBLY'
|
||||
? '조립품 전개도 이미지를 업로드하세요(JPG, PNG, PDF 등)'
|
||||
: '절곡품 전개도 이미지를 업로드하세요(JPG, PNG, PDF 등)'}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 전개도 이미지 미리보기 */}
|
||||
|
||||
@@ -2,7 +2,6 @@
|
||||
* 제품 (FG) 폼 컴포넌트
|
||||
*/
|
||||
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
@@ -14,7 +13,7 @@ import {
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { X } from 'lucide-react';
|
||||
import { FileInput } from '@/components/ui/file-input';
|
||||
import type { UseFormRegister, UseFormSetValue, UseFormGetValues, FieldErrors } from 'react-hook-form';
|
||||
import type { CreateItemFormData } from '@/lib/utils/validation';
|
||||
|
||||
@@ -243,79 +242,29 @@ export function ProductCertificationSection({
|
||||
{/* 시방서 파일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>시방서 (PDF)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="cursor-pointer">
|
||||
<span className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium border rounded-md bg-background hover:bg-accent hover:text-accent-foreground">
|
||||
파일 선택
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setSpecificationFile(file);
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{specificationFile ? specificationFile.name : '선택된 파일 없음'}
|
||||
</span>
|
||||
{specificationFile && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setSpecificationFile(null)}
|
||||
disabled={isSubmitting}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<FileInput
|
||||
value={specificationFile}
|
||||
onFileSelect={setSpecificationFile}
|
||||
onFileRemove={() => setSpecificationFile(null)}
|
||||
accept=".pdf"
|
||||
disabled={isSubmitting}
|
||||
buttonText="파일 선택"
|
||||
placeholder="선택된 파일 없음"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 인정서 파일 */}
|
||||
<div className="space-y-2">
|
||||
<Label>인정서 (PDF)</Label>
|
||||
<div className="flex items-center gap-2">
|
||||
<label className="cursor-pointer">
|
||||
<span className="inline-flex items-center justify-center px-4 py-2 text-sm font-medium border rounded-md bg-background hover:bg-accent hover:text-accent-foreground">
|
||||
파일 선택
|
||||
</span>
|
||||
<input
|
||||
type="file"
|
||||
accept=".pdf"
|
||||
onChange={(e) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setCertificationFile(file);
|
||||
}
|
||||
}}
|
||||
className="hidden"
|
||||
disabled={isSubmitting}
|
||||
/>
|
||||
</label>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{certificationFile ? certificationFile.name : '선택된 파일 없음'}
|
||||
</span>
|
||||
{certificationFile && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={() => setCertificationFile(null)}
|
||||
disabled={isSubmitting}
|
||||
className="h-6 w-6 p-0"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
<FileInput
|
||||
value={certificationFile}
|
||||
onFileSelect={setCertificationFile}
|
||||
onFileRemove={() => setCertificationFile(null)}
|
||||
accept=".pdf"
|
||||
disabled={isSubmitting}
|
||||
buttonText="파일 선택"
|
||||
placeholder="선택된 파일 없음"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 비고 */}
|
||||
|
||||
@@ -0,0 +1,130 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 입고증 문서 콘텐츠
|
||||
*
|
||||
* 공통 컴포넌트 사용:
|
||||
* - DocumentHeader: simple 레이아웃 (결재란 없음, 서명란 별도)
|
||||
*/
|
||||
|
||||
import type { ReceivingDetail } from './types';
|
||||
import { DocumentHeader } from '@/components/document-system';
|
||||
|
||||
interface ReceivingReceiptContentProps {
|
||||
data: ReceivingDetail;
|
||||
}
|
||||
|
||||
export function ReceivingReceiptContent({ data: detail }: ReceivingReceiptContentProps) {
|
||||
const today = new Date();
|
||||
const formattedDate = `${today.getFullYear()}년 ${today.getMonth() + 1}월 ${today.getDate()}일`;
|
||||
|
||||
return (
|
||||
<div className="p-8 bg-white rounded-lg">
|
||||
{/* 제목 (공통 컴포넌트) */}
|
||||
<DocumentHeader
|
||||
title="입고증"
|
||||
subtitle="RECEIVING SLIP"
|
||||
layout="simple"
|
||||
approval={null}
|
||||
className="mb-8"
|
||||
/>
|
||||
|
||||
{/* 입고 정보 / 공급업체 정보 */}
|
||||
<div className="grid grid-cols-2 gap-8 mb-8">
|
||||
{/* 입고 정보 */}
|
||||
<div className="space-y-3 text-sm">
|
||||
<h3 className="font-semibold border-b pb-2">입고 정보</h3>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-y-2">
|
||||
<span className="text-muted-foreground">입고번호</span>
|
||||
<span className="font-medium">{detail.orderNo}</span>
|
||||
<span className="text-muted-foreground">입고일자</span>
|
||||
<span>{detail.receivingDate || today.toISOString().split('T')[0]}</span>
|
||||
<span className="text-muted-foreground">발주번호</span>
|
||||
<span>{detail.orderNo}</span>
|
||||
<span className="text-muted-foreground">입고LOT</span>
|
||||
<span>{detail.receivingLot || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공급업체 정보 */}
|
||||
<div className="space-y-3 text-sm">
|
||||
<h3 className="font-semibold border-b pb-2">공급업체 정보</h3>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-y-2">
|
||||
<span className="text-muted-foreground">업체명</span>
|
||||
<span className="font-medium">{detail.supplier}</span>
|
||||
<span className="text-muted-foreground">공급업체LOT</span>
|
||||
<span>{detail.supplierLot || '-'}</span>
|
||||
<span className="text-muted-foreground">담당자</span>
|
||||
<span>{detail.receivingManager || '-'}</span>
|
||||
<span className="text-muted-foreground">입고위치</span>
|
||||
<span>{detail.receivingLocation || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 입고 품목 상세 */}
|
||||
<div className="mb-8">
|
||||
<h3 className="font-semibold text-sm mb-3">입고 품목 상세</h3>
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-y bg-gray-50">
|
||||
<th className="px-3 py-2 text-center font-medium w-12">No</th>
|
||||
<th className="px-3 py-2 text-left font-medium">품목코드</th>
|
||||
<th className="px-3 py-2 text-left font-medium">품목명</th>
|
||||
<th className="px-3 py-2 text-left font-medium">규격</th>
|
||||
<th className="px-3 py-2 text-center font-medium w-24">발주수량</th>
|
||||
<th className="px-3 py-2 text-center font-medium w-24">입고수량</th>
|
||||
<th className="px-3 py-2 text-center font-medium w-16">단위</th>
|
||||
<th className="px-3 py-2 text-left font-medium w-24">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b">
|
||||
<td className="px-3 py-2 text-center">1</td>
|
||||
<td className="px-3 py-2">{detail.itemCode}</td>
|
||||
<td className="px-3 py-2">{detail.itemName}</td>
|
||||
<td className="px-3 py-2">{detail.specification || '-'}</td>
|
||||
<td className="px-3 py-2 text-center">{detail.orderQty}</td>
|
||||
<td className="px-3 py-2 text-center">{detail.receivingQty || '-'}</td>
|
||||
<td className="px-3 py-2 text-center">{detail.orderUnit}</td>
|
||||
<td className="px-3 py-2">-</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 수입검사 안내 */}
|
||||
<div className="mb-8 p-4 bg-gray-50 rounded-lg text-sm">
|
||||
<p className="flex items-start gap-2">
|
||||
<span className="font-medium">📋 수입검사 안내</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
본 입고건에 대한 <span className="font-medium text-blue-600">수입검사(IQC)</span>가 필요합니다.<br />
|
||||
품질관리 > 수입검사(IQC) 메뉴에서 검사를 진행해주세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 서명란 */}
|
||||
<div className="grid grid-cols-3 gap-6 mb-8">
|
||||
<div className="border rounded p-4 text-center">
|
||||
<p className="text-sm font-medium mb-12">입고담당</p>
|
||||
<p className="text-xs text-muted-foreground">(인)</p>
|
||||
</div>
|
||||
<div className="border rounded p-4 text-center">
|
||||
<p className="text-sm font-medium mb-12">품질검사</p>
|
||||
<p className="text-xs text-muted-foreground">(인)</p>
|
||||
</div>
|
||||
<div className="border rounded p-4 text-center">
|
||||
<p className="text-sm font-medium mb-12">창고담당</p>
|
||||
<p className="text-xs text-muted-foreground">(인)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 발행일 / 회사명 */}
|
||||
<div className="text-right text-sm text-muted-foreground">
|
||||
<p>발행일: {formattedDate}</p>
|
||||
<p>(주) 코드빌더스</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,20 +1,16 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 입고증 다이얼로그 (인쇄용)
|
||||
* - 작업일지(WorkLogModal) 스타일 적용
|
||||
* 입고증 다이얼로그
|
||||
*
|
||||
* document-system 통합 버전 (2026-01-22)
|
||||
*/
|
||||
|
||||
import { Printer, Download, X } from 'lucide-react';
|
||||
import { Download } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
|
||||
import { printArea } from '@/lib/print-utils';
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import type { ReceivingDetail } from './types';
|
||||
import { ReceivingReceiptContent } from './ReceivingReceiptContent';
|
||||
|
||||
interface Props {
|
||||
open: boolean;
|
||||
@@ -23,163 +19,28 @@ interface Props {
|
||||
}
|
||||
|
||||
export function ReceivingReceiptDialog({ open, onOpenChange, detail }: Props) {
|
||||
const handlePrint = () => {
|
||||
printArea({ title: '입고증 인쇄' });
|
||||
};
|
||||
|
||||
const handleDownload = () => {
|
||||
// TODO: PDF 다운로드 기능
|
||||
console.log('PDF 다운로드:', detail);
|
||||
};
|
||||
|
||||
const today = new Date();
|
||||
const formattedDate = `${today.getFullYear()}년 ${today.getMonth() + 1}월 ${today.getDate()}일`;
|
||||
const toolbarExtra = (
|
||||
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||
<Download className="w-4 h-4 mr-1" />
|
||||
다운로드
|
||||
</Button>
|
||||
);
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-4xl max-h-[90vh] overflow-y-auto p-0 gap-0 bg-gray-100">
|
||||
{/* 접근성을 위한 숨겨진 타이틀 */}
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>입고증 - {detail.orderNo}</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
|
||||
{/* 모달 헤더 - 작업일지 스타일 (인쇄 시 숨김) */}
|
||||
<div className="print-hidden flex items-center justify-between px-6 py-4 border-b bg-white sticky top-0 z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-semibold text-lg">입고증</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{detail.supplier}
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({detail.orderNo})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="w-4 h-4 mr-1.5" />
|
||||
인쇄
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleDownload}>
|
||||
<Download className="w-4 h-4 mr-1.5" />
|
||||
다운로드
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 문서 본문 (인쇄 시 이 영역만 출력) */}
|
||||
<div className="print-area m-6 p-8 bg-white rounded-lg shadow-sm">
|
||||
{/* 제목 */}
|
||||
<div className="text-center mb-8">
|
||||
<h2 className="text-2xl font-bold">입고증</h2>
|
||||
<p className="text-sm text-muted-foreground">RECEIVING SLIP</p>
|
||||
</div>
|
||||
|
||||
{/* 입고 정보 / 공급업체 정보 */}
|
||||
<div className="grid grid-cols-2 gap-8 mb-8">
|
||||
{/* 입고 정보 */}
|
||||
<div className="space-y-3 text-sm">
|
||||
<h3 className="font-semibold border-b pb-2">입고 정보</h3>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-y-2">
|
||||
<span className="text-muted-foreground">입고번호</span>
|
||||
<span className="font-medium">{detail.orderNo}</span>
|
||||
<span className="text-muted-foreground">입고일자</span>
|
||||
<span>{detail.receivingDate || today.toISOString().split('T')[0]}</span>
|
||||
<span className="text-muted-foreground">발주번호</span>
|
||||
<span>{detail.orderNo}</span>
|
||||
<span className="text-muted-foreground">입고LOT</span>
|
||||
<span>{detail.receivingLot || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공급업체 정보 */}
|
||||
<div className="space-y-3 text-sm">
|
||||
<h3 className="font-semibold border-b pb-2">공급업체 정보</h3>
|
||||
<div className="grid grid-cols-[100px_1fr] gap-y-2">
|
||||
<span className="text-muted-foreground">업체명</span>
|
||||
<span className="font-medium">{detail.supplier}</span>
|
||||
<span className="text-muted-foreground">공급업체LOT</span>
|
||||
<span>{detail.supplierLot || '-'}</span>
|
||||
<span className="text-muted-foreground">담당자</span>
|
||||
<span>{detail.receivingManager || '-'}</span>
|
||||
<span className="text-muted-foreground">입고위치</span>
|
||||
<span>{detail.receivingLocation || '-'}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 입고 품목 상세 */}
|
||||
<div className="mb-8">
|
||||
<h3 className="font-semibold text-sm mb-3">입고 품목 상세</h3>
|
||||
<table className="w-full text-sm border-collapse">
|
||||
<thead>
|
||||
<tr className="border-y bg-gray-50">
|
||||
<th className="px-3 py-2 text-center font-medium w-12">No</th>
|
||||
<th className="px-3 py-2 text-left font-medium">품목코드</th>
|
||||
<th className="px-3 py-2 text-left font-medium">품목명</th>
|
||||
<th className="px-3 py-2 text-left font-medium">규격</th>
|
||||
<th className="px-3 py-2 text-center font-medium w-24">발주수량</th>
|
||||
<th className="px-3 py-2 text-center font-medium w-24">입고수량</th>
|
||||
<th className="px-3 py-2 text-center font-medium w-16">단위</th>
|
||||
<th className="px-3 py-2 text-left font-medium w-24">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b">
|
||||
<td className="px-3 py-2 text-center">1</td>
|
||||
<td className="px-3 py-2">{detail.itemCode}</td>
|
||||
<td className="px-3 py-2">{detail.itemName}</td>
|
||||
<td className="px-3 py-2">{detail.specification || '-'}</td>
|
||||
<td className="px-3 py-2 text-center">{detail.orderQty}</td>
|
||||
<td className="px-3 py-2 text-center">{detail.receivingQty || '-'}</td>
|
||||
<td className="px-3 py-2 text-center">{detail.orderUnit}</td>
|
||||
<td className="px-3 py-2">-</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 수입검사 안내 */}
|
||||
<div className="mb-8 p-4 bg-gray-50 rounded-lg text-sm">
|
||||
<p className="flex items-start gap-2">
|
||||
<span className="font-medium">📋 수입검사 안내</span>
|
||||
</p>
|
||||
<p className="text-muted-foreground mt-1">
|
||||
본 입고건에 대한 <span className="font-medium text-blue-600">수입검사(IQC)</span>가 필요합니다.<br />
|
||||
품질관리 > 수입검사(IQC) 메뉴에서 검사를 진행해주세요.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 서명란 */}
|
||||
<div className="grid grid-cols-3 gap-6 mb-8">
|
||||
<div className="border rounded p-4 text-center">
|
||||
<p className="text-sm font-medium mb-12">입고담당</p>
|
||||
<p className="text-xs text-muted-foreground">(인)</p>
|
||||
</div>
|
||||
<div className="border rounded p-4 text-center">
|
||||
<p className="text-sm font-medium mb-12">품질검사</p>
|
||||
<p className="text-xs text-muted-foreground">(인)</p>
|
||||
</div>
|
||||
<div className="border rounded p-4 text-center">
|
||||
<p className="text-sm font-medium mb-12">창고담당</p>
|
||||
<p className="text-xs text-muted-foreground">(인)</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 발행일 / 회사명 */}
|
||||
<div className="text-right text-sm text-muted-foreground">
|
||||
<p>발행일: {formattedDate}</p>
|
||||
<p>(주) 코드빌더스</p>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DocumentViewer
|
||||
title="입고증"
|
||||
subtitle={`${detail.supplier} (${detail.orderNo})`}
|
||||
preset="inspection"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
toolbarExtra={toolbarExtra}
|
||||
>
|
||||
<ReceivingReceiptContent data={detail} />
|
||||
</DocumentViewer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
135
src/components/process-management/ProcessWorkLogContent.tsx
Normal file
135
src/components/process-management/ProcessWorkLogContent.tsx
Normal file
@@ -0,0 +1,135 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 공정 작업일지 문서 콘텐츠
|
||||
*
|
||||
* 공통 컴포넌트 사용:
|
||||
* - DocumentHeader: default 레이아웃 + 4col 결재란
|
||||
* - SectionHeader: 섹션 제목
|
||||
*/
|
||||
|
||||
import type { Process } from '@/types/process';
|
||||
import { DocumentHeader, SectionHeader } from '@/components/document-system';
|
||||
|
||||
const getDocumentCode = (processName: string): string => {
|
||||
if (processName.includes('스크린')) return 'WL-SCR';
|
||||
if (processName.includes('슬랫')) return 'WL-SLT';
|
||||
if (processName.includes('절곡') || processName.includes('포밍')) return 'WL-BEN';
|
||||
return 'WL-STK';
|
||||
};
|
||||
|
||||
const getDepartmentName = (processName: string, department?: string): string => {
|
||||
if (processName.includes('스크린')) return '스크린 생산부서';
|
||||
if (processName.includes('슬랫')) return '슬랫 생산부서';
|
||||
if (processName.includes('절곡')) return '절곡 생산부서';
|
||||
if (processName.includes('포밍')) return '포밍 생산부서';
|
||||
return department || '생산부서';
|
||||
};
|
||||
|
||||
interface ProcessWorkLogContentProps {
|
||||
data: Process;
|
||||
}
|
||||
|
||||
export function ProcessWorkLogContent({ data: process }: ProcessWorkLogContentProps) {
|
||||
const documentCode = getDocumentCode(process.processName);
|
||||
const departmentName = getDepartmentName(process.processName, process.department);
|
||||
const today = new Date().toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).replace(/\. /g, '-').replace('.', '');
|
||||
|
||||
const workItems = [
|
||||
{ no: 1, name: '원단 재단', spec: 'W2900 × H3900', qty: 9, unit: 'EA', worker: '이작업', note: '' },
|
||||
{ no: 2, name: '미싱 작업', spec: 'W2800 × H3800', qty: 8, unit: 'EA', worker: '김작업', note: '' },
|
||||
{ no: 3, name: '앤드락 조립', spec: 'W2700 × H3700', qty: 7, unit: 'EA', worker: '이작업', note: '' },
|
||||
{ no: 4, name: '검수', spec: 'W2600 × H3600', qty: 6, unit: 'EA', worker: '김작업', note: '' },
|
||||
{ no: 5, name: '포장', spec: 'W2500 × H3500', qty: 5, unit: 'EA', worker: '이작업', note: '' },
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white">
|
||||
{/* 문서 헤더 (공통 컴포넌트) */}
|
||||
<DocumentHeader
|
||||
title="작 업 일 지"
|
||||
documentCode={documentCode}
|
||||
subtitle={departmentName}
|
||||
logo={{ text: 'KD', subtext: '정동기업' }}
|
||||
approval={{
|
||||
type: '4col',
|
||||
writer: { name: '홍길동', date: '12/17' },
|
||||
showDepartment: true,
|
||||
departmentLabels: {
|
||||
writer: '판매/전진',
|
||||
reviewer: '생산',
|
||||
approver: '품질',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 신청업체 / 신청내용 테이블 */}
|
||||
<table className="w-full border-collapse border border-gray-300 mb-6 text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colSpan={2} className="bg-gray-800 text-white p-2.5 font-medium border-r border-gray-300">신 청 업 체</th>
|
||||
<th colSpan={2} className="bg-gray-800 text-white p-2.5 font-medium">신 청 내 용</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="w-24 bg-gray-100 p-3 font-medium border-r border-gray-300">발 주 일</td>
|
||||
<td className="p-3 border-r border-gray-300">{today}</td>
|
||||
<td className="w-24 bg-gray-100 p-3 font-medium border-r border-gray-300">현 장 명</td>
|
||||
<td className="p-3">송도 오피스텔 A동</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 p-3 font-medium border-r border-gray-300">업 체 명</td>
|
||||
<td className="p-3 border-r border-gray-300">(주)인천건설</td>
|
||||
<td className="bg-gray-100 p-3 font-medium border-r border-gray-300">작업일자</td>
|
||||
<td className="p-3">{today}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 p-3 font-medium border-r border-gray-300">담 당 자</td>
|
||||
<td className="p-3 border-r border-gray-300">김담당</td>
|
||||
<td className="bg-gray-100 p-3 font-medium border-r border-gray-300">제품 LOT NO.</td>
|
||||
<td className="p-3">KD-TS-251217-01-01</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-100 p-3 font-medium border-r border-gray-300">제품명</td>
|
||||
<td className="p-3 border-r border-gray-300">SH3040 방화셔터 W3000×H4000</td>
|
||||
<td className="bg-gray-100 p-3 font-medium border-r border-gray-300">마감유형</td>
|
||||
<td className="p-3">스크린 그레이</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 작업항목 테이블 */}
|
||||
<table className="w-full border-collapse border border-gray-300 text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-800 text-white">
|
||||
<th className="p-2.5 font-medium border-r border-gray-600 w-16">순번</th>
|
||||
<th className="p-2.5 font-medium border-r border-gray-600">작업항목</th>
|
||||
<th className="p-2.5 font-medium border-r border-gray-600 w-40">규격</th>
|
||||
<th className="p-2.5 font-medium border-r border-gray-600 w-20">수량</th>
|
||||
<th className="p-2.5 font-medium border-r border-gray-600 w-16">단위</th>
|
||||
<th className="p-2.5 font-medium border-r border-gray-600 w-20">작업자</th>
|
||||
<th className="p-2.5 font-medium w-24">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{workItems.map((item, index) => (
|
||||
<tr key={item.no} className={index < workItems.length - 1 ? 'border-b border-gray-300' : ''}>
|
||||
<td className="p-3 text-center border-r border-gray-300">{item.no}</td>
|
||||
<td className="p-3 border-r border-gray-300">{item.name}</td>
|
||||
<td className="p-3 text-center border-r border-gray-300">{item.spec}</td>
|
||||
<td className="p-3 text-center border-r border-gray-300">{item.qty}</td>
|
||||
<td className="p-3 text-center border-r border-gray-300">{item.unit}</td>
|
||||
<td className="p-3 text-center border-r border-gray-300">{item.worker}</td>
|
||||
<td className="p-3 text-center">{item.note}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,31 +1,15 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 공정 상세 - 작업일지 양식 미리보기 모달
|
||||
* 공정 작업일지 미리보기 모달
|
||||
*
|
||||
* 기획서 기준 양식:
|
||||
* - 신청업체/신청내용 테이블
|
||||
* - 순번/작업항목/규격/수량/단위/작업자/비고 테이블
|
||||
* document-system 통합 버전 (2026-01-22)
|
||||
*/
|
||||
|
||||
import { Printer, X } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { printArea } from '@/lib/print-utils';
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import type { Process } from '@/types/process';
|
||||
import { ProcessWorkLogContent } from './ProcessWorkLogContent';
|
||||
|
||||
interface ProcessWorkLogPreviewModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
process: Process;
|
||||
}
|
||||
|
||||
// 공정명을 문서 코드로 매핑
|
||||
const getDocumentCode = (processName: string): string => {
|
||||
if (processName.includes('스크린')) return 'WL-SCR';
|
||||
if (processName.includes('슬랫')) return 'WL-SLT';
|
||||
@@ -33,188 +17,28 @@ const getDocumentCode = (processName: string): string => {
|
||||
return 'WL-STK';
|
||||
};
|
||||
|
||||
// 공정명을 부서명으로 매핑
|
||||
const getDepartmentName = (processName: string, department?: string): string => {
|
||||
if (processName.includes('스크린')) return '스크린 생산부서';
|
||||
if (processName.includes('슬랫')) return '슬랫 생산부서';
|
||||
if (processName.includes('절곡')) return '절곡 생산부서';
|
||||
if (processName.includes('포밍')) return '포밍 생산부서';
|
||||
return department || '생산부서';
|
||||
};
|
||||
interface ProcessWorkLogPreviewModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
process: Process;
|
||||
}
|
||||
|
||||
export function ProcessWorkLogPreviewModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
process
|
||||
}: ProcessWorkLogPreviewModalProps) {
|
||||
const handlePrint = () => {
|
||||
printArea({ title: `${process.workLogTemplate} 인쇄` });
|
||||
};
|
||||
|
||||
const documentCode = getDocumentCode(process.processName);
|
||||
const departmentName = getDepartmentName(process.processName, process.department);
|
||||
const today = new Date().toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).replace(/\. /g, '-').replace('.', '');
|
||||
|
||||
// 샘플 작업항목 데이터 (기획서 기준)
|
||||
const workItems = [
|
||||
{ no: 1, name: '원단 재단', spec: 'W2900 × H3900', qty: 9, unit: 'EA', worker: '이작업', note: '' },
|
||||
{ no: 2, name: '미싱 작업', spec: 'W2800 × H3800', qty: 8, unit: 'EA', worker: '김작업', note: '' },
|
||||
{ no: 3, name: '앤드락 조립', spec: 'W2700 × H3700', qty: 7, unit: 'EA', worker: '이작업', note: '' },
|
||||
{ no: 4, name: '검수', spec: 'W2600 × H3600', qty: 6, unit: 'EA', worker: '김작업', note: '' },
|
||||
{ no: 5, name: '포장', spec: 'W2500 × H3500', qty: 5, unit: 'EA', worker: '이작업', note: '' },
|
||||
];
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto p-0 gap-0 bg-gray-100">
|
||||
{/* 접근성을 위한 숨겨진 타이틀 */}
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>{process.workLogTemplate} 미리보기</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
|
||||
{/* 모달 헤더 (인쇄 시 숨김) */}
|
||||
<div className="print-hidden flex items-center justify-between px-6 py-4 border-b bg-white sticky top-0 z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-semibold text-lg">{process.workLogTemplate} 미리보기</span>
|
||||
<span className="text-sm text-muted-foreground">({documentCode})</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="w-4 h-4 mr-1.5" />
|
||||
인쇄
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 문서 본문 (인쇄 시 이 영역만 출력) */}
|
||||
<div className="print-area m-6 p-6 bg-white rounded-lg shadow-sm">
|
||||
{/* 문서 헤더: 로고 + 제목 + 결재라인 */}
|
||||
<div className="flex border border-gray-300 mb-6">
|
||||
{/* 좌측: 로고 영역 */}
|
||||
<div className="w-24 border-r border-gray-300 flex flex-col items-center justify-center p-3 shrink-0">
|
||||
<div className="text-2xl font-bold">KD</div>
|
||||
<div className="text-xs text-gray-500">정동기업</div>
|
||||
</div>
|
||||
|
||||
{/* 중앙: 문서 제목 */}
|
||||
<div className="flex-1 border-r border-gray-300 flex flex-col items-center justify-center p-3">
|
||||
<h1 className="text-xl font-bold tracking-widest mb-1">작 업 일 지</h1>
|
||||
<p className="text-xs text-gray-500">{documentCode}</p>
|
||||
<p className="text-sm font-medium mt-1">{departmentName}</p>
|
||||
</div>
|
||||
|
||||
{/* 우측: 결재라인 */}
|
||||
<div className="shrink-0">
|
||||
<table className="border-collapse text-xs h-full">
|
||||
<tbody>
|
||||
<tr>
|
||||
<td rowSpan={3} className="w-8 border-r border-gray-300 text-center align-middle bg-gray-100 font-medium">
|
||||
<div>결</div>
|
||||
<div>재</div>
|
||||
</td>
|
||||
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-b border-gray-300">작성</td>
|
||||
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-b border-gray-300">검토</td>
|
||||
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-b border-gray-300">승인</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="w-16 p-2 text-center border-r border-b border-gray-300">
|
||||
<div>홍길동</div>
|
||||
<div className="text-[10px] text-gray-500">12/17</div>
|
||||
</td>
|
||||
<td className="w-16 p-2 text-center border-r border-b border-gray-300"></td>
|
||||
<td className="w-16 p-2 text-center border-b border-gray-300"></td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300">판매/전진</td>
|
||||
<td className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300">생산</td>
|
||||
<td className="w-16 p-2 text-center bg-gray-50">품질</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 신청업체 / 신청내용 테이블 */}
|
||||
<table className="w-full border-collapse border border-gray-300 mb-6 text-sm">
|
||||
<thead>
|
||||
<tr>
|
||||
<th colSpan={2} className="bg-gray-800 text-white p-2.5 font-medium border-r border-gray-300">
|
||||
신 청 업 체
|
||||
</th>
|
||||
<th colSpan={2} className="bg-gray-800 text-white p-2.5 font-medium">
|
||||
신 청 내 용
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="w-24 bg-gray-100 p-3 font-medium border-r border-gray-300">발 주 일</td>
|
||||
<td className="p-3 border-r border-gray-300">{today}</td>
|
||||
<td className="w-24 bg-gray-100 p-3 font-medium border-r border-gray-300">현 장 명</td>
|
||||
<td className="p-3">송도 오피스텔 A동</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 p-3 font-medium border-r border-gray-300">업 체 명</td>
|
||||
<td className="p-3 border-r border-gray-300">(주)인천건설</td>
|
||||
<td className="bg-gray-100 p-3 font-medium border-r border-gray-300">작업일자</td>
|
||||
<td className="p-3">{today}</td>
|
||||
</tr>
|
||||
<tr className="border-b border-gray-300">
|
||||
<td className="bg-gray-100 p-3 font-medium border-r border-gray-300">담 당 자</td>
|
||||
<td className="p-3 border-r border-gray-300">김담당</td>
|
||||
<td className="bg-gray-100 p-3 font-medium border-r border-gray-300">제품 LOT NO.</td>
|
||||
<td className="p-3">KD-TS-251217-01-01</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td className="bg-gray-100 p-3 font-medium border-r border-gray-300">제품명</td>
|
||||
<td className="p-3 border-r border-gray-300">SH3040 방화셔터 W3000×H4000</td>
|
||||
<td className="bg-gray-100 p-3 font-medium border-r border-gray-300">마감유형</td>
|
||||
<td className="p-3">스크린 그레이</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
|
||||
{/* 작업항목 테이블 */}
|
||||
<table className="w-full border-collapse border border-gray-300 text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-800 text-white">
|
||||
<th className="p-2.5 font-medium border-r border-gray-600 w-16">순번</th>
|
||||
<th className="p-2.5 font-medium border-r border-gray-600">작업항목</th>
|
||||
<th className="p-2.5 font-medium border-r border-gray-600 w-40">규격</th>
|
||||
<th className="p-2.5 font-medium border-r border-gray-600 w-20">수량</th>
|
||||
<th className="p-2.5 font-medium border-r border-gray-600 w-16">단위</th>
|
||||
<th className="p-2.5 font-medium border-r border-gray-600 w-20">작업자</th>
|
||||
<th className="p-2.5 font-medium w-24">비고</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{workItems.map((item, index) => (
|
||||
<tr key={item.no} className={index < workItems.length - 1 ? 'border-b border-gray-300' : ''}>
|
||||
<td className="p-3 text-center border-r border-gray-300">{item.no}</td>
|
||||
<td className="p-3 border-r border-gray-300">{item.name}</td>
|
||||
<td className="p-3 text-center border-r border-gray-300">{item.spec}</td>
|
||||
<td className="p-3 text-center border-r border-gray-300">{item.qty}</td>
|
||||
<td className="p-3 text-center border-r border-gray-300">{item.unit}</td>
|
||||
<td className="p-3 text-center border-r border-gray-300">{item.worker}</td>
|
||||
<td className="p-3 text-center">{item.note}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DocumentViewer
|
||||
title={`${process.workLogTemplate} 미리보기`}
|
||||
subtitle={`(${documentCode})`}
|
||||
preset="inspection"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
<ProcessWorkLogContent data={process} />
|
||||
</DocumentViewer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
194
src/components/production/WorkerScreen/WorkLogContent.tsx
Normal file
194
src/components/production/WorkerScreen/WorkLogContent.tsx
Normal file
@@ -0,0 +1,194 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 작업일지 문서 콘텐츠
|
||||
*
|
||||
* DocumentViewer와 함께 사용하는 문서 본문 컴포넌트
|
||||
* 인쇄 영역만 포함 (모달 헤더/툴바는 DocumentViewer가 처리)
|
||||
*
|
||||
* 공통 컴포넌트 사용:
|
||||
* - DocumentHeader: 로고 + 제목 + 결재라인
|
||||
* - InfoTable: 라벨-값 정보 테이블
|
||||
* - SectionHeader: 섹션 제목 (작업내역, 특이사항)
|
||||
*/
|
||||
|
||||
import type { WorkOrder, WorkOrderItem } from '../WorkOrders/types';
|
||||
import { ITEM_STATUS_LABELS } from '../WorkOrders/types';
|
||||
import {
|
||||
DocumentHeader,
|
||||
InfoTable,
|
||||
SectionHeader,
|
||||
} from '@/components/document-system';
|
||||
|
||||
interface WorkLogContentProps {
|
||||
data: WorkOrder;
|
||||
}
|
||||
|
||||
// 작업 통계 타입
|
||||
interface WorkStats {
|
||||
orderQty: number;
|
||||
completedQty: number;
|
||||
inProgressQty: number;
|
||||
waitingQty: number;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
// 품목 데이터에서 작업 통계 계산
|
||||
function calculateWorkStats(items: WorkOrderItem[]): WorkStats {
|
||||
const orderQty = items.length;
|
||||
const completedQty = items.filter(i => i.status === 'completed').length;
|
||||
const inProgressQty = items.filter(i => i.status === 'in_progress').length;
|
||||
const waitingQty = items.filter(i => i.status === 'waiting').length;
|
||||
const progress = orderQty > 0 ? Math.round((completedQty / orderQty) * 100) : 0;
|
||||
|
||||
return {
|
||||
orderQty,
|
||||
completedQty,
|
||||
inProgressQty,
|
||||
waitingQty,
|
||||
progress,
|
||||
};
|
||||
}
|
||||
|
||||
export function WorkLogContent({ data: order }: WorkLogContentProps) {
|
||||
const today = new Date().toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).replace(/\. /g, '-').replace('.', '');
|
||||
|
||||
const documentNo = `WL-${order.processCode.toUpperCase().slice(0, 3)}`;
|
||||
|
||||
// 품목 데이터
|
||||
const items = order.items || [];
|
||||
|
||||
// 작업 통계 계산
|
||||
const workStats = calculateWorkStats(items);
|
||||
|
||||
// 주 담당자
|
||||
const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-';
|
||||
|
||||
// 포맷된 납기일
|
||||
const formattedDueDate = order.dueDate !== '-'
|
||||
? new Date(order.dueDate).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).replace(/\. /g, '-').replace('.', '')
|
||||
: '-';
|
||||
|
||||
// 작성자 날짜 포맷
|
||||
const writerDate = new Date().toLocaleDateString('ko-KR', {
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).replace('. ', '/').replace('.', '');
|
||||
|
||||
return (
|
||||
<div className="p-6 bg-white">
|
||||
{/* 문서 헤더: 로고 + 제목 + 결재라인 (공통 컴포넌트) */}
|
||||
<DocumentHeader
|
||||
title="작 업 일 지"
|
||||
documentCode={documentNo}
|
||||
subtitle={`${order.processName} 생산부서`}
|
||||
logo={{ text: 'KD', subtext: '정동기업' }}
|
||||
approval={{
|
||||
type: '4col',
|
||||
writer: { name: primaryAssignee, date: writerDate },
|
||||
showDepartment: true,
|
||||
departmentLabels: {
|
||||
writer: '판매/전진',
|
||||
reviewer: '생산',
|
||||
approver: '품질',
|
||||
},
|
||||
}}
|
||||
/>
|
||||
|
||||
{/* 기본 정보 테이블 (공통 컴포넌트) */}
|
||||
<InfoTable
|
||||
className="mb-6"
|
||||
rows={[
|
||||
[
|
||||
{ label: '발주처', value: order.client },
|
||||
{ label: '현장명', value: order.projectName },
|
||||
],
|
||||
[
|
||||
{ label: '작업일자', value: today },
|
||||
{ label: 'LOT NO.', value: order.lotNo },
|
||||
],
|
||||
[
|
||||
{ label: '납기일', value: formattedDueDate },
|
||||
{ label: '작업지시번호', value: order.workOrderNo },
|
||||
],
|
||||
]}
|
||||
/>
|
||||
|
||||
{/* 품목 테이블 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
{/* 테이블 헤더 */}
|
||||
<div className="grid grid-cols-12 border-b border-gray-300 bg-gray-100">
|
||||
<div className="col-span-1 p-2 text-sm font-medium text-center border-r border-gray-300">No</div>
|
||||
<div className="col-span-4 p-2 text-sm font-medium text-center border-r border-gray-300">품목명</div>
|
||||
<div className="col-span-2 p-2 text-sm font-medium text-center border-r border-gray-300">층/부호</div>
|
||||
<div className="col-span-2 p-2 text-sm font-medium text-center border-r border-gray-300">규격</div>
|
||||
<div className="col-span-1 p-2 text-sm font-medium text-center border-r border-gray-300">수량</div>
|
||||
<div className="col-span-2 p-2 text-sm font-medium text-center">상태</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 데이터 */}
|
||||
{items.length > 0 ? (
|
||||
items.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`grid grid-cols-12 ${index < items.length - 1 ? 'border-b border-gray-300' : ''}`}
|
||||
>
|
||||
<div className="col-span-1 p-2 text-sm text-center border-r border-gray-300">{item.no}</div>
|
||||
<div className="col-span-4 p-2 text-sm border-r border-gray-300">{item.productName}</div>
|
||||
<div className="col-span-2 p-2 text-sm text-center border-r border-gray-300">{item.floorCode}</div>
|
||||
<div className="col-span-2 p-2 text-sm text-center border-r border-gray-300">{item.specification}</div>
|
||||
<div className="col-span-1 p-2 text-sm text-center border-r border-gray-300">{item.quantity}</div>
|
||||
<div className="col-span-2 p-2 text-sm text-center">{ITEM_STATUS_LABELS[item.status]}</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-4 text-center text-muted-foreground text-sm">
|
||||
등록된 품목이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 작업내역 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
{/* 섹션 헤더 (공통 컴포넌트) */}
|
||||
<SectionHeader>{order.processName} 작업내역</SectionHeader>
|
||||
|
||||
{/* 수량 및 진행률 */}
|
||||
<div className="grid grid-cols-6 border-b border-gray-300">
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">지시수량</div>
|
||||
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.orderQty} EA</div>
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">완료수량</div>
|
||||
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.completedQty} EA</div>
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">진행률</div>
|
||||
<div className="p-2 text-sm text-center font-medium text-blue-600">{workStats.progress}%</div>
|
||||
</div>
|
||||
|
||||
{/* 상세 상태 */}
|
||||
<div className="grid grid-cols-6">
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">대기</div>
|
||||
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.waitingQty} EA</div>
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">작업중</div>
|
||||
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.inProgressQty} EA</div>
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">완료</div>
|
||||
<div className="p-2 text-sm text-center">{workStats.completedQty} EA</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 특이사항 (공통 컴포넌트) */}
|
||||
<div className="border border-gray-300">
|
||||
<SectionHeader>특이사항</SectionHeader>
|
||||
<div className="p-4 min-h-[60px] text-sm">
|
||||
{order.note || '-'}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -3,24 +3,17 @@
|
||||
/**
|
||||
* 작업일지 모달
|
||||
*
|
||||
* - 헤더: sam-design 작업일지 스타일
|
||||
* - 내부 문서: 스크린샷 기준 작업일지 양식
|
||||
* - API 연동 완료 (2025-01-14)
|
||||
* document-system 통합 버전 (2026-01-22)
|
||||
* - DocumentViewer 사용
|
||||
* - WorkLogContent로 문서 본문 분리
|
||||
*/
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
import { Printer, X, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { VisuallyHidden } from '@radix-ui/react-visually-hidden';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { printArea } from '@/lib/print-utils';
|
||||
import { Loader2 } from 'lucide-react';
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import { getWorkOrderById } from '../WorkOrders/actions';
|
||||
import type { WorkOrder, WorkOrderItem } from '../WorkOrders/types';
|
||||
import { ITEM_STATUS_LABELS } from '../WorkOrders/types';
|
||||
import type { WorkOrder } from '../WorkOrders/types';
|
||||
import { WorkLogContent } from './WorkLogContent';
|
||||
|
||||
interface WorkLogModalProps {
|
||||
open: boolean;
|
||||
@@ -28,32 +21,6 @@ interface WorkLogModalProps {
|
||||
workOrderId: string | null;
|
||||
}
|
||||
|
||||
// 작업 통계 타입
|
||||
interface WorkStats {
|
||||
orderQty: number;
|
||||
completedQty: number;
|
||||
inProgressQty: number;
|
||||
waitingQty: number;
|
||||
progress: number;
|
||||
}
|
||||
|
||||
// 품목 데이터에서 작업 통계 계산
|
||||
function calculateWorkStats(items: WorkOrderItem[]): WorkStats {
|
||||
const orderQty = items.length;
|
||||
const completedQty = items.filter(i => i.status === 'completed').length;
|
||||
const inProgressQty = items.filter(i => i.status === 'in_progress').length;
|
||||
const waitingQty = items.filter(i => i.status === 'waiting').length;
|
||||
const progress = orderQty > 0 ? Math.round((completedQty / orderQty) * 100) : 0;
|
||||
|
||||
return {
|
||||
orderQty,
|
||||
completedQty,
|
||||
inProgressQty,
|
||||
waitingQty,
|
||||
progress,
|
||||
};
|
||||
}
|
||||
|
||||
export function WorkLogModal({ open, onOpenChange, workOrderId }: WorkLogModalProps) {
|
||||
const [order, setOrder] = useState<WorkOrder | null>(null);
|
||||
const [isLoading, setIsLoading] = useState(false);
|
||||
@@ -86,283 +53,30 @@ export function WorkLogModal({ open, onOpenChange, workOrderId }: WorkLogModalPr
|
||||
}
|
||||
}, [open, workOrderId]);
|
||||
|
||||
const handlePrint = () => {
|
||||
printArea({ title: '작업일지 인쇄' });
|
||||
};
|
||||
|
||||
if (!workOrderId) return null;
|
||||
|
||||
// 로딩 상태
|
||||
if (isLoading) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto p-0 gap-0 bg-gray-100">
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>작업일지 로딩 중</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
<div className="flex items-center justify-center h-64">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// 에러 상태
|
||||
if (error || !order) {
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto p-0 gap-0 bg-gray-100">
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>작업일지 오류</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-4">
|
||||
<p className="text-muted-foreground">{error || '데이터를 불러올 수 없습니다.'}</p>
|
||||
<Button variant="outline" onClick={() => onOpenChange(false)}>
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
const today = new Date().toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).replace(/\. /g, '-').replace('.', '');
|
||||
|
||||
const documentNo = `WL-${order.processCode.toUpperCase().slice(0, 3)}`;
|
||||
|
||||
// 품목 데이터
|
||||
const items = order.items || [];
|
||||
|
||||
// 작업 통계 계산
|
||||
const workStats = calculateWorkStats(items);
|
||||
|
||||
// 주 담당자
|
||||
const primaryAssignee = order.assignees?.find(a => a.isPrimary)?.name || order.assignee || '-';
|
||||
// 로딩/에러 상태는 DocumentViewer 내부에서 처리
|
||||
const subtitle = order ? `${order.processName} 생산부서` : undefined;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-5xl max-h-[90vh] overflow-y-auto p-0 gap-0 bg-gray-100">
|
||||
{/* 접근성을 위한 숨겨진 타이틀 */}
|
||||
<VisuallyHidden>
|
||||
<DialogTitle>작업일지 - {order.workOrderNo}</DialogTitle>
|
||||
</VisuallyHidden>
|
||||
{/* 모달 헤더 - sam-design 스타일 (인쇄 시 숨김) */}
|
||||
<div className="print-hidden flex items-center justify-between px-6 py-4 border-b bg-white sticky top-0 z-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="font-semibold text-lg">작업일지</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{order.processName} 생산부서
|
||||
</span>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
({documentNo})
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={handlePrint}>
|
||||
<Printer className="w-4 h-4 mr-1.5" />
|
||||
인쇄
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => onOpenChange(false)}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<DocumentViewer
|
||||
title="작업일지"
|
||||
subtitle={subtitle}
|
||||
preset="inspection"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
>
|
||||
{isLoading ? (
|
||||
<div className="flex items-center justify-center h-64 bg-white">
|
||||
<Loader2 className="w-8 h-8 animate-spin text-muted-foreground" />
|
||||
</div>
|
||||
|
||||
{/* 문서 본문 (인쇄 시 이 영역만 출력) */}
|
||||
<div className="print-area m-6 p-6 bg-white rounded-lg shadow-sm">
|
||||
{/* 문서 헤더: 로고 + 제목 + 결재라인 */}
|
||||
<div className="flex justify-between items-start mb-6 border border-gray-300">
|
||||
{/* 좌측: 로고 영역 */}
|
||||
<div className="w-24 border-r border-gray-300 flex flex-col items-center justify-center p-3">
|
||||
<span className="text-2xl font-bold">KD</span>
|
||||
<span className="text-xs text-gray-500">정동기업</span>
|
||||
</div>
|
||||
|
||||
{/* 중앙: 문서 제목 */}
|
||||
<div className="flex-1 flex flex-col items-center justify-center p-3 border-r border-gray-300">
|
||||
<h1 className="text-xl font-bold tracking-widest mb-1">작 업 일 지</h1>
|
||||
<p className="text-xs text-gray-500">{documentNo}</p>
|
||||
<p className="text-sm font-medium mt-1">{order.processName} 생산부서</p>
|
||||
</div>
|
||||
|
||||
{/* 우측: 결재라인 */}
|
||||
<div className="shrink-0 text-xs">
|
||||
<table className="border-collapse">
|
||||
<tbody>
|
||||
{/* 첫 번째 행: 결재 + 작성/검토/승인 */}
|
||||
<tr>
|
||||
<td rowSpan={3} className="w-8 text-center font-medium bg-gray-100 border-r border-b border-gray-300 align-middle">
|
||||
<div className="flex flex-col items-center">
|
||||
<span>결</span>
|
||||
<span>재</span>
|
||||
</div>
|
||||
</td>
|
||||
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-b border-gray-300">작성</td>
|
||||
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-r border-b border-gray-300">검토</td>
|
||||
<td className="w-16 p-2 text-center font-medium bg-gray-100 border-b border-gray-300">승인</td>
|
||||
</tr>
|
||||
{/* 두 번째 행: 이름 + 날짜 */}
|
||||
<tr>
|
||||
<td className="w-16 p-2 text-center border-r border-b border-gray-300">
|
||||
<div>{primaryAssignee}</div>
|
||||
<div className="text-[10px] text-gray-500">
|
||||
{new Date().toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' }).replace('. ', '/').replace('.', '')}
|
||||
</div>
|
||||
</td>
|
||||
<td className="w-16 p-2 text-center border-r border-b border-gray-300"></td>
|
||||
<td className="w-16 p-2 text-center border-b border-gray-300"></td>
|
||||
</tr>
|
||||
{/* 세 번째 행: 부서 */}
|
||||
<tr>
|
||||
<td className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300">판매/전진</td>
|
||||
<td className="w-16 p-2 text-center bg-gray-50 border-r border-gray-300">생산</td>
|
||||
<td className="w-16 p-2 text-center bg-gray-50">품질</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 기본 정보 테이블 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
{/* Row 1 */}
|
||||
<div className="grid grid-cols-2 border-b border-gray-300">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300 flex items-center">
|
||||
발주처
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm flex items-center">{order.client}</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300 flex items-center">
|
||||
현장명
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm flex items-center">{order.projectName}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 2 */}
|
||||
<div className="grid grid-cols-2 border-b border-gray-300">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300 flex items-center">
|
||||
작업일자
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm flex items-center">{today}</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300 flex items-center">
|
||||
LOT NO.
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm flex items-center">{order.lotNo}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Row 3 */}
|
||||
<div className="grid grid-cols-2">
|
||||
<div className="flex border-r border-gray-300">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300 flex items-center">
|
||||
납기일
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm flex items-center">
|
||||
{order.dueDate !== '-' ? new Date(order.dueDate).toLocaleDateString('ko-KR', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
}).replace(/\. /g, '-').replace('.', '') : '-'}
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<div className="w-24 bg-gray-100 p-3 text-sm font-medium border-r border-gray-300 flex items-center">
|
||||
작업지시번호
|
||||
</div>
|
||||
<div className="flex-1 p-3 text-sm flex items-center">{order.workOrderNo}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 테이블 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
{/* 테이블 헤더 */}
|
||||
<div className="grid grid-cols-12 border-b border-gray-300 bg-gray-100">
|
||||
<div className="col-span-1 p-2 text-sm font-medium text-center border-r border-gray-300">No</div>
|
||||
<div className="col-span-4 p-2 text-sm font-medium text-center border-r border-gray-300">품목명</div>
|
||||
<div className="col-span-2 p-2 text-sm font-medium text-center border-r border-gray-300">층/부호</div>
|
||||
<div className="col-span-2 p-2 text-sm font-medium text-center border-r border-gray-300">규격</div>
|
||||
<div className="col-span-1 p-2 text-sm font-medium text-center border-r border-gray-300">수량</div>
|
||||
<div className="col-span-2 p-2 text-sm font-medium text-center">상태</div>
|
||||
</div>
|
||||
|
||||
{/* 테이블 데이터 */}
|
||||
{items.length > 0 ? (
|
||||
items.map((item, index) => (
|
||||
<div
|
||||
key={item.id}
|
||||
className={`grid grid-cols-12 ${index < items.length - 1 ? 'border-b border-gray-300' : ''}`}
|
||||
>
|
||||
<div className="col-span-1 p-2 text-sm text-center border-r border-gray-300">{item.no}</div>
|
||||
<div className="col-span-4 p-2 text-sm border-r border-gray-300">{item.productName}</div>
|
||||
<div className="col-span-2 p-2 text-sm text-center border-r border-gray-300">{item.floorCode}</div>
|
||||
<div className="col-span-2 p-2 text-sm text-center border-r border-gray-300">{item.specification}</div>
|
||||
<div className="col-span-1 p-2 text-sm text-center border-r border-gray-300">{item.quantity}</div>
|
||||
<div className="col-span-2 p-2 text-sm text-center">{ITEM_STATUS_LABELS[item.status]}</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-4 text-center text-muted-foreground text-sm">
|
||||
등록된 품목이 없습니다.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 작업내역 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
{/* 검정 헤더 */}
|
||||
<div className="bg-gray-800 text-white p-2.5 text-sm font-medium text-center">
|
||||
{order.processName} 작업내역
|
||||
</div>
|
||||
|
||||
{/* 수량 및 진행률 */}
|
||||
<div className="grid grid-cols-6 border-b border-gray-300">
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">지시수량</div>
|
||||
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.orderQty} EA</div>
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">완료수량</div>
|
||||
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.completedQty} EA</div>
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">진행률</div>
|
||||
<div className="p-2 text-sm text-center font-medium text-blue-600">{workStats.progress}%</div>
|
||||
</div>
|
||||
|
||||
{/* 상세 상태 */}
|
||||
<div className="grid grid-cols-6">
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">대기</div>
|
||||
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.waitingQty} EA</div>
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">작업중</div>
|
||||
<div className="p-2 text-sm border-r border-gray-300 text-center">{workStats.inProgressQty} EA</div>
|
||||
<div className="p-2 text-sm bg-gray-100 border-r border-gray-300 text-center font-medium">완료</div>
|
||||
<div className="p-2 text-sm text-center">{workStats.completedQty} EA</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 특이 사항 */}
|
||||
<div className="border border-gray-300">
|
||||
<div className="bg-gray-800 text-white p-2.5 text-sm font-medium text-center">
|
||||
특이사항
|
||||
</div>
|
||||
<div className="p-4 min-h-[60px] text-sm">
|
||||
{order.note || '-'}
|
||||
</div>
|
||||
</div>
|
||||
) : error || !order ? (
|
||||
<div className="flex flex-col items-center justify-center h-64 gap-4 bg-white">
|
||||
<p className="text-muted-foreground">{error || '데이터를 불러올 수 없습니다.'}</p>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
) : (
|
||||
<WorkLogContent data={order} />
|
||||
)}
|
||||
</DocumentViewer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -79,116 +79,12 @@ function transformToWorkerScreenFormat(api: WorkOrderApiItem): WorkOrder {
|
||||
};
|
||||
}
|
||||
|
||||
// ===== 목 데이터 (API 연동 전 UI 확인용) =====
|
||||
const USE_MOCK_DATA = false; // API 연동 시 false로 변경
|
||||
|
||||
const MOCK_WORK_ORDERS: WorkOrder[] = [
|
||||
{
|
||||
id: '1',
|
||||
orderNo: 'KD-WO-260121-01',
|
||||
productName: '스크린 셔터 (표준형)',
|
||||
processCode: 'P-001',
|
||||
processName: '스크린',
|
||||
client: '삼성물산(주)',
|
||||
projectName: '강남 타워 신축현장',
|
||||
assignees: ['홍길동'],
|
||||
quantity: 50,
|
||||
dueDate: '2026-01-25',
|
||||
priority: 1,
|
||||
status: 'inProgress',
|
||||
isUrgent: true,
|
||||
isDelayed: false,
|
||||
instruction: '1층 우선 생산',
|
||||
createdAt: '2026-01-20T09:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '2',
|
||||
orderNo: 'KD-WO-260121-02',
|
||||
productName: '슬랫 (알루미늄)',
|
||||
processCode: 'P-002',
|
||||
processName: '슬랫',
|
||||
client: '현대건설(주)',
|
||||
projectName: '판교 테크노밸리 2단지',
|
||||
assignees: ['홍길동', '김철수'],
|
||||
quantity: 120,
|
||||
dueDate: '2026-01-22',
|
||||
priority: 2,
|
||||
status: 'waiting',
|
||||
isUrgent: false,
|
||||
isDelayed: true,
|
||||
delayDays: 1,
|
||||
instruction: '색상 샘플 확인 후 작업',
|
||||
createdAt: '2026-01-19T14:30:00Z',
|
||||
},
|
||||
{
|
||||
id: '3',
|
||||
orderNo: 'KD-WO-260121-03',
|
||||
productName: '절곡판 (스틸)',
|
||||
processCode: 'P-003',
|
||||
processName: '절곡',
|
||||
client: 'GS건설(주)',
|
||||
projectName: '마포 래미안 재건축',
|
||||
assignees: ['홍길동'],
|
||||
quantity: 30,
|
||||
dueDate: '2026-01-28',
|
||||
priority: 3,
|
||||
status: 'waiting',
|
||||
isUrgent: false,
|
||||
isDelayed: false,
|
||||
createdAt: '2026-01-21T08:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '4',
|
||||
orderNo: 'KD-WO-260120-01',
|
||||
productName: '스크린 셔터 (고급형)',
|
||||
processCode: 'P-001',
|
||||
processName: '스크린',
|
||||
client: '롯데건설(주)',
|
||||
projectName: '잠실 롯데캐슬',
|
||||
assignees: ['홍길동'],
|
||||
quantity: 80,
|
||||
dueDate: '2026-01-23',
|
||||
priority: 1,
|
||||
status: 'inProgress',
|
||||
isUrgent: true,
|
||||
isDelayed: false,
|
||||
instruction: '긴급 납품 요청',
|
||||
createdAt: '2026-01-20T10:00:00Z',
|
||||
},
|
||||
{
|
||||
id: '5',
|
||||
orderNo: 'KD-WO-260119-02',
|
||||
productName: '슬랫 (목재)',
|
||||
processCode: 'P-002',
|
||||
processName: '슬랫',
|
||||
client: '대우건설(주)',
|
||||
projectName: '송도 센트럴파크',
|
||||
assignees: ['홍길동', '박영희'],
|
||||
quantity: 200,
|
||||
dueDate: '2026-01-30',
|
||||
priority: 4,
|
||||
status: 'waiting',
|
||||
isUrgent: false,
|
||||
isDelayed: false,
|
||||
createdAt: '2026-01-19T11:00:00Z',
|
||||
},
|
||||
];
|
||||
|
||||
// ===== 내 작업 목록 조회 =====
|
||||
export async function getMyWorkOrders(): Promise<{
|
||||
success: boolean;
|
||||
data: WorkOrder[];
|
||||
error?: string;
|
||||
}> {
|
||||
// 목 데이터 사용 시
|
||||
if (USE_MOCK_DATA) {
|
||||
console.log('[WorkerScreenActions] Using MOCK data');
|
||||
return {
|
||||
success: true,
|
||||
data: MOCK_WORK_ORDERS,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// 작업 대기 + 작업중 상태만 조회 (완료 제외)
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders?per_page=100&assigned_to_me=1`;
|
||||
@@ -252,13 +148,6 @@ export async function completeWorkOrder(
|
||||
id: string,
|
||||
materials?: { materialId: number; quantity: number; lotNo?: string }[]
|
||||
): Promise<{ success: boolean; lotNo?: string; error?: string }> {
|
||||
// 목 데이터 사용 시
|
||||
if (USE_MOCK_DATA) {
|
||||
console.log('[WorkerScreenActions] MOCK complete work order:', id, materials);
|
||||
const lotNo = `KD-SA-${new Date().toISOString().slice(2, 10).replace(/-/g, '')}-01`;
|
||||
return { success: true, lotNo };
|
||||
}
|
||||
|
||||
try {
|
||||
// 상태를 completed로 변경
|
||||
const { response, error } = await serverFetch(
|
||||
@@ -316,15 +205,6 @@ export interface MaterialForInput {
|
||||
fifoRank: number;
|
||||
}
|
||||
|
||||
// 목 자재 데이터
|
||||
const MOCK_MATERIALS: MaterialForInput[] = [
|
||||
{ id: 1, materialCode: 'MAT-001', materialName: '알루미늄 판재 (T1.2)', unit: 'EA', currentStock: 150, fifoRank: 1 },
|
||||
{ id: 2, materialCode: 'MAT-002', materialName: '스테인레스 볼트 M6', unit: 'EA', currentStock: 500, fifoRank: 1 },
|
||||
{ id: 3, materialCode: 'MAT-003', materialName: '방음재 (폴리우레탄)', unit: 'M', currentStock: 80, fifoRank: 2 },
|
||||
{ id: 4, materialCode: 'MAT-004', materialName: '실리콘 씰링재', unit: 'EA', currentStock: 45, fifoRank: 1 },
|
||||
{ id: 5, materialCode: 'MAT-005', materialName: '스프링 (φ8)', unit: 'EA', currentStock: 200, fifoRank: 3 },
|
||||
];
|
||||
|
||||
export async function getMaterialsForWorkOrder(
|
||||
workOrderId: string
|
||||
): Promise<{
|
||||
@@ -332,15 +212,6 @@ export async function getMaterialsForWorkOrder(
|
||||
data: MaterialForInput[];
|
||||
error?: string;
|
||||
}> {
|
||||
// 목 데이터 사용 시
|
||||
if (USE_MOCK_DATA) {
|
||||
console.log('[WorkerScreenActions] MOCK materials for work order:', workOrderId);
|
||||
return {
|
||||
success: true,
|
||||
data: MOCK_MATERIALS,
|
||||
};
|
||||
}
|
||||
|
||||
try {
|
||||
// 작업지시 BOM 기준 자재 목록 조회
|
||||
const url = `${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/materials`;
|
||||
@@ -414,12 +285,6 @@ export async function registerMaterialInput(
|
||||
workOrderId: string,
|
||||
materialIds: number[]
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// 목 데이터 사용 시
|
||||
if (USE_MOCK_DATA) {
|
||||
console.log('[WorkerScreenActions] MOCK register material input:', workOrderId, materialIds);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/material-inputs`,
|
||||
@@ -466,12 +331,6 @@ export async function reportIssue(
|
||||
priority?: 'low' | 'medium' | 'high';
|
||||
}
|
||||
): Promise<{ success: boolean; error?: string }> {
|
||||
// 목 데이터 사용 시
|
||||
if (USE_MOCK_DATA) {
|
||||
console.log('[WorkerScreenActions] MOCK report issue:', workOrderId, data);
|
||||
return { success: true };
|
||||
}
|
||||
|
||||
try {
|
||||
const { response, error } = await serverFetch(
|
||||
`${process.env.NEXT_PUBLIC_API_URL}/api/v1/work-orders/${workOrderId}/issues`,
|
||||
@@ -662,4 +521,4 @@ export async function requestInspection(
|
||||
error: '서버 오류가 발생했습니다.',
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
218
src/components/quotes/QuotePreviewContent.tsx
Normal file
218
src/components/quotes/QuotePreviewContent.tsx
Normal file
@@ -0,0 +1,218 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 견적서 문서 콘텐츠
|
||||
*
|
||||
* 공통 컴포넌트 사용:
|
||||
* - DocumentHeader: simple 레이아웃 (결재란 없음)
|
||||
* - SectionHeader: 섹션 제목
|
||||
*/
|
||||
|
||||
import type { QuoteFormDataV2 } from './QuoteRegistrationV2';
|
||||
import { DocumentHeader, SectionHeader } from '@/components/document-system';
|
||||
|
||||
interface QuotePreviewContentProps {
|
||||
data: QuoteFormDataV2;
|
||||
}
|
||||
|
||||
export function QuotePreviewContent({ data: quoteData }: QuotePreviewContentProps) {
|
||||
// 총 금액 계산
|
||||
const totalAmount = quoteData.locations.reduce(
|
||||
(sum, loc) => sum + (loc.totalPrice || 0),
|
||||
0
|
||||
);
|
||||
|
||||
// 부가세
|
||||
const vat = Math.round(totalAmount * 0.1);
|
||||
const grandTotal = totalAmount + vat;
|
||||
|
||||
return (
|
||||
<div className="max-w-[210mm] mx-auto bg-white shadow-lg rounded-lg p-8">
|
||||
{/* 제목 (공통 컴포넌트) */}
|
||||
<DocumentHeader
|
||||
title="견 적 서"
|
||||
subtitle={`문서번호: ${quoteData.id || "-"} | 작성일자: ${quoteData.registrationDate || "-"}`}
|
||||
layout="simple"
|
||||
approval={null}
|
||||
/>
|
||||
|
||||
{/* 수요자 정보 */}
|
||||
<div className="border border-gray-300 mb-4">
|
||||
<div className="bg-gray-100 px-3 py-2 font-semibold border-b border-gray-300">
|
||||
수 요 자
|
||||
</div>
|
||||
<div className="p-3 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">업체명</span>
|
||||
<span className="font-medium">{quoteData.clientName || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">담당자</span>
|
||||
<span className="font-medium">{quoteData.manager || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">프로젝트명</span>
|
||||
<span className="font-medium">{quoteData.siteName || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">연락처</span>
|
||||
<span className="font-medium">{quoteData.contact || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">견적일자</span>
|
||||
<span className="font-medium">{quoteData.registrationDate || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">유효기간</span>
|
||||
<span className="font-medium">{quoteData.dueDate || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공급자 정보 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
<div className="bg-gray-100 px-3 py-2 font-semibold border-b border-gray-300">
|
||||
공 급 자
|
||||
</div>
|
||||
<div className="p-3 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex">
|
||||
<span className="w-24 text-gray-600">상호</span>
|
||||
<span className="font-medium">프론트_테스트회사</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-24 text-gray-600">사업자등록번호</span>
|
||||
<span className="font-medium">123-45-67890</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-24 text-gray-600">대표자</span>
|
||||
<span className="font-medium">프론트</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-24 text-gray-600">업태</span>
|
||||
<span className="font-medium">업태명</span>
|
||||
</div>
|
||||
<div className="flex col-span-2">
|
||||
<span className="w-24 text-gray-600">종목</span>
|
||||
<span className="font-medium">김종명</span>
|
||||
</div>
|
||||
<div className="flex col-span-2">
|
||||
<span className="w-24 text-gray-600">사업장주소</span>
|
||||
<span className="font-medium">07547 서울 강서구 양천로 583 B-1602</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-24 text-gray-600">전화</span>
|
||||
<span className="font-medium">01048209104</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-24 text-gray-600">이메일</span>
|
||||
<span className="font-medium">codebridgex@codebridge-x.com</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 총 견적금액 */}
|
||||
<div className="border-2 border-gray-800 p-4 mb-6 text-center">
|
||||
<p className="text-sm text-gray-600 mb-1">총 견적금액</p>
|
||||
<p className="text-3xl font-bold">
|
||||
₩ {grandTotal.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">※ 부가가치세 포함</p>
|
||||
</div>
|
||||
|
||||
{/* 제품 구성정보 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
<SectionHeader>제 품 구 성 정 보</SectionHeader>
|
||||
<div className="p-3 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">모델</span>
|
||||
<span className="font-medium">
|
||||
{quoteData.locations[0]?.productCode || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">총 수량</span>
|
||||
<span className="font-medium">{quoteData.locations.length}개소</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">오픈사이즈</span>
|
||||
<span className="font-medium">
|
||||
{quoteData.locations[0]?.openWidth || "-"} × {quoteData.locations[0]?.openHeight || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">설치유형</span>
|
||||
<span className="font-medium">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 내역 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
<SectionHeader>품 목 내 역</SectionHeader>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b border-gray-300">
|
||||
<th className="px-3 py-2 text-left">No.</th>
|
||||
<th className="px-3 py-2 text-left">품목명</th>
|
||||
<th className="px-3 py-2 text-center">규격</th>
|
||||
<th className="px-3 py-2 text-center">수량</th>
|
||||
<th className="px-3 py-2 text-center">단위</th>
|
||||
<th className="px-3 py-2 text-right">단가</th>
|
||||
<th className="px-3 py-2 text-right">금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{quoteData.locations.map((loc, index) => (
|
||||
<tr key={loc.id} className="border-b border-gray-200">
|
||||
<td className="px-3 py-2">{index + 1}</td>
|
||||
<td className="px-3 py-2">{loc.productCode}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{loc.openWidth}×{loc.openHeight}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">{loc.quantity}</td>
|
||||
<td className="px-3 py-2 text-center">EA</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{(loc.unitPrice || 0).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{(loc.totalPrice || 0).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-400">
|
||||
<td colSpan={5}></td>
|
||||
<td className="px-3 py-2 text-right font-medium">공급가액 합계</td>
|
||||
<td className="px-3 py-2 text-right font-bold">
|
||||
{totalAmount.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={5}></td>
|
||||
<td className="px-3 py-2 text-right font-medium">부가가치세 (10%)</td>
|
||||
<td className="px-3 py-2 text-right font-bold">
|
||||
{vat.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="bg-gray-100">
|
||||
<td colSpan={5}></td>
|
||||
<td className="px-3 py-2 text-right font-medium">총 견적금액</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-lg">
|
||||
{grandTotal.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 비고사항 */}
|
||||
<div className="border border-gray-300">
|
||||
<SectionHeader>비 고 사 항</SectionHeader>
|
||||
<div className="p-3 min-h-[80px] text-sm text-gray-600">
|
||||
{quoteData.remarks || "비고 테스트"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,28 +1,16 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 견적서 미리보기 모달
|
||||
*
|
||||
* - 견적서 문서 형식으로 미리보기
|
||||
* - PDF, 이메일 전송 버튼
|
||||
* - 인쇄 기능
|
||||
* document-system 통합 버전 (2026-01-22)
|
||||
*/
|
||||
|
||||
"use client";
|
||||
|
||||
import { Download, Mail, Printer, X as XIcon } from "lucide-react";
|
||||
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogTitle,
|
||||
VisuallyHidden,
|
||||
} from "../ui/dialog";
|
||||
import { Button } from "../ui/button";
|
||||
|
||||
import type { QuoteFormDataV2 } from "./QuoteRegistrationV2";
|
||||
|
||||
// =============================================================================
|
||||
// Props
|
||||
// =============================================================================
|
||||
import { Download, Mail } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { DocumentViewer } from '@/components/document-system';
|
||||
import type { QuoteFormDataV2 } from './QuoteRegistrationV2';
|
||||
import { QuotePreviewContent } from './QuotePreviewContent';
|
||||
|
||||
interface QuotePreviewModalProps {
|
||||
open: boolean;
|
||||
@@ -30,10 +18,6 @@ interface QuotePreviewModalProps {
|
||||
quoteData: QuoteFormDataV2 | null;
|
||||
}
|
||||
|
||||
// =============================================================================
|
||||
// 컴포넌트
|
||||
// =============================================================================
|
||||
|
||||
export function QuotePreviewModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
@@ -41,263 +25,46 @@ export function QuotePreviewModal({
|
||||
}: QuotePreviewModalProps) {
|
||||
if (!quoteData) return null;
|
||||
|
||||
// 총 금액 계산
|
||||
const totalAmount = quoteData.locations.reduce(
|
||||
(sum, loc) => sum + (loc.totalPrice || 0),
|
||||
0
|
||||
);
|
||||
const handlePdfDownload = () => {
|
||||
console.log('[테스트] PDF 다운로드');
|
||||
};
|
||||
|
||||
// 부가세
|
||||
const vat = Math.round(totalAmount * 0.1);
|
||||
const grandTotal = totalAmount + vat;
|
||||
const handleEmailSend = () => {
|
||||
console.log('[테스트] 이메일 전송');
|
||||
};
|
||||
|
||||
const toolbarExtra = (
|
||||
<>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-red-500 hover:bg-red-600 text-white border-red-500"
|
||||
onClick={handlePdfDownload}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
PDF
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-yellow-500 hover:bg-yellow-600 text-white border-yellow-500"
|
||||
onClick={handleEmailSend}
|
||||
>
|
||||
<Mail className="h-4 w-4 mr-1" />
|
||||
이메일
|
||||
</Button>
|
||||
</>
|
||||
);
|
||||
|
||||
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="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>
|
||||
|
||||
{/* 버튼 영역 - PDF, 이메일, 인쇄 */}
|
||||
<div className="flex-shrink-0 flex items-center gap-2 px-6 py-3 bg-muted/30 border-b">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-red-500 hover:bg-red-600 text-white border-red-500"
|
||||
onClick={() => console.log("[테스트] PDF 다운로드")}
|
||||
>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
PDF
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="bg-yellow-500 hover:bg-yellow-600 text-white border-yellow-500"
|
||||
onClick={() => console.log("[테스트] 이메일 전송")}
|
||||
>
|
||||
<Mail className="h-4 w-4 mr-1" />
|
||||
이메일
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => console.log("[테스트] 인쇄")}
|
||||
>
|
||||
<Printer className="h-4 w-4 mr-1" />
|
||||
인쇄
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* 문서 영역 - 스크롤 */}
|
||||
<div className="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="text-center mb-6">
|
||||
<h1 className="text-3xl font-bold tracking-widest">견 적 서</h1>
|
||||
<p className="text-sm text-gray-500 mt-2">
|
||||
문서번호: {quoteData.id || "-"} | 작성일자: {quoteData.registrationDate || "-"}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* 수요자 정보 */}
|
||||
<div className="border border-gray-300 mb-4">
|
||||
<div className="bg-gray-100 px-3 py-2 font-semibold border-b border-gray-300">
|
||||
수 요 자
|
||||
</div>
|
||||
<div className="p-3 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">업체명</span>
|
||||
<span className="font-medium">{quoteData.clientName || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">담당자</span>
|
||||
<span className="font-medium">{quoteData.manager || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">프로젝트명</span>
|
||||
<span className="font-medium">{quoteData.siteName || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">연락처</span>
|
||||
<span className="font-medium">{quoteData.contact || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">견적일자</span>
|
||||
<span className="font-medium">{quoteData.registrationDate || "-"}</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">유효기간</span>
|
||||
<span className="font-medium">{quoteData.dueDate || "-"}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 공급자 정보 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
<div className="bg-gray-100 px-3 py-2 font-semibold border-b border-gray-300">
|
||||
공 급 자
|
||||
</div>
|
||||
<div className="p-3 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex">
|
||||
<span className="w-24 text-gray-600">상호</span>
|
||||
<span className="font-medium">프론트_테스트회사</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-24 text-gray-600">사업자등록번호</span>
|
||||
<span className="font-medium">123-45-67890</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-24 text-gray-600">대표자</span>
|
||||
<span className="font-medium">프론트</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-24 text-gray-600">업태</span>
|
||||
<span className="font-medium">업태명</span>
|
||||
</div>
|
||||
<div className="flex col-span-2">
|
||||
<span className="w-24 text-gray-600">종목</span>
|
||||
<span className="font-medium">김종명</span>
|
||||
</div>
|
||||
<div className="flex col-span-2">
|
||||
<span className="w-24 text-gray-600">사업장주소</span>
|
||||
<span className="font-medium">07547 서울 강서구 양천로 583 B-1602</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-24 text-gray-600">전화</span>
|
||||
<span className="font-medium">01048209104</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-24 text-gray-600">이메일</span>
|
||||
<span className="font-medium">codebridgex@codebridge-x.com</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 총 견적금액 */}
|
||||
<div className="border-2 border-gray-800 p-4 mb-6 text-center">
|
||||
<p className="text-sm text-gray-600 mb-1">총 견적금액</p>
|
||||
<p className="text-3xl font-bold">
|
||||
₩ {grandTotal.toLocaleString()}
|
||||
</p>
|
||||
<p className="text-xs text-gray-500 mt-1">※ 부가가치세 포함</p>
|
||||
</div>
|
||||
|
||||
{/* 제품 구성정보 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
<div className="bg-gray-800 text-white px-3 py-2 font-semibold">
|
||||
제 품 구 성 정 보
|
||||
</div>
|
||||
<div className="p-3 grid grid-cols-2 gap-2 text-sm">
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">모델</span>
|
||||
<span className="font-medium">
|
||||
{quoteData.locations[0]?.productCode || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">총 수량</span>
|
||||
<span className="font-medium">{quoteData.locations.length}개소</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">오픈사이즈</span>
|
||||
<span className="font-medium">
|
||||
{quoteData.locations[0]?.openWidth || "-"} × {quoteData.locations[0]?.openHeight || "-"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex">
|
||||
<span className="w-20 text-gray-600">설치유형</span>
|
||||
<span className="font-medium">-</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 품목 내역 */}
|
||||
<div className="border border-gray-300 mb-6">
|
||||
<div className="bg-gray-800 text-white px-3 py-2 font-semibold">
|
||||
품 목 내 역
|
||||
</div>
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="bg-gray-100 border-b border-gray-300">
|
||||
<th className="px-3 py-2 text-left">No.</th>
|
||||
<th className="px-3 py-2 text-left">품목명</th>
|
||||
<th className="px-3 py-2 text-center">규격</th>
|
||||
<th className="px-3 py-2 text-center">수량</th>
|
||||
<th className="px-3 py-2 text-center">단위</th>
|
||||
<th className="px-3 py-2 text-right">단가</th>
|
||||
<th className="px-3 py-2 text-right">금액</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{quoteData.locations.map((loc, index) => (
|
||||
<tr key={loc.id} className="border-b border-gray-200">
|
||||
<td className="px-3 py-2">{index + 1}</td>
|
||||
<td className="px-3 py-2">{loc.productCode}</td>
|
||||
<td className="px-3 py-2 text-center">
|
||||
{loc.openWidth}×{loc.openHeight}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-center">{loc.quantity}</td>
|
||||
<td className="px-3 py-2 text-center">EA</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{(loc.unitPrice || 0).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{(loc.totalPrice || 0).toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr className="border-t-2 border-gray-400">
|
||||
<td colSpan={5}></td>
|
||||
<td className="px-3 py-2 text-right font-medium">공급가액 합계</td>
|
||||
<td className="px-3 py-2 text-right font-bold">
|
||||
{totalAmount.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
<tr>
|
||||
<td colSpan={5}></td>
|
||||
<td className="px-3 py-2 text-right font-medium">부가가치세 (10%)</td>
|
||||
<td className="px-3 py-2 text-right font-bold">
|
||||
{vat.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
<tr className="bg-gray-100">
|
||||
<td colSpan={5}></td>
|
||||
<td className="px-3 py-2 text-right font-medium">총 견적금액</td>
|
||||
<td className="px-3 py-2 text-right font-bold text-lg">
|
||||
{grandTotal.toLocaleString()}
|
||||
</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* 비고사항 */}
|
||||
<div className="border border-gray-300">
|
||||
<div className="bg-gray-800 text-white px-3 py-2 font-semibold">
|
||||
비 고 사 항
|
||||
</div>
|
||||
<div className="p-3 min-h-[80px] text-sm text-gray-600">
|
||||
{quoteData.remarks || "비고 테스트"}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
<DocumentViewer
|
||||
title="견적서"
|
||||
preset="inspection"
|
||||
open={open}
|
||||
onOpenChange={onOpenChange}
|
||||
toolbarExtra={toolbarExtra}
|
||||
>
|
||||
<QuotePreviewContent data={quoteData} />
|
||||
</DocumentViewer>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,8 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useRef, useEffect } from 'react';
|
||||
import { useState, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import {
|
||||
User,
|
||||
Upload,
|
||||
X,
|
||||
} from 'lucide-react';
|
||||
import { User } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
@@ -14,6 +10,7 @@ import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { ImageUpload } from '@/components/ui/image-upload';
|
||||
import {
|
||||
AlertDialog,
|
||||
AlertDialogAction,
|
||||
@@ -45,7 +42,6 @@ export function AccountInfoClient({
|
||||
error,
|
||||
}: AccountInfoClientProps) {
|
||||
const router = useRouter();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// ===== 상태 관리 =====
|
||||
const [accountInfo] = useState<AccountInfo>(initialAccountInfo);
|
||||
@@ -74,23 +70,7 @@ export function AccountInfoClient({
|
||||
const canSuspend = accountInfo.isTenantMaster; // 테넌트 마스터인 경우만
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// 파일 크기 체크 (10MB)
|
||||
if (file.size > 10 * 1024 * 1024) {
|
||||
toast.error('파일 크기는 10MB 이하여야 합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
// 파일 타입 체크
|
||||
const validTypes = ['image/png', 'image/jpeg', 'image/gif'];
|
||||
if (!validTypes.includes(file.type)) {
|
||||
toast.error('PNG, JPEG, GIF 파일만 업로드 가능합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const handleImageUpload = async (file: File) => {
|
||||
// 미리보기 생성 (낙관적 업데이트)
|
||||
const previousImage = profileImage;
|
||||
const reader = new FileReader();
|
||||
@@ -119,17 +99,11 @@ export function AccountInfoClient({
|
||||
toast.error('서버 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsUploadingImage(false);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveImage = () => {
|
||||
setProfileImage(undefined);
|
||||
if (fileInputRef.current) {
|
||||
fileInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handlePasswordChange = () => {
|
||||
@@ -287,53 +261,15 @@ export function AccountInfoClient({
|
||||
{/* 프로필 사진 */}
|
||||
<div className="space-y-2">
|
||||
<Label>프로필 사진</Label>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="relative w-[250px] h-[250px] border-2 border-dashed border-gray-300 rounded-lg flex items-center justify-center bg-gray-50 overflow-hidden">
|
||||
{profileImage ? (
|
||||
<>
|
||||
<img
|
||||
src={profileImage}
|
||||
alt="프로필"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleRemoveImage}
|
||||
className="absolute top-2 right-2 p-1 bg-white rounded-full shadow-md hover:bg-gray-100"
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-600" />
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="text-center">
|
||||
<Upload className="w-8 h-8 mx-auto text-gray-400 mb-2" />
|
||||
<span className="text-sm text-gray-500">IMG</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif"
|
||||
onChange={handleImageUpload}
|
||||
className="hidden"
|
||||
disabled={isUploadingImage}
|
||||
/>
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
disabled={isUploadingImage}
|
||||
>
|
||||
{isUploadingImage ? '업로드 중...' : '이미지 업로드'}
|
||||
</Button>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
1250 X 250px, 10MB 이하의 PNG, JPEG, GIF
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<ImageUpload
|
||||
value={profileImage}
|
||||
onChange={handleImageUpload}
|
||||
onRemove={handleRemoveImage}
|
||||
disabled={isUploadingImage}
|
||||
size="lg"
|
||||
maxSize={10}
|
||||
hint="1250 X 250px, 10MB 이하의 PNG, JPEG, GIF"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 아이디 & 비밀번호 */}
|
||||
|
||||
@@ -2,12 +2,14 @@
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { useDaumPostcode } from '@/hooks/useDaumPostcode';
|
||||
import { Building2, Plus, Save, Upload, X, Loader2 } from 'lucide-react';
|
||||
import { Building2, Plus, Save, X, Loader2 } from 'lucide-react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { BusinessNumberInput } from '@/components/ui/business-number-input';
|
||||
import { AccountNumberInput } from '@/components/ui/account-number-input';
|
||||
import { ImageUpload } from '@/components/ui/image-upload';
|
||||
import { FileInput } from '@/components/ui/file-input';
|
||||
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
||||
import {
|
||||
Select,
|
||||
@@ -53,16 +55,12 @@ export function CompanyInfoManagement() {
|
||||
loadCompanyInfo();
|
||||
}, []);
|
||||
|
||||
// 파일 input refs
|
||||
const logoInputRef = useRef<HTMLInputElement>(null);
|
||||
const licenseInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 로고 업로드 상태
|
||||
const [isUploadingLogo, setIsUploadingLogo] = useState(false);
|
||||
// 로고 미리보기 URL (로컬 파일 미리보기용)
|
||||
const [logoPreviewUrl, setLogoPreviewUrl] = useState<string | null>(null);
|
||||
// 사업자등록증 파일명
|
||||
const [licenseFileName, setLicenseFileName] = useState<string>('');
|
||||
// 사업자등록증 파일
|
||||
const [licenseFile, setLicenseFile] = useState<File | null>(null);
|
||||
|
||||
// 로고 URL 계산 (서버 URL 또는 로컬 미리보기)
|
||||
const currentLogoUrl = logoPreviewUrl || (
|
||||
@@ -73,29 +71,11 @@ export function CompanyInfoManagement() {
|
||||
setFormData(prev => ({ ...prev, [field]: value }));
|
||||
}, []);
|
||||
|
||||
const handleLogoUpload = () => {
|
||||
logoInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleLogoChange = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (!file) return;
|
||||
|
||||
// 파일 크기 체크 (5MB - API 제한에 맞춤)
|
||||
if (file.size > 5 * 1024 * 1024) {
|
||||
toast.error('파일 크기는 5MB 이하여야 합니다.');
|
||||
return;
|
||||
}
|
||||
// 파일 타입 체크
|
||||
if (!['image/png', 'image/jpeg', 'image/gif', 'image/webp'].includes(file.type)) {
|
||||
toast.error('PNG, JPEG, GIF, WEBP 파일만 업로드 가능합니다.');
|
||||
return;
|
||||
}
|
||||
|
||||
const handleLogoChange = async (file: File) => {
|
||||
// 이전 이미지 저장 (롤백용)
|
||||
const previousLogo = logoPreviewUrl;
|
||||
|
||||
// FileReader로 base64 미리보기 생성 (account-info 방식)
|
||||
// FileReader로 base64 미리보기 생성
|
||||
const reader = new FileReader();
|
||||
reader.onload = (event) => {
|
||||
setLogoPreviewUrl(event.target?.result as string);
|
||||
@@ -103,12 +83,12 @@ export function CompanyInfoManagement() {
|
||||
reader.readAsDataURL(file);
|
||||
|
||||
// FormData 생성 (Server Action에 전달)
|
||||
const formData = new FormData();
|
||||
formData.append('logo', file);
|
||||
const uploadData = new FormData();
|
||||
uploadData.append('logo', file);
|
||||
|
||||
// 즉시 업로드
|
||||
setIsUploadingLogo(true);
|
||||
const result = await uploadCompanyLogo(formData);
|
||||
const result = await uploadCompanyLogo(uploadData);
|
||||
|
||||
if (result.success && result.data) {
|
||||
// 성공: 서버 URL도 저장 (다음 페이지 로드 시 사용)
|
||||
@@ -121,37 +101,21 @@ export function CompanyInfoManagement() {
|
||||
}
|
||||
|
||||
setIsUploadingLogo(false);
|
||||
if (logoInputRef.current) {
|
||||
logoInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleRemoveLogo = () => {
|
||||
setLogoPreviewUrl(null);
|
||||
setFormData(prev => ({ ...prev, companyLogo: undefined }));
|
||||
if (logoInputRef.current) {
|
||||
logoInputRef.current.value = '';
|
||||
}
|
||||
};
|
||||
|
||||
const handleLicenseUpload = () => {
|
||||
licenseInputRef.current?.click();
|
||||
};
|
||||
|
||||
const handleLicenseChange = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
setFormData(prev => ({ ...prev, businessLicense: file }));
|
||||
setLicenseFileName(file.name);
|
||||
}
|
||||
const handleLicenseChange = (file: File) => {
|
||||
setFormData(prev => ({ ...prev, businessLicense: file }));
|
||||
setLicenseFile(file);
|
||||
};
|
||||
|
||||
const handleRemoveLicense = () => {
|
||||
setFormData(prev => ({ ...prev, businessLicense: undefined }));
|
||||
setLicenseFileName('');
|
||||
if (licenseInputRef.current) {
|
||||
licenseInputRef.current.value = '';
|
||||
}
|
||||
setLicenseFile(null);
|
||||
};
|
||||
|
||||
// Daum 우편번호 서비스
|
||||
@@ -227,69 +191,24 @@ export function CompanyInfoManagement() {
|
||||
{/* 회사 로고 */}
|
||||
<div className="space-y-2">
|
||||
<Label>회사 로고</Label>
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="relative w-[200px] h-[67px] border rounded-lg flex items-center justify-center bg-muted/50 overflow-hidden">
|
||||
{currentLogoUrl ? (
|
||||
<img
|
||||
src={currentLogoUrl}
|
||||
alt="회사 로고"
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">IMG</span>
|
||||
)}
|
||||
{/* 업로드 중 오버레이 */}
|
||||
{isUploadingLogo && (
|
||||
<div className="absolute inset-0 bg-background/80 flex items-center justify-center">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
{isEditMode && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleLogoUpload}
|
||||
disabled={isUploadingLogo}
|
||||
>
|
||||
{isUploadingLogo ? (
|
||||
<>
|
||||
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
|
||||
업로드 중...
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Upload className="w-4 h-4 mr-2" />
|
||||
업로드
|
||||
</>
|
||||
)}
|
||||
</Button>
|
||||
{currentLogoUrl && !isUploadingLogo && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRemoveLogo}
|
||||
>
|
||||
<X className="w-4 h-4 mr-2" />
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
<div className="relative">
|
||||
<ImageUpload
|
||||
value={currentLogoUrl}
|
||||
onChange={handleLogoChange}
|
||||
onRemove={handleRemoveLogo}
|
||||
disabled={!isEditMode || isUploadingLogo}
|
||||
aspectRatio="wide"
|
||||
size="lg"
|
||||
maxSize={5}
|
||||
hint="750 X 250px, 5MB 이하의 PNG, JPEG, GIF, WEBP"
|
||||
/>
|
||||
{/* 업로드 중 오버레이 */}
|
||||
{isUploadingLogo && (
|
||||
<div className="absolute inset-0 bg-background/80 flex items-center justify-center rounded-lg">
|
||||
<Loader2 className="w-6 h-6 animate-spin text-primary" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
750 X 250px, 5MB 이하의 PNG, JPEG, GIF, WEBP
|
||||
</p>
|
||||
<input
|
||||
ref={logoInputRef}
|
||||
type="file"
|
||||
accept="image/png,image/jpeg,image/gif,image/webp"
|
||||
className="hidden"
|
||||
onChange={handleLogoChange}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 회사명 / 대표자명 */}
|
||||
@@ -423,37 +342,14 @@ export function CompanyInfoManagement() {
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div className="space-y-2">
|
||||
<Label>사업자등록증</Label>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
onClick={handleLicenseUpload}
|
||||
disabled={!isEditMode}
|
||||
>
|
||||
찾기
|
||||
</Button>
|
||||
{licenseFileName && (
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm">{licenseFileName}</span>
|
||||
{isEditMode && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRemoveLicense}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<input
|
||||
ref={licenseInputRef}
|
||||
type="file"
|
||||
<FileInput
|
||||
value={licenseFile}
|
||||
onFileSelect={handleLicenseChange}
|
||||
onFileRemove={handleRemoveLicense}
|
||||
accept=".pdf,.jpg,.jpeg,.png"
|
||||
className="hidden"
|
||||
onChange={handleLicenseChange}
|
||||
disabled={!isEditMode}
|
||||
buttonText="찾기"
|
||||
placeholder="파일을 선택하세요"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
|
||||
@@ -44,6 +44,7 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
|
||||
onDelete,
|
||||
onCancel,
|
||||
onModeChange,
|
||||
onEdit: onEditProp,
|
||||
renderView,
|
||||
renderForm,
|
||||
renderField,
|
||||
@@ -255,9 +256,15 @@ export function IntegratedDetailTemplate<T extends Record<string, unknown>>({
|
||||
|
||||
// ===== 수정 모드 전환 =====
|
||||
const handleEdit = useCallback(() => {
|
||||
// 커스텀 onEdit이 제공되면 해당 핸들러 사용 (예: 페이지 이동)
|
||||
if (onEditProp) {
|
||||
onEditProp();
|
||||
return;
|
||||
}
|
||||
// 기본 동작: 내부 모드 변경
|
||||
setMode('edit');
|
||||
onModeChange?.('edit');
|
||||
}, [onModeChange]);
|
||||
}, [onEditProp, onModeChange]);
|
||||
|
||||
// ===== 액션 설정 =====
|
||||
const actions = config.actions || {};
|
||||
|
||||
@@ -190,6 +190,8 @@ export interface IntegratedDetailTemplateProps<T = Record<string, unknown>> {
|
||||
onCancel?: () => void;
|
||||
/** 모드 변경 핸들러 (view → edit) */
|
||||
onModeChange?: (mode: DetailMode) => void;
|
||||
/** 수정 버튼 클릭 핸들러 (기본: 내부 모드 변경, 제공 시 커스텀 동작) */
|
||||
onEdit?: () => void;
|
||||
/** 커스텀 상세 화면 렌더러 */
|
||||
renderView?: (data: T) => ReactNode;
|
||||
/** 커스텀 폼 렌더러 */
|
||||
|
||||
226
src/components/ui/file-dropzone.tsx
Normal file
226
src/components/ui/file-dropzone.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* FileDropzone - 드래그 앤 드롭 파일 업로드 영역
|
||||
*
|
||||
* 도면, 첨부파일 등 다중 파일 업로드에 사용
|
||||
*
|
||||
* 사용 예시:
|
||||
* <FileDropzone
|
||||
* accept=".pdf,.dwg,.jpg"
|
||||
* multiple
|
||||
* maxSize={10}
|
||||
* onFilesSelect={(files) => setFiles(prev => [...prev, ...files])}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { Upload, AlertCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface FileDropzoneProps {
|
||||
/** 파일 선택 시 콜백 */
|
||||
onFilesSelect: (files: File[]) => void;
|
||||
/** 허용 파일 타입 */
|
||||
accept?: string;
|
||||
/** 다중 파일 선택 허용 */
|
||||
multiple?: boolean;
|
||||
/** 최대 파일 크기 (MB) */
|
||||
maxSize?: number;
|
||||
/** 최대 파일 개수 */
|
||||
maxFiles?: number;
|
||||
/** 비활성화 */
|
||||
disabled?: boolean;
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
/** 안내 텍스트 (메인) */
|
||||
title?: string;
|
||||
/** 안내 텍스트 (서브) */
|
||||
description?: string;
|
||||
/** 에러 상태 */
|
||||
error?: boolean;
|
||||
/** 에러 메시지 */
|
||||
errorMessage?: string;
|
||||
/** 컴팩트 모드 */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function FileDropzone({
|
||||
onFilesSelect,
|
||||
accept = '*/*',
|
||||
multiple = false,
|
||||
maxSize = 10,
|
||||
maxFiles,
|
||||
disabled = false,
|
||||
className,
|
||||
title,
|
||||
description,
|
||||
error = false,
|
||||
errorMessage,
|
||||
compact = false,
|
||||
}: FileDropzoneProps) {
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
const displayError = errorMessage || validationError;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!disabled) {
|
||||
inputRef.current?.click();
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const validateFiles = useCallback((files: File[]): File[] => {
|
||||
const validFiles: File[] = [];
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
// 파일 크기 검증
|
||||
const fileSizeMB = file.size / (1024 * 1024);
|
||||
if (fileSizeMB > maxSize) {
|
||||
errors.push(`${file.name}: ${maxSize}MB 초과`);
|
||||
continue;
|
||||
}
|
||||
|
||||
validFiles.push(file);
|
||||
}
|
||||
|
||||
// 최대 파일 개수 검증
|
||||
if (maxFiles && validFiles.length > maxFiles) {
|
||||
setValidationError(`최대 ${maxFiles}개 파일만 선택할 수 있습니다`);
|
||||
return validFiles.slice(0, maxFiles);
|
||||
}
|
||||
|
||||
if (errors.length > 0) {
|
||||
setValidationError(errors.join(', '));
|
||||
} else {
|
||||
setValidationError(null);
|
||||
}
|
||||
|
||||
return validFiles;
|
||||
}, [maxSize, maxFiles]);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const files = e.target.files;
|
||||
if (files && files.length > 0) {
|
||||
const fileArray = Array.from(files);
|
||||
const validFiles = validateFiles(fileArray);
|
||||
if (validFiles.length > 0) {
|
||||
onFilesSelect(validFiles);
|
||||
}
|
||||
}
|
||||
// input 초기화
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
}
|
||||
}, [validateFiles, onFilesSelect]);
|
||||
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const files = e.dataTransfer.files;
|
||||
if (files && files.length > 0) {
|
||||
const fileArray = multiple ? Array.from(files) : [files[0]];
|
||||
const validFiles = validateFiles(fileArray);
|
||||
if (validFiles.length > 0) {
|
||||
onFilesSelect(validFiles);
|
||||
}
|
||||
}
|
||||
}, [disabled, multiple, validateFiles, onFilesSelect]);
|
||||
|
||||
// 기본 텍스트
|
||||
const defaultTitle = isDragging
|
||||
? '파일을 여기에 놓으세요'
|
||||
: '클릭하거나 파일을 드래그하세요';
|
||||
const defaultDescription = `최대 ${maxSize}MB${accept !== '*/*' ? ` (${accept})` : ''}`;
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
'border-2 border-dashed rounded-lg transition-colors',
|
||||
compact ? 'p-4' : 'p-8',
|
||||
error || displayError
|
||||
? 'border-red-500 bg-red-50'
|
||||
: isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-gray-300 hover:border-primary/50',
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
||||
)}
|
||||
>
|
||||
{/* 숨겨진 파일 input */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
multiple={multiple}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
<div className="flex flex-col items-center justify-center text-center">
|
||||
<Upload
|
||||
className={cn(
|
||||
'mb-2 transition-colors',
|
||||
compact ? 'w-6 h-6' : 'w-10 h-10',
|
||||
isDragging ? 'text-primary' : 'text-gray-400',
|
||||
)}
|
||||
/>
|
||||
<p className={cn(
|
||||
'font-medium',
|
||||
compact ? 'text-sm' : 'text-base',
|
||||
isDragging ? 'text-primary' : 'text-gray-700',
|
||||
)}>
|
||||
{title || defaultTitle}
|
||||
</p>
|
||||
<p className={cn(
|
||||
'text-muted-foreground mt-1',
|
||||
compact ? 'text-xs' : 'text-sm',
|
||||
)}>
|
||||
{description || defaultDescription}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{displayError && (
|
||||
<div className="flex items-center gap-1 text-sm text-red-500">
|
||||
<AlertCircle className="w-3 h-3 shrink-0" />
|
||||
<span>{displayError}</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileDropzone;
|
||||
203
src/components/ui/file-input.tsx
Normal file
203
src/components/ui/file-input.tsx
Normal file
@@ -0,0 +1,203 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* FileInput - 기본 파일 선택 컴포넌트
|
||||
*
|
||||
* 사용 예시:
|
||||
* <FileInput
|
||||
* accept=".pdf,.doc"
|
||||
* maxSize={10}
|
||||
* onFileSelect={(file) => setFile(file)}
|
||||
* onFileRemove={() => setFile(null)}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Upload, X, FileText, AlertCircle } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface FileInputProps {
|
||||
/** 파일 선택 시 콜백 */
|
||||
onFileSelect: (file: File) => void;
|
||||
/** 파일 제거 시 콜백 */
|
||||
onFileRemove?: () => void;
|
||||
/** 허용 파일 타입 (예: ".pdf,.doc" 또는 "image/*") */
|
||||
accept?: string;
|
||||
/** 최대 파일 크기 (MB) */
|
||||
maxSize?: number;
|
||||
/** 비활성화 */
|
||||
disabled?: boolean;
|
||||
/** 버튼 텍스트 */
|
||||
buttonText?: string;
|
||||
/** 플레이스홀더 텍스트 */
|
||||
placeholder?: string;
|
||||
/** 현재 선택된 파일 (외부 제어용) */
|
||||
value?: File | null;
|
||||
/** 기존 파일 정보 (수정 모드용) */
|
||||
existingFile?: {
|
||||
name: string;
|
||||
url?: string;
|
||||
};
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
/** 에러 상태 */
|
||||
error?: boolean;
|
||||
/** 에러 메시지 */
|
||||
errorMessage?: string;
|
||||
}
|
||||
|
||||
export function FileInput({
|
||||
onFileSelect,
|
||||
onFileRemove,
|
||||
accept = '*/*',
|
||||
maxSize = 10,
|
||||
disabled = false,
|
||||
buttonText = '파일 선택',
|
||||
placeholder = '선택된 파일 없음',
|
||||
value,
|
||||
existingFile,
|
||||
className,
|
||||
error = false,
|
||||
errorMessage,
|
||||
}: FileInputProps) {
|
||||
const [internalFile, setInternalFile] = useState<File | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 외부 제어 또는 내부 상태 사용
|
||||
const selectedFile = value !== undefined ? value : internalFile;
|
||||
const displayError = errorMessage || validationError;
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!disabled) {
|
||||
inputRef.current?.click();
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
|
||||
if (!file) return;
|
||||
|
||||
// 파일 크기 검증
|
||||
const fileSizeMB = file.size / (1024 * 1024);
|
||||
if (fileSizeMB > maxSize) {
|
||||
setValidationError(`파일 크기는 ${maxSize}MB 이하여야 합니다`);
|
||||
return;
|
||||
}
|
||||
|
||||
setValidationError(null);
|
||||
setInternalFile(file);
|
||||
onFileSelect(file);
|
||||
|
||||
// input 초기화 (같은 파일 재선택 가능하도록)
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
}
|
||||
}, [maxSize, onFileSelect]);
|
||||
|
||||
const handleRemove = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
setInternalFile(null);
|
||||
setValidationError(null);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
}
|
||||
onFileRemove?.();
|
||||
}, [onFileRemove]);
|
||||
|
||||
// 표시할 파일명 결정
|
||||
const displayFileName = selectedFile?.name || existingFile?.name;
|
||||
const hasFile = !!selectedFile || !!existingFile;
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-1', className)}>
|
||||
<div
|
||||
className={cn(
|
||||
'flex items-center gap-2 p-2 border rounded-md bg-background',
|
||||
error || displayError ? 'border-red-500' : 'border-input',
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer hover:border-primary/50',
|
||||
)}
|
||||
onClick={handleClick}
|
||||
>
|
||||
{/* 숨겨진 파일 input */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{/* 파일 선택 버튼 */}
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
disabled={disabled}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClick();
|
||||
}}
|
||||
className="shrink-0"
|
||||
>
|
||||
<Upload className="w-4 h-4 mr-1" />
|
||||
{buttonText}
|
||||
</Button>
|
||||
|
||||
{/* 파일명 표시 영역 */}
|
||||
<div className="flex-1 flex items-center gap-2 min-w-0">
|
||||
{hasFile ? (
|
||||
<>
|
||||
<FileText className="w-4 h-4 text-primary shrink-0" />
|
||||
<span className="text-sm truncate">
|
||||
{displayFileName}
|
||||
</span>
|
||||
{selectedFile && (
|
||||
<span className="text-xs text-muted-foreground shrink-0">
|
||||
({(selectedFile.size / 1024).toFixed(1)} KB)
|
||||
</span>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{placeholder}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 파일 제거 버튼 */}
|
||||
{hasFile && !disabled && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleRemove}
|
||||
className="shrink-0 h-7 w-7 p-0 hover:bg-destructive/10"
|
||||
>
|
||||
<X className="w-4 h-4 text-muted-foreground hover:text-destructive" />
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{displayError && (
|
||||
<div className="flex items-center gap-1 text-sm text-red-500">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
<span>{displayError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 허용 파일 형식 안내 */}
|
||||
{!displayError && accept !== '*/*' && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
허용 형식: {accept}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileInput;
|
||||
273
src/components/ui/file-list.tsx
Normal file
273
src/components/ui/file-list.tsx
Normal file
@@ -0,0 +1,273 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* FileList - 업로드된 파일 목록 표시 컴포넌트
|
||||
*
|
||||
* 첨부파일 목록 표시, 다운로드, 삭제 기능
|
||||
*
|
||||
* 사용 예시:
|
||||
* <FileList
|
||||
* files={uploadedFiles}
|
||||
* onRemove={(index) => removeFile(index)}
|
||||
* onDownload={(file) => downloadFile(file)}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { useCallback } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { FileText, Download, Trash2, ExternalLink, Loader2 } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
/** 새로 업로드된 파일 */
|
||||
export interface NewFile {
|
||||
file: File;
|
||||
/** 업로드 진행률 (0-100) */
|
||||
progress?: number;
|
||||
/** 업로드 중 여부 */
|
||||
uploading?: boolean;
|
||||
/** 에러 메시지 */
|
||||
error?: string;
|
||||
}
|
||||
|
||||
/** 기존 파일 (서버에서 가져온) */
|
||||
export interface ExistingFile {
|
||||
id: string | number;
|
||||
name: string;
|
||||
url?: string;
|
||||
size?: number;
|
||||
/** 삭제 중 여부 */
|
||||
deleting?: boolean;
|
||||
}
|
||||
|
||||
export interface FileListProps {
|
||||
/** 새로 업로드된 파일 목록 */
|
||||
files?: NewFile[];
|
||||
/** 기존 파일 목록 */
|
||||
existingFiles?: ExistingFile[];
|
||||
/** 새 파일 제거 콜백 */
|
||||
onRemove?: (index: number) => void;
|
||||
/** 기존 파일 제거 콜백 */
|
||||
onRemoveExisting?: (id: string | number) => void;
|
||||
/** 기존 파일 다운로드 콜백 */
|
||||
onDownload?: (file: ExistingFile) => void;
|
||||
/** 읽기 전용 */
|
||||
readOnly?: boolean;
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
/** 파일 없을 때 메시지 */
|
||||
emptyMessage?: string;
|
||||
/** 컴팩트 모드 */
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
// 파일 크기 포맷
|
||||
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`;
|
||||
}
|
||||
|
||||
// 파일 확장자에 따른 아이콘 색상
|
||||
function getFileColor(fileName: string): string {
|
||||
const ext = fileName.split('.').pop()?.toLowerCase();
|
||||
switch (ext) {
|
||||
case 'pdf':
|
||||
return 'text-red-500';
|
||||
case 'doc':
|
||||
case 'docx':
|
||||
return 'text-blue-500';
|
||||
case 'xls':
|
||||
case 'xlsx':
|
||||
return 'text-green-500';
|
||||
case 'jpg':
|
||||
case 'jpeg':
|
||||
case 'png':
|
||||
case 'gif':
|
||||
case 'webp':
|
||||
return 'text-purple-500';
|
||||
case 'dwg':
|
||||
case 'dxf':
|
||||
return 'text-orange-500';
|
||||
default:
|
||||
return 'text-gray-500';
|
||||
}
|
||||
}
|
||||
|
||||
export function FileList({
|
||||
files = [],
|
||||
existingFiles = [],
|
||||
onRemove,
|
||||
onRemoveExisting,
|
||||
onDownload,
|
||||
readOnly = false,
|
||||
className,
|
||||
emptyMessage = '파일이 없습니다',
|
||||
compact = false,
|
||||
}: FileListProps) {
|
||||
const handleDownload = useCallback((file: ExistingFile) => {
|
||||
if (onDownload) {
|
||||
onDownload(file);
|
||||
} else if (file.url) {
|
||||
// 기본 다운로드 동작
|
||||
const link = document.createElement('a');
|
||||
link.href = file.url;
|
||||
link.download = file.name;
|
||||
link.click();
|
||||
}
|
||||
}, [onDownload]);
|
||||
|
||||
const totalFiles = files.length + existingFiles.length;
|
||||
|
||||
if (totalFiles === 0) {
|
||||
return (
|
||||
<div className={cn('text-sm text-muted-foreground text-center py-4', className)}>
|
||||
{emptyMessage}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{/* 기존 파일 목록 */}
|
||||
{existingFiles.map((file) => (
|
||||
<div
|
||||
key={file.id}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-2 rounded-md border bg-muted/30',
|
||||
compact ? 'p-2' : 'p-3',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<FileText className={cn('shrink-0', getFileColor(file.name), compact ? 'w-5 h-5' : 'w-6 h-6')} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<p className={cn('font-medium truncate', compact ? 'text-xs' : 'text-sm')}>
|
||||
{file.name}
|
||||
</p>
|
||||
{file.size && (
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(file.size)}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{/* 다운로드 버튼 */}
|
||||
{(file.url || onDownload) && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size={compact ? 'sm' : 'default'}
|
||||
onClick={() => handleDownload(file)}
|
||||
className={cn('text-blue-600 hover:text-blue-700 hover:bg-blue-50', compact && 'h-7 w-7 p-0')}
|
||||
title="다운로드"
|
||||
>
|
||||
<Download className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 새 탭에서 열기 */}
|
||||
{file.url && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size={compact ? 'sm' : 'default'}
|
||||
onClick={() => window.open(file.url, '_blank')}
|
||||
className={cn('text-gray-600 hover:text-gray-700', compact && 'h-7 w-7 p-0')}
|
||||
title="새 탭에서 열기"
|
||||
>
|
||||
<ExternalLink className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 삭제 버튼 */}
|
||||
{!readOnly && onRemoveExisting && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size={compact ? 'sm' : 'default'}
|
||||
onClick={() => onRemoveExisting(file.id)}
|
||||
disabled={file.deleting}
|
||||
className={cn('text-red-500 hover:text-red-700 hover:bg-red-50', compact && 'h-7 w-7 p-0')}
|
||||
title="삭제"
|
||||
>
|
||||
{file.deleting ? (
|
||||
<Loader2 className={cn('animate-spin', compact ? 'w-3.5 h-3.5' : 'w-4 h-4')} />
|
||||
) : (
|
||||
<Trash2 className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* 새로 업로드된 파일 목록 */}
|
||||
{files.map((item, index) => (
|
||||
<div
|
||||
key={`new-${index}`}
|
||||
className={cn(
|
||||
'flex items-center justify-between gap-2 rounded-md border',
|
||||
item.error ? 'border-red-300 bg-red-50' : 'border-blue-200 bg-blue-50',
|
||||
compact ? 'p-2' : 'p-3',
|
||||
)}
|
||||
>
|
||||
<div className="flex items-center gap-3 min-w-0 flex-1">
|
||||
<FileText className={cn('shrink-0', getFileColor(item.file.name), compact ? 'w-5 h-5' : 'w-6 h-6')} />
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<p className={cn('font-medium truncate', compact ? 'text-xs' : 'text-sm')}>
|
||||
{item.file.name}
|
||||
</p>
|
||||
{!item.error && (
|
||||
<span className={cn('text-blue-600 shrink-0', compact ? 'text-[10px]' : 'text-xs')}>
|
||||
(새 파일)
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(item.file.size)}
|
||||
</p>
|
||||
{/* 에러 메시지 */}
|
||||
{item.error && (
|
||||
<p className="text-xs text-red-500 mt-1">{item.error}</p>
|
||||
)}
|
||||
{/* 업로드 진행률 */}
|
||||
{item.uploading && item.progress !== undefined && (
|
||||
<div className="w-full bg-gray-200 rounded-full h-1.5 mt-1">
|
||||
<div
|
||||
className="bg-blue-600 h-1.5 rounded-full transition-all"
|
||||
style={{ width: `${item.progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-1 shrink-0">
|
||||
{/* 삭제/취소 버튼 */}
|
||||
{!readOnly && onRemove && (
|
||||
<Button
|
||||
type="button"
|
||||
variant="ghost"
|
||||
size={compact ? 'sm' : 'default'}
|
||||
onClick={() => onRemove(index)}
|
||||
disabled={item.uploading}
|
||||
className={cn('text-red-500 hover:text-red-700 hover:bg-red-50', compact && 'h-7 w-7 p-0')}
|
||||
title={item.uploading ? '업로드 취소' : '삭제'}
|
||||
>
|
||||
{item.uploading ? (
|
||||
<Loader2 className={cn('animate-spin', compact ? 'w-3.5 h-3.5' : 'w-4 h-4')} />
|
||||
) : (
|
||||
<Trash2 className={compact ? 'w-3.5 h-3.5' : 'w-4 h-4'} />
|
||||
)}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default FileList;
|
||||
296
src/components/ui/image-upload.tsx
Normal file
296
src/components/ui/image-upload.tsx
Normal file
@@ -0,0 +1,296 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* ImageUpload - 이미지 업로드 컴포넌트 (미리보기 포함)
|
||||
*
|
||||
* 프로필 이미지, 로고, 썸네일 등에 사용
|
||||
*
|
||||
* 사용 예시:
|
||||
* <ImageUpload
|
||||
* value={profileImageUrl}
|
||||
* onChange={(file) => handleUpload(file)}
|
||||
* onRemove={() => setProfileImageUrl(null)}
|
||||
* aspectRatio="square"
|
||||
* maxSize={5}
|
||||
* />
|
||||
*/
|
||||
|
||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Camera, X, Upload, AlertCircle, Image as ImageIcon } from 'lucide-react';
|
||||
import { cn } from '@/lib/utils';
|
||||
|
||||
export interface ImageUploadProps {
|
||||
/** 현재 이미지 URL (기존 이미지 또는 미리보기) */
|
||||
value?: string | null;
|
||||
/** 이미지 선택 시 콜백 (File 객체 전달) */
|
||||
onChange: (file: File) => void;
|
||||
/** 이미지 제거 시 콜백 */
|
||||
onRemove?: () => void;
|
||||
/** 비활성화 */
|
||||
disabled?: boolean;
|
||||
/** 최대 파일 크기 (MB) */
|
||||
maxSize?: number;
|
||||
/** 허용 이미지 타입 */
|
||||
accept?: string;
|
||||
/** 가로세로 비율 */
|
||||
aspectRatio?: 'square' | 'video' | 'wide' | 'portrait';
|
||||
/** 컴포넌트 크기 */
|
||||
size?: 'sm' | 'md' | 'lg';
|
||||
/** 추가 클래스 */
|
||||
className?: string;
|
||||
/** 안내 텍스트 */
|
||||
hint?: string;
|
||||
/** 에러 상태 */
|
||||
error?: boolean;
|
||||
/** 에러 메시지 */
|
||||
errorMessage?: string;
|
||||
/** 원형 (프로필용) */
|
||||
rounded?: boolean;
|
||||
}
|
||||
|
||||
const sizeClasses = {
|
||||
sm: 'w-24 h-24',
|
||||
md: 'w-32 h-32',
|
||||
lg: 'w-40 h-40',
|
||||
};
|
||||
|
||||
const aspectRatioClasses = {
|
||||
square: 'aspect-square',
|
||||
video: 'aspect-video',
|
||||
wide: 'aspect-[2/1]',
|
||||
portrait: 'aspect-[3/4]',
|
||||
};
|
||||
|
||||
export function ImageUpload({
|
||||
value,
|
||||
onChange,
|
||||
onRemove,
|
||||
disabled = false,
|
||||
maxSize = 10,
|
||||
accept = 'image/png,image/jpeg,image/gif,image/webp',
|
||||
aspectRatio = 'square',
|
||||
size = 'md',
|
||||
className,
|
||||
hint,
|
||||
error = false,
|
||||
errorMessage,
|
||||
rounded = false,
|
||||
}: ImageUploadProps) {
|
||||
const [previewUrl, setPreviewUrl] = useState<string | null>(null);
|
||||
const [validationError, setValidationError] = useState<string | null>(null);
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const inputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
// 외부 value 또는 내부 preview 사용
|
||||
const displayUrl = value || previewUrl;
|
||||
const displayError = errorMessage || validationError;
|
||||
|
||||
// value가 변경되면 previewUrl 초기화
|
||||
useEffect(() => {
|
||||
if (value) {
|
||||
setPreviewUrl(null);
|
||||
}
|
||||
}, [value]);
|
||||
|
||||
const handleClick = useCallback(() => {
|
||||
if (!disabled) {
|
||||
inputRef.current?.click();
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const validateAndProcessFile = useCallback((file: File) => {
|
||||
// 이미지 타입 검증
|
||||
if (!file.type.startsWith('image/')) {
|
||||
setValidationError('이미지 파일만 업로드할 수 있습니다');
|
||||
return false;
|
||||
}
|
||||
|
||||
// 파일 크기 검증
|
||||
const fileSizeMB = file.size / (1024 * 1024);
|
||||
if (fileSizeMB > maxSize) {
|
||||
setValidationError(`파일 크기는 ${maxSize}MB 이하여야 합니다`);
|
||||
return false;
|
||||
}
|
||||
|
||||
setValidationError(null);
|
||||
|
||||
// 미리보기 URL 생성
|
||||
const objectUrl = URL.createObjectURL(file);
|
||||
setPreviewUrl(objectUrl);
|
||||
|
||||
onChange(file);
|
||||
return true;
|
||||
}, [maxSize, onChange]);
|
||||
|
||||
const handleChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0];
|
||||
if (file) {
|
||||
validateAndProcessFile(file);
|
||||
}
|
||||
// input 초기화
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
}
|
||||
}, [validateAndProcessFile]);
|
||||
|
||||
const handleRemove = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
setPreviewUrl(null);
|
||||
setValidationError(null);
|
||||
if (inputRef.current) {
|
||||
inputRef.current.value = '';
|
||||
}
|
||||
onRemove?.();
|
||||
}, [previewUrl, onRemove]);
|
||||
|
||||
// 드래그 앤 드롭 핸들러
|
||||
const handleDragEnter = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
if (!disabled) {
|
||||
setIsDragging(true);
|
||||
}
|
||||
}, [disabled]);
|
||||
|
||||
const handleDragLeave = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
const handleDragOver = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
}, []);
|
||||
|
||||
const handleDrop = useCallback((e: React.DragEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setIsDragging(false);
|
||||
|
||||
if (disabled) return;
|
||||
|
||||
const file = e.dataTransfer.files?.[0];
|
||||
if (file) {
|
||||
validateAndProcessFile(file);
|
||||
}
|
||||
}, [disabled, validateAndProcessFile]);
|
||||
|
||||
// 컴포넌트 언마운트 시 미리보기 URL 정리
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (previewUrl) {
|
||||
URL.revokeObjectURL(previewUrl);
|
||||
}
|
||||
};
|
||||
}, [previewUrl]);
|
||||
|
||||
return (
|
||||
<div className={cn('space-y-2', className)}>
|
||||
{/* 업로드 영역 */}
|
||||
<div
|
||||
onClick={handleClick}
|
||||
onDragEnter={handleDragEnter}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDragOver={handleDragOver}
|
||||
onDrop={handleDrop}
|
||||
className={cn(
|
||||
'relative border-2 border-dashed flex flex-col items-center justify-center overflow-hidden transition-colors',
|
||||
rounded ? 'rounded-full' : 'rounded-lg',
|
||||
sizeClasses[size],
|
||||
aspectRatio !== 'square' && !rounded && aspectRatioClasses[aspectRatio],
|
||||
error || displayError
|
||||
? 'border-red-500 bg-red-50'
|
||||
: isDragging
|
||||
? 'border-primary bg-primary/5'
|
||||
: 'border-gray-300 bg-gray-50 hover:border-primary/50',
|
||||
disabled ? 'opacity-50 cursor-not-allowed' : 'cursor-pointer',
|
||||
)}
|
||||
>
|
||||
{/* 숨겨진 파일 input */}
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept={accept}
|
||||
onChange={handleChange}
|
||||
disabled={disabled}
|
||||
className="hidden"
|
||||
/>
|
||||
|
||||
{displayUrl ? (
|
||||
// 이미지 미리보기
|
||||
<>
|
||||
<img
|
||||
src={displayUrl}
|
||||
alt="미리보기"
|
||||
className={cn(
|
||||
'w-full h-full object-cover',
|
||||
rounded && 'rounded-full',
|
||||
)}
|
||||
/>
|
||||
{/* 호버 오버레이 */}
|
||||
{!disabled && (
|
||||
<div className={cn(
|
||||
'absolute inset-0 bg-black/50 opacity-0 hover:opacity-100 transition-opacity flex items-center justify-center gap-2',
|
||||
rounded && 'rounded-full',
|
||||
)}>
|
||||
<Button
|
||||
type="button"
|
||||
variant="secondary"
|
||||
size="sm"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleClick();
|
||||
}}
|
||||
>
|
||||
<Camera className="w-4 h-4" />
|
||||
</Button>
|
||||
<Button
|
||||
type="button"
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={handleRemove}
|
||||
>
|
||||
<X className="w-4 h-4" />
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
// 업로드 안내
|
||||
<div className="flex flex-col items-center justify-center text-center p-2">
|
||||
{isDragging ? (
|
||||
<Upload className="w-8 h-8 text-primary mb-1" />
|
||||
) : (
|
||||
<ImageIcon className="w-8 h-8 text-gray-400 mb-1" />
|
||||
)}
|
||||
<span className="text-xs text-gray-500">
|
||||
{isDragging ? '놓으세요' : '클릭 또는 드래그'}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 에러 메시지 */}
|
||||
{displayError && (
|
||||
<div className="flex items-center gap-1 text-sm text-red-500">
|
||||
<AlertCircle className="w-3 h-3" />
|
||||
<span>{displayError}</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 안내 텍스트 */}
|
||||
{!displayError && hint && (
|
||||
<p className="text-xs text-muted-foreground text-center">
|
||||
{hint}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default ImageUpload;
|
||||
Reference in New Issue
Block a user