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:
유병철
2026-01-22 15:07:17 +09:00
parent 6fa69d81f4
commit 9464a368ba
48 changed files with 3900 additions and 4063 deletions

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

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

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

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

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

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

View File

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

View File

@@ -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="필수 컬럼: 이름 | 선택 컬럼: 휴대폰, 이메일, 부서, 직책, 입사일, 상태"
/>
)}
{/* 파일 정보 및 미리보기 */}

View File

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

View File

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

View File

@@ -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>
{/* 전개도 이미지 미리보기 */}

View File

@@ -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>
{/* 비고 */}

View File

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

View File

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

View 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 &nbsp; W3000×H4000</td>
<td className="bg-gray-100 p-3 font-medium border-r border-gray-300"></td>
<td className="p-3"> &nbsp; </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>
);
}

View File

@@ -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 &nbsp; W3000×H4000</td>
<td className="bg-gray-100 p-3 font-medium border-r border-gray-300"></td>
<td className="p-3"> &nbsp; </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>
);
}
}

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

View File

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

View File

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

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

View File

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

View File

@@ -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>
{/* 아이디 & 비밀번호 */}

View File

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

View File

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

View File

@@ -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;
/** 커스텀 폼 렌더러 */

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

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

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

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