Files
sam-react-prod/src/components/accounting/GeneralJournalEntry/ManualJournalEntryModal.tsx
유병철 7d369d1404 feat: 계정과목 공통화 및 회계 모듈 전반 개선
- 계정과목 관리를 accounting/common/으로 통합 (AccountSubjectSettingModal 이동)
- GeneralJournalEntry: 계정과목 actions/types 분리, 모달 import 경로 변경
- CardTransactionInquiry: JournalEntryModal/ManualInputModal 개선
- TaxInvoiceManagement: actions/types 리팩토링
- DepositManagement/WithdrawalManagement: 소폭 개선
- ExpectedExpenseManagement: UI 개선
- GiftCertificateManagement: 상세/목록 개선
- BillManagement: BillDetail/Client/index 소폭 추가
- PurchaseManagement/SalesManagement: 상세뷰 개선
- CEO 대시보드: dashboard-invalidation 유틸 추가, useCEODashboard 확장
- OrderRegistration/OrderSalesDetailView 소폭 수정
- claudedocs: 계정과목 통합 계획/분석/체크리스트, 대시보드 검증 문서 추가
2026-03-08 12:44:36 +09:00

388 lines
13 KiB
TypeScript

'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<JournalEntryRow[]>([createEmptyRow()]);
// 옵션 데이터
const [vendors, setVendors] = useState<VendorOption[]>([]);
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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[850px] max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle> </DialogTitle>
<DialogDescription className="sr-only"> </DialogDescription>
</DialogHeader>
{/* 거래 정보 */}
<div className="grid grid-cols-1 sm:grid-cols-3 gap-3 p-3 bg-muted/50 rounded-lg">
<div>
<Label className="text-sm font-medium">
<span className="text-red-500">*</span>
</Label>
<DatePicker
value={journalDate}
onChange={setJournalDate}
className="mt-1"
size="sm"
displayFormat="yyyy년 MM월 dd일"
/>
</div>
<FormField
label="전표번호"
value={journalNumber}
onChange={() => {}}
disabled
/>
<FormField
label="적요"
value={description}
onChange={setDescription}
placeholder="적요 입력"
/>
</div>
{/* 분개 내역 헤더 */}
<div className="flex items-center justify-between">
<Label className="text-sm font-semibold"> </Label>
<Button variant="outline" size="sm" onClick={handleAddRow}>
<Plus className="h-4 w-4 mr-1" />
</Button>
</div>
{/* 분개 테이블 */}
<div className="flex-1 min-h-0 max-h-[40vh] overflow-auto border rounded-md">
<Table className="min-w-[750px]">
<TableHeader>
<TableRow>
<TableHead className="text-center w-[90px]"></TableHead>
<TableHead className="w-[150px]"></TableHead>
<TableHead className="w-[140px]"></TableHead>
<TableHead className="text-right w-[120px]"> </TableHead>
<TableHead className="text-right w-[120px]"> </TableHead>
<TableHead className="w-[120px]"></TableHead>
<TableHead className="text-center w-[50px]"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
<div className="flex">
{JOURNAL_SIDE_OPTIONS.map((opt) => (
<Button
key={opt.value}
type="button"
size="sm"
variant={row.side === opt.value ? 'default' : 'outline'}
className="h-7 px-2 text-xs flex-1 rounded-none first:rounded-l-md last:rounded-r-md"
onClick={() => handleRowChange(row.id, 'side', opt.value)}
>
{opt.label}
</Button>
))}
</div>
</TableCell>
<TableCell className="p-1">
<AccountSubjectSelect
value={row.accountSubjectId}
onValueChange={(v) =>
handleRowChange(row.id, 'accountSubjectId', v)
}
size="sm"
placeholder="선택"
/>
</TableCell>
<TableCell className="p-1">
<Select
value={row.vendorId || 'none'}
onValueChange={(v) =>
handleRowChange(row.id, 'vendorId', v === 'none' ? '' : v)
}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue placeholder="선택" />
</SelectTrigger>
<SelectContent>
<SelectItem value="none"></SelectItem>
{vendors.map((v) => (
<SelectItem key={v.id} value={v.id}>
{v.name}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1">
<Input
type="number"
value={row.debitAmount || ''}
onChange={(e) =>
handleRowChange(row.id, 'debitAmount', Number(e.target.value) || 0)
}
disabled={row.side === 'credit'}
className="h-8 text-sm text-right"
placeholder="0"
/>
</TableCell>
<TableCell className="p-1">
<Input
type="number"
value={row.creditAmount || ''}
onChange={(e) =>
handleRowChange(row.id, 'creditAmount', Number(e.target.value) || 0)
}
disabled={row.side === 'debit'}
className="h-8 text-sm text-right"
placeholder="0"
/>
</TableCell>
<TableCell className="p-1">
<Input
value={row.memo}
onChange={(e) => handleRowChange(row.id, 'memo', e.target.value)}
className="h-8 text-sm"
placeholder="적요"
/>
</TableCell>
<TableCell className="p-1 text-center">
<Button
variant="ghost"
size="icon"
className="h-7 w-7 text-red-500 hover:text-red-600 hover:bg-red-50"
onClick={() => handleRemoveRow(row.id)}
disabled={rows.length <= 1}
>
<Trash2 className="h-3.5 w-3.5" />
</Button>
</TableCell>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow className="bg-muted/50 font-medium">
<TableCell colSpan={3} className="text-right text-sm">
</TableCell>
<TableCell className="text-right text-sm font-bold">
{formatNumber(totals.debitTotal)}
</TableCell>
<TableCell className="text-right text-sm font-bold">
{formatNumber(totals.creditTotal)}
</TableCell>
<TableCell colSpan={2} />
</TableRow>
</TableFooter>
</Table>
</div>
{/* 차대변 불일치 경고 */}
{totals.debitTotal !== totals.creditTotal && totals.debitTotal > 0 && (
<p className="text-xs text-red-500">
({formatNumber(totals.debitTotal)}) (
{formatNumber(totals.creditTotal)}) .
</p>
)}
<DialogFooter className="gap-3">
<Button variant="outline" onClick={() => onOpenChange(false)} disabled={isSubmitting}>
</Button>
<Button onClick={handleSubmit} disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
...
</>
) : (
'저장'
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}