Files
sam-react-prod/src/components/approval/DocumentCreate/index.tsx
권혁성 c8890c1a85 feat(WEB): 결재함 문서 상세 모달 데이터 연동 개선
- ApprovalBox: 문서 클릭 시 API 데이터 로드하여 모달에 표시
- DocumentCreate: 품의서 폼 개선 및 actions 수정
- 결재자 정보 (직책, 부서) 표시 개선
2026-01-22 23:19:37 +09:00

640 lines
22 KiB
TypeScript

'use client';
import { useState, useCallback, useEffect, useTransition, useRef } from 'react';
import { useRouter, useSearchParams } from 'next/navigation';
import { format } from 'date-fns';
import { Trash2, Send, Save, Eye, Loader2 } from 'lucide-react';
import { toast } from 'sonner';
import { IntegratedDetailTemplate } from '@/components/templates/IntegratedDetailTemplate';
import {
documentCreateConfig,
documentEditConfig,
documentCopyConfig,
} from './documentCreateConfig';
import {
getExpenseEstimateItems,
createApproval,
createAndSubmitApproval,
getApprovalById,
updateApproval,
updateAndSubmitApproval,
deleteApproval,
getEmployees,
} from './actions';
import { useAuth } from '@/contexts/AuthContext';
import { Button } from '@/components/ui/button';
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 {
BasicInfo,
ApprovalPerson,
ProposalData,
ExpenseReportData,
ExpenseEstimateData,
} from './types';
import { isNextRedirectError } from '@/lib/utils/redirect-error';
import { useDevFill, generatePurchaseApprovalData } from '@/components/dev';
import { getClients } from '@/components/accounting/VendorManagement/actions';
// 초기 데이터 - SSR에서는 빈 문자열, 클라이언트에서 날짜 설정
const getInitialBasicInfo = (): BasicInfo => ({
drafter: '홍길동',
draftDate: '', // 클라이언트에서 설정
documentNo: '',
documentType: 'proposal',
});
const getInitialProposalData = (): ProposalData => ({
vendorId: '',
vendor: '',
vendorPaymentDate: '', // 클라이언트에서 설정
title: '',
description: '',
reason: '',
estimatedCost: 0,
attachments: [],
});
const getInitialExpenseReportData = (): ExpenseReportData => ({
requestDate: '', // 클라이언트에서 설정
paymentDate: '', // 클라이언트에서 설정
items: [],
cardId: '',
totalAmount: 0,
attachments: [],
});
const getInitialExpenseEstimateData = (): ExpenseEstimateData => ({
items: [],
totalExpense: 0,
accountBalance: 10000000,
finalDifference: 10000000,
});
export function DocumentCreate() {
const router = useRouter();
const searchParams = useSearchParams();
const [isPending, startTransition] = useTransition();
const { currentUser } = useAuth();
// 수정 모드 / 복제 모드 상태
const documentId = searchParams.get('id');
const mode = searchParams.get('mode');
const copyFromId = searchParams.get('copyFrom');
const isEditMode = mode === 'edit' && !!documentId;
const isCopyMode = !!copyFromId;
const [isLoadingDocument, setIsLoadingDocument] = useState(false);
// 상태 관리
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 [isLoadingEstimate, setIsLoadingEstimate] = useState(false);
// 복제 모드 toast 중복 호출 방지
const copyToastShownRef = useRef(false);
// Hydration 불일치 방지: 클라이언트에서만 날짜 초기화
useEffect(() => {
const today = format(new Date(), 'yyyy-MM-dd');
const now = format(new Date(), 'yyyy-MM-dd HH:mm');
setBasicInfo(prev => ({ ...prev, draftDate: prev.draftDate || now }));
setProposalData(prev => ({ ...prev, vendorPaymentDate: prev.vendorPaymentDate || today }));
setExpenseReportData(prev => ({
...prev,
requestDate: prev.requestDate || today,
paymentDate: prev.paymentDate || today,
}));
}, []);
// 미리보기 모달 상태
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
// ===== DevFill: 자동 입력 기능 =====
useDevFill('purchaseApproval', useCallback(async () => {
if (!isEditMode && !isCopyMode) {
const mockData = generatePurchaseApprovalData();
// 직원 목록 가져오기
const employees = await getEmployees();
// 거래처 목록 가져오기 (매입 거래처만)
const clientsResult = await getClients({ size: 1000, only_active: true });
const purchaseClients = clientsResult.success
? clientsResult.data
.filter((v) => v.category === 'purchase' || v.category === 'both')
.map((v) => ({ id: v.id, name: v.vendorName }))
: [];
// 랜덤 거래처 선택
const randomClient = purchaseClients.length > 0
? purchaseClients[Math.floor(Math.random() * purchaseClients.length)]
: null;
// localStorage에서 실제 로그인 사용자 이름 가져오기 (우측 상단 표시와 동일한 소스)
const userDataStr = localStorage.getItem("user");
const currentUserName = userDataStr ? JSON.parse(userDataStr).name : currentUser?.name;
// 현재 사용자 이름으로 결재선에 추가할 직원 찾기
const approver = currentUserName
? employees.find(e => e.name === currentUserName)
: null;
// 경리/회계/재무 부서 직원 중 랜덤 1명 참조 추가
const accountingDepts = ['경리', '회계', '재무'];
const accountingStaff = employees.filter(e =>
accountingDepts.some(dept => e.department?.includes(dept))
);
const randomReference = accountingStaff.length > 0
? accountingStaff[Math.floor(Math.random() * accountingStaff.length)]
: null;
setBasicInfo(prev => ({
...prev,
...mockData.basicInfo,
draftDate: prev.draftDate || mockData.basicInfo.draftDate,
}));
// 결재선: 현재 사용자가 직원 목록에 있으면 설정, 없으면 랜덤 1명
if (approver) {
setApprovalLine([approver]);
} else if (employees.length > 0) {
const randomApprover = employees[Math.floor(Math.random() * employees.length)];
setApprovalLine([randomApprover]);
}
// 참조: 경리/회계/재무 직원이 있으면 설정
if (randomReference) {
setReferences([randomReference]);
}
setProposalData(prev => ({
...prev,
...mockData.proposalData,
// 실제 API 거래처로 덮어쓰기
vendorId: randomClient?.id || '',
vendor: randomClient?.name || '',
}));
toast.success('지출결의서 데이터가 자동 입력되었습니다.');
}
}, [isEditMode, isCopyMode, currentUser?.name]));
// 수정 모드: 문서 로드
useEffect(() => {
if (!isEditMode || !documentId) return;
const loadDocument = async () => {
setIsLoadingDocument(true);
try {
const result = await getApprovalById(parseInt(documentId));
if (result.success && result.data) {
const { basicInfo: loadedBasicInfo, approvalLine: loadedApprovalLine, references: loadedReferences, proposalData: loadedProposalData, expenseReportData: loadedExpenseReportData, expenseEstimateData: loadedExpenseEstimateData } = result.data;
setBasicInfo(loadedBasicInfo);
setApprovalLine(loadedApprovalLine);
setReferences(loadedReferences);
if (loadedProposalData) setProposalData(loadedProposalData);
if (loadedExpenseReportData) setExpenseReportData(loadedExpenseReportData);
if (loadedExpenseEstimateData) setExpenseEstimateData(loadedExpenseEstimateData);
} else {
toast.error(result.error || '문서를 불러오는데 실패했습니다.');
router.back();
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to load document:', error);
toast.error('문서를 불러오는데 실패했습니다.');
router.back();
} finally {
setIsLoadingDocument(false);
}
};
loadDocument();
}, [isEditMode, documentId, router]);
// 복제 모드: 원본 문서 로드 후 새 문서로 설정
useEffect(() => {
if (!isCopyMode || !copyFromId) return;
const loadDocumentForCopy = async () => {
setIsLoadingDocument(true);
try {
const result = await getApprovalById(parseInt(copyFromId));
if (result.success && result.data) {
const { basicInfo: loadedBasicInfo, approvalLine: loadedApprovalLine, references: loadedReferences, proposalData: loadedProposalData, expenseReportData: loadedExpenseReportData, expenseEstimateData: loadedExpenseEstimateData } = result.data;
// 복제: 문서번호 초기화, 기안일 현재 시간으로
const now = format(new Date(), 'yyyy-MM-dd HH:mm');
setBasicInfo({
...loadedBasicInfo,
documentNo: '', // 새 문서이므로 문서번호 초기화
draftDate: now,
});
// 결재선/참조는 그대로 유지
setApprovalLine(loadedApprovalLine);
setReferences(loadedReferences);
// 문서 내용 복제
if (loadedProposalData) setProposalData(loadedProposalData);
if (loadedExpenseReportData) setExpenseReportData(loadedExpenseReportData);
if (loadedExpenseEstimateData) setExpenseEstimateData(loadedExpenseEstimateData);
// React.StrictMode에서 useEffect 두 번 실행으로 인한 toast 중복 방지
if (!copyToastShownRef.current) {
copyToastShownRef.current = true;
toast.info('문서가 복제되었습니다. 수정 후 상신해주세요.');
}
} else {
toast.error(result.error || '원본 문서를 불러오는데 실패했습니다.');
router.back();
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to load document for copy:', error);
toast.error('원본 문서를 불러오는데 실패했습니다.');
router.back();
} finally {
setIsLoadingDocument(false);
}
};
loadDocumentForCopy();
}, [isCopyMode, copyFromId, router]);
// 비용견적서 항목 로드
const loadExpenseEstimateItems = useCallback(async () => {
setIsLoadingEstimate(true);
try {
const result = await getExpenseEstimateItems();
if (result) {
setExpenseEstimateData({
items: result.items,
totalExpense: result.totalExpense,
accountBalance: result.accountBalance,
finalDifference: result.finalDifference,
});
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Failed to load expense estimate items:', error);
toast.error('비용견적서 항목을 불러오는데 실패했습니다.');
} finally {
setIsLoadingEstimate(false);
}
}, []);
// 문서 유형이 비용견적서로 변경되면 항목 로드
useEffect(() => {
if (basicInfo.documentType === 'expenseEstimate' && expenseEstimateData.items.length === 0) {
loadExpenseEstimateItems();
}
}, [basicInfo.documentType, expenseEstimateData.items.length, loadExpenseEstimateItems]);
// 폼 데이터 수집
const getFormData = useCallback(() => {
return {
basicInfo,
approvalLine,
references,
proposalData: basicInfo.documentType === 'proposal' ? proposalData : undefined,
expenseReportData: basicInfo.documentType === 'expenseReport' ? expenseReportData : undefined,
expenseEstimateData: basicInfo.documentType === 'expenseEstimate' ? expenseEstimateData : undefined,
};
}, [basicInfo, approvalLine, references, proposalData, expenseReportData, expenseEstimateData]);
// 핸들러
const handleBack = useCallback(() => {
router.back();
}, [router]);
const handleDelete = useCallback(async () => {
if (!confirm('작성 중인 문서를 삭제하시겠습니까?')) {
return;
}
// 수정 모드: 실제 문서 삭제
if (isEditMode && documentId) {
startTransition(async () => {
try {
const result = await deleteApproval(parseInt(documentId));
if (result.success) {
toast.success('문서가 삭제되었습니다.');
router.back();
} else {
toast.error(result.error || '문서 삭제에 실패했습니다.');
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Delete error:', error);
toast.error('문서 삭제 중 오류가 발생했습니다.');
}
});
} else {
// 새 문서: 그냥 뒤로가기
router.back();
}
}, [router, isEditMode, documentId]);
const handleSubmit = useCallback(async () => {
// 유효성 검사
if (approvalLine.length === 0) {
toast.error('결재선을 지정해주세요.');
return;
}
startTransition(async () => {
try {
const formData = getFormData();
// 수정 모드: 수정 후 상신
if (isEditMode && documentId) {
const result = await updateAndSubmitApproval(parseInt(documentId), formData);
if (result.success) {
toast.success('수정 및 상신 완료', {
description: `문서번호: ${result.data?.documentNo}`,
});
router.back();
} else {
toast.error(result.error || '문서 상신에 실패했습니다.');
}
} else {
// 새 문서: 생성 후 상신
const result = await createAndSubmitApproval(formData);
if (result.success) {
toast.success('상신 완료', {
description: `문서번호: ${result.data?.documentNo}`,
});
router.back();
} else {
toast.error(result.error || '문서 상신에 실패했습니다.');
}
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Submit error:', error);
toast.error('문서 상신 중 오류가 발생했습니다.');
}
});
}, [approvalLine, getFormData, router, isEditMode, documentId]);
const handleSaveDraft = useCallback(async () => {
startTransition(async () => {
try {
const formData = getFormData();
// 수정 모드: 기존 문서 업데이트
if (isEditMode && documentId) {
const result = await updateApproval(parseInt(documentId), formData);
if (result.success) {
toast.success('저장 완료', {
description: `문서번호: ${result.data?.documentNo}`,
});
} else {
toast.error(result.error || '저장에 실패했습니다.');
}
} else {
// 새 문서: 임시저장
const result = await createApproval(formData);
if (result.success) {
toast.success('임시저장 완료', {
description: `문서번호: ${result.data?.documentNo}`,
});
// 문서번호 업데이트
if (result.data?.documentNo) {
setBasicInfo(prev => ({ ...prev, documentNo: result.data!.documentNo }));
}
} else {
toast.error(result.error || '임시저장에 실패했습니다.');
}
}
} catch (error) {
if (isNextRedirectError(error)) throw error;
console.error('Save draft error:', error);
toast.error('저장 중 오류가 발생했습니다.');
}
});
}, [getFormData, isEditMode, documentId]);
// 미리보기 핸들러
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.map(f => f.name),
approvers,
drafter,
};
default: {
// 이미 업로드된 파일 URL (Next.js 프록시 사용) + 새로 추가된 파일 미리보기 URL
const uploadedFileUrls = (proposalData.uploadedFiles || []).map(f =>
`/api/proxy/files/${f.id}/download`
);
const newFileUrls = proposalData.attachments.map(f => URL.createObjectURL(f));
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: [...uploadedFileUrls, ...newFileUrls],
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} isLoading={isLoadingEstimate} />;
default:
return null;
}
};
// 현재 모드에 맞는 config 선택
const currentConfig = isEditMode
? documentEditConfig
: isCopyMode
? documentCopyConfig
: documentCreateConfig;
// 헤더 액션 버튼 렌더링
const renderHeaderActions = useCallback(() => {
return (
<div className="flex items-center gap-2">
<Button variant="outline" size="sm" onClick={handlePreview}>
<Eye className="w-4 h-4 mr-1" />
</Button>
<Button
variant="outline"
size="sm"
onClick={handleDelete}
disabled={isPending}
>
<Trash2 className="w-4 h-4 mr-1" />
</Button>
<Button
variant="default"
size="sm"
onClick={handleSubmit}
disabled={isPending}
>
{isPending ? (
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
) : (
<Send className="w-4 h-4 mr-1" />
)}
</Button>
<Button
variant="secondary"
size="sm"
onClick={handleSaveDraft}
disabled={isPending}
>
{isPending ? (
<Loader2 className="w-4 h-4 mr-1 animate-spin" />
) : (
<Save className="w-4 h-4 mr-1" />
)}
{isEditMode ? '저장' : '임시저장'}
</Button>
</div>
);
}, [handlePreview, handleDelete, handleSubmit, handleSaveDraft, isPending, isEditMode]);
// 폼 컨텐츠 렌더링
const renderFormContent = useCallback(() => {
return (
<div className="space-y-6">
{/* 기본 정보 */}
<BasicInfoSection data={basicInfo} onChange={setBasicInfo} />
{/* 결재선 */}
<ApprovalLineSection data={approvalLine} onChange={setApprovalLine} />
{/* 참조 */}
<ReferenceSection data={references} onChange={setReferences} />
{/* 문서 유형별 폼 */}
{renderDocumentTypeForm()}
</div>
);
}, [basicInfo, approvalLine, references, renderDocumentTypeForm]);
return (
<>
<IntegratedDetailTemplate
config={currentConfig}
mode={isEditMode ? 'edit' : 'create'}
isLoading={isLoadingDocument}
onBack={handleBack}
renderForm={renderFormContent}
headerActions={renderHeaderActions()}
/>
{/* 미리보기 모달 */}
<DocumentDetailModal
open={isPreviewOpen}
onOpenChange={setIsPreviewOpen}
documentType={basicInfo.documentType as ModalDocumentType}
data={getPreviewData()}
mode="draft"
documentStatus="draft"
onCopy={() => {
// 복제: 현재 데이터를 기반으로 새 문서 등록 화면으로 이동
if (documentId) {
router.push(`/ko/approval/draft/new?copyFrom=${documentId}`);
setIsPreviewOpen(false);
}
}}
onSubmit={() => {
setIsPreviewOpen(false);
handleSubmit();
}}
/>
</>
);
}