'use client'; /** * 수기 전표 입력 팝업 * * - 거래 정보: 전표일자*(필수), 전표번호(자동생성, 읽기전용), 적요 Input * - 분개 내역 테이블: 구분(차변/대변 토글), 계정과목 Select, 거래처 Select, 차변 금액, 대변 금액, 적요, 삭제 * - 행 추가 버튼 + 합계 행 * - 버튼: 취소, 저장 */ import { useState, useCallback, useEffect, useMemo } from 'react'; import { toast } from 'sonner'; import { Plus, Trash2, Loader2 } from 'lucide-react'; import { formatNumber } from '@/lib/utils/amount'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Label } from '@/components/ui/label'; import { DatePicker } from '@/components/ui/date-picker'; import { FormField } from '@/components/molecules/FormField'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle, DialogFooter, } from '@/components/ui/dialog'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue, } from '@/components/ui/select'; import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow, TableFooter, } from '@/components/ui/table'; import { AccountSubjectSelect } from '@/components/accounting/common'; import { createManualJournal, getVendorList } from './actions'; import type { JournalEntryRow, JournalSide, VendorOption } from './types'; import { JOURNAL_SIDE_OPTIONS } from './types'; import { getTodayString } from '@/lib/utils/date'; interface ManualJournalEntryModalProps { open: boolean; onOpenChange: (open: boolean) => void; onSuccess: () => void; } function createEmptyRow(): JournalEntryRow { return { id: crypto.randomUUID(), side: 'debit', accountSubjectId: '', accountSubjectName: '', vendorId: '', vendorName: '', debitAmount: 0, creditAmount: 0, memo: '', }; } export function ManualJournalEntryModal({ open, onOpenChange, onSuccess, }: ManualJournalEntryModalProps) { // 거래 정보 const [journalDate, setJournalDate] = useState(() => getTodayString()); const [journalNumber, setJournalNumber] = useState('자동생성'); const [description, setDescription] = useState(''); // 분개 행 const [rows, setRows] = useState([createEmptyRow()]); // 옵션 데이터 const [vendors, setVendors] = useState([]); const [isSubmitting, setIsSubmitting] = useState(false); // 옵션 로드 useEffect(() => { if (!open) return; // 초기화 setJournalDate(getTodayString()); setJournalNumber('자동생성'); setDescription(''); setRows([createEmptyRow()]); getVendorList().then((vendorsRes) => { if (vendorsRes.success && vendorsRes.data) { setVendors(vendorsRes.data); } }); }, [open]); // 행 추가 const handleAddRow = useCallback(() => { setRows((prev) => [...prev, createEmptyRow()]); }, []); // 행 삭제 const handleRemoveRow = useCallback((rowId: string) => { setRows((prev) => { if (prev.length <= 1) return prev; return prev.filter((r) => r.id !== rowId); }); }, []); // 행 수정 const handleRowChange = useCallback( (rowId: string, field: keyof JournalEntryRow, value: string | number) => { setRows((prev) => prev.map((r) => { if (r.id !== rowId) return r; const updated = { ...r, [field]: value }; // 구분 변경 시 금액 초기화 if (field === 'side') { if (value === 'debit') { updated.creditAmount = 0; } else { updated.debitAmount = 0; } } return updated; }) ); }, [] ); // 합계 const totals = useMemo(() => { const debitTotal = rows.reduce((sum, r) => sum + (r.debitAmount || 0), 0); const creditTotal = rows.reduce((sum, r) => sum + (r.creditAmount || 0), 0); return { debitTotal, creditTotal }; }, [rows]); // 저장 const handleSubmit = useCallback(async () => { if (!journalDate) { toast.warning('전표일자를 입력해주세요.'); return; } const hasEmptyAccount = rows.some((r) => !r.accountSubjectId); if (hasEmptyAccount) { toast.warning('모든 행의 계정과목을 선택해주세요.'); return; } if (totals.debitTotal !== totals.creditTotal) { toast.warning('차변 합계와 대변 합계가 일치하지 않습니다.'); return; } if (totals.debitTotal === 0) { toast.warning('금액을 입력해주세요.'); return; } setIsSubmitting(true); try { const result = await createManualJournal({ journalDate, description, rows, }); if (result.success) { toast.success('수기 전표가 등록되었습니다.'); onOpenChange(false); onSuccess(); } else { toast.error(result.error || '등록에 실패했습니다.'); } } catch { toast.error('등록 중 오류가 발생했습니다.'); } finally { setIsSubmitting(false); } }, [journalDate, description, rows, totals, onOpenChange, onSuccess]); return ( 수기 전표 입력 수기 전표를 입력하고 분개 내역을 등록합니다 {/* 거래 정보 */}
{}} disabled />
{/* 분개 내역 헤더 */}
{/* 분개 테이블 */}
구분 계정과목 거래처 차변 금액 대변 금액 적요 {rows.map((row) => (
{JOURNAL_SIDE_OPTIONS.map((opt) => ( ))}
handleRowChange(row.id, 'accountSubjectId', v) } size="sm" placeholder="선택" /> handleRowChange(row.id, 'debitAmount', Number(e.target.value) || 0) } disabled={row.side === 'credit'} className="h-8 text-sm text-right" placeholder="0" /> handleRowChange(row.id, 'creditAmount', Number(e.target.value) || 0) } disabled={row.side === 'debit'} className="h-8 text-sm text-right" placeholder="0" /> handleRowChange(row.id, 'memo', e.target.value)} className="h-8 text-sm" placeholder="적요" />
))}
합계 {formatNumber(totals.debitTotal)} {formatNumber(totals.creditTotal)}
{/* 차대변 불일치 경고 */} {totals.debitTotal !== totals.creditTotal && totals.debitTotal > 0 && (

차변 합계({formatNumber(totals.debitTotal)})와 대변 합계( {formatNumber(totals.creditTotal)})가 일치하지 않습니다.

)}
); }