From f0987127ebbf12b1ae15cfcbe4277028c045c5e7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=EC=9C=A0=EB=B3=91=EC=B2=A0?= Date: Tue, 3 Feb 2026 09:09:05 +0900 Subject: [PATCH] =?UTF-8?q?feat(WEB):=20QMS=20=EA=B2=80=EC=82=AC=20?= =?UTF-8?q?=EB=AA=A8=EB=8B=AC=20=EA=B0=9C=EC=84=A0,=20=EC=A0=84=EC=9E=90?= =?UTF-8?q?=EA=B2=B0=EC=9E=AC/=EC=83=9D=EC=82=B0=EB=8C=80=EC=8B=9C?= =?UTF-8?q?=EB=B3=B4=EB=93=9C/=ED=85=9C=ED=94=8C=EB=A6=BF=20=EA=B8=B0?= =?UTF-8?q?=EB=8A=A5=20=EC=88=98=EC=A0=95?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - QMS: InspectionModal/InspectionModalV2 개선, mockData 정리 - 전자결재: DocumentCreate 기능 수정 - 생산대시보드: ProductionDashboard 개선 - 템플릿: IntegratedDetailTemplate/UniversalListPage 기능 수정 - 문서: i18n 가이드 업데이트, 문서뷰어 아키텍처 계획 추가 Co-Authored-By: Claude Opus 4.5 --- .../[IMPL-2025-11-06] i18n-usage-guide.md | 149 +++++++++ ...026-02-02] document-viewer-architecture.md | 295 ++++++++++++++++++ .../qms/components/InspectionModal.tsx | 6 +- .../qms/components/InspectionModalV2.tsx | 238 ++++++-------- .../(protected)/quality/qms/mockData.ts | 53 ---- .../[locale]/(protected)/quality/qms/page.tsx | 1 + .../approval/DocumentCreate/index.tsx | 26 +- .../production/ProductionDashboard/index.tsx | 21 +- .../IntegratedDetailTemplate/index.tsx | 11 +- .../templates/UniversalListPage/index.tsx | 8 +- 10 files changed, 578 insertions(+), 230 deletions(-) create mode 100644 claudedocs/quality/[PLAN-2026-02-02] document-viewer-architecture.md diff --git a/claudedocs/guides/[IMPL-2025-11-06] i18n-usage-guide.md b/claudedocs/guides/[IMPL-2025-11-06] i18n-usage-guide.md index 450e4cb2..baf9a316 100644 --- a/claudedocs/guides/[IMPL-2025-11-06] i18n-usage-guide.md +++ b/claudedocs/guides/[IMPL-2025-11-06] i18n-usage-guide.md @@ -6,6 +6,154 @@ --- +## 적용 현황 분석 (2026-02-03 기준) + +### 인프라 상태: 완비 + +| 항목 | 상태 | 설명 | +|------|------|------| +| 라이브러리 | `next-intl` ^4.4.0 | 설치 및 설정 완료 | +| 지원 언어 | ko, en, ja | 3개 locale | +| 번역 파일 | `src/messages/{ko,en,ja}.json` | 13개 네임스페이스, ~215개 키 | +| 미들웨어 | `src/middleware.ts` | locale 라우팅 + 인증 통합 완료 | +| Provider | `[locale]/layout.tsx` | `NextIntlClientProvider` 정상 | +| URL 라우팅 | `localePrefix: 'as-needed'` | ko는 URL 생략, en/ja는 prefix | + +### 실제 적용 현황: 극히 일부만 적용 + +**`useTranslations` 사용 중인 파일: 3개 (전체의 ~1% 미만)** + +| 파일 | 사용 네임스페이스 | +|------|-------------------| +| `src/components/auth/LoginPage.tsx` | auth, common, validation | +| `src/components/auth/SignupPage.tsx` | auth, signup, validation | +| `src/app/[locale]/layout.tsx` | Provider 설정만 | + +**하드코딩된 한국어가 있는 파일: ~1,131개** + +### 영역별 하드코딩 현황 + +| 영역 | 파일 수 | 주요 내용 | +|------|---------|----------| +| `business/` (건설) | ~171 | 계약, 입찰, 견적, 기성, 현장관리, 노무, 단가 | +| `items/` (품목) | ~101 | 품목마스터, BOM, 폼, 동적 품목관리 | +| `accounting/` (회계) | ~69 | 거래처, 입출금, 매출, 매입, 어음, 카드 | +| `settings/` (설정) | ~59 | 계정, 권한, 팝업, 구독, 회사정보, 직급, 휴가정책 | +| `hr/` (인사) | ~45 | 직원, 근태, 휴가, 급여, 카드, 부서 | +| `production/` (생산) | ~38 | 작업지시, 작업일지, 검사, 작업자화면 | +| `ui/` (공통 UI) | ~26 | file-list, confirm-dialog, error-card, inputs 등 | +| `approval/` (결재) | ~26 | 결재 워크플로우, 문서생성, 상세 | +| `quotes/` (견적) | ~21 | 견적 등록, 관리, 문서, 계산 | +| `outbound/` (출고) | ~21 | 출고증, 배차차량 | +| `quality/` (품질) | ~13 | 제품검사, 수입검사 | +| 기타 (dashboard, board 등) | ~541 | 대시보드, 게시판, CRM 등 | + +### 하드코딩 유형 분류 + +**1. Config 파일 (~47개)** - 페이지 타이틀, 설명, 라벨 +```typescript +// 예: vehicleDispatchConfig.ts +title: '배차차량 상세', +description: '배차차량 정보를 조회합니다', +backLabel: '목록', +``` + +**2. JSX 직접 텍스트** - 버튼, 라벨, placeholder +```tsx + +placeholder="거래처명" + +``` + +**3. 토스트/알림 메시지** +```typescript +toast.success('저장되었습니다'); +toast.error('삭제에 실패했습니다'); +``` + +**4. 상태/상수 정의 (types.ts)** +```typescript +export const STATUS_LABELS = { draft: '작성대기', completed: '작성완료' }; +export const FREIGHT_COST_LABELS = { prepaid: '선불', collect: '착불' }; +``` + +**5. 로딩/에러 상태** +```typescript +if (isLoading) return
로딩 중...
; // 79+ 인스턴스 +``` + +**6. Mock 데이터 (~7개)** +```typescript +siteName: '위브 청라', +orderCustomer: '두산건설(주)', +``` + +### 번역 파일 동기화 상태 + +| 비교 | 상태 | 이슈 | +|------|------|------| +| ko ↔ en | 동기화 완료 | 231줄 일치 | +| ko ↔ ja | 1건 누락 | `auth.loggingIn` 키 없음 (ja.json) | + +### 현재 번역 파일 네임스페이스 + +``` +common : ~25개 키 (기본 UI 액션) +auth : ~63개 키 (로그인, 회원가입, 역할, 직급) +signup : ~14개 키 (업종, 회사규모) +navigation : ~10개 키 (메인 메뉴) +dashboard : ~10개 키 (대시보드 위젯) +inventory : ~15개 키 (재고관리) +finance : ~11개 키 (회계) +hr : ~14개 키 (인사) +crm : ~8개 키 (고객관리) +settings : ~13개 키 (설정) +errors : ~7개 키 (에러 메시지) +validation : ~6개 키 (폼 유효성) +messages : ~8개 키 (성공/실패 알림) +``` + +### 작업 규모 (전체 적용 시) + +| 작업 항목 | 수량 | 비고 | +|-----------|------|------| +| 번역 키 추출 대상 파일 | ~1,131개 | 반복적 패턴 작업 | +| 추가 필요 번역 키 (추정) | ~3,000~5,000개 | 3개 언어 동기화 필요 | +| Config 파일 전환 | ~47개 | 패턴 동일, 일괄 처리 가능 | +| 공통 UI 컴포넌트 | ~26개 | 영향 범위 넓음 (전체 앱) | +| 비즈니스 컴포넌트 | ~1,000+개 | 단순 반복이나 양 많음 | + +### 단계별 적용 계획 (권장) + +**Phase 1: 공통 기반** - 효과 가장 큼 +- 공통 UI 컴포넌트 26개 (confirm-dialog, error-card, inputs 등) +- 공통 라벨 (저장, 삭제, 취소, 검색, 로딩 중... 등) +- "로딩 중..." 같은 반복 문자열 일괄 처리 +- 이것만 해도 전체 앱에 영향 + +**Phase 2: Config 파일** - 기계적 작업 +- 47개 config 파일의 title/description/label +- 패턴이 동일해서 일괄 처리 가능 + +**Phase 3: 영역별 비즈니스 컴포넌트** +- accounting (69개) → hr (45개) → production (38개) → ... +- 영역당 번역 네임스페이스 1개씩 추가 +- business/ (171개)가 가장 큰 덩어리 + +**Phase 4: 나머지 + 검증** +- 누락 확인, 빌드 테스트 +- 실제 언어 전환 테스트 (ko → en → ja) +- ja.json `auth.loggingIn` 누락 수정 + +### 즉시 수정 필요 사항 + +1. **ja.json 누락 키**: `auth.loggingIn` 추가 필요 + - ko: `"loggingIn": "로그인 중..."` + - en: `"loggingIn": "Logging in..."` + - ja: (누락) → `"loggingIn": "ログイン中..."` 추가 + +--- + ## 📦 설치된 패키지 ```json @@ -697,6 +845,7 @@ export default function ClientComponent() { | 날짜 | 버전 | 변경 내용 | |-----|------|---------| | 2025-11-06 | 1.0.0 | 초기 i18n 설정 구현 (ko, en, ja 지원) | +| 2026-02-03 | 1.1.0 | 전체 프로젝트 다국어 적용 현황 분석 추가 (하드코딩 현황, 단계별 적용 계획) | --- diff --git a/claudedocs/quality/[PLAN-2026-02-02] document-viewer-architecture.md b/claudedocs/quality/[PLAN-2026-02-02] document-viewer-architecture.md new file mode 100644 index 00000000..38ce77dd --- /dev/null +++ b/claudedocs/quality/[PLAN-2026-02-02] document-viewer-architecture.md @@ -0,0 +1,295 @@ +# 문서 뷰어 아키텍처 설계 + +> 품질인정심사 1일차 기준/매뉴얼 문서 및 범용 문서 뷰어 구현 방안 + +## 1. 배경 + +품질인정심사 시스템의 1일차(기준/매뉴얼 심사)에서 작업표준서, 검사기준서 등 다양한 포맷의 문서를 웹에서 바로 열람해야 함. 문서 포맷은 HWP, HWPX, Excel, PDF 등 다양하며, 수정 없이 읽기 전용으로 표시하는 것이 목적. + +### 적용 대상 +- 품질인정심사 1일차: 스크린 작업표준서, 검사기준서 등 +- 향후 확장: ERP 전반의 문서 열람 기능 + +--- + +## 2. 기술 검토 결과 + +### 2.1 HWP 웹 뷰어 현황 + +| 방식 | HWP | HWPX | PDF | Excel | 비용 | 비고 | +|------|:---:|:----:|:---:|:-----:|------|------| +| LibreOffice headless | ✅ | ✅ | - | ✅ | 무료 | H2Orestart 확장 필요 | +| 한컴 공식 API | ✅ | ✅ | ✅ | ✅ | 엔터프라이즈 | DocsConverter SDK | +| CloudConvert API | ✅ | ✅ | ✅ | ✅ | 월 250건 무료 | 클라우드 의존 | +| hwp.js (클라이언트) | △ | ❌ | ❌ | ❌ | 무료 | HWP 스펙 ~20% 구현, 사실상 폐기 | +| @ssabrojs/hwpxjs | ❌ | ✅ | ❌ | ❌ | 무료 | HWPX만 지원 | + +- **웨일 브라우저**: 네이티브 한글 뷰어 내장 (브라우저 바이너리에 포함). Chrome에서 재현 불가. +- **Google Docs Viewer**: HWP 미지원. +- **순수 클라이언트 HWP 렌더링**: 신뢰할 수 있는 라이브러리 없음 (2026년 기준). + +### 2.2 결론 + +순수 클라이언트에서 HWP를 직접 렌더링하는 것은 현실적으로 불가능. **서버사이드 PDF 변환 후 프론트에서 PDF 표시**가 유일한 프로덕션 옵션. + +--- + +## 3. 채택 아키텍처: 서버사이드 변환 + react-pdf + +### 3.1 전체 흐름 + +``` +┌─────────────┐ ┌──────────────────┐ ┌─────────────────┐ +│ 프론트엔드 │ │ Laravel 백엔드 │ │ 파일 스토리지 │ +│ (Next.js) │ │ (PHP) │ │ (S3/로컬) │ +└──────┬───────┘ └────────┬──────────┘ └────────┬────────┘ + │ │ │ + │ 1. 문서 열람 요청 │ │ + │─────────────────────>│ │ + │ │ 2. 원본 파일 조회 │ + │ │────────────────────────>│ + │ │<────────────────────────│ + │ │ │ + │ │ 3. PDF 변환 (캐싱) │ + │ │ ┌───────────────────┐ │ + │ │ │ LibreOffice │ │ + │ │ │ --headless │ │ + │ │ │ --convert-to pdf │ │ + │ │ └───────────────────┘ │ + │ │ │ + │ │ 4. 변환된 PDF 저장 │ + │ │────────────────────────>│ + │ │ │ + │ 5. PDF URL 반환 │ │ + │<─────────────────────│ │ + │ │ │ + │ 6. react-pdf로 표시 │ │ + │ (DocumentViewer 모달)│ │ +``` + +### 3.2 백엔드 (Laravel) + +#### 변환 방식 A: LibreOffice headless (무료, 자체 호스팅) + +```bash +# 서버 설치 +sudo apt install libreoffice + +# H2Orestart 확장 설치 (HWP 필터 지원) +# https://extensions.libreoffice.org/en/extensions/show/27504 + +# 변환 커맨드 +libreoffice --headless --infilter="Hwp2002_File" --convert-to pdf:writer_pdf_Export input.hwp +libreoffice --headless --convert-to pdf:writer_pdf_Export input.hwpx +libreoffice --headless --convert-to pdf:writer_pdf_Export input.xlsx +``` + +```php +// Laravel Controller 예시 +class DocumentConvertController extends Controller +{ + public function convert(Request $request, string $documentId) + { + $document = Document::findOrFail($documentId); + $originalPath = $document->file_path; + $pdfPath = $this->getCachedPdfPath($document); + + // 캐시된 PDF가 있으면 바로 반환 + if (Storage::exists($pdfPath)) { + return response()->json(['pdf_url' => Storage::url($pdfPath)]); + } + + // LibreOffice로 변환 + $outputDir = storage_path('app/converted'); + $command = sprintf( + 'libreoffice --headless --convert-to pdf --outdir %s %s', + escapeshellarg($outputDir), + escapeshellarg(Storage::path($originalPath)) + ); + exec($command, $output, $returnCode); + + if ($returnCode !== 0) { + return response()->json(['error' => '변환 실패'], 500); + } + + // 변환된 PDF 저장 및 URL 반환 + return response()->json(['pdf_url' => Storage::url($pdfPath)]); + } +} +``` + +#### 변환 방식 B: 한컴 API (유료, 높은 정확도) + +```php +// 한컴 DocsConverter API 활용 +class HancomConvertController extends Controller +{ + public function convert(Request $request, string $documentId) + { + $document = Document::findOrFail($documentId); + + // 한컴 DocsConverter SDK 호출 + // https://developer.hancom.com/docsconverter + $result = HancomSDK::convert($document->file_path, 'pdf'); + + return response()->json(['pdf_url' => $result->getPdfUrl()]); + } +} +``` + +#### API 엔드포인트 설계 + +``` +GET /api/documents/{id}/preview → PDF URL 반환 (변환 필요시 자동 변환) +POST /api/documents/upload → 원본 파일 업로드 +GET /api/documents/{id}/download → 원본 파일 다운로드 +``` + +#### PDF 캐싱 전략 + +``` +원본 파일 업로드 시점 또는 최초 열람 시점에 PDF 변환 + → converted/{document_id}.pdf로 캐싱 + → 원본 파일 변경 시 캐시 무효화 (파일 해시 비교) + → 이미 PDF인 파일은 변환 없이 바로 반환 +``` + +### 3.3 프론트엔드 (Next.js) + +#### 라이브러리 + +```bash +npm install react-pdf +# 또는 +npm install @react-pdf-viewer/core @react-pdf-viewer/default-layout +``` + +#### 구현 구조 + +``` +품질인정심사 1일차 +├── 점검표 항목 (좌측 패널) +├── 기준 문서화 (우측 패널) +│ └── 문서 클릭 시 → DocumentViewer 모달로 PDF 표시 +│ ├── 기존 DocumentViewer 모달 재사용 (zoom/drag/print/PDF) +│ └── 내부에 react-pdf PDF 렌더러 삽입 +``` + +```tsx +// 개념 코드 (실제 구현은 기획서 확정 후) +import { Document, Page } from 'react-pdf'; + +function PdfDocumentViewer({ pdfUrl }: { pdfUrl: string }) { + const [numPages, setNumPages] = useState(0); + + return ( + setNumPages(numPages)} + > + {Array.from({ length: numPages }, (_, i) => ( + + ))} + + ); +} +``` + +#### 모달 통합 + +``` +기존 DocumentViewer 모달 (2일차에서 사용 중) +├── 헤더: 문서명 + 날짜 +├── 툴바: 축소/확대/맞춤/PDF/인쇄/다운로드 +├── 본문: +│ ├── 기존: React 컴포넌트 렌더링 (수주서, 성적서 등) +│ └── 추가: PDF 렌더링 (업로드된 HWP/Excel/기타 문서) +``` + +--- + +## 4. 지원 포맷별 처리 + +| 파일 포맷 | 처리 방식 | 변환 필요 | +|-----------|----------|:---------:| +| PDF | 바로 표시 | ❌ | +| HWP | LibreOffice + H2Orestart → PDF | ✅ | +| HWPX | LibreOffice → PDF | ✅ | +| Excel (.xlsx/.xls) | LibreOffice → PDF | ✅ | +| Word (.docx/.doc) | LibreOffice → PDF | ✅ | +| PowerPoint (.pptx) | LibreOffice → PDF | ✅ | +| 이미지 (.png/.jpg) | 바로 표시 (img 태그) | ❌ | + +--- + +## 5. 서버 환경 요구사항 + +### LibreOffice 방식 + +```yaml +# Docker 예시 +FROM php:8.2-fpm +RUN apt-get update && apt-get install -y libreoffice default-jre +# H2Orestart 확장 설치 +COPY h2orestart.oxt /tmp/ +RUN libreoffice --headless --norestore --nofirststartwizard \ + "macro:///Tools.Install.installOxt(/tmp/h2orestart.oxt)" +``` + +| 항목 | 요구사항 | +|------|---------| +| LibreOffice | 7.x 이상 | +| JRE | 8 이상 (H2Orestart용) | +| 디스크 | LibreOffice ~500MB + 변환 캐시 | +| 메모리 | 변환당 ~200-500MB | +| 주의사항 | LibreOffice는 thread-safe하지 않음 → 동시 변환 시 큐 처리 필요 | + +### 한컴 API 방식 + +| 항목 | 요구사항 | +|------|---------| +| 라이선스 | 엔터프라이즈 B2B (한컴 영업팀 문의) | +| SDK | DocsConverter SDK (서버 설치) | +| 장점 | HWP 변환 정확도 최고 | +| 단점 | 비용, 라이선스 관리 | + +--- + +## 6. 1일차 UI 변경 사항 (요약) + +### 현재 구조 (3컬럼) +``` +[ 점검표 항목 ] [ 기준 문서화 ] [ 스크린 작업표준서 뷰어 ] +``` + +### 변경 후 구조 (2컬럼 + 모달) +``` +[ 점검표 항목 ] [ 기준 문서화 ] + │ + └── 문서 클릭 → DocumentViewer 모달 (PDF 표시) +``` + +- 우측 `Day1DocumentViewer` 패널 제거 +- 기준 문서화에서 문서 클릭 시 2일차와 동일한 `DocumentViewer` 모달로 표시 +- 모달 내부에서 서버 변환된 PDF를 `react-pdf`로 렌더링 + +--- + +## 7. 구현 우선순위 + +1. **백엔드 API**: 문서 업로드 + LibreOffice PDF 변환 + 캐싱 +2. **프론트엔드**: react-pdf 통합 + DocumentViewer 모달 연동 +3. **1일차 UI**: 3컬럼 → 2컬럼 레이아웃 변경 + 모달 연동 +4. **선택사항**: 한컴 API 도입 (LibreOffice 변환 품질 불만족 시) + +--- + +## 8. 참고 자료 + +- [LibreOffice H2Orestart 확장](https://extensions.libreoffice.org/en/extensions/show/27504) - HWP 필터 지원 +- [react-pdf](https://www.npmjs.com/package/react-pdf) - PDF 렌더링 +- [@react-pdf-viewer](https://react-pdf-viewer.dev/) - PDF 뷰어 (줌/검색/썸네일) +- [한컴 DocsConverter](https://developer.hancom.com/docsconverter) - 한컴 공식 변환 SDK +- [CloudConvert HWP to PDF](https://cloudconvert.com/hwp-to-pdf) - 클라우드 변환 API (월 250건 무료) +- [hwp.js GitHub](https://github.com/hahnlee/hwp.js) - 클라이언트 HWP 파서 (참고용, 프로덕션 부적합) +- [@ssabrojs/hwpxjs](https://www.npmjs.com/package/@ssabrojs/hwpxjs) - HWPX 파서 (참고용) diff --git a/src/app/[locale]/(protected)/quality/qms/components/InspectionModal.tsx b/src/app/[locale]/(protected)/quality/qms/components/InspectionModal.tsx index 9162a3a6..0d495285 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/InspectionModal.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/InspectionModal.tsx @@ -10,7 +10,7 @@ import { import { ZoomIn, ZoomOut, Download, Printer, AlertCircle, Maximize2 } from 'lucide-react'; import { Button } from "@/components/ui/button"; import { Document, DocumentItem } from '../types'; -import { MOCK_ORDER_DATA, MOCK_WORK_ORDER, MOCK_SHIPMENT_DETAIL } from '../mockData'; +import { MOCK_WORK_ORDER, MOCK_SHIPMENT_DETAIL } from '../mockData'; // 기존 문서 컴포넌트 import import { DeliveryConfirmation } from '@/components/outbound/ShipmentManagement/documents/DeliveryConfirmation'; @@ -73,9 +73,9 @@ const PlaceholderDocument = ({ docType, docItem }: { docType: string; docItem: D ); }; -// 수주서 문서 컴포넌트 (간소화 버전) +// 수주서 문서 컴포넌트 (간소화 버전 - deprecated, V2 사용) const OrderDocument = () => { - const data = MOCK_ORDER_DATA; + const data = { lotNumber: '', orderDate: '', client: '', siteName: '', manager: '', managerContact: '', deliveryRequestDate: '', expectedShipDate: '', deliveryMethod: '', address: '', items: [] as { id: string; name: string; specification: string; unit: string; quantity: number; unitPrice?: number; amount?: number }[], subtotal: 0, discountRate: 0, totalAmount: 0, remarks: '' }; return (
diff --git a/src/app/[locale]/(protected)/quality/qms/components/InspectionModalV2.tsx b/src/app/[locale]/(protected)/quality/qms/components/InspectionModalV2.tsx index d383ceb7..9099cdd4 100644 --- a/src/app/[locale]/(protected)/quality/qms/components/InspectionModalV2.tsx +++ b/src/app/[locale]/(protected)/quality/qms/components/InspectionModalV2.tsx @@ -6,19 +6,28 @@ import { DocumentViewer } from '@/components/document-system'; import { Button } from '@/components/ui/button'; import { toast } from 'sonner'; import { Document, DocumentItem } from '../types'; -import { MOCK_ORDER_DATA, MOCK_SHIPMENT_DETAIL } from '../mockData'; +import { MOCK_SHIPMENT_DETAIL } from '../mockData'; // 기존 문서 컴포넌트 import import { DeliveryConfirmation } from '@/components/outbound/ShipmentManagement/documents/DeliveryConfirmation'; import { ShippingSlip } from '@/components/outbound/ShipmentManagement/documents/ShippingSlip'; +// 수주서 문서 컴포넌트 import +import { SalesOrderDocument } from '@/components/orders/documents/SalesOrderDocument'; +import type { ProductInfo } from '@/components/orders/documents/OrderDocumentModal'; +import type { OrderItem } from '@/components/orders/actions'; + // 품질검사 문서 컴포넌트 import import { ImportInspectionDocument, - ProductInspectionDocument, JointbarInspectionDocument, QualityDocumentUploader, } from './documents'; + +// 제품검사 성적서 (신규 양식) import +import { InspectionReportDocument } from '@/components/quality/InspectionManagement/documents/InspectionReportDocument'; +import { mockReportInspectionItems } from '@/components/quality/InspectionManagement/mockData'; +import type { InspectionReportDocument as InspectionReportDocumentType } from '@/components/quality/InspectionManagement/types'; import type { ImportInspectionTemplate, ImportInspectionRef } from './documents/ImportInspectionDocument'; // 작업일지 + 중간검사 성적서 문서 컴포넌트 import (공정별 신규 버전) @@ -44,6 +53,8 @@ interface InspectionModalV2Props { itemName?: string; specification?: string; supplier?: string; + // 읽기 전용 모드 (QMS 심사 확인용) + readOnly?: boolean; } // 문서 타입별 정보 @@ -85,132 +96,38 @@ const PlaceholderDocument = ({ docType, docItem }: { docType: string; docItem: D ); }; -// 수주서 문서 컴포넌트 (간소화 버전) -const OrderDocument = () => { - const data = MOCK_ORDER_DATA; +// QMS용 수주서 Mock 데이터 +const QMS_MOCK_PRODUCTS: ProductInfo[] = [ + { productName: '방화 스크린 셔터 (표준형)', productCategory: '스크린', openWidth: '3000', openHeight: '2500', quantity: 5, floor: '1F', code: 'FSS-01' }, + { productName: '방화 스크린 셔터 (방화형)', productCategory: '스크린', openWidth: '3000', openHeight: '2500', quantity: 3, floor: '2F', code: 'FSS-02' }, +]; +const QMS_MOCK_ORDER_ITEMS: OrderItem[] = [ + { id: 'mt-1', itemCode: 'MT-001', itemName: '모터(380V 단상)', specification: '150K', type: '모터', quantity: 8, unit: 'EA', unitPrice: 120000, supplyAmount: 960000, taxAmount: 96000, totalAmount: 1056000, sortOrder: 1 }, + { id: 'br-1', itemCode: 'BR-001', itemName: '브라켓트', specification: '380X180 [2-4"]', type: '브라켓', quantity: 16, unit: 'EA', unitPrice: 15000, supplyAmount: 240000, taxAmount: 24000, totalAmount: 264000, sortOrder: 2 }, + { id: 'gr-1', itemCode: 'GR-001', itemName: '가이드레일 백면형 (120X70)', specification: 'EGI 1.5ST', type: '가이드레일', quantity: 16, unit: 'EA', unitPrice: 25000, supplyAmount: 400000, taxAmount: 40000, totalAmount: 440000, width: 120, height: 2500, sortOrder: 3 }, + { id: 'cs-1', itemCode: 'CS-001', itemName: '케이스(셔터박스)', specification: 'EGI 1.5ST 380X180', type: '케이스', quantity: 8, unit: 'EA', unitPrice: 35000, supplyAmount: 280000, taxAmount: 28000, totalAmount: 308000, width: 380, height: 180, sortOrder: 4 }, + { id: 'bf-1', itemCode: 'BF-001', itemName: '하단마감재', specification: 'EGI 1.5ST', type: '하단마감재', quantity: 8, unit: 'EA', unitPrice: 18000, supplyAmount: 144000, taxAmount: 14400, totalAmount: 158400, sortOrder: 5 }, +]; - return ( -
- {/* 헤더 */} -
-
-
KD
-
경동기업
-
-
수 주 서
- - - - - - - - - - - - - - - - - - - -
-
- -
-
작성검토승인
판매생산품질
-
- - {/* 기본 정보 */} - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
LOT NO.{data.lotNumber}수주일{data.orderDate}
발주처{data.client}현장명{data.siteName}
담당자{data.manager}연락처{data.managerContact}
납기요청일{data.deliveryRequestDate}출고예정일{data.expectedShipDate}
배송방법{data.deliveryMethod}배송지{data.address}
- - {/* 품목 테이블 */} - - - - - - - - - - - - - - {data.items.map((item, index) => ( - - - - - - - - - - ))} - - - - - - - - - - - - - - - -
No품목명규격단위수량단가금액
{index + 1}{item.name}{item.specification}{item.unit}{item.quantity}{item.unitPrice?.toLocaleString()}{item.amount?.toLocaleString()}
소계{data.subtotal.toLocaleString()}원
할인 ({data.discountRate}%)-{(data.subtotal * data.discountRate / 100).toLocaleString()}원
총액{data.totalAmount.toLocaleString()}원
- - {/* 비고 */} - {data.remarks && ( -
-

비고

-

{data.remarks}

-
- )} -
- ); +// QMS용 제품검사 성적서 Mock 데이터 +const QMS_MOCK_REPORT_DATA: InspectionReportDocumentType = { + documentNumber: 'RPT-KD-SS-2024-530', + createdDate: '2024-09-24', + approvalLine: [ + { role: '작성', name: '김검사', department: '품질관리부' }, + { role: '승인', name: '박승인', department: '품질관리부' }, + ], + productName: '방화스크린', + productLotNo: 'KD-SS-240924-19', + productCode: 'WY-SC780', + lotSize: '8', + client: '삼성물산(주)', + inspectionDate: '2024-09-26', + siteName: '강남 아파트 단지', + inspector: '김검사', + inspectionItems: mockReportInspectionItems, + specialNotes: '', + finalJudgment: '합격', }; // QMS용 작업일지 Mock WorkOrder 생성 @@ -289,6 +206,7 @@ export const InspectionModalV2 = ({ itemName, specification, supplier, + readOnly = false, }: InspectionModalV2Props) => { // 수입검사 템플릿 상태 const [importTemplate, setImportTemplate] = useState(null); @@ -339,6 +257,23 @@ export const InspectionModalV2 = ({ } }; + // 수입검사 저장 핸들러 (hooks는 early return 전에 호출해야 함) + const handleImportSave = useCallback(async () => { + if (!importDocRef.current) return; + + const data = importDocRef.current.getInspectionData(); + setIsSaving(true); + try { + // TODO: 실제 저장 API 연동 + console.log('[InspectionModalV2] 수입검사 저장 데이터:', data); + toast.success('검사 데이터가 저장되었습니다.'); + } catch { + toast.error('저장 중 오류가 발생했습니다.'); + } finally { + setIsSaving(false); + } + }, []); + if (!doc) return null; const docInfo = DOCUMENT_INFO[doc.type] || { label: doc.title, hasTemplate: false, color: 'text-gray-600' }; @@ -391,23 +326,6 @@ export const InspectionModalV2 = ({ } }; - // 수입검사 저장 핸들러 - const handleImportSave = useCallback(async () => { - if (!importDocRef.current) return; - - const data = importDocRef.current.getInspectionData(); - setIsSaving(true); - try { - // TODO: 실제 저장 API 연동 - console.log('[InspectionModalV2] 수입검사 저장 데이터:', data); - toast.success('검사 데이터가 저장되었습니다.'); - } catch { - toast.error('저장 중 오류가 발생했습니다.'); - } finally { - setIsSaving(false); - } - }, []); - // 수입검사 문서 렌더링 (Lazy Loading) const renderImportInspectionDocument = () => { if (isLoadingTemplate) { @@ -419,14 +337,35 @@ export const InspectionModalV2 = ({ } // 템플릿이 로드되면 전달, 아니면 기본 템플릿 사용 - return ; + return ; }; // 문서 타입에 따른 컨텐츠 렌더링 const renderDocumentContent = () => { switch (doc.type) { case 'order': - return ; + return ( + + ); case 'log': return renderWorkLogDocument(); case 'confirmation': @@ -436,7 +375,7 @@ export const InspectionModalV2 = ({ case 'import': return renderImportInspectionDocument(); case 'product': - return ; + return ; case 'report': return renderReportDocument(); case 'quality': @@ -444,6 +383,7 @@ export const InspectionModalV2 = ({ ); default: @@ -456,8 +396,8 @@ export const InspectionModalV2 = ({ console.log('[InspectionModalV2] 다운로드 요청:', doc.type); }; - // 수입검사 저장 버튼 (toolbarExtra) - const importToolbarExtra = doc.type === 'import' ? ( + // 수입검사 저장 버튼 (toolbarExtra) - readOnly일 때 숨김 + const importToolbarExtra = doc.type === 'import' && !readOnly ? (
); diff --git a/src/components/approval/DocumentCreate/index.tsx b/src/components/approval/DocumentCreate/index.tsx index 5dda4dd6..d9a1bf21 100644 --- a/src/components/approval/DocumentCreate/index.tsx +++ b/src/components/approval/DocumentCreate/index.tsx @@ -2,6 +2,7 @@ import { useState, useCallback, useEffect, useTransition, useRef } from 'react'; import { useRouter, useSearchParams } from 'next/navigation'; +import { usePermission } from '@/hooks/usePermission'; import { format } from 'date-fns'; import { Trash2, Send, Save, Eye, Loader2 } from 'lucide-react'; import { toast } from 'sonner'; @@ -87,6 +88,7 @@ export function DocumentCreate() { const searchParams = useSearchParams(); const [isPending, startTransition] = useTransition(); const { currentUser } = useAuth(); + const { canCreate, canDelete } = usePermission(); // 수정 모드 / 복제 모드 상태 const documentId = searchParams.get('id'); @@ -546,20 +548,22 @@ export function DocumentCreate() { 미리보기 - + {canDelete && ( + + )} ); - }, [handlePreview, handleDelete, handleSubmit, handleSaveDraft, isPending, isEditMode]); + }, [handlePreview, handleDelete, handleSubmit, handleSaveDraft, isPending, isEditMode, canCreate, canDelete]); // 폼 컨텐츠 렌더링 const renderFormContent = useCallback(() => { diff --git a/src/components/production/ProductionDashboard/index.tsx b/src/components/production/ProductionDashboard/index.tsx index 89463793..0eb0454c 100644 --- a/src/components/production/ProductionDashboard/index.tsx +++ b/src/components/production/ProductionDashboard/index.tsx @@ -12,6 +12,7 @@ import { useState, useMemo, useCallback, useEffect } from 'react'; import { useRouter } from 'next/navigation'; +import { usePermission } from '@/hooks/usePermission'; import { Factory, Clock, PlayCircle, CheckCircle2, AlertTriangle, Timer, Users } from 'lucide-react'; import { StatCardGridSkeleton } from '@/components/ui/skeleton'; import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs'; @@ -27,6 +28,8 @@ import { STATUS_LABELS } from './types'; export default function ProductionDashboard() { const router = useRouter(); + const screenPerm = usePermission('/production/screen'); + const workOrderPerm = usePermission('/production/work-order-management'); // ===== 상태 관리 ===== const [selectedTab, setSelectedTab] = useState('all'); @@ -152,13 +155,17 @@ export default function ProductionDashboard() {
- - + {screenPerm.canView && ( + + )} + {workOrderPerm.canView && ( + + )}
diff --git a/src/components/templates/IntegratedDetailTemplate/index.tsx b/src/components/templates/IntegratedDetailTemplate/index.tsx index b32f91bd..e147952e 100644 --- a/src/components/templates/IntegratedDetailTemplate/index.tsx +++ b/src/components/templates/IntegratedDetailTemplate/index.tsx @@ -11,6 +11,7 @@ import { useState, useEffect, useCallback, useMemo, forwardRef, useImperativeHandle } from 'react'; import { useRouter, useParams } from 'next/navigation'; +import { usePermission } from '@/hooks/usePermission'; import { DeleteConfirmDialog } from '@/components/ui/confirm-dialog'; import { PageLayout } from '@/components/organisms/PageLayout'; import { PageHeader } from '@/components/organisms/PageHeader'; @@ -53,6 +54,7 @@ function IntegratedDetailTemplateInner>( const router = useRouter(); const params = useParams(); const locale = (params.locale as string) || 'ko'; + const { canCreate: permCanCreate, canUpdate: permCanUpdate, canDelete: permCanDelete } = usePermission(); // ===== 상태 ===== const [mode, setMode] = useState(initialMode); @@ -104,14 +106,15 @@ function IntegratedDetailTemplateInner>( }), [formData, config, initialData]); // ===== 권한 계산 ===== + // config.permissions가 명시적으로 설정되면 우선, 아니면 usePermission() fallback const permissions = useMemo(() => { const p = config.permissions || {}; return { - canEdit: typeof p.canEdit === 'function' ? p.canEdit() : p.canEdit ?? true, - canDelete: typeof p.canDelete === 'function' ? p.canDelete() : p.canDelete ?? true, - canCreate: typeof p.canCreate === 'function' ? p.canCreate() : p.canCreate ?? true, + canEdit: typeof p.canEdit === 'function' ? p.canEdit() : (p.canEdit !== undefined ? p.canEdit : permCanUpdate), + canDelete: typeof p.canDelete === 'function' ? p.canDelete() : (p.canDelete !== undefined ? p.canDelete : permCanDelete), + canCreate: typeof p.canCreate === 'function' ? p.canCreate() : (p.canCreate !== undefined ? p.canCreate : permCanCreate), }; - }, [config.permissions]); + }, [config.permissions, permCanUpdate, permCanDelete, permCanCreate]); // ===== 모드 헬퍼 ===== const isViewMode = mode === 'view'; diff --git a/src/components/templates/UniversalListPage/index.tsx b/src/components/templates/UniversalListPage/index.tsx index 2867d9e3..3f327fc1 100644 --- a/src/components/templates/UniversalListPage/index.tsx +++ b/src/components/templates/UniversalListPage/index.tsx @@ -13,6 +13,7 @@ import { useState, useEffect, useCallback, useMemo, useRef } from 'react'; import { useRouter, useParams } from 'next/navigation'; +import { usePermission } from '@/hooks/usePermission'; import { toast } from 'sonner'; import { Download, Loader2 } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -43,6 +44,7 @@ export function UniversalListPage({ const router = useRouter(); const params = useParams(); const locale = (params.locale as string) || 'ko'; + const { canCreate: permCanCreate, canDelete: permCanDelete } = usePermission(); // ===== 상태 관리 ===== // 원본 데이터 (클라이언트 사이드 필터링용) @@ -825,7 +827,7 @@ export function UniversalListPage({ onToggle: () => toggleSelection(id), onRowClick: () => handleRowClick(item), onEdit: () => handleEdit(item), - onDelete: () => handleDeleteClick(item), + onDelete: permCanDelete ? () => handleDeleteClick(item) : undefined, }); }, [config, effectiveGetItemId, handleDeleteClick, handleEdit, handleRowClick, effectiveSelectedItems, toggleSelection] @@ -838,7 +840,7 @@ export function UniversalListPage({ onToggle, onRowClick: () => handleRowClick(item), onEdit: () => handleEdit(item), - onDelete: () => handleDeleteClick(item), + onDelete: permCanDelete ? () => handleDeleteClick(item) : undefined, }); }, [config, handleDeleteClick, handleEdit, handleRowClick] @@ -874,7 +876,7 @@ export function UniversalListPage({ } // 공통 헤더 옵션 (달력/등록버튼) dateRangeSelector={config.dateRangeSelector} - createButton={config.createButton} + createButton={permCanCreate ? config.createButton : undefined} // 탭 콘텐츠 tabsContent={config.tabsContent} // 통계 카드