feat: 전자결재 시스템 구현 (기안함, 결재함, 참조함, 문서상세)

- 기안함(DraftBox): 문서 목록, 상신/삭제, 문서작성 연결
- 결재함(ApprovalBox): 결재 대기 문서 목록, 문서상세 모달 연결
- 참조함(ReferenceBox): 참조 문서 목록, 열람/미열람 처리
- 문서작성(DocumentCreate): 품의서, 지출결의서, 지출예상내역서 폼
- 문서상세(DocumentDetail): 공유 모달, 결재선 박스, 3종 문서 뷰어
- 테이블 번호 컬럼 추가 (1번부터 시작)
- sonner toast 적용

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
byeongcheolryu
2025-12-17 20:37:51 +09:00
parent 25f9d4e55f
commit d742c0ce26
25 changed files with 4032 additions and 0 deletions

View File

@@ -0,0 +1,293 @@
'use client';
import { useState, useCallback } from 'react';
import { useRouter } from 'next/navigation';
import { format } from 'date-fns';
import { FileText, Trash2, Send, Save, ArrowLeft, Eye } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle, CardDescription } from '@/components/ui/card';
import { BasicInfoSection } from './BasicInfoSection';
import { ApprovalLineSection } from './ApprovalLineSection';
import { ReferenceSection } from './ReferenceSection';
import { ProposalForm } from './ProposalForm';
import { ExpenseReportForm } from './ExpenseReportForm';
import { ExpenseEstimateForm } from './ExpenseEstimateForm';
import { DocumentDetailModal } from '@/components/approval/DocumentDetail';
import type {
DocumentType as ModalDocumentType,
ProposalDocumentData,
ExpenseReportDocumentData,
ExpenseEstimateDocumentData,
} from '@/components/approval/DocumentDetail/types';
import type {
DocumentType,
BasicInfo,
ApprovalPerson,
ProposalData,
ExpenseReportData,
ExpenseEstimateData,
} from './types';
// 초기 데이터
const getInitialBasicInfo = (): BasicInfo => ({
drafter: '홍길동',
draftDate: format(new Date(), 'yyyy-MM-dd HH:mm'),
documentNo: '',
documentType: 'proposal',
});
const getInitialProposalData = (): ProposalData => ({
vendor: '',
vendorPaymentDate: format(new Date(), 'yyyy-MM-dd'),
title: '',
description: '',
reason: '',
estimatedCost: 0,
attachments: [],
});
const getInitialExpenseReportData = (): ExpenseReportData => ({
requestDate: format(new Date(), 'yyyy-MM-dd'),
paymentDate: format(new Date(), 'yyyy-MM-dd'),
items: [],
cardId: '',
totalAmount: 0,
attachments: [],
});
const getInitialExpenseEstimateData = (): ExpenseEstimateData => ({
items: [],
totalExpense: 0,
accountBalance: 10000000,
finalDifference: 10000000,
});
export function DocumentCreate() {
const router = useRouter();
// 상태 관리
const [basicInfo, setBasicInfo] = useState<BasicInfo>(getInitialBasicInfo);
const [approvalLine, setApprovalLine] = useState<ApprovalPerson[]>([]);
const [references, setReferences] = useState<ApprovalPerson[]>([]);
const [proposalData, setProposalData] = useState<ProposalData>(getInitialProposalData);
const [expenseReportData, setExpenseReportData] = useState<ExpenseReportData>(getInitialExpenseReportData);
const [expenseEstimateData, setExpenseEstimateData] = useState<ExpenseEstimateData>(getInitialExpenseEstimateData);
// 미리보기 모달 상태
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
// 핸들러
const handleBack = useCallback(() => {
router.back();
}, [router]);
const handleDelete = useCallback(() => {
if (confirm('작성 중인 문서를 삭제하시겠습니까?')) {
router.back();
}
}, [router]);
const handleSubmit = useCallback(() => {
console.log('상신:', {
basicInfo,
approvalLine,
references,
proposalData: basicInfo.documentType === 'proposal' ? proposalData : undefined,
expenseReportData: basicInfo.documentType === 'expenseReport' ? expenseReportData : undefined,
expenseEstimateData: basicInfo.documentType === 'expenseEstimate' ? expenseEstimateData : undefined,
});
alert('문서가 상신되었습니다.');
router.back();
}, [basicInfo, approvalLine, references, proposalData, expenseReportData, expenseEstimateData, router]);
const handleSaveDraft = useCallback(() => {
console.log('임시저장:', {
basicInfo,
approvalLine,
references,
proposalData: basicInfo.documentType === 'proposal' ? proposalData : undefined,
expenseReportData: basicInfo.documentType === 'expenseReport' ? expenseReportData : undefined,
expenseEstimateData: basicInfo.documentType === 'expenseEstimate' ? expenseEstimateData : undefined,
});
alert('임시저장되었습니다.');
}, [basicInfo, approvalLine, references, proposalData, expenseReportData, expenseEstimateData]);
// 미리보기 핸들러
const handlePreview = useCallback(() => {
setIsPreviewOpen(true);
}, []);
// 미리보기용 데이터 변환
const getPreviewData = useCallback((): ProposalDocumentData | ExpenseReportDocumentData | ExpenseEstimateDocumentData => {
const drafter = {
id: 'drafter-1',
name: basicInfo.drafter,
position: '사원',
department: '개발팀',
status: 'approved' as const,
};
const approvers = approvalLine.map((a, index) => ({
id: a.id,
name: a.name,
position: a.position,
department: a.department,
status: (index === 0 ? 'pending' : 'none') as 'pending' | 'approved' | 'rejected' | 'none',
}));
switch (basicInfo.documentType) {
case 'expenseEstimate':
return {
documentNo: basicInfo.documentNo || '미발급',
createdAt: basicInfo.draftDate,
items: expenseEstimateData.items.map(item => ({
id: item.id,
expectedPaymentDate: item.expectedPaymentDate,
category: item.category,
amount: item.amount,
vendor: item.vendor,
account: item.memo || '',
})),
totalExpense: expenseEstimateData.totalExpense,
accountBalance: expenseEstimateData.accountBalance,
finalDifference: expenseEstimateData.finalDifference,
approvers,
drafter,
};
case 'expenseReport':
return {
documentNo: basicInfo.documentNo || '미발급',
createdAt: basicInfo.draftDate,
requestDate: expenseReportData.requestDate,
paymentDate: expenseReportData.paymentDate,
items: expenseReportData.items.map((item, index) => ({
id: item.id,
no: index + 1,
description: item.description,
amount: item.amount,
note: item.note,
})),
cardInfo: expenseReportData.cardId || '-',
totalAmount: expenseReportData.totalAmount,
attachments: expenseReportData.attachments,
approvers,
drafter,
};
default:
return {
documentNo: basicInfo.documentNo || '미발급',
createdAt: basicInfo.draftDate,
vendor: proposalData.vendor || '-',
vendorPaymentDate: proposalData.vendorPaymentDate,
title: proposalData.title || '(제목 없음)',
description: proposalData.description || '-',
reason: proposalData.reason || '-',
estimatedCost: proposalData.estimatedCost,
attachments: proposalData.attachments,
approvers,
drafter,
};
}
}, [basicInfo, approvalLine, proposalData, expenseReportData, expenseEstimateData]);
// 문서 유형별 폼 렌더링
const renderDocumentTypeForm = () => {
switch (basicInfo.documentType) {
case 'proposal':
return <ProposalForm data={proposalData} onChange={setProposalData} />;
case 'expenseReport':
return <ExpenseReportForm data={expenseReportData} onChange={setExpenseReportData} />;
case 'expenseEstimate':
return <ExpenseEstimateForm data={expenseEstimateData} onChange={setExpenseEstimateData} />;
default:
return null;
}
};
return (
<div className="container mx-auto py-6 px-4 max-w-4xl">
{/* 헤더 */}
<Card className="mb-6">
<CardHeader>
<div className="flex items-center justify-between">
<div className="flex items-center gap-3">
<Button variant="ghost" size="icon" onClick={handleBack}>
<ArrowLeft className="h-5 w-5" />
</Button>
<div className="flex items-center gap-2">
<FileText className="h-6 w-6 text-primary" />
<div>
<CardTitle> </CardTitle>
<CardDescription> </CardDescription>
</div>
</div>
</div>
</div>
</CardHeader>
</Card>
{/* 액션 버튼 (스텝) */}
<div className="flex items-center justify-center gap-2 mb-6">
<Button variant="outline" className="min-w-[80px]" onClick={handlePreview}>
<Eye className="w-4 h-4 mr-1" />
</Button>
<Button variant="outline" className="min-w-[80px]" onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-1" />
</Button>
<Button variant="default" className="min-w-[80px]" onClick={handleSubmit}>
<Send className="w-4 h-4 mr-1" />
</Button>
<Button variant="secondary" className="min-w-[80px]" onClick={handleSaveDraft}>
<Save className="w-4 h-4 mr-1" />
</Button>
</div>
{/* 폼 영역 */}
<div className="space-y-6">
{/* 기본 정보 */}
<BasicInfoSection data={basicInfo} onChange={setBasicInfo} />
{/* 결재선 */}
<ApprovalLineSection data={approvalLine} onChange={setApprovalLine} />
{/* 참조 */}
<ReferenceSection data={references} onChange={setReferences} />
{/* 문서 유형별 폼 */}
{renderDocumentTypeForm()}
</div>
{/* 하단 고정 버튼 (모바일) */}
<div className="fixed bottom-0 left-0 right-0 p-4 bg-white border-t md:hidden">
<div className="flex gap-2">
<Button variant="outline" className="flex-1" onClick={handleDelete}>
<Trash2 className="w-4 h-4 mr-1" />
</Button>
<Button variant="secondary" className="flex-1" onClick={handleSaveDraft}>
<Save className="w-4 h-4 mr-1" />
</Button>
<Button variant="default" className="flex-1" onClick={handleSubmit}>
<Send className="w-4 h-4 mr-1" />
</Button>
</div>
</div>
{/* 모바일 하단 여백 */}
<div className="h-20 md:hidden" />
{/* 미리보기 모달 */}
<DocumentDetailModal
open={isPreviewOpen}
onOpenChange={setIsPreviewOpen}
documentType={basicInfo.documentType as ModalDocumentType}
data={getPreviewData()}
/>
</div>
);
}