refactor: UI 컴포넌트 추상화 및 입금/출금 등록 버튼 추가

- 입금관리, 출금관리 리스트에 등록 버튼 추가
- skeleton, confirm-dialog, empty-state, status-badge UI 컴포넌트 추가
- document-system 컴포넌트 추상화 (ApprovalLine, DocumentHeader 등)
- 여러 페이지 컴포넌트 리팩토링 및 코드 정리

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
유병철
2026-01-22 17:21:42 +09:00
parent 777dccc7bd
commit 269b901e64
86 changed files with 3761 additions and 2614 deletions

View File

@@ -23,8 +23,8 @@ export interface ApprovalPerson {
}
export interface ApprovalLineProps {
/** 결재란 유형: 3열(작성/승인) 또는 4열(작성/검토/승인) */
type?: '3col' | '4col';
/** 결재란 유형: 2열(담당/부서장), 3열(작성/승인), 4열(작성/검토/승인) */
type?: '2col' | '3col' | '4col';
/** 외부 송부 시 숨김 */
visible?: boolean;
/** 문서 모드: internal(내부), external(외부송부) */
@@ -43,6 +43,12 @@ export interface ApprovalLineProps {
reviewer?: string;
approver?: string;
};
/** 컬럼 헤더 라벨 커스텀 (기본값: 작성/검토/승인) */
columnLabels?: {
writer?: string;
reviewer?: string;
approver?: string;
};
/** 추가 className */
className?: string;
}
@@ -60,6 +66,7 @@ export function ApprovalLine({
reviewer: '생산',
approver: '품질',
},
columnLabels,
className,
}: ApprovalLineProps) {
// 외부 송부 모드이거나 visible이 false면 렌더링 안함
@@ -68,6 +75,14 @@ export function ApprovalLine({
}
const is4Col = type === '4col';
const is2Col = type === '2col';
// 기본 컬럼 라벨
const labels = {
writer: columnLabels?.writer ?? (is2Col ? '담당' : '작성'),
reviewer: columnLabels?.reviewer ?? '검토',
approver: columnLabels?.approver ?? (is2Col ? '부서장' : '승인'),
};
return (
<table className={cn('border-collapse text-xs', className)}>
@@ -84,15 +99,15 @@ export function ApprovalLine({
</div>
</td>
<td className="w-16 p-2 text-center font-medium bg-gray-100 border border-gray-300">
{labels.writer}
</td>
{is4Col && (
<td className="w-16 p-2 text-center font-medium bg-gray-100 border border-gray-300">
{labels.reviewer}
</td>
)}
<td className="w-16 p-2 text-center font-medium bg-gray-100 border border-gray-300">
{labels.approver}
</td>
</tr>

View File

@@ -0,0 +1,115 @@
'use client';
/**
* 건설 문서용 결재란 컴포넌트
*
* @example
* // 4열 결재란 (작성 + 승인 3개)
* <ConstructionApprovalTable
* approvers={{
* writer: { name: '홍길동', department: '영업부' },
* approver1: { name: '김부장', department: '기획부' },
* approver2: { name: '이이사', department: '개발부' },
* approver3: { name: '박대표', department: '경영부' },
* }}
* />
*/
import { cn } from '@/lib/utils';
export interface ConstructionApprover {
name?: string;
department?: string;
}
export interface ConstructionApprovalTableProps {
/** 결재자 정보 */
approvers?: {
writer?: ConstructionApprover;
approver1?: ConstructionApprover;
approver2?: ConstructionApprover;
approver3?: ConstructionApprover;
};
/** 컬럼 헤더 라벨 커스텀 */
columnLabels?: {
writer?: string;
approver1?: string;
approver2?: string;
approver3?: string;
};
/** 추가 className */
className?: string;
}
export function ConstructionApprovalTable({
approvers = {},
columnLabels = {},
className,
}: ConstructionApprovalTableProps) {
const labels = {
writer: columnLabels.writer ?? '작성',
approver1: columnLabels.approver1 ?? '승인',
approver2: columnLabels.approver2 ?? '승인',
approver3: columnLabels.approver3 ?? '승인',
};
return (
<table className={cn('border-collapse border border-gray-300 text-sm', className)}>
<tbody>
{/* 헤더 행 */}
<tr>
<th
rowSpan={3}
className="border border-gray-300 px-2 py-1 bg-gray-50 text-center w-8 align-middle"
>
<span className="writing-vertical"><br /></span>
</th>
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16">
{labels.writer}
</th>
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16">
{labels.approver1}
</th>
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16">
{labels.approver2}
</th>
<th className="border border-gray-300 px-3 py-1 bg-gray-50 text-center w-16">
{labels.approver3}
</th>
</tr>
{/* 이름 행 */}
<tr>
<td className="border border-gray-300 px-3 py-2 text-center h-10">
{approvers.writer?.name || ''}
</td>
<td className="border border-gray-300 px-3 py-2 text-center h-10">
{approvers.approver1?.name || ''}
</td>
<td className="border border-gray-300 px-3 py-2 text-center h-10">
{approvers.approver2?.name || ''}
</td>
<td className="border border-gray-300 px-3 py-2 text-center h-10">
{approvers.approver3?.name || ''}
</td>
</tr>
{/* 부서 행 */}
<tr>
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500">
{approvers.writer?.department || '부서명'}
</td>
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500">
{approvers.approver1?.department || '부서명'}
</td>
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500">
{approvers.approver2?.department || '부서명'}
</td>
<td className="border border-gray-300 px-3 py-1 text-center text-xs text-gray-500">
{approvers.approver3?.department || '부서명'}
</td>
</tr>
</tbody>
</table>
);
}

View File

@@ -28,6 +28,15 @@
*
* // 외부 송부 (결재선 숨김)
* <DocumentHeader title="견적서" mode="external" />
*
* // 품질검사 레이아웃 (2줄 제목)
* <DocumentHeader
* title="스크린"
* subtitle="중간검사 성적서"
* layout="quality"
* logo={{ text: 'KD', subtext: '경동기업\nKYUNGDONG COMPANY' }}
* customApproval={<QualityApprovalTable type="4col" approvers={approvers} />}
* />
*/
import { ReactNode } from 'react';
@@ -56,10 +65,12 @@ export interface DocumentHeaderProps {
topInfo?: ReactNode;
/** 결재란 설정 (null이면 숨김) */
approval?: Omit<ApprovalLineProps, 'mode'> | null;
/** 커스텀 결재란 컴포넌트 (approval 대신 사용) */
customApproval?: ReactNode;
/** 문서 모드: internal(내부), external(외부송부) */
mode?: 'internal' | 'external';
/** 레이아웃 유형 */
layout?: 'default' | 'centered' | 'simple';
layout?: 'default' | 'centered' | 'simple' | 'quality' | 'construction' | 'quote';
/** 추가 className */
className?: string;
}
@@ -71,12 +82,13 @@ export function DocumentHeader({
logo,
topInfo,
approval,
customApproval,
mode = 'internal',
layout = 'default',
className,
}: DocumentHeaderProps) {
const isExternal = mode === 'external';
const showApproval = approval !== null && !isExternal;
const showApproval = (approval !== null || customApproval) && !isExternal;
// 간단한 레이아웃 (제목만)
if (layout === 'simple') {
@@ -93,6 +105,79 @@ export function DocumentHeader({
);
}
// 품질검사 레이아웃 (로고 + 2줄 제목 + 결재란, 테두리 없음)
if (layout === 'quality') {
return (
<div className={cn('flex justify-between items-start mb-4', className)}>
{/* 좌측: 로고 영역 */}
{logo && (
<div className="flex items-center gap-2">
{logo.imageUrl ? (
<img src={logo.imageUrl} alt={logo.text} className="h-8" />
) : (
<div className="text-2xl font-bold">{logo.text}</div>
)}
{logo.subtext && (
<div className="text-xs text-gray-600 whitespace-pre-line">{logo.subtext}</div>
)}
</div>
)}
{/* 중앙: 2줄 제목 */}
<div className="text-center">
<div className="text-xl font-bold">{title}</div>
{subtitle && (
<div className="text-xl font-bold tracking-[0.2rem]">{subtitle}</div>
)}
</div>
{/* 우측: 결재란 */}
{showApproval && (
customApproval || (approval && <ApprovalLine {...approval} mode={mode} />)
)}
</div>
);
}
// 건설 문서 레이아웃 (좌측 정렬 제목 + 결재란)
if (layout === 'construction') {
return (
<div className={cn('flex justify-between items-start mb-6', className)}>
{/* 좌측: 제목 및 문서정보 */}
<div>
<h1 className="text-2xl font-bold mb-2">{title}</h1>
{(documentCode || subtitle) && (
<div className="text-sm text-gray-600">
{documentCode && <span>: {documentCode}</span>}
{documentCode && subtitle && <span> | </span>}
{subtitle && <span>{subtitle}</span>}
</div>
)}
</div>
{/* 우측: 결재란 */}
{showApproval && (
customApproval || (approval && <ApprovalLine {...approval} mode={mode} />)
)}
</div>
);
}
// 견적/발주 문서 레이아웃 (중앙 제목 + 우측 로트번호/결재란)
if (layout === 'quote') {
return (
<div className={cn('flex items-center justify-between mb-5 pb-4 border-b-2 border-black', className)}>
{/* 중앙: 제목 */}
<div className="flex-1 text-center">
<h1 className="text-4xl font-bold tracking-[8px]">{title}</h1>
</div>
{/* 우측: 로트번호 + 결재란 (customApproval로 전달) */}
{showApproval && customApproval}
</div>
);
}
// 중앙 정렬 레이아웃 (견적서 스타일)
if (layout === 'centered') {
return (
@@ -107,8 +192,10 @@ export function DocumentHeader({
</div>
)}
</div>
{showApproval && approval && (
<ApprovalLine {...approval} mode={mode} className="ml-4" />
{showApproval && (
<div className="ml-4">
{customApproval || (approval && <ApprovalLine {...approval} mode={mode} />)}
</div>
)}
</div>
);
@@ -150,8 +237,8 @@ export function DocumentHeader({
{topInfo}
</div>
)}
{showApproval && approval && (
<ApprovalLine {...approval} mode={mode} />
{showApproval && (
customApproval || (approval && <ApprovalLine {...approval} mode={mode} />)
)}
</div>
)}

View File

@@ -0,0 +1,121 @@
'use client';
/**
* 로트번호 + 결재란 통합 컴포넌트
*
* @example
* <LotApprovalTable
* lotNumber="KQ#-SC-250122-01"
* approvers={{
* writer: { name: '전진', department: '판매/전진' },
* reviewer: { name: '', department: '회계' },
* approver: { name: '', department: '생산' },
* }}
* />
*/
import { cn } from '@/lib/utils';
export interface LotApprover {
name?: string;
department?: string;
}
export interface LotApprovalTableProps {
/** 로트번호 */
lotNumber: string;
/** 로트번호 라벨 (기본값: '로트번호') */
lotLabel?: string;
/** 결재자 정보 */
approvers?: {
writer?: LotApprover;
reviewer?: LotApprover;
approver?: LotApprover;
};
/** 컬럼 헤더 라벨 커스텀 */
columnLabels?: {
writer?: string;
reviewer?: string;
approver?: string;
};
/** 추가 className */
className?: string;
}
export function LotApprovalTable({
lotNumber,
lotLabel = '로트번호',
approvers = {},
columnLabels = {},
className,
}: LotApprovalTableProps) {
const labels = {
writer: columnLabels.writer ?? '작성',
reviewer: columnLabels.reviewer ?? '검토',
approver: columnLabels.approver ?? '승인',
};
return (
<div className={cn('border-2 border-black bg-white', className)}>
{/* 로트번호 행 */}
<div className="grid grid-cols-[100px_1fr] border-b-2 border-black">
<div className="bg-gray-200 border-r-2 border-black px-2 py-2 text-center font-bold text-xs flex items-center justify-center">
{lotLabel}
</div>
<div className="bg-white px-2 py-2 text-center font-bold text-sm flex items-center justify-center">
{lotNumber}
</div>
</div>
{/* 결재란 */}
<div className="grid grid-cols-[60px_1fr]">
{/* 결재 세로 셀 */}
<div className="border-r border-black flex items-center justify-center bg-white row-span-3">
<span className="text-xs font-semibold"><br /></span>
</div>
{/* 결재 내용 */}
<div>
{/* 헤더 행 */}
<div className="grid grid-cols-3 border-b border-black">
<div className="border-r border-black px-3 py-2 text-center font-semibold text-xs bg-white">
{labels.writer}
</div>
<div className="border-r border-black px-3 py-2 text-center font-semibold text-xs bg-white">
{labels.reviewer}
</div>
<div className="px-3 py-2 text-center font-semibold text-xs bg-white">
{labels.approver}
</div>
</div>
{/* 서명 행 */}
<div className="grid grid-cols-3 border-b border-black">
<div className="border-r border-black px-3 py-2 text-center text-xs h-12 flex items-center justify-center bg-white">
{approvers.writer?.name || ''}
</div>
<div className="border-r border-black px-3 py-2 text-center text-xs h-12 flex items-center justify-center bg-white">
{approvers.reviewer?.name || ''}
</div>
<div className="px-3 py-2 text-center text-xs h-12 flex items-center justify-center bg-white">
{approvers.approver?.name || ''}
</div>
</div>
{/* 부서 행 */}
<div className="grid grid-cols-3">
<div className="border-r border-black px-2 py-1 text-center text-xs font-semibold bg-white">
{approvers.writer?.department || ''}
</div>
<div className="border-r border-black px-2 py-1 text-center text-xs font-semibold bg-white">
{approvers.reviewer?.department || ''}
</div>
<div className="px-2 py-1 text-center text-xs font-semibold bg-white">
{approvers.approver?.department || ''}
</div>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,122 @@
'use client';
/**
* 품질검사 문서용 결재란 컴포넌트
*
* @example
* // 2열 타입 (수입검사 성적서)
* <QualityApprovalTable
* type="2col"
* approvers={{ writer: '노원호' }}
* reportDate="2025-07-15"
* />
*
* // 4열 타입 (중간검사 성적서)
* <QualityApprovalTable
* type="4col"
* approvers={{ writer: '전진', reviewer: '', approver: '' }}
* departments={{ writer: '판매/전진', reviewer: '생산', approver: '품질' }}
* />
*/
import { cn } from '@/lib/utils';
export interface QualityApprovers {
writer?: string;
reviewer?: string;
approver?: string;
}
export interface QualityDepartments {
writer?: string;
reviewer?: string;
approver?: string;
}
export interface QualityApprovalTableProps {
/** 결재란 타입: 2col(담당/부서장), 4col(결재/작성/검토/승인) */
type?: '2col' | '4col';
/** 결재자 정보 */
approvers?: QualityApprovers;
/** 부서 정보 (4col 전용) */
departments?: QualityDepartments;
/** 보고일자 (2col 전용) */
reportDate?: string;
/** 추가 className */
className?: string;
}
export function QualityApprovalTable({
type = '4col',
approvers = {},
departments = {
writer: '판매/전진',
reviewer: '생산',
approver: '품질',
},
reportDate,
className,
}: QualityApprovalTableProps) {
// 2열 타입 (수입검사 성적서 - 담당/부서장)
if (type === '2col') {
return (
<div className={cn('text-right', className)}>
<table className="text-xs border-collapse">
<tbody>
<tr>
<td className="border border-gray-400 px-2 py-1 bg-gray-100 w-12"></td>
<td className="border border-gray-400 px-2 py-1 w-16"></td>
</tr>
<tr>
<td className="border border-gray-400 px-2 py-1 bg-gray-100"></td>
<td className="border border-gray-400 px-2 py-1 h-8">{approvers.writer || ''}</td>
</tr>
</tbody>
</table>
{reportDate && (
<div className="text-xs text-right mt-1">: {reportDate}</div>
)}
</div>
);
}
// 4열 타입 (중간검사 성적서 - 결재/작성/검토/승인 + 부서)
return (
<table className={cn('text-xs border-collapse', className)}>
<tbody>
<tr>
<td className="border border-gray-400 px-2 py-1 bg-gray-100 w-8 text-center" rowSpan={3}>
<div className="flex flex-col items-center">
<span></span><span></span>
</div>
</td>
<td className="border border-gray-400 px-2 py-1 bg-gray-100 w-14 text-center"></td>
<td className="border border-gray-400 px-2 py-1 bg-gray-100 w-14 text-center"></td>
<td className="border border-gray-400 px-2 py-1 bg-gray-100 w-14 text-center"></td>
</tr>
<tr>
<td className="border border-gray-400 px-2 py-1 h-8 text-center font-medium">
{approvers.writer || ''}
</td>
<td className="border border-gray-400 px-2 py-1 h-8 text-center">
{approvers.reviewer || ''}
</td>
<td className="border border-gray-400 px-2 py-1 h-8 text-center">
{approvers.approver || ''}
</td>
</tr>
<tr>
<td className="border border-gray-400 px-2 py-1 text-center bg-gray-50">
{departments.writer || '판매/전진'}
</td>
<td className="border border-gray-400 px-2 py-1 text-center bg-gray-50">
{departments.reviewer || '생산'}
</td>
<td className="border border-gray-400 px-2 py-1 text-center bg-gray-50">
{departments.approver || '품질'}
</td>
</tr>
</tbody>
</table>
);
}

View File

@@ -0,0 +1,106 @@
'use client';
/**
* 서명/도장 영역 컴포넌트
*
* @example
* // 기본 사용 (도장 영역 포함)
* <SignatureSection
* date="2025년 01월 22일"
* companyName="경동기업"
* showStamp={true}
* />
*
* // 커스텀 문구
* <SignatureSection
* label="상기와 같이 견적합니다."
* date="2025년 01월 22일"
* companyName="경동기업"
* role="공급자"
* />
*/
import { cn } from '@/lib/utils';
export interface SignatureSectionProps {
/** 상단 안내 문구 (기본값: '상기와 같이 견적합니다.') */
label?: string;
/** 날짜 */
date?: string;
/** 회사명 */
companyName?: string;
/** 역할 라벨 (기본값: '공급자') */
role?: string;
/** 도장 영역 표시 여부 */
showStamp?: boolean;
/** 도장 내부 텍스트 */
stampText?: string;
/** 도장 이미지 URL */
stampImageUrl?: string;
/** 정렬 (기본값: 'right') */
align?: 'left' | 'center' | 'right';
/** 추가 className */
className?: string;
}
export function SignatureSection({
label = '상기와 같이 견적합니다.',
date,
companyName,
role = '공급자',
showStamp = true,
stampText = '(인감\n날인)',
stampImageUrl,
align = 'right',
className,
}: SignatureSectionProps) {
const alignClass = {
left: 'text-left',
center: 'text-center',
right: 'text-right',
}[align];
return (
<div className={cn('mt-8', alignClass, className)}>
<div className="inline-block text-left">
{/* 안내 문구 */}
{label && (
<div className="mb-4 text-sm">
{label}
</div>
)}
{/* 날짜 + 회사명 + 도장 */}
<div className="flex items-center gap-5">
<div>
{date && (
<div className="text-sm mb-1">{date}</div>
)}
{companyName && (
<div className="text-base font-semibold">
{role}: {companyName} ()
</div>
)}
</div>
{/* 도장 영역 */}
{showStamp && (
<div className="border-2 border-black w-20 h-20 relative inline-block ml-5">
{stampImageUrl ? (
<img
src={stampImageUrl}
alt="도장"
className="w-full h-full object-contain"
/>
) : (
<div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 text-[10px] text-gray-400 text-center whitespace-pre-line leading-tight">
{stampText}
</div>
)}
</div>
)}
</div>
</div>
</div>
);
}

View File

@@ -3,9 +3,27 @@ export { ApprovalLine } from './ApprovalLine';
export { DocumentHeader } from './DocumentHeader';
export { SectionHeader } from './SectionHeader';
export { InfoTable } from './InfoTable';
export { QualityApprovalTable } from './QualityApprovalTable';
export { ConstructionApprovalTable } from './ConstructionApprovalTable';
export { LotApprovalTable } from './LotApprovalTable';
export { SignatureSection } from './SignatureSection';
// Types
export type { ApprovalPerson, ApprovalLineProps } from './ApprovalLine';
export type { DocumentHeaderLogo, DocumentHeaderProps } from './DocumentHeader';
export type { SectionHeaderProps } from './SectionHeader';
export type { InfoTableCell, InfoTableProps } from './InfoTable';
export type {
QualityApprovers,
QualityDepartments,
QualityApprovalTableProps,
} from './QualityApprovalTable';
export type {
ConstructionApprover,
ConstructionApprovalTableProps,
} from './ConstructionApprovalTable';
export type {
LotApprover,
LotApprovalTableProps,
} from './LotApprovalTable';
export type { SignatureSectionProps } from './SignatureSection';

View File

@@ -7,6 +7,10 @@ export {
DocumentHeader,
SectionHeader,
InfoTable,
QualityApprovalTable,
ConstructionApprovalTable,
LotApprovalTable,
SignatureSection,
} from './components';
// Hooks
@@ -25,6 +29,14 @@ export type {
SectionHeaderProps,
InfoTableCell,
InfoTableProps,
QualityApprovers,
QualityDepartments,
QualityApprovalTableProps,
ConstructionApprover,
ConstructionApprovalTableProps,
LotApprover,
LotApprovalTableProps,
SignatureSectionProps,
} from './components';
export type {