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:
293
src/components/approval/DocumentCreate/index.tsx
Normal file
293
src/components/approval/DocumentCreate/index.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user