feat(WEB): 입력 컴포넌트 공통화 및 UI 개선
- 숫자/통화/전화번호/사업자번호 등 특수 입력 컴포넌트 추가 - MobileCard 컴포넌트 통합 (ListMobileCard 제거) - IntegratedListTemplateV2 페이지네이션 버그 수정 (NaN 이슈) - IntegratedDetailTemplate 타이틀 중복 수정 - 문서 시스템 컴포넌트 추가 - 헤더 벨 아이콘 포커스 스타일 개선 Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
47
src/components/document-system/configs/approval/index.ts
Normal file
47
src/components/document-system/configs/approval/index.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import { DocumentConfig } from '../../types';
|
||||
import {
|
||||
ProposalDocument,
|
||||
ExpenseReportDocument,
|
||||
ExpenseEstimateDocument,
|
||||
} from '@/components/approval/DocumentDetail';
|
||||
|
||||
/**
|
||||
* 품의서
|
||||
*/
|
||||
export const proposalConfig: DocumentConfig = {
|
||||
type: 'proposal',
|
||||
title: '품의서',
|
||||
preset: 'approval',
|
||||
component: ProposalDocument,
|
||||
};
|
||||
|
||||
/**
|
||||
* 지출결의서
|
||||
*/
|
||||
export const expenseReportConfig: DocumentConfig = {
|
||||
type: 'expense-report',
|
||||
title: '지출결의서',
|
||||
preset: 'approval',
|
||||
component: ExpenseReportDocument,
|
||||
};
|
||||
|
||||
/**
|
||||
* 지출 예상 내역서
|
||||
*/
|
||||
export const expenseEstimateConfig: DocumentConfig = {
|
||||
type: 'expense-estimate',
|
||||
title: '지출 예상 내역서',
|
||||
preset: 'approval',
|
||||
component: ExpenseEstimateDocument,
|
||||
};
|
||||
|
||||
/**
|
||||
* 결재 문서 Config 맵
|
||||
*/
|
||||
export const APPROVAL_DOCUMENT_CONFIGS = {
|
||||
'proposal': proposalConfig,
|
||||
'expense-report': expenseReportConfig,
|
||||
'expense-estimate': expenseEstimateConfig,
|
||||
} as const;
|
||||
|
||||
export type ApprovalDocumentType = keyof typeof APPROVAL_DOCUMENT_CONFIGS;
|
||||
21
src/components/document-system/configs/construction/index.ts
Normal file
21
src/components/document-system/configs/construction/index.ts
Normal file
@@ -0,0 +1,21 @@
|
||||
import { DocumentConfig } from '../../types';
|
||||
|
||||
/**
|
||||
* 계약서 Config
|
||||
* - PDF iframe 렌더링
|
||||
*/
|
||||
export const contractDocumentConfig: DocumentConfig = {
|
||||
type: 'contract',
|
||||
title: '계약서',
|
||||
preset: 'construction',
|
||||
// component는 사용하지 않고 children으로 PDF iframe 전달
|
||||
};
|
||||
|
||||
/**
|
||||
* 건설 문서 Config 맵
|
||||
*/
|
||||
export const CONSTRUCTION_DOCUMENT_CONFIGS = {
|
||||
'contract': contractDocumentConfig,
|
||||
} as const;
|
||||
|
||||
export type ConstructionDocumentType = keyof typeof CONSTRUCTION_DOCUMENT_CONFIGS;
|
||||
11
src/components/document-system/configs/index.ts
Normal file
11
src/components/document-system/configs/index.ts
Normal file
@@ -0,0 +1,11 @@
|
||||
// QMS Configs
|
||||
export * from './qms';
|
||||
|
||||
// Approval Configs
|
||||
export * from './approval';
|
||||
|
||||
// Construction Configs
|
||||
export * from './construction';
|
||||
|
||||
// TODO: Orders Configs
|
||||
// export * from './orders';
|
||||
@@ -0,0 +1,12 @@
|
||||
import { DocumentConfig } from '../../types';
|
||||
import { ImportInspectionDocument } from '@/app/[locale]/(protected)/quality/qms/components/documents';
|
||||
|
||||
/**
|
||||
* 수입검사 성적서 Config
|
||||
*/
|
||||
export const importInspectionConfig: DocumentConfig = {
|
||||
type: 'import-inspection',
|
||||
title: '수입검사 성적서',
|
||||
preset: 'inspection',
|
||||
component: ImportInspectionDocument,
|
||||
};
|
||||
83
src/components/document-system/configs/qms/index.ts
Normal file
83
src/components/document-system/configs/qms/index.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { DocumentConfig } from '../../types';
|
||||
import {
|
||||
ImportInspectionDocument,
|
||||
ProductInspectionDocument,
|
||||
ScreenInspectionDocument,
|
||||
BendingInspectionDocument,
|
||||
SlatInspectionDocument,
|
||||
JointbarInspectionDocument,
|
||||
} from '@/app/[locale]/(protected)/quality/qms/components/documents';
|
||||
|
||||
/**
|
||||
* 수입검사 성적서
|
||||
*/
|
||||
export const importInspectionConfig: DocumentConfig = {
|
||||
type: 'import-inspection',
|
||||
title: '수입검사 성적서',
|
||||
preset: 'inspection',
|
||||
component: ImportInspectionDocument,
|
||||
};
|
||||
|
||||
/**
|
||||
* 제품검사 성적서
|
||||
*/
|
||||
export const productInspectionConfig: DocumentConfig = {
|
||||
type: 'product-inspection',
|
||||
title: '제품검사 성적서',
|
||||
preset: 'inspection',
|
||||
component: ProductInspectionDocument,
|
||||
};
|
||||
|
||||
/**
|
||||
* 스크린 중간검사 성적서
|
||||
*/
|
||||
export const screenInspectionConfig: DocumentConfig = {
|
||||
type: 'screen-inspection',
|
||||
title: '스크린 중간검사 성적서',
|
||||
preset: 'inspection',
|
||||
component: ScreenInspectionDocument,
|
||||
};
|
||||
|
||||
/**
|
||||
* 절곡품 중간검사 성적서
|
||||
*/
|
||||
export const bendingInspectionConfig: DocumentConfig = {
|
||||
type: 'bending-inspection',
|
||||
title: '절곡품 중간검사 성적서',
|
||||
preset: 'inspection',
|
||||
component: BendingInspectionDocument,
|
||||
};
|
||||
|
||||
/**
|
||||
* 슬랫 중간검사 성적서
|
||||
*/
|
||||
export const slatInspectionConfig: DocumentConfig = {
|
||||
type: 'slat-inspection',
|
||||
title: '슬랫 중간검사 성적서',
|
||||
preset: 'inspection',
|
||||
component: SlatInspectionDocument,
|
||||
};
|
||||
|
||||
/**
|
||||
* 금속프레임 중간검사 성적서
|
||||
*/
|
||||
export const jointbarInspectionConfig: DocumentConfig = {
|
||||
type: 'jointbar-inspection',
|
||||
title: '금속프레임 중간검사 성적서',
|
||||
preset: 'inspection',
|
||||
component: JointbarInspectionDocument,
|
||||
};
|
||||
|
||||
/**
|
||||
* QMS 문서 Config 맵
|
||||
*/
|
||||
export const QMS_DOCUMENT_CONFIGS = {
|
||||
'import-inspection': importInspectionConfig,
|
||||
'product-inspection': productInspectionConfig,
|
||||
'screen-inspection': screenInspectionConfig,
|
||||
'bending-inspection': bendingInspectionConfig,
|
||||
'slat-inspection': slatInspectionConfig,
|
||||
'jointbar-inspection': jointbarInspectionConfig,
|
||||
} as const;
|
||||
|
||||
export type QMSDocumentType = keyof typeof QMS_DOCUMENT_CONFIGS;
|
||||
28
src/components/document-system/index.ts
Normal file
28
src/components/document-system/index.ts
Normal file
@@ -0,0 +1,28 @@
|
||||
// Main Component
|
||||
export { DocumentViewer } from './viewer';
|
||||
|
||||
// Hooks
|
||||
export { useZoom, useDrag } from './viewer/hooks';
|
||||
|
||||
// Presets
|
||||
export { DOCUMENT_PRESETS, getPreset, mergeWithPreset } from './presets';
|
||||
|
||||
// Types
|
||||
export type {
|
||||
DocumentConfig,
|
||||
DocumentViewerProps,
|
||||
DocumentFeatures,
|
||||
ActionType,
|
||||
PresetType,
|
||||
PresetConfig,
|
||||
// Block types (Phase 2)
|
||||
DocumentBlock,
|
||||
HeaderBlock,
|
||||
InfoTableBlock,
|
||||
ItemTableBlock,
|
||||
ApprovalLineBlock,
|
||||
SignatureBlock,
|
||||
TextSectionBlock,
|
||||
ImageGridBlock,
|
||||
CustomBlock,
|
||||
} from './types';
|
||||
113
src/components/document-system/presets/index.ts
Normal file
113
src/components/document-system/presets/index.ts
Normal file
@@ -0,0 +1,113 @@
|
||||
import { PresetConfig, PresetType } from '../types';
|
||||
|
||||
/**
|
||||
* 문서 프리셋 정의
|
||||
* - inspection: QMS 검사 문서용 (인쇄/다운로드)
|
||||
* - construction: 건설 프로젝트용 (수정/삭제/인쇄)
|
||||
* - approval: 결재 문서용 (수정/상신/인쇄)
|
||||
* - readonly: 조회 전용 (인쇄만)
|
||||
*/
|
||||
export const DOCUMENT_PRESETS: Record<PresetType, PresetConfig> = {
|
||||
// QMS 검사 문서용
|
||||
inspection: {
|
||||
features: {
|
||||
zoom: true,
|
||||
drag: true,
|
||||
print: true,
|
||||
download: true,
|
||||
},
|
||||
actions: ['print', 'download'],
|
||||
},
|
||||
|
||||
// 건설 프로젝트용 (CRUD)
|
||||
construction: {
|
||||
features: {
|
||||
zoom: true,
|
||||
drag: true,
|
||||
print: true,
|
||||
download: false,
|
||||
},
|
||||
actions: ['edit', 'delete', 'print'],
|
||||
},
|
||||
|
||||
// 결재 문서용 (기본)
|
||||
approval: {
|
||||
features: {
|
||||
zoom: true,
|
||||
drag: true,
|
||||
print: true,
|
||||
download: false,
|
||||
},
|
||||
actions: ['edit', 'submit', 'print'],
|
||||
},
|
||||
|
||||
// 결재 문서용 - 기안함 모드 (임시저장 상태: 복제, 상신, 인쇄)
|
||||
'approval-draft': {
|
||||
features: {
|
||||
zoom: true,
|
||||
drag: true,
|
||||
print: true,
|
||||
download: false,
|
||||
},
|
||||
actions: ['copy', 'submit', 'print'],
|
||||
},
|
||||
|
||||
// 결재 문서용 - 결재함 모드 (수정, 반려, 승인, 인쇄)
|
||||
'approval-inbox': {
|
||||
features: {
|
||||
zoom: true,
|
||||
drag: true,
|
||||
print: true,
|
||||
download: false,
|
||||
},
|
||||
actions: ['edit', 'reject', 'approve', 'print'],
|
||||
},
|
||||
|
||||
// 조회 전용
|
||||
readonly: {
|
||||
features: {
|
||||
zoom: true,
|
||||
drag: true,
|
||||
print: true,
|
||||
download: false,
|
||||
},
|
||||
actions: ['print'],
|
||||
},
|
||||
|
||||
// 견적서/문서 전송용 (PDF, 이메일, 팩스, 카카오톡, 인쇄)
|
||||
quote: {
|
||||
features: {
|
||||
zoom: true,
|
||||
drag: true,
|
||||
print: true,
|
||||
download: true,
|
||||
},
|
||||
actions: ['pdf', 'email', 'fax', 'kakao', 'print'],
|
||||
},
|
||||
};
|
||||
|
||||
/**
|
||||
* 프리셋 가져오기
|
||||
*/
|
||||
export function getPreset(preset: PresetType): PresetConfig {
|
||||
return DOCUMENT_PRESETS[preset] || DOCUMENT_PRESETS.readonly;
|
||||
}
|
||||
|
||||
/**
|
||||
* 프리셋과 커스텀 설정 병합
|
||||
*/
|
||||
export function mergeWithPreset(
|
||||
preset: PresetType | undefined,
|
||||
customFeatures?: Partial<PresetConfig['features']>,
|
||||
customActions?: PresetConfig['actions']
|
||||
): PresetConfig {
|
||||
const base = preset ? getPreset(preset) : DOCUMENT_PRESETS.readonly;
|
||||
|
||||
return {
|
||||
features: {
|
||||
...base.features,
|
||||
...customFeatures,
|
||||
},
|
||||
actions: customActions || base.actions,
|
||||
};
|
||||
}
|
||||
224
src/components/document-system/types.ts
Normal file
224
src/components/document-system/types.ts
Normal file
@@ -0,0 +1,224 @@
|
||||
import { ReactNode, ComponentType } from 'react';
|
||||
|
||||
// ============================================================
|
||||
// Action Types
|
||||
// ============================================================
|
||||
|
||||
export type ActionType = 'print' | 'download' | 'edit' | 'delete' | 'submit' | 'approve' | 'reject' | 'copy' | 'pdf' | 'email' | 'fax' | 'kakao';
|
||||
|
||||
export interface ActionHandlers {
|
||||
onPrint?: () => void;
|
||||
onDownload?: () => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
onSubmit?: () => void;
|
||||
onApprove?: () => void;
|
||||
onReject?: () => void;
|
||||
onCopy?: () => void;
|
||||
onPdf?: () => void;
|
||||
onEmail?: () => void;
|
||||
onFax?: () => void;
|
||||
onKakao?: () => void;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Feature Types
|
||||
// ============================================================
|
||||
|
||||
export interface DocumentFeatures {
|
||||
zoom?: boolean;
|
||||
drag?: boolean;
|
||||
print?: boolean;
|
||||
download?: boolean;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Preset Types
|
||||
// ============================================================
|
||||
|
||||
export type PresetType = 'inspection' | 'construction' | 'approval' | 'approval-draft' | 'approval-inbox' | 'readonly' | 'quote';
|
||||
|
||||
export interface PresetConfig {
|
||||
features: DocumentFeatures;
|
||||
actions: ActionType[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Document Config
|
||||
// ============================================================
|
||||
|
||||
export interface DocumentConfig {
|
||||
// 메타 정보
|
||||
type: string;
|
||||
title: string;
|
||||
preset?: PresetType;
|
||||
|
||||
// 뷰어 설정 (프리셋 오버라이드)
|
||||
features?: DocumentFeatures;
|
||||
actions?: ActionType[];
|
||||
|
||||
// 콘텐츠 설정 - Phase 1: 정적 모드
|
||||
component?: ComponentType<any>;
|
||||
|
||||
// 콘텐츠 설정 - Phase 2: 동적 모드 (빌더용)
|
||||
blocks?: DocumentBlock[];
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Block Types (문서 빌더용)
|
||||
// ============================================================
|
||||
|
||||
export type DocumentBlock =
|
||||
| HeaderBlock
|
||||
| InfoTableBlock
|
||||
| ItemTableBlock
|
||||
| ApprovalLineBlock
|
||||
| SignatureBlock
|
||||
| TextSectionBlock
|
||||
| ImageGridBlock
|
||||
| CustomBlock;
|
||||
|
||||
export interface HeaderBlock {
|
||||
type: 'header';
|
||||
title: string;
|
||||
logo?: boolean;
|
||||
companyName?: string;
|
||||
showApprovalLine?: boolean;
|
||||
approvalPositions?: string[];
|
||||
}
|
||||
|
||||
export interface InfoTableBlock {
|
||||
type: 'info-table';
|
||||
columns: 2 | 3 | 4;
|
||||
fields: {
|
||||
label: string;
|
||||
key: string;
|
||||
colSpan?: number;
|
||||
rowSpan?: number;
|
||||
}[];
|
||||
}
|
||||
|
||||
export interface ItemTableBlock {
|
||||
type: 'item-table';
|
||||
columns: {
|
||||
key: string;
|
||||
label: string;
|
||||
width?: string;
|
||||
align?: 'left' | 'center' | 'right';
|
||||
}[];
|
||||
showTotal?: boolean;
|
||||
totalFields?: string[];
|
||||
}
|
||||
|
||||
export interface ApprovalLineBlock {
|
||||
type: 'approval-line';
|
||||
positions: string[];
|
||||
layout?: 'horizontal' | 'vertical';
|
||||
}
|
||||
|
||||
export interface SignatureBlock {
|
||||
type: 'signature';
|
||||
positions: { label: string; name?: string }[];
|
||||
}
|
||||
|
||||
export interface TextSectionBlock {
|
||||
type: 'text-section';
|
||||
title?: string;
|
||||
content: string;
|
||||
style?: 'normal' | 'highlight' | 'note';
|
||||
}
|
||||
|
||||
export interface ImageGridBlock {
|
||||
type: 'image-grid';
|
||||
columns: 2 | 3 | 4;
|
||||
imageKey: string; // data에서 이미지 배열을 가져올 키
|
||||
}
|
||||
|
||||
export interface CustomBlock {
|
||||
type: 'custom';
|
||||
componentKey: string;
|
||||
props?: Record<string, any>;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// DocumentViewer Props
|
||||
// ============================================================
|
||||
|
||||
export interface DocumentViewerProps {
|
||||
// Config 기반 (권장)
|
||||
config?: DocumentConfig;
|
||||
|
||||
// 개별 props (하위 호환)
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
preset?: PresetType;
|
||||
features?: DocumentFeatures;
|
||||
actions?: ActionType[];
|
||||
|
||||
// 데이터
|
||||
data?: any;
|
||||
|
||||
// 액션 핸들러
|
||||
onPrint?: () => void;
|
||||
onDownload?: () => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
onSubmit?: () => void;
|
||||
onApprove?: () => void;
|
||||
onReject?: () => void;
|
||||
onCopy?: () => void;
|
||||
onPdf?: () => void;
|
||||
onEmail?: () => void;
|
||||
onFax?: () => void;
|
||||
onKakao?: () => void;
|
||||
|
||||
// 모달 제어
|
||||
open?: boolean;
|
||||
onOpenChange?: (open: boolean) => void;
|
||||
|
||||
// 정적 모드용 children
|
||||
children?: ReactNode;
|
||||
|
||||
// 툴바 확장 영역 (액션 버튼 옆에 추가 요소)
|
||||
toolbarExtra?: ReactNode;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// Hook Types
|
||||
// ============================================================
|
||||
|
||||
export interface UseZoomOptions {
|
||||
defaultZoom?: number;
|
||||
minZoom?: number;
|
||||
maxZoom?: number;
|
||||
zoomLevels?: number[];
|
||||
}
|
||||
|
||||
export interface UseZoomReturn {
|
||||
zoom: number;
|
||||
zoomIn: () => void;
|
||||
zoomOut: () => void;
|
||||
zoomReset: () => void;
|
||||
setZoom: (zoom: number) => void;
|
||||
canZoomIn: boolean;
|
||||
canZoomOut: boolean;
|
||||
}
|
||||
|
||||
export interface UseDragOptions {
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface UseDragReturn {
|
||||
position: { x: number; y: number };
|
||||
isDragging: boolean;
|
||||
handlers: {
|
||||
onMouseDown: (e: React.MouseEvent) => void;
|
||||
onMouseMove: (e: React.MouseEvent) => void;
|
||||
onMouseUp: () => void;
|
||||
onMouseLeave: () => void;
|
||||
onTouchStart: (e: React.TouchEvent) => void;
|
||||
onTouchMove: (e: React.TouchEvent) => void;
|
||||
onTouchEnd: () => void;
|
||||
};
|
||||
resetPosition: () => void;
|
||||
}
|
||||
58
src/components/document-system/viewer/DocumentContent.tsx
Normal file
58
src/components/document-system/viewer/DocumentContent.tsx
Normal file
@@ -0,0 +1,58 @@
|
||||
'use client';
|
||||
|
||||
import React, { useRef, ReactNode } from 'react';
|
||||
import { UseDragReturn } from '../types';
|
||||
|
||||
interface DocumentContentProps {
|
||||
children: ReactNode;
|
||||
zoom: number;
|
||||
drag: UseDragReturn;
|
||||
enableDrag?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 콘텐츠 영역
|
||||
* - 줌 적용
|
||||
* - 드래그 이동 (줌 100% 초과 시)
|
||||
*/
|
||||
export function DocumentContent({
|
||||
children,
|
||||
zoom,
|
||||
drag,
|
||||
enableDrag = true,
|
||||
}: DocumentContentProps) {
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const contentRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
// 드래그는 줌 100% 초과 시에만 활성화
|
||||
const isDragEnabled = enableDrag && zoom > 100;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={containerRef}
|
||||
className="flex-1 overflow-auto bg-gray-100 relative print:overflow-visible print:bg-white"
|
||||
{...(isDragEnabled ? drag.handlers : {})}
|
||||
style={{
|
||||
cursor: isDragEnabled ? (drag.isDragging ? 'grabbing' : 'grab') : 'default',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
ref={contentRef}
|
||||
className="p-4 origin-top-left transition-transform duration-150 ease-out print:p-0 print:transform-none"
|
||||
style={{
|
||||
transform: `scale(${zoom / 100}) translate(${drag.position.x / (zoom / 100)}px, ${drag.position.y / (zoom / 100)}px)`,
|
||||
minWidth: '800px',
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{/* 모바일 줌 힌트 */}
|
||||
{zoom === 100 && enableDrag && (
|
||||
<div className="sm:hidden absolute bottom-4 left-1/2 -translate-x-1/2 bg-black/70 text-white text-xs px-3 py-1.5 rounded-full print:hidden">
|
||||
확대 후 드래그로 이동
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
305
src/components/document-system/viewer/DocumentToolbar.tsx
Normal file
305
src/components/document-system/viewer/DocumentToolbar.tsx
Normal file
@@ -0,0 +1,305 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
import {
|
||||
ZoomIn,
|
||||
ZoomOut,
|
||||
Maximize2,
|
||||
Printer,
|
||||
Download,
|
||||
Pencil,
|
||||
Trash2,
|
||||
Send,
|
||||
CheckCircle,
|
||||
XCircle,
|
||||
Copy,
|
||||
FileOutput,
|
||||
Mail,
|
||||
MessageCircle,
|
||||
} from 'lucide-react';
|
||||
import { ReactNode } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { ActionType, DocumentFeatures, UseZoomReturn } from '../types';
|
||||
|
||||
interface DocumentToolbarProps {
|
||||
// 줌 컨트롤
|
||||
zoom: UseZoomReturn;
|
||||
features: DocumentFeatures;
|
||||
|
||||
// 액션 버튼
|
||||
actions: ActionType[];
|
||||
onPrint?: () => void;
|
||||
onDownload?: () => void;
|
||||
onEdit?: () => void;
|
||||
onDelete?: () => void;
|
||||
onSubmit?: () => void;
|
||||
onApprove?: () => void;
|
||||
onReject?: () => void;
|
||||
onCopy?: () => void;
|
||||
onPdf?: () => void;
|
||||
onEmail?: () => void;
|
||||
onFax?: () => void;
|
||||
onKakao?: () => void;
|
||||
toolbarExtra?: ReactNode;
|
||||
}
|
||||
|
||||
/**
|
||||
* 문서 뷰어 툴바
|
||||
* - 좌측: 줌 컨트롤 (축소, 확대, 맞춤, 배율)
|
||||
* - 우측: 액션 버튼 (인쇄, 다운로드, 수정, 삭제, 상신)
|
||||
*/
|
||||
export function DocumentToolbar({
|
||||
zoom,
|
||||
features,
|
||||
actions,
|
||||
onPrint,
|
||||
onDownload,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onSubmit,
|
||||
onApprove,
|
||||
onReject,
|
||||
onCopy,
|
||||
onPdf,
|
||||
onEmail,
|
||||
onFax,
|
||||
onKakao,
|
||||
toolbarExtra,
|
||||
}: DocumentToolbarProps) {
|
||||
const showZoomControls = features.zoom !== false;
|
||||
|
||||
// 액션 버튼 렌더링
|
||||
const renderActionButton = (action: ActionType) => {
|
||||
switch (action) {
|
||||
case 'print':
|
||||
if (!onPrint) return null;
|
||||
return (
|
||||
<Button
|
||||
key="print"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1 text-xs px-2 sm:px-3"
|
||||
onClick={onPrint}
|
||||
>
|
||||
<Printer size={14} />
|
||||
<span className="hidden sm:inline">인쇄</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
case 'download':
|
||||
if (!onDownload) return null;
|
||||
return (
|
||||
<Button
|
||||
key="download"
|
||||
size="sm"
|
||||
className="h-8 bg-green-600 hover:bg-green-700 text-white gap-1 text-xs px-2 sm:px-3"
|
||||
onClick={onDownload}
|
||||
>
|
||||
<Download size={14} />
|
||||
<span className="hidden sm:inline">다운로드</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
case 'edit':
|
||||
if (!onEdit) return null;
|
||||
return (
|
||||
<Button
|
||||
key="edit"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1 text-xs px-2 sm:px-3"
|
||||
onClick={onEdit}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
<span className="hidden sm:inline">수정</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
case 'delete':
|
||||
if (!onDelete) return null;
|
||||
return (
|
||||
<Button
|
||||
key="delete"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1 text-xs px-2 sm:px-3 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={onDelete}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
<span className="hidden sm:inline">삭제</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
case 'submit':
|
||||
if (!onSubmit) return null;
|
||||
return (
|
||||
<Button
|
||||
key="submit"
|
||||
size="sm"
|
||||
className="h-8 bg-blue-600 hover:bg-blue-700 text-white gap-1 text-xs px-2 sm:px-3"
|
||||
onClick={onSubmit}
|
||||
>
|
||||
<Send size={14} />
|
||||
<span className="hidden sm:inline">상신</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
case 'approve':
|
||||
if (!onApprove) return null;
|
||||
return (
|
||||
<Button
|
||||
key="approve"
|
||||
size="sm"
|
||||
className="h-8 bg-blue-600 hover:bg-blue-700 text-white gap-1 text-xs px-2 sm:px-3"
|
||||
onClick={onApprove}
|
||||
>
|
||||
<CheckCircle size={14} />
|
||||
<span className="hidden sm:inline">승인</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
case 'reject':
|
||||
if (!onReject) return null;
|
||||
return (
|
||||
<Button
|
||||
key="reject"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1 text-xs px-2 sm:px-3 text-red-600 hover:text-red-700 hover:bg-red-50"
|
||||
onClick={onReject}
|
||||
>
|
||||
<XCircle size={14} />
|
||||
<span className="hidden sm:inline">반려</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
case 'copy':
|
||||
if (!onCopy) return null;
|
||||
return (
|
||||
<Button
|
||||
key="copy"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 gap-1 text-xs px-2 sm:px-3"
|
||||
onClick={onCopy}
|
||||
>
|
||||
<Copy size={14} />
|
||||
<span className="hidden sm:inline">복제</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
case 'pdf':
|
||||
if (!onPdf) return null;
|
||||
return (
|
||||
<Button
|
||||
key="pdf"
|
||||
size="sm"
|
||||
className="h-8 bg-red-600 hover:bg-red-700 text-white gap-1 text-xs px-2 sm:px-3"
|
||||
onClick={onPdf}
|
||||
>
|
||||
<Download size={14} />
|
||||
<span className="hidden sm:inline">PDF</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
case 'email':
|
||||
if (!onEmail) return null;
|
||||
return (
|
||||
<Button
|
||||
key="email"
|
||||
size="sm"
|
||||
className="h-8 bg-blue-600 hover:bg-blue-700 text-white gap-1 text-xs px-2 sm:px-3"
|
||||
onClick={onEmail}
|
||||
>
|
||||
<Mail size={14} />
|
||||
<span className="hidden sm:inline">이메일</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
case 'fax':
|
||||
if (!onFax) return null;
|
||||
return (
|
||||
<Button
|
||||
key="fax"
|
||||
size="sm"
|
||||
className="h-8 bg-purple-600 hover:bg-purple-700 text-white gap-1 text-xs px-2 sm:px-3"
|
||||
onClick={onFax}
|
||||
>
|
||||
<FileOutput size={14} />
|
||||
<span className="hidden sm:inline">팩스</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
case 'kakao':
|
||||
if (!onKakao) return null;
|
||||
return (
|
||||
<Button
|
||||
key="kakao"
|
||||
size="sm"
|
||||
className="h-8 bg-yellow-500 hover:bg-yellow-600 text-white gap-1 text-xs px-2 sm:px-3"
|
||||
onClick={onKakao}
|
||||
>
|
||||
<MessageCircle size={14} />
|
||||
<span className="hidden sm:inline">카카오톡</span>
|
||||
</Button>
|
||||
);
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="flex items-center justify-between px-4 py-2 bg-white border-b border-gray-100 shrink-0 flex-wrap gap-2 print:hidden">
|
||||
{/* 좌측: 줌 컨트롤 */}
|
||||
<div className="flex items-center gap-1 sm:gap-2">
|
||||
{showZoomControls && (
|
||||
<>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1 text-xs px-2 sm:px-3"
|
||||
onClick={zoom.zoomOut}
|
||||
disabled={!zoom.canZoomOut}
|
||||
>
|
||||
<ZoomOut size={14} />
|
||||
<span className="hidden sm:inline">축소</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1 text-xs px-2 sm:px-3"
|
||||
onClick={zoom.zoomIn}
|
||||
disabled={!zoom.canZoomIn}
|
||||
>
|
||||
<ZoomIn size={14} />
|
||||
<span className="hidden sm:inline">확대</span>
|
||||
</Button>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-8 gap-1 text-xs px-2 sm:px-3"
|
||||
onClick={zoom.zoomReset}
|
||||
>
|
||||
<Maximize2 size={14} />
|
||||
<span className="hidden sm:inline">맞춤</span>
|
||||
</Button>
|
||||
<span className="text-xs font-mono text-gray-600 bg-gray-100 px-2 py-1 rounded min-w-[48px] text-center">
|
||||
{zoom.zoom}%
|
||||
</span>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측: 액션 버튼 + 추가 요소 */}
|
||||
<div className="flex items-center gap-1 sm:gap-2 flex-wrap">
|
||||
{actions.map(renderActionButton)}
|
||||
{toolbarExtra && (
|
||||
<div className="flex items-center gap-2 pl-2 border-l border-gray-200">
|
||||
{toolbarExtra}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
200
src/components/document-system/viewer/DocumentViewer.tsx
Normal file
200
src/components/document-system/viewer/DocumentViewer.tsx
Normal file
@@ -0,0 +1,200 @@
|
||||
'use client';
|
||||
|
||||
import React, { useEffect, ReactNode } from 'react';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import { printArea } from '@/lib/print-utils';
|
||||
import { DocumentToolbar } from './DocumentToolbar';
|
||||
import { DocumentContent } from './DocumentContent';
|
||||
import { useZoom, useDrag } from './hooks';
|
||||
import { mergeWithPreset } from '../presets';
|
||||
import {
|
||||
DocumentConfig,
|
||||
DocumentViewerProps,
|
||||
ActionType,
|
||||
DocumentFeatures,
|
||||
} from '../types';
|
||||
|
||||
/**
|
||||
* 문서 뷰어 (Shell)
|
||||
*
|
||||
* 사용법 1: Config 기반
|
||||
* ```tsx
|
||||
* <DocumentViewer
|
||||
* config={importInspectionConfig}
|
||||
* data={inspectionData}
|
||||
* open={isOpen}
|
||||
* onOpenChange={setIsOpen}
|
||||
* />
|
||||
* ```
|
||||
*
|
||||
* 사용법 2: 개별 props
|
||||
* ```tsx
|
||||
* <DocumentViewer
|
||||
* title="수입검사 성적서"
|
||||
* preset="inspection"
|
||||
* open={isOpen}
|
||||
* onOpenChange={setIsOpen}
|
||||
* onPrint={handlePrint}
|
||||
* >
|
||||
* <ImportInspectionDocument data={data} />
|
||||
* </DocumentViewer>
|
||||
* ```
|
||||
*/
|
||||
export function DocumentViewer({
|
||||
// Config
|
||||
config,
|
||||
|
||||
// 개별 props
|
||||
title: propTitle,
|
||||
subtitle,
|
||||
preset: propPreset,
|
||||
features: propFeatures,
|
||||
actions: propActions,
|
||||
|
||||
// 데이터
|
||||
data,
|
||||
|
||||
// 액션 핸들러
|
||||
onPrint: propOnPrint,
|
||||
onDownload,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onSubmit,
|
||||
onApprove,
|
||||
onReject,
|
||||
onCopy,
|
||||
onPdf,
|
||||
onEmail,
|
||||
onFax,
|
||||
onKakao,
|
||||
|
||||
// 모달 제어
|
||||
open = false,
|
||||
onOpenChange,
|
||||
|
||||
// 정적 모드용 children
|
||||
children,
|
||||
|
||||
// 툴바 확장
|
||||
toolbarExtra,
|
||||
}: DocumentViewerProps) {
|
||||
// Config 또는 개별 props에서 값 추출
|
||||
const title = config?.title || propTitle || '문서';
|
||||
const preset = config?.preset || propPreset;
|
||||
|
||||
// 프리셋과 커스텀 설정 병합
|
||||
const merged = mergeWithPreset(
|
||||
preset,
|
||||
config?.features || propFeatures,
|
||||
config?.actions || propActions
|
||||
);
|
||||
|
||||
const features: DocumentFeatures = merged.features;
|
||||
const actions: ActionType[] = merged.actions;
|
||||
|
||||
// 줌 훅
|
||||
const zoom = useZoom();
|
||||
|
||||
// 드래그 훅
|
||||
const drag = useDrag({ enabled: features.drag !== false && zoom.zoom > 100 });
|
||||
|
||||
// 모달 열릴 때 상태 초기화
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
zoom.zoomReset();
|
||||
drag.resetPosition();
|
||||
}
|
||||
}, [open]);
|
||||
|
||||
// 인쇄 핸들러
|
||||
const handlePrint = () => {
|
||||
if (propOnPrint) {
|
||||
propOnPrint();
|
||||
} else {
|
||||
printArea({ title });
|
||||
}
|
||||
};
|
||||
|
||||
// 콘텐츠 렌더링
|
||||
const renderContent = (): ReactNode => {
|
||||
// 1. children이 있으면 children 사용 (정적 모드)
|
||||
if (children) {
|
||||
return children;
|
||||
}
|
||||
|
||||
// 2. Config에 component가 있으면 컴포넌트 렌더링
|
||||
if (config?.component) {
|
||||
const Component = config.component;
|
||||
return <Component data={data} />;
|
||||
}
|
||||
|
||||
// 3. Config에 blocks가 있으면 블록 렌더링 (Phase 2)
|
||||
if (config?.blocks) {
|
||||
// TODO: BlockRenderer 구현 시 연결
|
||||
return (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
블록 기반 렌더링은 준비 중입니다.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 4. 아무것도 없으면 빈 상태
|
||||
return (
|
||||
<div className="p-8 text-center text-gray-500">
|
||||
문서 콘텐츠가 없습니다.
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="w-[95vw] max-w-[1200px] sm:max-w-[1200px] h-[90vh] p-0 overflow-hidden bg-gray-50 flex flex-col">
|
||||
{/* 헤더 */}
|
||||
<DialogHeader className="p-4 bg-white border-b border-gray-200 flex flex-row items-center justify-between space-y-0 shrink-0 print:hidden">
|
||||
<div>
|
||||
<DialogTitle className="text-lg font-bold text-gray-800">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
{subtitle && (
|
||||
<p className="text-xs text-gray-500 mt-1">{subtitle}</p>
|
||||
)}
|
||||
</div>
|
||||
</DialogHeader>
|
||||
|
||||
{/* 툴바 */}
|
||||
<DocumentToolbar
|
||||
zoom={zoom}
|
||||
features={features}
|
||||
actions={actions}
|
||||
onPrint={features.print ? handlePrint : undefined}
|
||||
onDownload={features.download ? onDownload : undefined}
|
||||
onEdit={onEdit}
|
||||
onDelete={onDelete}
|
||||
onSubmit={onSubmit}
|
||||
onApprove={onApprove}
|
||||
onReject={onReject}
|
||||
onCopy={onCopy}
|
||||
onPdf={onPdf}
|
||||
onEmail={onEmail}
|
||||
onFax={onFax}
|
||||
onKakao={onKakao}
|
||||
toolbarExtra={toolbarExtra}
|
||||
/>
|
||||
|
||||
{/* 콘텐츠 */}
|
||||
<DocumentContent
|
||||
zoom={zoom.zoom}
|
||||
drag={drag}
|
||||
enableDrag={features.drag !== false}
|
||||
>
|
||||
<div className="print-area">{renderContent()}</div>
|
||||
</DocumentContent>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
2
src/components/document-system/viewer/hooks/index.ts
Normal file
2
src/components/document-system/viewer/hooks/index.ts
Normal file
@@ -0,0 +1,2 @@
|
||||
export { useZoom } from './useZoom';
|
||||
export { useDrag } from './useDrag';
|
||||
95
src/components/document-system/viewer/hooks/useDrag.ts
Normal file
95
src/components/document-system/viewer/hooks/useDrag.ts
Normal file
@@ -0,0 +1,95 @@
|
||||
import { useState, useCallback, useRef } from 'react';
|
||||
import { UseDragOptions, UseDragReturn } from '../../types';
|
||||
|
||||
/**
|
||||
* 드래그 기능 훅
|
||||
* - 마우스/터치 드래그 지원
|
||||
* - 줌 100% 초과 시에만 활성화 권장
|
||||
*/
|
||||
export function useDrag(options: UseDragOptions = {}): UseDragReturn {
|
||||
const { enabled = true } = options;
|
||||
|
||||
const [isDragging, setIsDragging] = useState(false);
|
||||
const [position, setPosition] = useState({ x: 0, y: 0 });
|
||||
const startPos = useRef({ x: 0, y: 0 });
|
||||
|
||||
// 마우스 드래그 시작
|
||||
const onMouseDown = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!enabled) return;
|
||||
setIsDragging(true);
|
||||
startPos.current = {
|
||||
x: e.clientX - position.x,
|
||||
y: e.clientY - position.y,
|
||||
};
|
||||
},
|
||||
[enabled, position]
|
||||
);
|
||||
|
||||
// 마우스 이동
|
||||
const onMouseMove = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (!isDragging) return;
|
||||
setPosition({
|
||||
x: e.clientX - startPos.current.x,
|
||||
y: e.clientY - startPos.current.y,
|
||||
});
|
||||
},
|
||||
[isDragging]
|
||||
);
|
||||
|
||||
// 마우스 드래그 종료
|
||||
const onMouseUp = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
// 터치 드래그 시작
|
||||
const onTouchStart = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
if (!enabled || e.touches.length !== 1) return;
|
||||
setIsDragging(true);
|
||||
startPos.current = {
|
||||
x: e.touches[0].clientX - position.x,
|
||||
y: e.touches[0].clientY - position.y,
|
||||
};
|
||||
},
|
||||
[enabled, position]
|
||||
);
|
||||
|
||||
// 터치 이동
|
||||
const onTouchMove = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
if (!isDragging || e.touches.length !== 1) return;
|
||||
setPosition({
|
||||
x: e.touches[0].clientX - startPos.current.x,
|
||||
y: e.touches[0].clientY - startPos.current.y,
|
||||
});
|
||||
},
|
||||
[isDragging]
|
||||
);
|
||||
|
||||
// 터치 종료
|
||||
const onTouchEnd = useCallback(() => {
|
||||
setIsDragging(false);
|
||||
}, []);
|
||||
|
||||
// 위치 리셋
|
||||
const resetPosition = useCallback(() => {
|
||||
setPosition({ x: 0, y: 0 });
|
||||
}, []);
|
||||
|
||||
return {
|
||||
position,
|
||||
isDragging,
|
||||
handlers: {
|
||||
onMouseDown,
|
||||
onMouseMove,
|
||||
onMouseUp,
|
||||
onMouseLeave: onMouseUp,
|
||||
onTouchStart,
|
||||
onTouchMove,
|
||||
onTouchEnd,
|
||||
},
|
||||
resetPosition,
|
||||
};
|
||||
}
|
||||
63
src/components/document-system/viewer/hooks/useZoom.ts
Normal file
63
src/components/document-system/viewer/hooks/useZoom.ts
Normal file
@@ -0,0 +1,63 @@
|
||||
import { useState, useCallback } from 'react';
|
||||
import { UseZoomOptions, UseZoomReturn } from '../../types';
|
||||
|
||||
const DEFAULT_ZOOM_LEVELS = [50, 75, 100, 125, 150, 200];
|
||||
const DEFAULT_MIN_ZOOM = 50;
|
||||
const DEFAULT_MAX_ZOOM = 200;
|
||||
const DEFAULT_ZOOM = 100;
|
||||
|
||||
/**
|
||||
* 줌 기능 훅
|
||||
* - 단계별 줌 인/아웃
|
||||
* - 맞춤(리셋) 기능
|
||||
*/
|
||||
export function useZoom(options: UseZoomOptions = {}): UseZoomReturn {
|
||||
const {
|
||||
defaultZoom = DEFAULT_ZOOM,
|
||||
minZoom = DEFAULT_MIN_ZOOM,
|
||||
maxZoom = DEFAULT_MAX_ZOOM,
|
||||
zoomLevels = DEFAULT_ZOOM_LEVELS,
|
||||
} = options;
|
||||
|
||||
const [zoom, setZoomState] = useState(defaultZoom);
|
||||
|
||||
// 줌 인 - 다음 단계로
|
||||
const zoomIn = useCallback(() => {
|
||||
setZoomState((prev) => {
|
||||
const nextIndex = zoomLevels.findIndex((z) => z > prev);
|
||||
return nextIndex !== -1 ? zoomLevels[nextIndex] : maxZoom;
|
||||
});
|
||||
}, [zoomLevels, maxZoom]);
|
||||
|
||||
// 줌 아웃 - 이전 단계로
|
||||
const zoomOut = useCallback(() => {
|
||||
setZoomState((prev) => {
|
||||
const prevIndex = zoomLevels.slice().reverse().findIndex((z) => z < prev);
|
||||
const index = prevIndex !== -1 ? zoomLevels.length - 1 - prevIndex : 0;
|
||||
return zoomLevels[index] || minZoom;
|
||||
});
|
||||
}, [zoomLevels, minZoom]);
|
||||
|
||||
// 줌 리셋 - 100%로
|
||||
const zoomReset = useCallback(() => {
|
||||
setZoomState(100);
|
||||
}, []);
|
||||
|
||||
// 직접 줌 설정
|
||||
const setZoom = useCallback(
|
||||
(newZoom: number) => {
|
||||
setZoomState(Math.min(Math.max(newZoom, minZoom), maxZoom));
|
||||
},
|
||||
[minZoom, maxZoom]
|
||||
);
|
||||
|
||||
return {
|
||||
zoom,
|
||||
zoomIn,
|
||||
zoomOut,
|
||||
zoomReset,
|
||||
setZoom,
|
||||
canZoomIn: zoom < maxZoom,
|
||||
canZoomOut: zoom > minZoom,
|
||||
};
|
||||
}
|
||||
4
src/components/document-system/viewer/index.ts
Normal file
4
src/components/document-system/viewer/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export { DocumentViewer } from './DocumentViewer';
|
||||
export { DocumentToolbar } from './DocumentToolbar';
|
||||
export { DocumentContent } from './DocumentContent';
|
||||
export { useZoom, useDrag } from './hooks';
|
||||
Reference in New Issue
Block a user