Files
sam-react-prod/src/components/accounting/TaxInvoiceManagement/JournalEntryModal.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

402 lines
13 KiB
TypeScript

'use client';
/**
* 분개 수정 팝업
*
* - 세금계산서 정보 (읽기전용): 구분, 거래처, 공급가액, 세액
* - 분개 내역 테이블: 구분(차변/대변), 계정과목 Select, 차변 금액, 대변 금액
* - 동적 행 추가/삭제 + 합계 행
* - 버튼: 분개 삭제, 취소, 분개 수정
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import { toast } from 'sonner';
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 {
Dialog,
DialogContent,
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 {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
} from '@/components/ui/alert-dialog';
import {
getJournalEntries,
updateJournalEntry,
deleteJournalEntry,
} from './actions';
import { AccountSubjectSelect } from '@/components/accounting/common';
import type { TaxInvoiceMgmtRecord, JournalEntryRow, JournalSide } from './types';
import {
TAB_OPTIONS,
JOURNAL_SIDE_OPTIONS,
} from './types';
interface JournalEntryModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
invoice: TaxInvoiceMgmtRecord;
onSuccess: () => void;
}
function createEmptyRow(): JournalEntryRow {
return {
id: crypto.randomUUID(),
side: 'debit',
accountSubject: '',
debitAmount: 0,
creditAmount: 0,
};
}
export function JournalEntryModal({
open,
onOpenChange,
invoice,
onSuccess,
}: JournalEntryModalProps) {
const [rows, setRows] = useState<JournalEntryRow[]>([createEmptyRow()]);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// 기존 분개 내역 로드
useEffect(() => {
if (!open || !invoice) return;
const loadEntries = async () => {
setIsLoading(true);
try {
const result = await getJournalEntries(invoice.id);
if (result.success && result.data && result.data.rows.length > 0) {
setRows(
result.data.rows.map((r) => ({
id: crypto.randomUUID(),
side: r.side as JournalSide,
accountSubject: r.account_subject,
debitAmount: r.debit_amount,
creditAmount: r.credit_amount,
}))
);
} else {
// 기본 행 2개 (차변/대변)
setRows([
{ ...createEmptyRow(), side: 'debit', debitAmount: invoice.totalAmount },
{ ...createEmptyRow(), side: 'credit', creditAmount: invoice.totalAmount },
]);
}
} catch {
setRows([
{ ...createEmptyRow(), side: 'debit', debitAmount: invoice.totalAmount },
{ ...createEmptyRow(), side: 'credit', creditAmount: invoice.totalAmount },
]);
} finally {
setIsLoading(false);
}
};
loadEntries();
}, [open, invoice]);
// 행 추가
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 handleSave = useCallback(async () => {
// 유효성 검사
const hasEmptyAccount = rows.some((r) => !r.accountSubject);
if (hasEmptyAccount) {
toast.warning('모든 행의 계정과목을 선택해주세요.');
return;
}
if (totals.debitTotal !== totals.creditTotal) {
toast.warning('차변 합계와 대변 합계가 일치하지 않습니다.');
return;
}
setIsSaving(true);
try {
const result = await updateJournalEntry(invoice.id, rows);
if (result.success) {
toast.success('분개가 수정되었습니다.');
onSuccess();
} else {
toast.error(result.error || '분개 수정에 실패했습니다.');
}
} catch {
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
}, [invoice.id, rows, totals, onSuccess]);
// 분개 삭제
const handleDelete = useCallback(async () => {
setShowDeleteConfirm(false);
try {
const result = await deleteJournalEntry(invoice.id);
if (result.success) {
toast.success('분개가 삭제되었습니다.');
onSuccess();
} else {
toast.error(result.error || '분개 삭제에 실패했습니다.');
}
} catch {
toast.error('서버 오류가 발생했습니다.');
}
}, [invoice.id, onSuccess]);
const divisionLabel =
TAB_OPTIONS.find((t) => t.value === invoice.division)?.label || invoice.division;
return (
<>
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-[700px] max-h-[85vh] flex flex-col">
<DialogHeader>
<DialogTitle> </DialogTitle>
</DialogHeader>
{/* 세금계산서 정보 (읽기전용) */}
<div className="space-y-2 p-3 border rounded-lg">
<Label className="text-sm font-semibold"> </Label>
<div className="grid grid-cols-4 gap-3 text-sm">
<div>
<Label className="text-xs text-muted-foreground"></Label>
<div className="font-medium">{divisionLabel}</div>
</div>
<div>
<Label className="text-xs text-muted-foreground"></Label>
<div className="font-medium">{invoice.vendorName}</div>
</div>
<div>
<Label className="text-xs text-muted-foreground"></Label>
<div className="font-medium">{formatNumber(invoice.supplyAmount)}</div>
</div>
<div>
<Label className="text-xs text-muted-foreground"></Label>
<div className="font-medium">{formatNumber(invoice.taxAmount)}</div>
</div>
</div>
</div>
{/* 분개 내역 */}
<div className="space-y-2 p-3 border rounded-lg flex-1 overflow-auto">
<Label className="text-sm font-semibold"> </Label>
<div className="overflow-auto border rounded-md">
{isLoading ? (
<div className="flex items-center justify-center h-[150px] text-sm text-muted-foreground">
...
</div>
) : (
<Table>
<TableHeader>
<TableRow>
<TableHead className="text-center w-[100px]"></TableHead>
<TableHead></TableHead>
<TableHead className="text-right w-[140px]"> </TableHead>
<TableHead className="text-right w-[140px]"> </TableHead>
</TableRow>
</TableHeader>
<TableBody>
{rows.map((row) => (
<TableRow key={row.id}>
<TableCell className="p-1">
<Select
value={row.side}
onValueChange={(v) => handleRowChange(row.id, 'side', v)}
>
<SelectTrigger className="h-8 text-sm">
<SelectValue />
</SelectTrigger>
<SelectContent>
{JOURNAL_SIDE_OPTIONS.map((opt) => (
<SelectItem key={opt.value} value={opt.value}>
{opt.label}
</SelectItem>
))}
</SelectContent>
</Select>
</TableCell>
<TableCell className="p-1">
<AccountSubjectSelect
value={row.accountSubject}
onValueChange={(v) =>
handleRowChange(row.id, 'accountSubject', v)
}
placeholder="선택"
size="sm"
/>
</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>
</TableRow>
))}
</TableBody>
<TableFooter>
<TableRow className="bg-muted/50 font-medium">
<TableCell colSpan={2} 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>
</TableRow>
</TableFooter>
</Table>
)}
</div>
</div>
{/* 차대변 불일치 경고 */}
{totals.debitTotal !== totals.creditTotal && (
<p className="text-xs text-red-500">
({formatNumber(totals.debitTotal)}) (
{formatNumber(totals.creditTotal)}) .
</p>
)}
<DialogFooter className="gap-2">
<Button
variant="destructive"
onClick={() => setShowDeleteConfirm(true)}
className="mr-auto"
>
</Button>
<Button variant="outline" onClick={() => onOpenChange(false)}>
</Button>
<Button onClick={handleSave} disabled={isSaving}>
{isSaving ? '저장 중...' : '분개 수정'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* 분개 삭제 확인 */}
<AlertDialog open={showDeleteConfirm} onOpenChange={setShowDeleteConfirm}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle> </AlertDialogTitle>
<AlertDialogDescription>
? .
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel></AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
className="bg-red-500 hover:bg-red-600"
>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
}