'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(getInitialBasicInfo); const [approvalLine, setApprovalLine] = useState([]); const [references, setReferences] = useState([]); const [proposalData, setProposalData] = useState(getInitialProposalData); const [expenseReportData, setExpenseReportData] = useState(getInitialExpenseReportData); const [expenseEstimateData, setExpenseEstimateData] = useState(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 ; case 'expenseReport': return ; case 'expenseEstimate': return ; default: return null; } }; // 현재 모드에 맞는 config 선택 const currentConfig = isEditMode ? documentEditConfig : isCopyMode ? documentCopyConfig : documentCreateConfig; // 헤더 액션 버튼 렌더링 const renderHeaderActions = useCallback(() => { return (
); }, [handlePreview, handleDelete, handleSubmit, handleSaveDraft, isPending, isEditMode]); // 폼 컨텐츠 렌더링 const renderFormContent = useCallback(() => { return (
{/* 기본 정보 */} {/* 결재선 */} {/* 참조 */} {/* 문서 유형별 폼 */} {renderDocumentTypeForm()}
); }, [basicInfo, approvalLine, references, renderDocumentTypeForm]); return ( <> {/* 미리보기 모달 */} { // 복제: 현재 데이터를 기반으로 새 문서 등록 화면으로 이동 if (documentId) { router.push(`/ko/approval/draft/new?copyFrom=${documentId}`); setIsPreviewOpen(false); } }} onSubmit={() => { setIsPreviewOpen(false); handleSubmit(); }} /> ); }