feat(WEB): QMS 검사 모달 개선, 전자결재/생산대시보드/템플릿 기능 수정

- QMS: InspectionModal/InspectionModalV2 개선, mockData 정리
- 전자결재: DocumentCreate 기능 수정
- 생산대시보드: ProductionDashboard 개선
- 템플릿: IntegratedDetailTemplate/UniversalListPage 기능 수정
- 문서: i18n 가이드 업데이트, 문서뷰어 아키텍처 계획 추가

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-02-03 09:09:05 +09:00
parent ca6247286a
commit f0987127eb
10 changed files with 578 additions and 230 deletions

View File

@@ -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
<Button>삭제</Button>
placeholder="거래처명"
<Label>비고</Label>
```
**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 <div>로딩 ...</div>; // 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 | 전체 프로젝트 다국어 적용 현황 분석 추가 (하드코딩 현황, 단계별 적용 계획) |
---

View File

@@ -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 (
<Document
file={pdfUrl}
onLoadSuccess={({ numPages }) => setNumPages(numPages)}
>
{Array.from({ length: numPages }, (_, i) => (
<Page key={i} pageNumber={i + 1} />
))}
</Document>
);
}
```
#### 모달 통합
```
기존 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 파서 (참고용)

View File

@@ -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 (
<div className="bg-white p-8 w-full text-sm shadow-sm">

View File

@@ -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 (
<div className="bg-white p-8 w-full text-sm shadow-sm">
{/* 헤더 */}
<div className="flex justify-between items-start mb-6">
<div className="flex items-center gap-4">
<div className="text-2xl font-bold">KD</div>
<div className="text-xs"></div>
</div>
<div className="text-2xl font-bold tracking-[0.5rem]"> </div>
<table className="text-xs border-collapse">
<tbody>
<tr>
<td className="border px-2 py-1 bg-gray-100" rowSpan={3}>
<div className="flex flex-col items-center">
<span></span><span></span>
</div>
</td>
<td className="border px-2 py-1 bg-gray-100 text-center w-16"></td>
<td className="border px-2 py-1 bg-gray-100 text-center w-16"></td>
<td className="border px-2 py-1 bg-gray-100 text-center w-16"></td>
</tr>
<tr>
<td className="border px-2 py-1 h-10"></td>
<td className="border px-2 py-1 h-10"></td>
<td className="border px-2 py-1 h-10"></td>
</tr>
<tr>
<td className="border px-2 py-1 text-center bg-gray-50"></td>
<td className="border px-2 py-1 text-center bg-gray-50"></td>
<td className="border px-2 py-1 text-center bg-gray-50"></td>
</tr>
</tbody>
</table>
</div>
{/* 기본 정보 */}
<table className="w-full border-collapse mb-6 text-xs">
<tbody>
<tr>
<td className="border px-3 py-2 bg-gray-100 w-24">LOT NO.</td>
<td className="border px-3 py-2">{data.lotNumber}</td>
<td className="border px-3 py-2 bg-gray-100 w-24"></td>
<td className="border px-3 py-2">{data.orderDate}</td>
</tr>
<tr>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.client}</td>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.siteName}</td>
</tr>
<tr>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.manager}</td>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.managerContact}</td>
</tr>
<tr>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.deliveryRequestDate}</td>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.expectedShipDate}</td>
</tr>
<tr>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.deliveryMethod}</td>
<td className="border px-3 py-2 bg-gray-100"></td>
<td className="border px-3 py-2">{data.address}</td>
</tr>
</tbody>
</table>
{/* 품목 테이블 */}
<table className="w-full border-collapse mb-6 text-xs">
<thead>
<tr className="bg-gray-100">
<th className="border px-2 py-2 w-10">No</th>
<th className="border px-2 py-2"></th>
<th className="border px-2 py-2 w-24"></th>
<th className="border px-2 py-2 w-12"></th>
<th className="border px-2 py-2 w-12"></th>
<th className="border px-2 py-2 w-20"></th>
<th className="border px-2 py-2 w-24"></th>
</tr>
</thead>
<tbody>
{data.items.map((item, index) => (
<tr key={item.id}>
<td className="border px-2 py-2 text-center">{index + 1}</td>
<td className="border px-2 py-2">{item.name}</td>
<td className="border px-2 py-2 text-center">{item.specification}</td>
<td className="border px-2 py-2 text-center">{item.unit}</td>
<td className="border px-2 py-2 text-center">{item.quantity}</td>
<td className="border px-2 py-2 text-right">{item.unitPrice?.toLocaleString()}</td>
<td className="border px-2 py-2 text-right">{item.amount?.toLocaleString()}</td>
</tr>
))}
</tbody>
<tfoot>
<tr>
<td colSpan={5} className="border px-2 py-2 text-right bg-gray-50"></td>
<td colSpan={2} className="border px-2 py-2 text-right">{data.subtotal.toLocaleString()}</td>
</tr>
<tr>
<td colSpan={5} className="border px-2 py-2 text-right bg-gray-50"> ({data.discountRate}%)</td>
<td colSpan={2} className="border px-2 py-2 text-right text-red-600">-{(data.subtotal * data.discountRate / 100).toLocaleString()}</td>
</tr>
<tr>
<td colSpan={5} className="border px-2 py-2 text-right bg-gray-100 font-bold"></td>
<td colSpan={2} className="border px-2 py-2 text-right font-bold text-blue-600">{data.totalAmount.toLocaleString()}</td>
</tr>
</tfoot>
</table>
{/* 비고 */}
{data.remarks && (
<div className="border p-4">
<h3 className="font-medium mb-2 text-xs"></h3>
<p className="text-xs text-gray-600">{data.remarks}</p>
</div>
)}
</div>
);
// 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<ImportInspectionTemplate | null>(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 <ImportInspectionDocument ref={importDocRef} template={importTemplate || undefined} />;
return <ImportInspectionDocument ref={importDocRef} template={importTemplate || undefined} readOnly={readOnly} />;
};
// 문서 타입에 따른 컨텐츠 렌더링
const renderDocumentContent = () => {
switch (doc.type) {
case 'order':
return <OrderDocument />;
return (
<SalesOrderDocument
orderNumber="KD-SS-240924-19"
documentNumber="KD-SS-240924-19"
certificationNumber="KD-SS-240924-19"
orderDate="2024-09-24"
client="삼성물산(주)"
siteName="강남 아파트 단지"
manager="김담당"
managerContact="010-1234-5678"
deliveryRequestDate="2024-10-05"
expectedShipDate="2024-10-04"
deliveryMethod="직접배차"
address="서울시 강남구 테헤란로 123"
recipientName="김인수"
recipientContact="010-9876-5432"
shutterCount={8}
products={QMS_MOCK_PRODUCTS}
items={QMS_MOCK_ORDER_ITEMS}
remarks="납기일 엄수 요청"
/>
);
case 'log':
return renderWorkLogDocument();
case 'confirmation':
@@ -436,7 +375,7 @@ export const InspectionModalV2 = ({
case 'import':
return renderImportInspectionDocument();
case 'product':
return <ProductInspectionDocument />;
return <InspectionReportDocument data={QMS_MOCK_REPORT_DATA} />;
case 'report':
return renderReportDocument();
case 'quality':
@@ -444,6 +383,7 @@ export const InspectionModalV2 = ({
<QualityDocumentUploader
onFileUpload={handleQualityFileUpload}
onFileDelete={handleQualityFileDelete}
disabled={readOnly}
/>
);
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 ? (
<Button onClick={handleImportSave} disabled={isSaving} size="sm">
{isSaving ? (
<Loader2 className="w-4 h-4 mr-1.5 animate-spin" />

View File

@@ -2,59 +2,6 @@ import { InspectionReport, RouteItem, Document, ChecklistCategory, StandardDocum
import type { WorkOrder } from '@/components/production/ProductionDashboard/types';
import type { ShipmentDetail } from '@/components/outbound/ShipmentManagement/types';
// 품질검사용 수주서 아이템 타입 (자체 정의)
interface QualityOrderItem {
id: string;
name: string;
specification: string;
unit: string;
quantity: number;
unitPrice?: number;
amount?: number;
}
// 품질검사용 수주서 데이터 타입 (자체 정의)
export interface QualityOrderData {
lotNumber: string;
orderDate: string;
client: string;
siteName: string;
manager: string;
managerContact: string;
deliveryRequestDate: string;
expectedShipDate: string;
deliveryMethod: string;
address: string;
items: QualityOrderItem[];
subtotal: number;
discountRate: number;
totalAmount: number;
remarks?: string;
}
// 수주서 샘플 데이터
export const MOCK_ORDER_DATA: QualityOrderData = {
lotNumber: 'KD-SS-240924-19',
orderDate: '2024-09-24',
client: '삼성물산(주)',
siteName: '강남 아파트 단지',
manager: '김담당',
managerContact: '010-1234-5678',
deliveryRequestDate: '2024-10-05',
expectedShipDate: '2024-10-04',
deliveryMethod: '직접배차',
address: '서울시 강남구 테헤란로 123',
items: [
{ id: '1', name: '스크린 셔터 (표준형)', specification: '3000×2500', unit: 'SET', quantity: 5, unitPrice: 1200000, amount: 6000000 },
{ id: '2', name: '스크린 셔터 (방화형)', specification: '3000×2500', unit: 'SET', quantity: 3, unitPrice: 1500000, amount: 4500000 },
{ id: '3', name: '슬랫 패널', specification: '1000×500', unit: 'EA', quantity: 20, unitPrice: 50000, amount: 1000000 },
],
subtotal: 11500000,
discountRate: 5,
totalAmount: 10925000,
remarks: '납기일 엄수 요청',
};
// 작업일지 샘플 데이터
export const MOCK_WORK_ORDER: WorkOrder = {
id: 'wo-1',

View File

@@ -369,6 +369,7 @@ export default function QualityInspectionPage() {
onClose={() => setModalOpen(false)}
document={selectedDoc}
documentItem={selectedDocItem}
readOnly
/>
</div>
);

View File

@@ -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() {
<Eye className="w-4 h-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDelete}
disabled={isPending}
>
<Trash2 className="w-4 h-4 mr-1" />
</Button>
{canDelete && (
<Button
variant="outline"
size="sm"
onClick={handleDelete}
disabled={isPending}
>
<Trash2 className="w-4 h-4 mr-1" />
</Button>
)}
<Button
variant="default"
size="sm"
onClick={handleSubmit}
disabled={isPending}
disabled={isPending || !canCreate}
>
{isPending ? (
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
@@ -583,7 +587,7 @@ export function DocumentCreate() {
</Button>
</div>
);
}, [handlePreview, handleDelete, handleSubmit, handleSaveDraft, isPending, isEditMode]);
}, [handlePreview, handleDelete, handleSubmit, handleSaveDraft, isPending, isEditMode, canCreate, canDelete]);
// 폼 컨텐츠 렌더링
const renderFormContent = useCallback(() => {

View File

@@ -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<string>('all');
@@ -152,13 +155,17 @@ export default function ProductionDashboard() {
</div>
</div>
<div className="flex gap-2">
<Button variant="outline" onClick={handleWorkerScreenClick}>
<Users className="mr-2 h-4 w-4" />
</Button>
<Button variant="outline" onClick={handleWorkOrderListClick}>
</Button>
{screenPerm.canView && (
<Button variant="outline" onClick={handleWorkerScreenClick}>
<Users className="mr-2 h-4 w-4" />
</Button>
)}
{workOrderPerm.canView && (
<Button variant="outline" onClick={handleWorkOrderListClick}>
</Button>
)}
</div>
</div>

View File

@@ -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<T extends Record<string, unknown>>(
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<DetailMode>(initialMode);
@@ -104,14 +106,15 @@ function IntegratedDetailTemplateInner<T extends Record<string, unknown>>(
}), [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';

View File

@@ -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<T>({
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<T>({
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<T>({
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<T>({
}
// 공통 헤더 옵션 (달력/등록버튼)
dateRangeSelector={config.dateRangeSelector}
createButton={config.createButton}
createButton={permCanCreate ? config.createButton : undefined}
// 탭 콘텐츠
tabsContent={config.tabsContent}
// 통계 카드