- 계정과목 관리를 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: 계정과목 통합 계획/분석/체크리스트, 대시보드 검증 문서 추가
402 lines
13 KiB
TypeScript
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>
|
|
</>
|
|
);
|
|
}
|