feat(WEB): 입력 컴포넌트 공통화 및 UI 개선

- 숫자/통화/전화번호/사업자번호 등 특수 입력 컴포넌트 추가
- MobileCard 컴포넌트 통합 (ListMobileCard 제거)
- IntegratedListTemplateV2 페이지네이션 버그 수정 (NaN 이슈)
- IntegratedDetailTemplate 타이틀 중복 수정
- 문서 시스템 컴포넌트 추가
- 헤더 벨 아이콘 포커스 스타일 개선

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-21 20:56:17 +09:00
parent cfa72fe19b
commit 835c06ce94
190 changed files with 8575 additions and 2354 deletions

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

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

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

View File

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

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

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

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

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

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

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

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

View File

@@ -0,0 +1,2 @@
export { useZoom } from './useZoom';
export { useDrag } from './useDrag';

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

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

View File

@@ -0,0 +1,4 @@
export { DocumentViewer } from './DocumentViewer';
export { DocumentToolbar } from './DocumentToolbar';
export { DocumentContent } from './DocumentContent';
export { useZoom, useDrag } from './hooks';