refactor: 모달 Content 컴포넌트 분리 및 파일 입력 UI 공통화

- 모달 컴포넌트에서 Content 분리하여 재사용성 향상
  - EstimateDocumentContent, DirectConstructionContent 등
  - WorkLogContent, QuotePreviewContent, ReceivingReceiptContent
- 파일 입력 공통 UI 컴포넌트 추가
  - file-dropzone, file-input, file-list, image-upload
- 폼 컴포넌트 코드 정리 및 중복 제거 (-4,056줄)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-22 15:07:17 +09:00
parent 6fa69d81f4
commit 9464a368ba
48 changed files with 3900 additions and 4063 deletions

View File

@@ -0,0 +1,154 @@
'use client';
/**
* 결재란 공통 컴포넌트
*
* @example
* // 3열 결재란 (작성/승인)
* <ApprovalLine type="3col" writer="홍길동" writerDate="01/22" />
*
* // 4열 결재란 (작성/검토/승인)
* <ApprovalLine type="4col" writer="홍길동" reviewer="김검토" />
*
* // 외부 송부 시 숨김
* <ApprovalLine visible={false} />
*/
import { cn } from '@/lib/utils';
export interface ApprovalPerson {
name?: string;
date?: string;
department?: string;
}
export interface ApprovalLineProps {
/** 결재란 유형: 3열(작성/승인) 또는 4열(작성/검토/승인) */
type?: '3col' | '4col';
/** 외부 송부 시 숨김 */
visible?: boolean;
/** 문서 모드: internal(내부), external(외부송부) */
mode?: 'internal' | 'external';
/** 작성자 정보 */
writer?: ApprovalPerson;
/** 검토자 정보 (4col에서만 사용) */
reviewer?: ApprovalPerson;
/** 승인자 정보 */
approver?: ApprovalPerson;
/** 부서명 표시 여부 */
showDepartment?: boolean;
/** 부서 라벨 (기본값: 작성/검토/승인에 따라 다름) */
departmentLabels?: {
writer?: string;
reviewer?: string;
approver?: string;
};
/** 추가 className */
className?: string;
}
export function ApprovalLine({
type = '3col',
visible = true,
mode = 'internal',
writer,
reviewer,
approver,
showDepartment = true,
departmentLabels = {
writer: '판매/전진',
reviewer: '생산',
approver: '품질',
},
className,
}: ApprovalLineProps) {
// 외부 송부 모드이거나 visible이 false면 렌더링 안함
if (!visible || mode === 'external') {
return null;
}
const is4Col = type === '4col';
return (
<table className={cn('border-collapse text-xs', className)}>
<tbody>
{/* 헤더 행: 결재 + 작성/검토/승인 */}
<tr>
<td
rowSpan={showDepartment ? 3 : 2}
className="w-8 text-center font-medium bg-gray-100 border border-gray-300 align-middle"
>
<div className="flex flex-col items-center px-1">
<span></span>
<span></span>
</div>
</td>
<td className="w-16 p-2 text-center font-medium bg-gray-100 border border-gray-300">
</td>
{is4Col && (
<td className="w-16 p-2 text-center font-medium bg-gray-100 border border-gray-300">
</td>
)}
<td className="w-16 p-2 text-center font-medium bg-gray-100 border border-gray-300">
</td>
</tr>
{/* 서명 행: 이름 + 날짜 */}
<tr>
<td className="w-16 p-2 text-center border border-gray-300 h-10">
{writer?.name && (
<>
<div>{writer.name}</div>
{writer.date && (
<div className="text-[10px] text-gray-500">{writer.date}</div>
)}
</>
)}
</td>
{is4Col && (
<td className="w-16 p-2 text-center border border-gray-300 h-10">
{reviewer?.name && (
<>
<div>{reviewer.name}</div>
{reviewer.date && (
<div className="text-[10px] text-gray-500">{reviewer.date}</div>
)}
</>
)}
</td>
)}
<td className="w-16 p-2 text-center border border-gray-300 h-10">
{approver?.name && (
<>
<div>{approver.name}</div>
{approver.date && (
<div className="text-[10px] text-gray-500">{approver.date}</div>
)}
</>
)}
</td>
</tr>
{/* 부서 행 (선택적) */}
{showDepartment && (
<tr>
<td className="w-16 p-2 text-center bg-gray-50 border border-gray-300 text-[10px]">
{departmentLabels.writer}
</td>
{is4Col && (
<td className="w-16 p-2 text-center bg-gray-50 border border-gray-300 text-[10px]">
{departmentLabels.reviewer}
</td>
)}
<td className="w-16 p-2 text-center bg-gray-50 border border-gray-300 text-[10px]">
{departmentLabels.approver}
</td>
</tr>
)}
</tbody>
</table>
);
}

View File

@@ -0,0 +1,160 @@
'use client';
/**
* 문서 헤더 공통 컴포넌트
*
* @example
* // 기본 사용
* <DocumentHeader
* title="작업일지"
* documentCode="WL-SCR"
* subtitle="스크린 생산부서"
* approval={{ type: '4col', writer: { name: '홍길동', date: '01/22' } }}
* />
*
* // 로고 포함
* <DocumentHeader
* logo={{ text: 'KD', subtext: '정동기업' }}
* title="작업일지"
* approval={{ type: '4col' }}
* />
*
* // 결재선 위에 추가 정보
* <DocumentHeader
* title="견적서"
* topInfo={<div>LOT: KD-WO-260122</div>}
* approval={{ type: '3col' }}
* />
*
* // 외부 송부 (결재선 숨김)
* <DocumentHeader title="견적서" mode="external" />
*/
import { ReactNode } from 'react';
import { cn } from '@/lib/utils';
import { ApprovalLine, ApprovalLineProps } from './ApprovalLine';
export interface DocumentHeaderLogo {
/** 로고 텍스트 (예: 'KD') */
text: string;
/** 로고 서브텍스트 (예: '정동기업') */
subtext?: string;
/** 로고 이미지 URL (text 대신 사용) */
imageUrl?: string;
}
export interface DocumentHeaderProps {
/** 문서 제목 */
title: string;
/** 문서 코드 (예: 'WL-SCR') */
documentCode?: string;
/** 부제목 (예: '스크린 생산부서') */
subtitle?: string;
/** 로고 설정 */
logo?: DocumentHeaderLogo;
/** 결재선 위에 표시할 추가 정보 */
topInfo?: ReactNode;
/** 결재란 설정 (null이면 숨김) */
approval?: Omit<ApprovalLineProps, 'mode'> | null;
/** 문서 모드: internal(내부), external(외부송부) */
mode?: 'internal' | 'external';
/** 레이아웃 유형 */
layout?: 'default' | 'centered' | 'simple';
/** 추가 className */
className?: string;
}
export function DocumentHeader({
title,
documentCode,
subtitle,
logo,
topInfo,
approval,
mode = 'internal',
layout = 'default',
className,
}: DocumentHeaderProps) {
const isExternal = mode === 'external';
const showApproval = approval !== null && !isExternal;
// 간단한 레이아웃 (제목만)
if (layout === 'simple') {
return (
<div className={cn('mb-6', className)}>
<h1 className="text-2xl font-bold text-center tracking-widest">{title}</h1>
{documentCode && (
<p className="text-sm text-gray-500 text-center mt-1">{documentCode}</p>
)}
{subtitle && (
<p className="text-sm font-medium text-center mt-1">{subtitle}</p>
)}
</div>
);
}
// 중앙 정렬 레이아웃 (견적서 스타일)
if (layout === 'centered') {
return (
<div className={cn('flex justify-between items-start mb-6', className)}>
<div className="flex-1">
<h1 className="text-3xl font-bold text-center tracking-[0.3em]">{title}</h1>
{(documentCode || subtitle) && (
<div className="text-sm mt-4 text-center">
{documentCode && <span>: {documentCode}</span>}
{documentCode && subtitle && <span className="mx-2">|</span>}
{subtitle && <span>{subtitle}</span>}
</div>
)}
</div>
{showApproval && approval && (
<ApprovalLine {...approval} mode={mode} className="ml-4" />
)}
</div>
);
}
// 기본 레이아웃 (로고 + 제목 + 결재란)
return (
<div className={cn('flex border border-gray-300 mb-6', className)}>
{/* 좌측: 로고 영역 */}
{logo && (
<div className="w-24 border-r border-gray-300 flex flex-col items-center justify-center p-3 shrink-0">
{logo.imageUrl ? (
<img src={logo.imageUrl} alt={logo.text} className="h-8" />
) : (
<span className="text-2xl font-bold">{logo.text}</span>
)}
{logo.subtext && (
<span className="text-xs text-gray-500">{logo.subtext}</span>
)}
</div>
)}
{/* 중앙: 문서 제목 */}
<div className="flex-1 flex flex-col items-center justify-center p-3 border-r border-gray-300">
<h1 className="text-xl font-bold tracking-widest mb-1">{title}</h1>
{documentCode && (
<p className="text-xs text-gray-500">{documentCode}</p>
)}
{subtitle && (
<p className="text-sm font-medium mt-1">{subtitle}</p>
)}
</div>
{/* 우측: 결재선 위 정보 + 결재란 */}
{(showApproval || topInfo) && (
<div className="shrink-0 flex flex-col">
{topInfo && (
<div className="p-2 text-xs border-b border-gray-300">
{topInfo}
</div>
)}
{showApproval && approval && (
<ApprovalLine {...approval} mode={mode} />
)}
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,94 @@
'use client';
/**
* 정보 테이블 공통 컴포넌트 (라벨-값 구조)
*
* @example
* <InfoTable
* rows={[
* [{ label: '발주처', value: '(주)현대건설' }, { label: '현장명', value: '판교 테크노밸리' }],
* [{ label: '작업일자', value: '2026-01-22' }, { label: 'LOT NO.', value: 'KD-WO-260122' }],
* ]}
* />
*
* // 단일 열
* <InfoTable
* columns={1}
* rows={[
* [{ label: '비고', value: '특이사항 없음' }],
* ]}
* />
*/
import { ReactNode } from 'react';
import { cn } from '@/lib/utils';
export interface InfoTableCell {
/** 라벨 */
label: string;
/** 값 (문자열 또는 ReactNode) */
value: ReactNode;
/** 라벨 너비 (기본: w-24) */
labelWidth?: string;
/** 값 colSpan */
colSpan?: number;
}
export interface InfoTableProps {
/** 행 데이터 (2차원 배열: 행 > 셀) */
rows: InfoTableCell[][];
/** 열 개수 (기본: 2) */
columns?: 1 | 2 | 3 | 4;
/** 라벨 너비 (기본: w-24) */
labelWidth?: string;
/** 추가 className */
className?: string;
}
export function InfoTable({
rows,
columns = 2,
labelWidth = 'w-24',
className,
}: InfoTableProps) {
return (
<div className={cn('border border-gray-300', className)}>
{rows.map((row, rowIndex) => (
<div
key={rowIndex}
className={cn(
'grid',
columns === 1 && 'grid-cols-1',
columns === 2 && 'grid-cols-2',
columns === 3 && 'grid-cols-3',
columns === 4 && 'grid-cols-4',
rowIndex < rows.length - 1 && 'border-b border-gray-300'
)}
>
{row.map((cell, cellIndex) => (
<div
key={cellIndex}
className={cn(
'flex',
cellIndex < row.length - 1 && 'border-r border-gray-300'
)}
style={cell.colSpan ? { gridColumn: `span ${cell.colSpan}` } : undefined}
>
<div
className={cn(
'bg-gray-100 p-3 text-sm font-medium border-r border-gray-300 flex items-center shrink-0',
cell.labelWidth || labelWidth
)}
>
{cell.label}
</div>
<div className="flex-1 p-3 text-sm flex items-center">
{cell.value}
</div>
</div>
))}
</div>
))}
</div>
);
}

View File

@@ -0,0 +1,45 @@
'use client';
/**
* 섹션 헤더 공통 컴포넌트
*
* @example
* <SectionHeader>작업내역</SectionHeader>
* <SectionHeader variant="light">특이사항</SectionHeader>
*/
import { ReactNode } from 'react';
import { cn } from '@/lib/utils';
export interface SectionHeaderProps {
/** 섹션 제목 */
children: ReactNode;
/** 스타일 변형: dark(검정), light(회색), primary(파랑) */
variant?: 'dark' | 'light' | 'primary';
/** 추가 className */
className?: string;
}
const variantStyles = {
dark: 'bg-gray-800 text-white',
light: 'bg-gray-100 text-gray-800 border-b border-gray-300',
primary: 'bg-blue-600 text-white',
};
export function SectionHeader({
children,
variant = 'dark',
className,
}: SectionHeaderProps) {
return (
<div
className={cn(
'p-2.5 text-sm font-medium text-center',
variantStyles[variant],
className
)}
>
{children}
</div>
);
}

View File

@@ -0,0 +1,11 @@
// 문서 공통 컴포넌트
export { ApprovalLine } from './ApprovalLine';
export { DocumentHeader } from './DocumentHeader';
export { SectionHeader } from './SectionHeader';
export { InfoTable } from './InfoTable';
// Types
export type { ApprovalPerson, ApprovalLineProps } from './ApprovalLine';
export type { DocumentHeaderLogo, DocumentHeaderProps } from './DocumentHeader';
export type { SectionHeaderProps } from './SectionHeader';
export type { InfoTableCell, InfoTableProps } from './InfoTable';

View File

@@ -1,6 +1,14 @@
// Main Component
export { DocumentViewer } from './viewer';
// Document Components (공통 문서 요소)
export {
ApprovalLine,
DocumentHeader,
SectionHeader,
InfoTable,
} from './components';
// Hooks
export { useZoom, useDrag } from './viewer/hooks';
@@ -8,6 +16,17 @@ export { useZoom, useDrag } from './viewer/hooks';
export { DOCUMENT_PRESETS, getPreset, mergeWithPreset } from './presets';
// Types
export type {
// Document Component Types
ApprovalPerson,
ApprovalLineProps,
DocumentHeaderLogo,
DocumentHeaderProps,
SectionHeaderProps,
InfoTableCell,
InfoTableProps,
} from './components';
export type {
DocumentConfig,
DocumentViewerProps,