From 9464a368ba0a726e238cad3cf0b7c4d63db60180 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Thu, 22 Jan 2026 15:07:17 +0900 Subject: [PATCH] =?UTF-8?q?refactor:=20=EB=AA=A8=EB=8B=AC=20Content=20?= =?UTF-8?q?=EC=BB=B4=ED=8F=AC=EB=84=8C=ED=8A=B8=20=EB=B6=84=EB=A6=AC=20?= =?UTF-8?q?=EB=B0=8F=20=ED=8C=8C=EC=9D=BC=20=EC=9E=85=EB=A0=A5=20UI=20?= =?UTF-8?q?=EA=B3=B5=ED=86=B5=ED=99=94?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - 모달 컴포넌트에서 Content 분리하여 재사용성 향상 - EstimateDocumentContent, DirectConstructionContent 등 - WorkLogContent, QuotePreviewContent, ReceivingReceiptContent - 파일 입력 공통 UI 컴포넌트 추가 - file-dropzone, file-input, file-list, image-upload - 폼 컴포넌트 코드 정리 및 중복 제거 (-4,056줄) Co-Authored-By: Claude Opus 4.5 --- .../dev/[REF] page-builder-implementation.md | 298 ++++++++++++ .../DocumentCreate/ExpenseReportForm.tsx | 142 ++---- .../approval/DocumentCreate/ProposalForm.tsx | 142 ++---- src/components/board/BoardForm/index.tsx | 130 ++---- .../contract/ContractDetailForm.tsx | 217 ++------- .../estimates/EstimateDetailForm.tsx | 115 +---- .../modals/EstimateDocumentContent.tsx | 289 ++++++++++++ .../modals/EstimateDocumentModal.tsx | 423 ++---------------- .../sections/EstimateInfoSection.tsx | 104 +---- .../construction/partners/PartnerForm.tsx | 241 ++-------- .../modals/DirectConstructionContent.tsx | 156 +++++++ .../modals/DirectConstructionModal.tsx | 275 ++---------- .../modals/IndirectConstructionContent.tsx | 142 ++++++ .../modals/IndirectConstructionModal.tsx | 389 ++-------------- .../modals/PhotoDocumentContent.tsx | 129 ++++++ .../modals/PhotoDocumentModal.tsx | 213 ++------- .../site-briefings/SiteBriefingForm.tsx | 186 ++------ .../site-management/SiteDetailForm.tsx | 188 ++------ .../StructureReviewDetailForm.tsx | 188 ++------ .../InquiryManagement/InquiryForm.tsx | 121 ++--- .../components/ApprovalLine.tsx | 154 +++++++ .../components/DocumentHeader.tsx | 160 +++++++ .../document-system/components/InfoTable.tsx | 94 ++++ .../components/SectionHeader.tsx | 45 ++ .../document-system/components/index.ts | 11 + src/components/document-system/index.ts | 19 + .../hr/EmployeeManagement/CSVUploadDialog.tsx | 50 +-- .../hr/EmployeeManagement/EmployeeForm.tsx | 65 +-- src/components/hr/SalaryManagement/index.tsx | 6 - .../items/ItemForm/BendingDiagramSection.tsx | 21 +- .../items/ItemForm/forms/ProductForm.tsx | 89 +--- .../ReceivingReceiptContent.tsx | 130 ++++++ .../ReceivingReceiptDialog.tsx | 185 +------- .../ProcessWorkLogContent.tsx | 135 ++++++ .../ProcessWorkLogPreviewModal.tsx | 214 +-------- .../WorkerScreen/WorkLogContent.tsx | 194 ++++++++ .../production/WorkerScreen/WorkLogModal.tsx | 340 ++------------ .../production/WorkerScreen/actions.ts | 143 +----- src/components/quotes/QuotePreviewContent.tsx | 218 +++++++++ src/components/quotes/QuotePreviewModal.tsx | 327 ++------------ .../settings/AccountInfoManagement/index.tsx | 90 +--- .../settings/CompanyInfoManagement/index.tsx | 176 ++------ .../IntegratedDetailTemplate/index.tsx | 9 +- .../IntegratedDetailTemplate/types.ts | 2 + src/components/ui/file-dropzone.tsx | 226 ++++++++++ src/components/ui/file-input.tsx | 203 +++++++++ src/components/ui/file-list.tsx | 273 +++++++++++ src/components/ui/image-upload.tsx | 296 ++++++++++++ 48 files changed, 3900 insertions(+), 4063 deletions(-) create mode 100644 claudedocs/dev/[REF] page-builder-implementation.md create mode 100644 src/components/business/construction/estimates/modals/EstimateDocumentContent.tsx create mode 100644 src/components/business/construction/progress-billing/modals/DirectConstructionContent.tsx create mode 100644 src/components/business/construction/progress-billing/modals/IndirectConstructionContent.tsx create mode 100644 src/components/business/construction/progress-billing/modals/PhotoDocumentContent.tsx create mode 100644 src/components/document-system/components/ApprovalLine.tsx create mode 100644 src/components/document-system/components/DocumentHeader.tsx create mode 100644 src/components/document-system/components/InfoTable.tsx create mode 100644 src/components/document-system/components/SectionHeader.tsx create mode 100644 src/components/document-system/components/index.ts create mode 100644 src/components/material/ReceivingManagement/ReceivingReceiptContent.tsx create mode 100644 src/components/process-management/ProcessWorkLogContent.tsx create mode 100644 src/components/production/WorkerScreen/WorkLogContent.tsx create mode 100644 src/components/quotes/QuotePreviewContent.tsx create mode 100644 src/components/ui/file-dropzone.tsx create mode 100644 src/components/ui/file-input.tsx create mode 100644 src/components/ui/file-list.tsx create mode 100644 src/components/ui/image-upload.tsx diff --git a/claudedocs/dev/[REF] page-builder-implementation.md b/claudedocs/dev/[REF] page-builder-implementation.md new file mode 100644 index 00000000..505ba6bc --- /dev/null +++ b/claudedocs/dev/[REF] page-builder-implementation.md @@ -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* diff --git a/src/components/approval/DocumentCreate/ExpenseReportForm.tsx b/src/components/approval/DocumentCreate/ExpenseReportForm.tsx index 992f1f8e..9027a61b 100644 --- a/src/components/approval/DocumentCreate/ExpenseReportForm.tsx +++ b/src/components/approval/DocumentCreate/ExpenseReportForm.tsx @@ -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(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) => { - 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) {
-
- - -
+
- {/* 기존 업로드된 파일 목록 */} - {data.uploadedFiles && data.uploadedFiles.length > 0 && ( -
- -
- {data.uploadedFiles.map((file) => ( -
-
- - {file.name} - {file.size && ( - - ({Math.round(file.size / 1024)}KB) - - )} -
-
- {file.url && ( - - )} - -
-
- ))} -
-
- )} - - {/* 새로 추가할 파일 목록 */} - {data.attachments.length > 0 && ( -
- -
- {data.attachments.map((file, index) => ( -
-
- - {file.name} - - ({Math.round(file.size / 1024)}KB) - -
- -
- ))} -
-
- )} - - {/* 파일이 없을 때 안내 메시지 */} - {(!data.uploadedFiles || data.uploadedFiles.length === 0) && data.attachments.length === 0 && ( -
- 첨부된 파일이 없습니다. 위 버튼을 클릭하여 파일을 추가하세요. -
- )} + {/* 파일 목록 */} + ({ 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 + />
diff --git a/src/components/approval/DocumentCreate/ProposalForm.tsx b/src/components/approval/DocumentCreate/ProposalForm.tsx index e96ba152..454e2aec 100644 --- a/src/components/approval/DocumentCreate/ProposalForm.tsx +++ b/src/components/approval/DocumentCreate/ProposalForm.tsx @@ -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(null); - - const handleFileChange = (e: React.ChangeEvent) => { - 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) {
-
- - -
+
- {/* 기존 업로드된 파일 목록 */} - {data.uploadedFiles && data.uploadedFiles.length > 0 && ( -
- -
- {data.uploadedFiles.map((file) => ( -
-
- - {file.name} - {file.size && ( - - ({Math.round(file.size / 1024)}KB) - - )} -
-
- {file.url && ( - - )} - -
-
- ))} -
-
- )} - - {/* 새로 추가할 파일 목록 */} - {data.attachments.length > 0 && ( -
- -
- {data.attachments.map((file, index) => ( -
-
- - {file.name} - - ({Math.round(file.size / 1024)}KB) - -
- -
- ))} -
-
- )} - - {/* 파일이 없을 때 안내 메시지 */} - {(!data.uploadedFiles || data.uploadedFiles.length === 0) && data.attachments.length === 0 && ( -
- 첨부된 파일이 없습니다. 위 버튼을 클릭하여 파일을 추가하세요. -
- )} + {/* 파일 목록 */} + ({ 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 + />
diff --git a/src/components/board/BoardForm/index.tsx b/src/components/board/BoardForm/index.tsx index bcf03bbd..ea82809a 100644 --- a/src/components/board/BoardForm/index.tsx +++ b/src/components/board/BoardForm/index.tsx @@ -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(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([]); - const [existingAttachments, setExistingAttachments] = useState( - initialData?.attachments || [] + const [attachments, setAttachments] = useState([]); + const [existingAttachments, setExistingAttachments] = useState( + (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) => { - 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) { {/* 첨부파일 */}
-
- - -
- - {/* 기존 파일 목록 */} - {existingAttachments.length > 0 && ( -
- {existingAttachments.map((file) => ( -
-
- - {file.fileName} - - ({formatFileSize(file.fileSize)}) - -
- -
- ))} -
- )} - - {/* 새로 추가된 파일 목록 */} - {attachments.length > 0 && ( -
- {attachments.map((file, index) => ( -
-
- - {file.name} - - ({formatFileSize(file.size)}) - - (새 파일) -
- -
- ))} -
- )} + +
{/* 작성자 (읽기 전용) */} @@ -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, ]); diff --git a/src/components/business/construction/contract/ContractDetailForm.tsx b/src/components/business/construction/contract/ContractDetailForm.tsx index 2b710ccd..7ac64d0f 100644 --- a/src/components/business/construction/contract/ContractDetailForm.tsx +++ b/src/components/business/construction/contract/ContractDetailForm.tsx @@ -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(null); - const attachmentInputRef = useRef(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) => { - 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) => { - 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({
- {/* 파일 선택 버튼 (수정/생성 모드에서만) */} + {/* 파일 선택 (수정/생성 모드에서만) */} {(isEditMode || isCreateMode) && ( - + setFormData((prev) => ({ ...prev, contractFile: null }))} + accept=".pdf" + buttonText="찾기" + placeholder="PDF 파일만 업로드 가능합니다" + /> )} - {/* 새로 선택한 파일 */} - {formData.contractFile && ( + {/* 새로 선택한 파일 (view 모드에서 표시) */} + {isViewMode && formData.contractFile && (
{formData.contractFile.name} (새 파일)
- {(isEditMode || isCreateMode) && ( - - )}
)} @@ -517,19 +467,6 @@ export default function ContractDetailForm({
)} - - {/* 파일 없음 안내 */} - {!formData.contractFile && (isContractFileDeleted || !initialData?.contractFile) && ( - PDF 파일만 업로드 가능합니다 - )} - -
@@ -539,96 +476,32 @@ export default function ContractDetailForm({ 계약 첨부 문서 관리 - + {/* 드래그 앤 드롭 영역 */} {(isEditMode || isCreateMode) && ( -
- -

- 클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요. -

-
+ )} {/* 파일 목록 */} -
- {/* 기존 첨부파일 */} - {existingAttachments.map((att) => ( -
-
- -
-

{att.fileName}

-

- {formatFileSize(att.fileSize)} -

-
-
-
- - {(isEditMode || isCreateMode) && ( - - )} -
-
- ))} - - {/* 새로 추가된 파일 */} - {newAttachments.map((file, index) => ( -
-
- -
-

{file.name}

-

- {formatFileSize(file.size)} -

-
-
- -
- ))} -
- - ({ 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="첨부된 파일이 없습니다" />
diff --git a/src/components/business/construction/estimates/EstimateDetailForm.tsx b/src/components/business/construction/estimates/EstimateDetailForm.tsx index 71a645ad..086a72ad 100644 --- a/src/components/business/construction/estimates/EstimateDetailForm.tsx +++ b/src/components/business/construction/estimates/EstimateDetailForm.tsx @@ -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(null); - - // 드래그 상태 - const [isDragging, setIsDragging] = useState(false); - // 공과 품목 옵션 (Items API에서 조회) const [expenseOptions, setExpenseOptions] = useState([]); @@ -525,33 +519,23 @@ export default function EstimateDetailForm({ }, []); // ===== 파일 업로드 핸들러 ===== - const handleDocumentUpload = useCallback((e: React.ChangeEvent) => { - 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) => { - 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) => { - 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({ 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, diff --git a/src/components/business/construction/estimates/modals/EstimateDocumentContent.tsx b/src/components/business/construction/estimates/modals/EstimateDocumentContent.tsx new file mode 100644 index 00000000..408a7534 --- /dev/null +++ b/src/components/business/construction/estimates/modals/EstimateDocumentContent.tsx @@ -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 ( +
+ {/* 상단: 제목 + 결재란 (공통 컴포넌트) */} + + + {/* 기본 정보 테이블 */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
귀중회사명회사명{documentData.companyName}
현장명{documentData.projectName || '현장명'}주소{documentData.address || '주소명'}
금액 + {amountToKorean(documentData.amount)} 원정 + 일자{documentData.date}
담당자연락처 +
+
담당자 : {documentData.manager}
+
H . P : {documentData.contact.hp}
+
T E L : {documentData.contact.tel}
+
F A X : {documentData.contact.fax}
+
+
연락처
+ + {/* 안내 문구 */} +

하기와 같이 見積합니다.

+ + {/* 견적 요약 테이블 */} +
+
+ 견적 요약 +
+ + + + + + + + + + + + + + {formData.summaryItems.length === 0 ? ( + + + + ) : ( + formData.summaryItems.map((item) => ( + + + + + + + + + + )) + )} + {/* 합계 행 */} + + + + + + + + {/* 특기사항 행 */} + + + + +
명 칭수량단위재 료 비노 무 비합 계비 고
+ 등록된 항목이 없습니다. +
{item.name}{item.quantity}{item.unit}{formatAmount(item.materialCost)}{formatAmount(item.laborCost)}{formatAmount(item.totalCost)}{item.remarks}
합 계 + {formatAmount(formData.summaryItems.reduce((sum, item) => sum + item.materialCost, 0))} + + {formatAmount(formData.summaryItems.reduce((sum, item) => sum + item.laborCost, 0))} + + ₩{formatAmount(formData.summaryItems.reduce((sum, item) => sum + item.totalCost, 0))} +
+ * 특기사항 : 부가세 별도 / 현설조건에 따름 +
+
+ + {/* 견적 상세 테이블 */} +
+
+ 견적 상세 +
+ + + + + + + + + + + + + + + + + + + + + + + + + + {formData.detailItems.length === 0 ? ( + + + + ) : ( + formData.detailItems.map((item, index) => ( + + + + + + + + + + + + + + + + )) + )} + {/* 합계 행 */} + + + + + + + + + + + + {/* 비고 행 */} + + + + +
NO명칭제품규 격(mm)수량단위재 료 비노 무 비합 계
가로(W)높이(H)단가금액단가금액단가금액
+ 등록된 항목이 없습니다. +
{index + 1}{item.name}{item.material}{formatAmount(item.width)}{formatAmount(item.height)}{item.quantity}SET{formatAmount(item.unitPrice)}{formatAmount(item.materialCost)}{formatAmount(item.laborCost)}{formatAmount(item.laborCost * item.quantity)}{formatAmount(item.totalPrice)}{formatAmount(item.totalCost)}
합 계 + {formData.detailItems.reduce((sum, item) => sum + item.quantity, 0)} + SET + {formatAmount(formData.detailItems.reduce((sum, item) => sum + item.materialCost, 0))} + + {formatAmount(formData.detailItems.reduce((sum, item) => sum + item.laborCost * item.quantity, 0))} + + {formatAmount(formData.detailItems.reduce((sum, item) => sum + item.totalCost, 0))} +
+ * 비고 : +
+
+
+ ); +} diff --git a/src/components/business/construction/estimates/modals/EstimateDocumentModal.tsx b/src/components/business/construction/estimates/modals/EstimateDocumentModal.tsx index 30c1d8d2..8d83c916 100644 --- a/src/components/business/construction/estimates/modals/EstimateDocumentModal.tsx +++ b/src/components/business/construction/estimates/modals/EstimateDocumentModal.tsx @@ -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 = ( + <> + + + + ); return ( - !open && onClose()}> - - - 견적서 상세 - - - {/* 헤더 영역 - 고정 (인쇄 시 숨김) */} -
-

견적서 상세

- -
- - {/* 버튼 영역 - 고정 (인쇄 시 숨김) */} -
- - - -
- - {/* 문서 영역 - 스크롤 (인쇄 시 이 영역만 출력) */} -
-
- {/* 상단: 제목 + 결재란 */} -
- {/* 제목 영역 */} -
-

견 적 서

- {/* 문서번호 및 작성일자 */} -
- 문서번호: {documentData.documentNo} - | - 작성일자: {documentData.createdDate} -
-
- - {/* 결재란 (상단 우측) - 3열 3행 */} - - - - - - - - - - - - - - - - - - -
작성승인
결재{formData.estimatorName || ''}
-
- - {/* 기본 정보 테이블 */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
귀중회사명회사명{documentData.companyName}
현장명{documentData.projectName || '현장명'}주소{documentData.address || '주소명'}
금액 - {amountToKorean(documentData.amount)} 원정 - 일자{documentData.date}
담당자연락처 -
-
담당자 : {documentData.manager}
-
H . P : {documentData.contact.hp}
-
T E L : {documentData.contact.tel}
-
F A X : {documentData.contact.fax}
-
-
연락처
- - {/* 안내 문구 */} -

하기와 같이 見積합니다.

- - {/* 견적 요약 테이블 */} -
-
- 견적 요약 -
- - - - - - - - - - - - - - {formData.summaryItems.length === 0 ? ( - - - - ) : ( - formData.summaryItems.map((item) => ( - - - - - - - - - - )) - )} - {/* 합계 행 */} - - - - - - - - {/* 특기사항 행 */} - - - - -
명 칭수량단위재 료 비노 무 비합 계비 고
- 등록된 항목이 없습니다. -
{item.name} - {item.quantity} - - {item.unit} - - {formatAmount(item.materialCost)} - - {formatAmount(item.laborCost)} - - {formatAmount(item.totalCost)} - {item.remarks}
- 합 계 - - {formatAmount( - formData.summaryItems.reduce((sum, item) => sum + item.materialCost, 0) - )} - - {formatAmount( - formData.summaryItems.reduce((sum, item) => sum + item.laborCost, 0) - )} - - ₩{formatAmount( - formData.summaryItems.reduce((sum, item) => sum + item.totalCost, 0) - )} -
- * 특기사항 : 부가세 별도 / 현설조건에 따름 -
-
- - {/* 견적 상세 테이블 */} -
-
- 견적 상세 -
- - - - - - - - - - - - - - - - - - - - - - - - - - {formData.detailItems.length === 0 ? ( - - - - ) : ( - formData.detailItems.map((item, index) => ( - - - - - - - - - - - - - - - - )) - )} - {/* 합계 행 */} - - - - - - - - - - - - {/* 비고 행 */} - - - - -
NO명칭제품규 격(mm)수량단위재 료 비노 무 비합 계
가로(W)높이(H)단가금액단가금액단가금액
- 등록된 항목이 없습니다. -
- {index + 1} - {item.name}{item.material} - {formatAmount(item.width)} - - {formatAmount(item.height)} - - {item.quantity} - SET - {formatAmount(item.unitPrice)} - - {formatAmount(item.materialCost)} - - {formatAmount(item.laborCost)} - - {formatAmount(item.laborCost * item.quantity)} - - {formatAmount(item.totalPrice)} - - {formatAmount(item.totalCost)} -
- 합 계 - - {formData.detailItems.reduce((sum, item) => sum + item.quantity, 0)} - SET - {formatAmount( - formData.detailItems.reduce((sum, item) => sum + item.materialCost, 0) - )} - - {formatAmount( - formData.detailItems.reduce((sum, item) => sum + item.laborCost * item.quantity, 0) - )} - - {formatAmount( - formData.detailItems.reduce((sum, item) => sum + item.totalCost, 0) - )} -
- * 비고 : -
-
-
-
-
-
+ !open && onClose()} + toolbarExtra={toolbarExtra} + > + + ); -} \ No newline at end of file +} diff --git a/src/components/business/construction/estimates/sections/EstimateInfoSection.tsx b/src/components/business/construction/estimates/sections/EstimateInfoSection.tsx index ea47a091..0b5a556b 100644 --- a/src/components/business/construction/estimates/sections/EstimateInfoSection.tsx +++ b/src/components/business/construction/estimates/sections/EstimateInfoSection.tsx @@ -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; onFormDataChange: (updates: Partial) => void; onBidInfoChange: (field: string, value: string | number) => void; - onDocumentUpload: (e: React.ChangeEvent) => void; + onFilesSelect: (files: File[]) => void; onDocumentRemove: (docId: string) => void; - onDragOver: (e: React.DragEvent) => void; - onDragLeave: (e: React.DragEvent) => void; - onDrop: (e: React.DragEvent) => 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({ {/* 현장설명회 자료 */}
- {!isViewMode && ( -
documentInputRef.current?.click()} - onDragOver={onDragOver} - onDragLeave={onDragLeave} - onDrop={onDrop} - > - -

- {isDragging ? '파일을 여기에 놓으세요' : '클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요.'} -

-
- )} - {formData.bidInfo.documents.length > 0 && ( -
- {formData.bidInfo.documents.map((doc) => ( -
- - {doc.fileName} - {isViewMode ? ( - - ) : ( - - )} -
- ))} -
+ )} + ({ + id: doc.id, + name: doc.fileName, + size: doc.fileSize, + url: doc.fileUrl, + }))} + onRemoveExisting={onDocumentRemove} + showRemove={!isViewMode} + emptyMessage="업로드된 파일이 없습니다" + />
diff --git a/src/components/business/construction/partners/PartnerForm.tsx b/src/components/business/construction/partners/PartnerForm.tsx index f731a2be..ceff3259 100644 --- a/src/components/business/construction/partners/PartnerForm.tsx +++ b/src/components/business/construction/partners/PartnerForm.tsx @@ -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(null); - const documentInputRef = useRef(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) => { - 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) => { - 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) => { - 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: 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 {/* 회사 로고 */}
- -
!isViewMode && logoInputRef.current?.click()} - > - {formData.logoBlob || formData.logoUrl ? ( -
- 회사 로고 - {!isViewMode && ( - - )} -
- ) : ( - <> - -

750 X 250px, 10MB 이하의 PNG, JPEG, GIF

- {!isViewMode && ( - - )} - - )} -
{renderSelectField( @@ -717,74 +601,25 @@ export default function PartnerForm({ mode, partnerId, initialData }: PartnerFor 필요 서류 - -
!isViewMode && documentInputRef.current?.click()} - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - > - -

- {isDragging ? '파일을 여기에 놓으세요' : '클릭하여 파일을 첨부하거나, 마우스로 파일을 끌어오세요.'} -

-
- {/* 업로드된 파일 목록 */} - {formData.documents.length > 0 && ( -
- {formData.documents.map((doc) => ( -
-
- -
-

{doc.fileName}

-

- {(doc.fileSize / 1024).toFixed(1)} KB -

-
-
- {isViewMode ? ( - - ) : ( - - )} -
- ))} -
+ {!isViewMode && ( + )} + ({ + id: doc.id, + name: doc.fileName, + url: doc.fileUrl, + size: doc.fileSize, + }))} + onRemoveExisting={isViewMode ? undefined : handleDocumentRemove} + readOnly={isViewMode} + emptyMessage="등록된 서류가 없습니다" + />
diff --git a/src/components/business/construction/progress-billing/modals/DirectConstructionContent.tsx b/src/components/business/construction/progress-billing/modals/DirectConstructionContent.tsx new file mode 100644 index 00000000..983453e7 --- /dev/null +++ b/src/components/business/construction/progress-billing/modals/DirectConstructionContent.tsx @@ -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 ( +
+ {/* 문서 헤더 (공통 컴포넌트) */} + + +
+ 기성내역 제{data.billingRound || 1}회 ({data.billingYearMonth || '2025년 11월'}) +
+
+ ■ 현장: {data.siteName || '현장명'} +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {items.map((item, index) => ( + + + + + + + + + + + + + + + + + + + ))} + + + + + + + + + + + +
No.명칭제품규격 mm수량단위계약금액전회기성금회기성누계기성비고
가로세로단가금액수량금액수량금액수량금액
{index + 1}{item.name}{item.product}{formatNumber(item.width)}{formatNumber(item.height)}{item.quantity}{item.unit}{formatNumber(item.contractUnitPrice)}{formatNumber(item.contractAmount)}{item.prevQuantity || '-'}{item.prevAmount ? formatNumber(item.prevAmount) : '-'}{item.currentQuantity}{formatNumber(item.currentAmount)}{item.cumulativeQuantity}{formatNumber(item.cumulativeAmount)}{item.remark || ''}
합계{formatNumber(totalContractAmount)}{formatNumber(totalCurrentAmount)}{formatNumber(totalCumulativeAmount)}
+
+
+ ); +} diff --git a/src/components/business/construction/progress-billing/modals/DirectConstructionModal.tsx b/src/components/business/construction/progress-billing/modals/DirectConstructionModal.tsx index 41f672dd..82359b79 100644 --- a/src/components/business/construction/progress-billing/modals/DirectConstructionModal.tsx +++ b/src/components/business/construction/progress-billing/modals/DirectConstructionModal.tsx @@ -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 = ( + <> + + + + ); return ( - - - - 직접 공사 내역서 - - - {/* 헤더 영역 */} -
-

직접 공사 내역서

- -
- - {/* 버튼 영역 */} -
- - - -
- - {/* 문서 영역 */} -
-
- {/* 상단: 제목 + 결재란 */} -
- {/* 좌측: 제목 및 문서 정보 */} -
-

직접 공사 내역서

-
- 문서번호: {data.billingNumber || 'ABC123'} | 작성일자: 2025년 11월 11일 -
-
- - {/* 우측: 결재란 */} - - - - - - - - - - - - - - - - -
- 결
재 -
작성승인
홍길동이름
부서명부서명
-
- - {/* 기성내역 제목 */} -
- 기성내역 제{data.billingRound || 1}회 ({data.billingYearMonth || '2025년 11월'}) -
- - {/* 현장 정보 */} -
- ■ 현장: {data.siteName || '현장명'} -
- - {/* 테이블 */} -
- - - {/* 1행: 상위 헤더 */} - - - - - - - - - - - - - - {/* 2행: 하위 헤더 */} - - - - - - - - - - - - - - - {items.map((item, index) => ( - - - - - - - - - - - - - - - - - - - ))} - {/* 합계 행 */} - - - - - - - - - - - -
No.명칭제품 - - 규격 mm - - 수량단위 - - 계약금액 - - - - 전회기성 - - - - 금회기성 - - - - 누계기성 - - 비고
가로세로단가금액수량금액수량금액수량금액
{index + 1}{item.name}{item.product}{formatNumber(item.width)}{formatNumber(item.height)}{item.quantity}{item.unit}{formatNumber(item.contractUnitPrice)}{formatNumber(item.contractAmount)}{item.prevQuantity || '-'}{item.prevAmount ? formatNumber(item.prevAmount) : '-'}{item.currentQuantity}{formatNumber(item.currentAmount)}{item.cumulativeQuantity}{formatNumber(item.cumulativeAmount)}{item.remark || ''}
합계{formatNumber(totalContractAmount)}{formatNumber(totalCurrentAmount)}{formatNumber(totalCumulativeAmount)}
-
-
-
-
-
+ + + ); -} \ No newline at end of file +} diff --git a/src/components/business/construction/progress-billing/modals/IndirectConstructionContent.tsx b/src/components/business/construction/progress-billing/modals/IndirectConstructionContent.tsx new file mode 100644 index 00000000..e381cb11 --- /dev/null +++ b/src/components/business/construction/progress-billing/modals/IndirectConstructionContent.tsx @@ -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 ( +
+ {/* 문서 헤더 (공통 컴포넌트) */} + + +
+ 기성내역 제{data.billingRound || 1}회 ({data.billingYearMonth || '2025년 11월'}) +
+
+ ■ 현장: {data.siteName || '현장명'} +
+ +
+ + + + + + + + + + + + + + + + + + + + + + + + + + {items.map((item, index) => ( + + + + + + + + + + + + + + + + ))} + + + + + + + + + +
No.품명규격단위계약금액전회기성금회기성누계기성비고
수량금액수량금액수량금액수량금액
{index + 1}{item.name}{item.spec}{item.unit}{item.contractQuantity}{formatNumber(item.contractAmount)}{item.prevQuantity || '-'}{item.prevAmount ? formatNumber(item.prevAmount) : '-'}{item.currentQuantity || '-'}{formatNumber(item.currentAmount)}{item.cumulativeQuantity || '-'}{formatNumber(item.cumulativeAmount)}{item.remark || ''}
합계{formatNumber(totalContractAmount)}{formatNumber(totalCurrentAmount)}{formatNumber(totalCumulativeAmount)}
+
+
+ ); +} diff --git a/src/components/business/construction/progress-billing/modals/IndirectConstructionModal.tsx b/src/components/business/construction/progress-billing/modals/IndirectConstructionModal.tsx index fe6d406a..afe07513 100644 --- a/src/components/business/construction/progress-billing/modals/IndirectConstructionModal.tsx +++ b/src/components/business/construction/progress-billing/modals/IndirectConstructionModal.tsx @@ -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 = ( + <> + + + + ); return ( - - - - 간접 공사 내역서 - - - {/* 헤더 영역 */} -
-

간접 공사 내역서

- -
- - {/* 버튼 영역 */} -
- - - -
- - {/* 문서 영역 */} -
-
- {/* 상단: 제목 + 결재란 */} -
- {/* 좌측: 제목 및 문서 정보 */} -
-

간접 공사 내역서

-
- 문서번호: {data.billingNumber || 'ABC123'} | 작성일자: 2025년 11월 11일 -
-
- - {/* 우측: 결재란 */} - - - - - - - - - - - - - - - - -
- 결
재 -
작성승인
홍길동이름
부서명부서명
-
- - {/* 기성내역 제목 */} -
- 기성내역 제{data.billingRound || 1}회 ({data.billingYearMonth || '2025년 11월'}) -
- - {/* 현장 정보 */} -
- ■ 현장: {data.siteName || '현장명'} -
- - {/* 테이블 */} -
- - - {/* 1행: 상위 헤더 */} - - - - - - - - - - - - {/* 2행: 하위 헤더 */} - - - - - - - - - - - - - {items.map((item, index) => ( - - - - - - - - - - - - - - - - ))} - {/* 합계 행 */} - - - - - - - - - -
No.품명규격단위 - 계약금액 - - 전회기성 - - 금회기성 - - 누계기성 - 비고
수량금액수량금액수량금액수량금액
{index + 1}{item.name}{item.spec}{item.unit}{item.contractQuantity}{formatNumber(item.contractAmount)}{item.prevQuantity || '-'}{item.prevAmount ? formatNumber(item.prevAmount) : '-'}{item.currentQuantity || '-'}{formatNumber(item.currentAmount)}{item.cumulativeQuantity || '-'}{formatNumber(item.cumulativeAmount)}{item.remark || ''}
합계{formatNumber(totalContractAmount)}{formatNumber(totalCurrentAmount)}{formatNumber(totalCumulativeAmount)}
-
-
-
-
-
+ + + ); -} \ No newline at end of file +} diff --git a/src/components/business/construction/progress-billing/modals/PhotoDocumentContent.tsx b/src/components/business/construction/progress-billing/modals/PhotoDocumentContent.tsx new file mode 100644 index 00000000..94948772 --- /dev/null +++ b/src/components/business/construction/progress-billing/modals/PhotoDocumentContent.tsx @@ -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 ( +
+ {/* 문서 헤더 (공통 컴포넌트) */} + + + {/* 기성신청 사진대지 제목 */} +
+ 기성신청 사진대지 제{data.billingRound || 1}회 ({data.billingYearMonth || '2025년 11월'}) +
+ + {/* 현장 정보 */} +
+ ■ 현장: {data.siteName || '현장명'} +
+ + {/* 사진 그리드 */} +
+ {photoRows.map((row, rowIndex) => ( +
+ {row.map((photo, colIndex) => ( +
+ {/* 이미지 영역 */} +
+ {photo.imageUrl ? ( + {photo.name} + ) : ( + IMG + )} +
+ {/* 명칭 라벨 */} +
+ {photo.name} +
+
+ ))} + {/* 홀수 개일 때 빈 셀 채우기 */} + {row.length === 1 && ( +
+
+ IMG +
+
+ 명칭 +
+
+ )} +
+ ))} +
+
+ ); +} diff --git a/src/components/business/construction/progress-billing/modals/PhotoDocumentModal.tsx b/src/components/business/construction/progress-billing/modals/PhotoDocumentModal.tsx index 2c44f8d9..057fffd9 100644 --- a/src/components/business/construction/progress-billing/modals/PhotoDocumentModal.tsx +++ b/src/components/business/construction/progress-billing/modals/PhotoDocumentModal.tsx @@ -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 = ( + <> + + + + ); return ( - - - - 사진대지 - - - {/* 헤더 영역 */} -
-

사진대지

- -
- - {/* 버튼 영역 */} -
- - - -
- - {/* 문서 영역 */} -
-
- {/* 상단: 제목 + 결재란 */} -
- {/* 좌측: 제목 및 문서 정보 */} -
-

사진대지

-
- 문서번호: {data.billingNumber || 'ABC123'} | 작성일자: 2025년 11월 11일 -
-
- - {/* 우측: 결재란 */} - - - - - - - - - - - - - - - - -
- 결
재 -
작성승인
홍길동이름
부서명부서명
-
- - {/* 기성신청 사진대지 제목 */} -
- 기성신청 사진대지 제{data.billingRound || 1}회 ({data.billingYearMonth || '2025년 11월'}) -
- - {/* 현장 정보 */} -
- ■ 현장: {data.siteName || '현장명'} -
- - {/* 사진 그리드 */} -
- {photoRows.map((row, rowIndex) => ( -
- {row.map((photo, colIndex) => ( -
- {/* 이미지 영역 */} -
- {photo.imageUrl ? ( - {photo.name} - ) : ( - IMG - )} -
- {/* 명칭 라벨 */} -
- {photo.name} -
-
- ))} - {/* 홀수 개일 때 빈 셀 채우기 */} - {row.length === 1 && ( -
-
- IMG -
-
- 명칭 -
-
- )} -
- ))} -
-
-
-
-
+ + + ); -} \ No newline at end of file +} diff --git a/src/components/business/construction/site-briefings/SiteBriefingForm.tsx b/src/components/business/construction/site-briefings/SiteBriefingForm.tsx index b7d6ab0c..e6d87bac 100644 --- a/src/components/business/construction/site-briefings/SiteBriefingForm.tsx +++ b/src/components/business/construction/site-briefings/SiteBriefingForm.tsx @@ -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(null); - - // 드래그 상태 - const [isDragging, setIsDragging] = useState(false); + // 새 파일 상태 + const [newDocumentFiles, setNewDocumentFiles] = useState([]); // 거래처 목록 const [partners, setPartners] = useState([]); @@ -377,94 +376,24 @@ export default function SiteBriefingForm({ mode, briefingId, initialData }: Site [] ); - // 문서 업로드 핸들러 - const handleDocumentUpload = useCallback((e: React.ChangeEvent) => { - 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) => { - 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: 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 {/* 현장설명회 자료 */}
- -
!isViewMode && documentInputRef.current?.click()} - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - > - -

- {isDragging ? '파일을 여기에 놓으세요' : '클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요.'} -

-
- {/* 업로드된 파일 목록 */} - {formData.documents.length > 0 && ( -
- {formData.documents.map((doc) => ( -
- - {doc.fileName} - {isViewMode ? ( - - ) : ( - - )} -
- ))} -
+ {!isViewMode && ( + )} + ({ 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="업로드된 파일이 없습니다" + />
diff --git a/src/components/business/construction/site-management/SiteDetailForm.tsx b/src/components/business/construction/site-management/SiteDetailForm.tsx index 9677f985..35e59bb7 100644 --- a/src/components/business/construction/site-management/SiteDetailForm.tsx +++ b/src/components/business/construction/site-management/SiteDetailForm.tsx @@ -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(null); - - // 드래그 상태 - const [isDragging, setIsDragging] = useState(false); + // 새 파일 상태 + const [newFiles, setNewFiles] = useState([]); // 목데이터: 기존 도면 파일 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) => { - 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 = { @@ -359,74 +300,27 @@ export default function SiteDetailForm({ site, mode = 'view' }: SiteDetailFormPr 도면 정보 - -
!isViewMode && drawingInputRef.current?.click()} - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - > - -

- {isDragging ? '파일을 여기에 놓으세요' : '클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요.'} -

-
- {/* 업로드된 파일 목록 */} - {drawings.length > 0 && ( -
- {drawings.map((doc) => ( -
-
- -
-

{doc.fileName}

-

- {(doc.fileSize / 1024).toFixed(1)} KB -

-
-
- {!isViewMode ? ( - - ) : ( - - )} -
- ))} -
+ {!isViewMode && ( + )} + ({ 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="업로드된 파일이 없습니다" + />
@@ -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 ( diff --git a/src/components/business/construction/structure-review/StructureReviewDetailForm.tsx b/src/components/business/construction/structure-review/StructureReviewDetailForm.tsx index c89c1e3f..bb18369e 100644 --- a/src/components/business/construction/structure-review/StructureReviewDetailForm.tsx +++ b/src/components/business/construction/structure-review/StructureReviewDetailForm.tsx @@ -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(null); - - // 드래그 상태 - const [isDragging, setIsDragging] = useState(false); + // 새 파일 상태 + const [newFiles, setNewFiles] = useState([]); // 목데이터: 기존 구조검토 파일 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) => { - 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 = { @@ -404,74 +344,27 @@ export default function StructureReviewDetailForm({ 구조검토 파일 - -
!isViewMode && fileInputRef.current?.click()} - onDragOver={handleDragOver} - onDragLeave={handleDragLeave} - onDrop={handleDrop} - > - -

- {isDragging ? '파일을 여기에 놓으세요' : '클릭하여 파일을 찾거나, 마우스로 파일을 끌어오세요.'} -

-
- {/* 업로드된 파일 목록 */} - {reviewFiles.length > 0 && ( -
- {reviewFiles.map((doc) => ( -
-
- -
-

{doc.fileName}

-

- {(doc.fileSize / 1024).toFixed(1)} KB -

-
-
{isViewMode ? ( - - ) : ( - - )} -
- ))} -
+ {!isViewMode && ( + )} + ({ 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="업로드된 파일이 없습니다" + />
@@ -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 ( diff --git a/src/components/customer-center/InquiryManagement/InquiryForm.tsx b/src/components/customer-center/InquiryManagement/InquiryForm.tsx index 4125dd3e..223431e2 100644 --- a/src/components/customer-center/InquiryManagement/InquiryForm.tsx +++ b/src/components/customer-center/InquiryManagement/InquiryForm.tsx @@ -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(null); // ===== 폼 상태 ===== const [category, setCategory] = useState(initialData?.category || 'inquiry'); @@ -59,15 +58,8 @@ export function InquiryForm({ mode, initialData }: InquiryFormProps) { const [errors, setErrors] = useState>({}); // ===== 파일 업로드 핸들러 ===== - const handleFileSelect = useCallback((e: React.ChangeEvent) => { - 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(() => ( @@ -207,84 +192,30 @@ export function InquiryForm({ mode, initialData }: InquiryFormProps) { {/* 첨부파일 */}
-
- - -
- - {/* 기존 파일 목록 */} - {existingAttachments.length > 0 && ( -
- {existingAttachments.map((file) => ( -
-
- - {file.fileName} - - ({formatFileSize(file.fileSize)}) - -
- -
- ))} -
- )} - - {/* 새로 추가된 파일 목록 */} - {attachments.length > 0 && ( -
- {attachments.map((file, index) => ( -
-
- - {file.name} - - ({formatFileSize(file.size)}) - - (새 파일) -
- -
- ))} -
- )} + + ({ file }))} + existingFiles={existingAttachments.map((file): ExistingFile => ({ + id: file.id, + name: file.fileName, + url: file.fileUrl, + size: file.fileSize, + }))} + onRemove={handleRemoveFile} + onRemoveExisting={handleRemoveExistingFile} + compact + />
- ), [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; diff --git a/src/components/document-system/components/ApprovalLine.tsx b/src/components/document-system/components/ApprovalLine.tsx new file mode 100644 index 00000000..a66e8fcf --- /dev/null +++ b/src/components/document-system/components/ApprovalLine.tsx @@ -0,0 +1,154 @@ +'use client'; + +/** + * 결재란 공통 컴포넌트 + * + * @example + * // 3열 결재란 (작성/승인) + * + * + * // 4열 결재란 (작성/검토/승인) + * + * + * // 외부 송부 시 숨김 + * + */ + +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 ( + + + {/* 헤더 행: 결재 + 작성/검토/승인 */} + + + + {is4Col && ( + + )} + + + + {/* 서명 행: 이름 + 날짜 */} + + + {is4Col && ( + + )} + + + + {/* 부서 행 (선택적) */} + {showDepartment && ( + + + {is4Col && ( + + )} + + + )} + +
+
+ + +
+
+ 작성 + + 검토 + + 승인 +
+ {writer?.name && ( + <> +
{writer.name}
+ {writer.date && ( +
{writer.date}
+ )} + + )} +
+ {reviewer?.name && ( + <> +
{reviewer.name}
+ {reviewer.date && ( +
{reviewer.date}
+ )} + + )} +
+ {approver?.name && ( + <> +
{approver.name}
+ {approver.date && ( +
{approver.date}
+ )} + + )} +
+ {departmentLabels.writer} + + {departmentLabels.reviewer} + + {departmentLabels.approver} +
+ ); +} diff --git a/src/components/document-system/components/DocumentHeader.tsx b/src/components/document-system/components/DocumentHeader.tsx new file mode 100644 index 00000000..85067419 --- /dev/null +++ b/src/components/document-system/components/DocumentHeader.tsx @@ -0,0 +1,160 @@ +'use client'; + +/** + * 문서 헤더 공통 컴포넌트 + * + * @example + * // 기본 사용 + * + * + * // 로고 포함 + * + * + * // 결재선 위에 추가 정보 + * LOT: KD-WO-260122} + * approval={{ type: '3col' }} + * /> + * + * // 외부 송부 (결재선 숨김) + * + */ + +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 | 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 ( +
+

{title}

+ {documentCode && ( +

{documentCode}

+ )} + {subtitle && ( +

{subtitle}

+ )} +
+ ); + } + + // 중앙 정렬 레이아웃 (견적서 스타일) + if (layout === 'centered') { + return ( +
+
+

{title}

+ {(documentCode || subtitle) && ( +
+ {documentCode && 문서번호: {documentCode}} + {documentCode && subtitle && |} + {subtitle && {subtitle}} +
+ )} +
+ {showApproval && approval && ( + + )} +
+ ); + } + + // 기본 레이아웃 (로고 + 제목 + 결재란) + return ( +
+ {/* 좌측: 로고 영역 */} + {logo && ( +
+ {logo.imageUrl ? ( + {logo.text} + ) : ( + {logo.text} + )} + {logo.subtext && ( + {logo.subtext} + )} +
+ )} + + {/* 중앙: 문서 제목 */} +
+

{title}

+ {documentCode && ( +

{documentCode}

+ )} + {subtitle && ( +

{subtitle}

+ )} +
+ + {/* 우측: 결재선 위 정보 + 결재란 */} + {(showApproval || topInfo) && ( +
+ {topInfo && ( +
+ {topInfo} +
+ )} + {showApproval && approval && ( + + )} +
+ )} +
+ ); +} diff --git a/src/components/document-system/components/InfoTable.tsx b/src/components/document-system/components/InfoTable.tsx new file mode 100644 index 00000000..7225c0c7 --- /dev/null +++ b/src/components/document-system/components/InfoTable.tsx @@ -0,0 +1,94 @@ +'use client'; + +/** + * 정보 테이블 공통 컴포넌트 (라벨-값 구조) + * + * @example + * + * + * // 단일 열 + * + */ + +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 ( +
+ {rows.map((row, rowIndex) => ( +
+ {row.map((cell, cellIndex) => ( +
+
+ {cell.label} +
+
+ {cell.value} +
+
+ ))} +
+ ))} +
+ ); +} diff --git a/src/components/document-system/components/SectionHeader.tsx b/src/components/document-system/components/SectionHeader.tsx new file mode 100644 index 00000000..6e518c78 --- /dev/null +++ b/src/components/document-system/components/SectionHeader.tsx @@ -0,0 +1,45 @@ +'use client'; + +/** + * 섹션 헤더 공통 컴포넌트 + * + * @example + * 작업내역 + * 특이사항 + */ + +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 ( +
+ {children} +
+ ); +} diff --git a/src/components/document-system/components/index.ts b/src/components/document-system/components/index.ts new file mode 100644 index 00000000..963e33ea --- /dev/null +++ b/src/components/document-system/components/index.ts @@ -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'; diff --git a/src/components/document-system/index.ts b/src/components/document-system/index.ts index 6e6914ac..93ec067d 100644 --- a/src/components/document-system/index.ts +++ b/src/components/document-system/index.ts @@ -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, diff --git a/src/components/hr/EmployeeManagement/CSVUploadDialog.tsx b/src/components/hr/EmployeeManagement/CSVUploadDialog.tsx index 3c53267e..851b6f8a 100644 --- a/src/components/hr/EmployeeManagement/CSVUploadDialog.tsx +++ b/src/components/hr/EmployeeManagement/CSVUploadDialog.tsx @@ -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(null); const [validationResults, setValidationResults] = useState([]); const [isProcessing, setIsProcessing] = useState(false); - const fileInputRef = useRef(null); // 파일 선택 - const handleFileSelect = useCallback((e: React.ChangeEvent) => { - 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({
{/* 파일 업로드 영역 */} {!file && ( - - -
- -
-

- CSV 파일을 드래그하거나 클릭하여 업로드 -

-

- 필수 컬럼: 이름 | 선택 컬럼: 휴대폰, 이메일, 부서, 직책, 입사일, 상태 -

-
- - -
-
-
+ )} {/* 파일 정보 및 미리보기 */} diff --git a/src/components/hr/EmployeeManagement/EmployeeForm.tsx b/src/components/hr/EmployeeManagement/EmployeeForm.tsx index 6ce5b074..c324728f 100644 --- a/src/components/hr/EmployeeManagement/EmployeeForm.tsx +++ b/src/components/hr/EmployeeManagement/EmployeeForm.tsx @@ -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 && (
-
- {formData.profileImage ? ( - 프로필 - ) : ( - <> - IMG -
- -
- - )} - {!isViewMode && ( - { - 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); - } - } - }} - /> - )} -
- {!isViewMode && ( -

- 1 250 X 250px, 10MB 이하의
PNG, JPEG, GIF -

- )} + { + // 미리보기 즉시 표시 + 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'} + />
)} diff --git a/src/components/hr/SalaryManagement/index.tsx b/src/components/hr/SalaryManagement/index.tsx index b41ecdf4..72d05b4e 100644 --- a/src/components/hr/SalaryManagement/index.tsx +++ b/src/components/hr/SalaryManagement/index.tsx @@ -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); diff --git a/src/components/items/ItemForm/BendingDiagramSection.tsx b/src/components/items/ItemForm/BendingDiagramSection.tsx index a4a46e55..8bb5091a 100644 --- a/src/components/items/ItemForm/BendingDiagramSection.tsx +++ b/src/components/items/ItemForm/BendingDiagramSection.tsx @@ -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({
- { - const file = e.target.files?.[0]; + { + 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 등)'} /> -

- * {selectedPartType === 'ASSEMBLY' - ? '조립품 전개도 이미지를 업로드하세요(JPG, PNG, PDF 등)' - : '절곡품 전개도 이미지를 업로드하세요(JPG, PNG, PDF 등)'} -

{/* 전개도 이미지 미리보기 */} diff --git a/src/components/items/ItemForm/forms/ProductForm.tsx b/src/components/items/ItemForm/forms/ProductForm.tsx index ac35a6a3..3f59398e 100644 --- a/src/components/items/ItemForm/forms/ProductForm.tsx +++ b/src/components/items/ItemForm/forms/ProductForm.tsx @@ -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({ {/* 시방서 파일 */}
-
- - - {specificationFile ? specificationFile.name : '선택된 파일 없음'} - - {specificationFile && ( - - )} -
+ setSpecificationFile(null)} + accept=".pdf" + disabled={isSubmitting} + buttonText="파일 선택" + placeholder="선택된 파일 없음" + />
{/* 인정서 파일 */}
-
- - - {certificationFile ? certificationFile.name : '선택된 파일 없음'} - - {certificationFile && ( - - )} -
+ setCertificationFile(null)} + accept=".pdf" + disabled={isSubmitting} + buttonText="파일 선택" + placeholder="선택된 파일 없음" + />
{/* 비고 */} diff --git a/src/components/material/ReceivingManagement/ReceivingReceiptContent.tsx b/src/components/material/ReceivingManagement/ReceivingReceiptContent.tsx new file mode 100644 index 00000000..2f2a04fa --- /dev/null +++ b/src/components/material/ReceivingManagement/ReceivingReceiptContent.tsx @@ -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 ( +
+ {/* 제목 (공통 컴포넌트) */} + + + {/* 입고 정보 / 공급업체 정보 */} +
+ {/* 입고 정보 */} +
+

입고 정보

+
+ 입고번호 + {detail.orderNo} + 입고일자 + {detail.receivingDate || today.toISOString().split('T')[0]} + 발주번호 + {detail.orderNo} + 입고LOT + {detail.receivingLot || '-'} +
+
+ + {/* 공급업체 정보 */} +
+

공급업체 정보

+
+ 업체명 + {detail.supplier} + 공급업체LOT + {detail.supplierLot || '-'} + 담당자 + {detail.receivingManager || '-'} + 입고위치 + {detail.receivingLocation || '-'} +
+
+
+ + {/* 입고 품목 상세 */} +
+

입고 품목 상세

+ + + + + + + + + + + + + + + + + + + + + + + + + +
No품목코드품목명규격발주수량입고수량단위비고
1{detail.itemCode}{detail.itemName}{detail.specification || '-'}{detail.orderQty}{detail.receivingQty || '-'}{detail.orderUnit}-
+
+ + {/* 수입검사 안내 */} +
+

+ 📋 수입검사 안내 +

+

+ 본 입고건에 대한 수입검사(IQC)가 필요합니다.
+ 품질관리 > 수입검사(IQC) 메뉴에서 검사를 진행해주세요. +

+
+ + {/* 서명란 */} +
+
+

입고담당

+

(인)

+
+
+

품질검사

+

(인)

+
+
+

창고담당

+

(인)

+
+
+ + {/* 발행일 / 회사명 */} +
+

발행일: {formattedDate}

+

(주) 코드빌더스

+
+
+ ); +} diff --git a/src/components/material/ReceivingManagement/ReceivingReceiptDialog.tsx b/src/components/material/ReceivingManagement/ReceivingReceiptDialog.tsx index 19ec9840..c310faec 100644 --- a/src/components/material/ReceivingManagement/ReceivingReceiptDialog.tsx +++ b/src/components/material/ReceivingManagement/ReceivingReceiptDialog.tsx @@ -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 = ( + + ); return ( - - - {/* 접근성을 위한 숨겨진 타이틀 */} - - 입고증 - {detail.orderNo} - - - {/* 모달 헤더 - 작업일지 스타일 (인쇄 시 숨김) */} -
-
- 입고증 - - {detail.supplier} - - - ({detail.orderNo}) - -
-
- - - -
-
- - {/* 문서 본문 (인쇄 시 이 영역만 출력) */} -
- {/* 제목 */} -
-

입고증

-

RECEIVING SLIP

-
- - {/* 입고 정보 / 공급업체 정보 */} -
- {/* 입고 정보 */} -
-

입고 정보

-
- 입고번호 - {detail.orderNo} - 입고일자 - {detail.receivingDate || today.toISOString().split('T')[0]} - 발주번호 - {detail.orderNo} - 입고LOT - {detail.receivingLot || '-'} -
-
- - {/* 공급업체 정보 */} -
-

공급업체 정보

-
- 업체명 - {detail.supplier} - 공급업체LOT - {detail.supplierLot || '-'} - 담당자 - {detail.receivingManager || '-'} - 입고위치 - {detail.receivingLocation || '-'} -
-
-
- - {/* 입고 품목 상세 */} -
-

입고 품목 상세

- - - - - - - - - - - - - - - - - - - - - - - - - -
No품목코드품목명규격발주수량입고수량단위비고
1{detail.itemCode}{detail.itemName}{detail.specification || '-'}{detail.orderQty}{detail.receivingQty || '-'}{detail.orderUnit}-
-
- - {/* 수입검사 안내 */} -
-

- 📋 수입검사 안내 -

-

- 본 입고건에 대한 수입검사(IQC)가 필요합니다.
- 품질관리 > 수입검사(IQC) 메뉴에서 검사를 진행해주세요. -

-
- - {/* 서명란 */} -
-
-

입고담당

-

(인)

-
-
-

품질검사

-

(인)

-
-
-

창고담당

-

(인)

-
-
- - {/* 발행일 / 회사명 */} -
-

발행일: {formattedDate}

-

(주) 코드빌더스

-
-
-
-
+ + + ); -} \ No newline at end of file +} diff --git a/src/components/process-management/ProcessWorkLogContent.tsx b/src/components/process-management/ProcessWorkLogContent.tsx new file mode 100644 index 00000000..54689379 --- /dev/null +++ b/src/components/process-management/ProcessWorkLogContent.tsx @@ -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 ( +
+ {/* 문서 헤더 (공통 컴포넌트) */} + + + {/* 신청업체 / 신청내용 테이블 */} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
신 청 업 체신 청 내 용
발 주 일{today}현 장 명송도 오피스텔 A동
업 체 명(주)인천건설작업일자{today}
담 당 자김담당제품 LOT NO.KD-TS-251217-01-01
제품명SH3040   방화셔터 W3000×H4000마감유형스크린   그레이
+ + {/* 작업항목 테이블 */} + + + + + + + + + + + + + + {workItems.map((item, index) => ( + + + + + + + + + + ))} + +
순번작업항목규격수량단위작업자비고
{item.no}{item.name}{item.spec}{item.qty}{item.unit}{item.worker}{item.note}
+
+ ); +} diff --git a/src/components/process-management/ProcessWorkLogPreviewModal.tsx b/src/components/process-management/ProcessWorkLogPreviewModal.tsx index 845b83b3..6aa3a330 100644 --- a/src/components/process-management/ProcessWorkLogPreviewModal.tsx +++ b/src/components/process-management/ProcessWorkLogPreviewModal.tsx @@ -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 ( - - - {/* 접근성을 위한 숨겨진 타이틀 */} - - {process.workLogTemplate} 미리보기 - - - {/* 모달 헤더 (인쇄 시 숨김) */} -
-
- {process.workLogTemplate} 미리보기 - ({documentCode}) -
-
- - -
-
- - {/* 문서 본문 (인쇄 시 이 영역만 출력) */} -
- {/* 문서 헤더: 로고 + 제목 + 결재라인 */} -
- {/* 좌측: 로고 영역 */} -
-
KD
-
정동기업
-
- - {/* 중앙: 문서 제목 */} -
-

작 업 일 지

-

{documentCode}

-

{departmentName}

-
- - {/* 우측: 결재라인 */} -
- - - - - - - - - - - - - - - - - - - -
-
-
-
작성검토승인
-
홍길동
-
12/17
-
판매/전진생산품질
-
-
- - {/* 신청업체 / 신청내용 테이블 */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- 신 청 업 체 - - 신 청 내 용 -
발 주 일{today}현 장 명송도 오피스텔 A동
업 체 명(주)인천건설작업일자{today}
담 당 자김담당제품 LOT NO.KD-TS-251217-01-01
제품명SH3040   방화셔터 W3000×H4000마감유형스크린   그레이
- - {/* 작업항목 테이블 */} - - - - - - - - - - - - - - {workItems.map((item, index) => ( - - - - - - - - - - ))} - -
순번작업항목규격수량단위작업자비고
{item.no}{item.name}{item.spec}{item.qty}{item.unit}{item.worker}{item.note}
-
-
-
+ + + ); -} \ No newline at end of file +} diff --git a/src/components/production/WorkerScreen/WorkLogContent.tsx b/src/components/production/WorkerScreen/WorkLogContent.tsx new file mode 100644 index 00000000..bd984e31 --- /dev/null +++ b/src/components/production/WorkerScreen/WorkLogContent.tsx @@ -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 ( +
+ {/* 문서 헤더: 로고 + 제목 + 결재라인 (공통 컴포넌트) */} + + + {/* 기본 정보 테이블 (공통 컴포넌트) */} + + + {/* 품목 테이블 */} +
+ {/* 테이블 헤더 */} +
+
No
+
품목명
+
층/부호
+
규격
+
수량
+
상태
+
+ + {/* 테이블 데이터 */} + {items.length > 0 ? ( + items.map((item, index) => ( +
+
{item.no}
+
{item.productName}
+
{item.floorCode}
+
{item.specification}
+
{item.quantity}
+
{ITEM_STATUS_LABELS[item.status]}
+
+ )) + ) : ( +
+ 등록된 품목이 없습니다. +
+ )} +
+ + {/* 작업내역 */} +
+ {/* 섹션 헤더 (공통 컴포넌트) */} + {order.processName} 작업내역 + + {/* 수량 및 진행률 */} +
+
지시수량
+
{workStats.orderQty} EA
+
완료수량
+
{workStats.completedQty} EA
+
진행률
+
{workStats.progress}%
+
+ + {/* 상세 상태 */} +
+
대기
+
{workStats.waitingQty} EA
+
작업중
+
{workStats.inProgressQty} EA
+
완료
+
{workStats.completedQty} EA
+
+
+ + {/* 특이사항 (공통 컴포넌트) */} +
+ 특이사항 +
+ {order.note || '-'} +
+
+
+ ); +} diff --git a/src/components/production/WorkerScreen/WorkLogModal.tsx b/src/components/production/WorkerScreen/WorkLogModal.tsx index 2556acea..ef8e1848 100644 --- a/src/components/production/WorkerScreen/WorkLogModal.tsx +++ b/src/components/production/WorkerScreen/WorkLogModal.tsx @@ -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(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 ( - - - - 작업일지 로딩 중 - -
- -
-
-
- ); - } - - // 에러 상태 - if (error || !order) { - return ( - - - - 작업일지 오류 - -
-

{error || '데이터를 불러올 수 없습니다.'}

- -
-
-
- ); - } - - 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 ( - - - {/* 접근성을 위한 숨겨진 타이틀 */} - - 작업일지 - {order.workOrderNo} - - {/* 모달 헤더 - sam-design 스타일 (인쇄 시 숨김) */} -
-
- 작업일지 - - {order.processName} 생산부서 - - - ({documentNo}) - -
-
- - -
+ + {isLoading ? ( +
+
- - {/* 문서 본문 (인쇄 시 이 영역만 출력) */} -
- {/* 문서 헤더: 로고 + 제목 + 결재라인 */} -
- {/* 좌측: 로고 영역 */} -
- KD - 정동기업 -
- - {/* 중앙: 문서 제목 */} -
-

작 업 일 지

-

{documentNo}

-

{order.processName} 생산부서

-
- - {/* 우측: 결재라인 */} -
- - - {/* 첫 번째 행: 결재 + 작성/검토/승인 */} - - - - - - - {/* 두 번째 행: 이름 + 날짜 */} - - - - - - {/* 세 번째 행: 부서 */} - - - - - - -
-
- - -
-
작성검토승인
-
{primaryAssignee}
-
- {new Date().toLocaleDateString('ko-KR', { month: '2-digit', day: '2-digit' }).replace('. ', '/').replace('.', '')} -
-
판매/전진생산품질
-
-
- - {/* 기본 정보 테이블 */} -
- {/* Row 1 */} -
-
-
- 발주처 -
-
{order.client}
-
-
-
- 현장명 -
-
{order.projectName}
-
-
- - {/* Row 2 */} -
-
-
- 작업일자 -
-
{today}
-
-
-
- LOT NO. -
-
{order.lotNo}
-
-
- - {/* Row 3 */} -
-
-
- 납기일 -
-
- {order.dueDate !== '-' ? new Date(order.dueDate).toLocaleDateString('ko-KR', { - year: 'numeric', - month: '2-digit', - day: '2-digit', - }).replace(/\. /g, '-').replace('.', '') : '-'} -
-
-
-
- 작업지시번호 -
-
{order.workOrderNo}
-
-
-
- - {/* 품목 테이블 */} -
- {/* 테이블 헤더 */} -
-
No
-
품목명
-
층/부호
-
규격
-
수량
-
상태
-
- - {/* 테이블 데이터 */} - {items.length > 0 ? ( - items.map((item, index) => ( -
-
{item.no}
-
{item.productName}
-
{item.floorCode}
-
{item.specification}
-
{item.quantity}
-
{ITEM_STATUS_LABELS[item.status]}
-
- )) - ) : ( -
- 등록된 품목이 없습니다. -
- )} -
- - {/* 작업내역 */} -
- {/* 검정 헤더 */} -
- {order.processName} 작업내역 -
- - {/* 수량 및 진행률 */} -
-
지시수량
-
{workStats.orderQty} EA
-
완료수량
-
{workStats.completedQty} EA
-
진행률
-
{workStats.progress}%
-
- - {/* 상세 상태 */} -
-
대기
-
{workStats.waitingQty} EA
-
작업중
-
{workStats.inProgressQty} EA
-
완료
-
{workStats.completedQty} EA
-
-
- - {/* 특이 사항 */} -
-
- 특이사항 -
-
- {order.note || '-'} -
-
+ ) : error || !order ? ( +
+

{error || '데이터를 불러올 수 없습니다.'}

- -
+ ) : ( + + )} + ); -} \ No newline at end of file +} diff --git a/src/components/production/WorkerScreen/actions.ts b/src/components/production/WorkerScreen/actions.ts index d36dc514..361e23e4 100644 --- a/src/components/production/WorkerScreen/actions.ts +++ b/src/components/production/WorkerScreen/actions.ts @@ -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: '서버 오류가 발생했습니다.', }; } -} +} \ No newline at end of file diff --git a/src/components/quotes/QuotePreviewContent.tsx b/src/components/quotes/QuotePreviewContent.tsx new file mode 100644 index 00000000..dee9f720 --- /dev/null +++ b/src/components/quotes/QuotePreviewContent.tsx @@ -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 ( +
+ {/* 제목 (공통 컴포넌트) */} + + + {/* 수요자 정보 */} +
+
+ 수 요 자 +
+
+
+ 업체명 + {quoteData.clientName || "-"} +
+
+ 담당자 + {quoteData.manager || "-"} +
+
+ 프로젝트명 + {quoteData.siteName || "-"} +
+
+ 연락처 + {quoteData.contact || "-"} +
+
+ 견적일자 + {quoteData.registrationDate || "-"} +
+
+ 유효기간 + {quoteData.dueDate || "-"} +
+
+
+ + {/* 공급자 정보 */} +
+
+ 공 급 자 +
+
+
+ 상호 + 프론트_테스트회사 +
+
+ 사업자등록번호 + 123-45-67890 +
+
+ 대표자 + 프론트 +
+
+ 업태 + 업태명 +
+
+ 종목 + 김종명 +
+
+ 사업장주소 + 07547 서울 강서구 양천로 583 B-1602 +
+
+ 전화 + 01048209104 +
+
+ 이메일 + codebridgex@codebridge-x.com +
+
+
+ + {/* 총 견적금액 */} +
+

총 견적금액

+

+ ₩ {grandTotal.toLocaleString()} +

+

※ 부가가치세 포함

+
+ + {/* 제품 구성정보 */} +
+ 제 품 구 성 정 보 +
+
+ 모델 + + {quoteData.locations[0]?.productCode || "-"} + +
+
+ 총 수량 + {quoteData.locations.length}개소 +
+
+ 오픈사이즈 + + {quoteData.locations[0]?.openWidth || "-"} × {quoteData.locations[0]?.openHeight || "-"} + +
+
+ 설치유형 + - +
+
+
+ + {/* 품목 내역 */} +
+ 품 목 내 역 + + + + + + + + + + + + + + {quoteData.locations.map((loc, index) => ( + + + + + + + + + + ))} + + + + + + + + + + + + + + + + + + +
No.품목명규격수량단위단가금액
{index + 1}{loc.productCode} + {loc.openWidth}×{loc.openHeight} + {loc.quantity}EA + {(loc.unitPrice || 0).toLocaleString()} + + {(loc.totalPrice || 0).toLocaleString()} +
공급가액 합계 + {totalAmount.toLocaleString()} +
부가가치세 (10%) + {vat.toLocaleString()} +
총 견적금액 + {grandTotal.toLocaleString()} +
+
+ + {/* 비고사항 */} +
+ 비 고 사 항 +
+ {quoteData.remarks || "비고 테스트"} +
+
+
+ ); +} diff --git a/src/components/quotes/QuotePreviewModal.tsx b/src/components/quotes/QuotePreviewModal.tsx index 897b133e..8ac015c9 100644 --- a/src/components/quotes/QuotePreviewModal.tsx +++ b/src/components/quotes/QuotePreviewModal.tsx @@ -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 = ( + <> + + + + ); return ( - - - - 견적서 상세 - - - {/* 헤더 영역 - 제목 + 닫기 버튼 */} -
-

견적서

- -
- - {/* 버튼 영역 - PDF, 이메일, 인쇄 */} -
- - - -
- - {/* 문서 영역 - 스크롤 */} -
-
- {/* 제목 */} -
-

견 적 서

-

- 문서번호: {quoteData.id || "-"} | 작성일자: {quoteData.registrationDate || "-"} -

-
- - {/* 수요자 정보 */} -
-
- 수 요 자 -
-
-
- 업체명 - {quoteData.clientName || "-"} -
-
- 담당자 - {quoteData.manager || "-"} -
-
- 프로젝트명 - {quoteData.siteName || "-"} -
-
- 연락처 - {quoteData.contact || "-"} -
-
- 견적일자 - {quoteData.registrationDate || "-"} -
-
- 유효기간 - {quoteData.dueDate || "-"} -
-
-
- - {/* 공급자 정보 */} -
-
- 공 급 자 -
-
-
- 상호 - 프론트_테스트회사 -
-
- 사업자등록번호 - 123-45-67890 -
-
- 대표자 - 프론트 -
-
- 업태 - 업태명 -
-
- 종목 - 김종명 -
-
- 사업장주소 - 07547 서울 강서구 양천로 583 B-1602 -
-
- 전화 - 01048209104 -
-
- 이메일 - codebridgex@codebridge-x.com -
-
-
- - {/* 총 견적금액 */} -
-

총 견적금액

-

- ₩ {grandTotal.toLocaleString()} -

-

※ 부가가치세 포함

-
- - {/* 제품 구성정보 */} -
-
- 제 품 구 성 정 보 -
-
-
- 모델 - - {quoteData.locations[0]?.productCode || "-"} - -
-
- 총 수량 - {quoteData.locations.length}개소 -
-
- 오픈사이즈 - - {quoteData.locations[0]?.openWidth || "-"} × {quoteData.locations[0]?.openHeight || "-"} - -
-
- 설치유형 - - -
-
-
- - {/* 품목 내역 */} -
-
- 품 목 내 역 -
- - - - - - - - - - - - - - {quoteData.locations.map((loc, index) => ( - - - - - - - - - - ))} - - - - - - - - - - - - - - - - - - -
No.품목명규격수량단위단가금액
{index + 1}{loc.productCode} - {loc.openWidth}×{loc.openHeight} - {loc.quantity}EA - {(loc.unitPrice || 0).toLocaleString()} - - {(loc.totalPrice || 0).toLocaleString()} -
공급가액 합계 - {totalAmount.toLocaleString()} -
부가가치세 (10%) - {vat.toLocaleString()} -
총 견적금액 - {grandTotal.toLocaleString()} -
-
- - {/* 비고사항 */} -
-
- 비 고 사 항 -
-
- {quoteData.remarks || "비고 테스트"} -
-
-
-
-
-
+ + + ); -} \ No newline at end of file +} diff --git a/src/components/settings/AccountInfoManagement/index.tsx b/src/components/settings/AccountInfoManagement/index.tsx index bfd4438d..c6579f9a 100644 --- a/src/components/settings/AccountInfoManagement/index.tsx +++ b/src/components/settings/AccountInfoManagement/index.tsx @@ -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(null); // ===== 상태 관리 ===== const [accountInfo] = useState(initialAccountInfo); @@ -74,23 +70,7 @@ export function AccountInfoClient({ const canSuspend = accountInfo.isTenantMaster; // 테넌트 마스터인 경우만 // ===== 핸들러 ===== - const handleImageUpload = async (e: React.ChangeEvent) => { - 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({ {/* 프로필 사진 */}
-
-
- {profileImage ? ( - <> - 프로필 - - - ) : ( -
- - IMG -
- )} -
-
- - -

- 1250 X 250px, 10MB 이하의 PNG, JPEG, GIF -

-
-
+
{/* 아이디 & 비밀번호 */} diff --git a/src/components/settings/CompanyInfoManagement/index.tsx b/src/components/settings/CompanyInfoManagement/index.tsx index 660f8ab3..8c1d0807 100644 --- a/src/components/settings/CompanyInfoManagement/index.tsx +++ b/src/components/settings/CompanyInfoManagement/index.tsx @@ -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(null); - const licenseInputRef = useRef(null); - // 로고 업로드 상태 const [isUploadingLogo, setIsUploadingLogo] = useState(false); // 로고 미리보기 URL (로컬 파일 미리보기용) const [logoPreviewUrl, setLogoPreviewUrl] = useState(null); - // 사업자등록증 파일명 - const [licenseFileName, setLicenseFileName] = useState(''); + // 사업자등록증 파일 + const [licenseFile, setLicenseFile] = useState(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) => { - 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) => { - 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() { {/* 회사 로고 */}
-
-
- {currentLogoUrl ? ( - 회사 로고 - ) : ( - IMG - )} - {/* 업로드 중 오버레이 */} - {isUploadingLogo && ( -
- -
- )} -
- {isEditMode && ( -
- - {currentLogoUrl && !isUploadingLogo && ( - - )} +
+ + {/* 업로드 중 오버레이 */} + {isUploadingLogo && ( +
+
)}
-

- 750 X 250px, 5MB 이하의 PNG, JPEG, GIF, WEBP -

-
{/* 회사명 / 대표자명 */} @@ -423,37 +342,14 @@ export function CompanyInfoManagement() {
-
- - {licenseFileName && ( -
- {licenseFileName} - {isEditMode && ( - - )} -
- )} -
-
diff --git a/src/components/templates/IntegratedDetailTemplate/index.tsx b/src/components/templates/IntegratedDetailTemplate/index.tsx index 044e6e0a..55a0422c 100644 --- a/src/components/templates/IntegratedDetailTemplate/index.tsx +++ b/src/components/templates/IntegratedDetailTemplate/index.tsx @@ -44,6 +44,7 @@ export function IntegratedDetailTemplate>({ onDelete, onCancel, onModeChange, + onEdit: onEditProp, renderView, renderForm, renderField, @@ -255,9 +256,15 @@ export function IntegratedDetailTemplate>({ // ===== 수정 모드 전환 ===== const handleEdit = useCallback(() => { + // 커스텀 onEdit이 제공되면 해당 핸들러 사용 (예: 페이지 이동) + if (onEditProp) { + onEditProp(); + return; + } + // 기본 동작: 내부 모드 변경 setMode('edit'); onModeChange?.('edit'); - }, [onModeChange]); + }, [onEditProp, onModeChange]); // ===== 액션 설정 ===== const actions = config.actions || {}; diff --git a/src/components/templates/IntegratedDetailTemplate/types.ts b/src/components/templates/IntegratedDetailTemplate/types.ts index e9f2dfa4..00125cbd 100644 --- a/src/components/templates/IntegratedDetailTemplate/types.ts +++ b/src/components/templates/IntegratedDetailTemplate/types.ts @@ -190,6 +190,8 @@ export interface IntegratedDetailTemplateProps> { onCancel?: () => void; /** 모드 변경 핸들러 (view → edit) */ onModeChange?: (mode: DetailMode) => void; + /** 수정 버튼 클릭 핸들러 (기본: 내부 모드 변경, 제공 시 커스텀 동작) */ + onEdit?: () => void; /** 커스텀 상세 화면 렌더러 */ renderView?: (data: T) => ReactNode; /** 커스텀 폼 렌더러 */ diff --git a/src/components/ui/file-dropzone.tsx b/src/components/ui/file-dropzone.tsx new file mode 100644 index 00000000..e2890736 --- /dev/null +++ b/src/components/ui/file-dropzone.tsx @@ -0,0 +1,226 @@ +'use client'; + +/** + * FileDropzone - 드래그 앤 드롭 파일 업로드 영역 + * + * 도면, 첨부파일 등 다중 파일 업로드에 사용 + * + * 사용 예시: + * 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(null); + const inputRef = useRef(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) => { + 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 ( +
+
+ {/* 숨겨진 파일 input */} + + +
+ +

+ {title || defaultTitle} +

+

+ {description || defaultDescription} +

+
+
+ + {/* 에러 메시지 */} + {displayError && ( +
+ + {displayError} +
+ )} +
+ ); +} + +export default FileDropzone; diff --git a/src/components/ui/file-input.tsx b/src/components/ui/file-input.tsx new file mode 100644 index 00000000..5978a030 --- /dev/null +++ b/src/components/ui/file-input.tsx @@ -0,0 +1,203 @@ +'use client'; + +/** + * FileInput - 기본 파일 선택 컴포넌트 + * + * 사용 예시: + * 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(null); + const [validationError, setValidationError] = useState(null); + const inputRef = useRef(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) => { + 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 ( +
+
+ {/* 숨겨진 파일 input */} + + + {/* 파일 선택 버튼 */} + + + {/* 파일명 표시 영역 */} +
+ {hasFile ? ( + <> + + + {displayFileName} + + {selectedFile && ( + + ({(selectedFile.size / 1024).toFixed(1)} KB) + + )} + + ) : ( + + {placeholder} + + )} +
+ + {/* 파일 제거 버튼 */} + {hasFile && !disabled && ( + + )} +
+ + {/* 에러 메시지 */} + {displayError && ( +
+ + {displayError} +
+ )} + + {/* 허용 파일 형식 안내 */} + {!displayError && accept !== '*/*' && ( +

+ 허용 형식: {accept} +

+ )} +
+ ); +} + +export default FileInput; diff --git a/src/components/ui/file-list.tsx b/src/components/ui/file-list.tsx new file mode 100644 index 00000000..7be06db8 --- /dev/null +++ b/src/components/ui/file-list.tsx @@ -0,0 +1,273 @@ +'use client'; + +/** + * FileList - 업로드된 파일 목록 표시 컴포넌트 + * + * 첨부파일 목록 표시, 다운로드, 삭제 기능 + * + * 사용 예시: + * 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 ( +
+ {emptyMessage} +
+ ); + } + + return ( +
+ {/* 기존 파일 목록 */} + {existingFiles.map((file) => ( +
+
+ +
+

+ {file.name} +

+ {file.size && ( +

+ {formatFileSize(file.size)} +

+ )} +
+
+ +
+ {/* 다운로드 버튼 */} + {(file.url || onDownload) && ( + + )} + + {/* 새 탭에서 열기 */} + {file.url && ( + + )} + + {/* 삭제 버튼 */} + {!readOnly && onRemoveExisting && ( + + )} +
+
+ ))} + + {/* 새로 업로드된 파일 목록 */} + {files.map((item, index) => ( +
+
+ +
+
+

+ {item.file.name} +

+ {!item.error && ( + + (새 파일) + + )} +
+

+ {formatFileSize(item.file.size)} +

+ {/* 에러 메시지 */} + {item.error && ( +

{item.error}

+ )} + {/* 업로드 진행률 */} + {item.uploading && item.progress !== undefined && ( +
+
+
+ )} +
+
+ +
+ {/* 삭제/취소 버튼 */} + {!readOnly && onRemove && ( + + )} +
+
+ ))} +
+ ); +} + +export default FileList; diff --git a/src/components/ui/image-upload.tsx b/src/components/ui/image-upload.tsx new file mode 100644 index 00000000..b02e2fe5 --- /dev/null +++ b/src/components/ui/image-upload.tsx @@ -0,0 +1,296 @@ +'use client'; + +/** + * ImageUpload - 이미지 업로드 컴포넌트 (미리보기 포함) + * + * 프로필 이미지, 로고, 썸네일 등에 사용 + * + * 사용 예시: + * 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(null); + const [validationError, setValidationError] = useState(null); + const [isDragging, setIsDragging] = useState(false); + const inputRef = useRef(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) => { + 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 ( +
+ {/* 업로드 영역 */} +
+ {/* 숨겨진 파일 input */} + + + {displayUrl ? ( + // 이미지 미리보기 + <> + 미리보기 + {/* 호버 오버레이 */} + {!disabled && ( +
+ + +
+ )} + + ) : ( + // 업로드 안내 +
+ {isDragging ? ( + + ) : ( + + )} + + {isDragging ? '놓으세요' : '클릭 또는 드래그'} + +
+ )} +
+ + {/* 에러 메시지 */} + {displayError && ( +
+ + {displayError} +
+ )} + + {/* 안내 텍스트 */} + {!displayError && hint && ( +

+ {hint} +

+ )} +
+ ); +} + +export default ImageUpload;