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

500 lines
17 KiB
TypeScript

'use client';
/**
* 분개 수정 팝업
*
* - 거래 정보 (읽기전용): 날짜, 구분, 금액, 적요, 계좌(은행명+계좌번호)
* - 전표 적요: 적요 Input
* - 분개 내역 테이블: 구분(차변/대변), 계정과목, 거래처, 차변 금액, 대변 금액, 적요, 삭제
* - 합계 행 + 대차 균형 표시
* - 버튼: 분개 삭제(왼쪽), 취소, 분개 수정
*/
import { useState, useCallback, useEffect, useMemo } from 'react';
import { toast } from 'sonner';
import { Plus, Trash2 } 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 { 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 { AccountSubjectSelect } from '@/components/accounting/common';
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 {
getJournalDetail,
updateJournalDetail,
deleteJournalDetail,
getVendorList,
} from './actions';
import type {
GeneralJournalRecord,
JournalEntryRow,
JournalSide,
VendorOption,
} from './types';
import { JOURNAL_SIDE_OPTIONS, JOURNAL_DIVISION_LABELS } from './types';
interface JournalEditModalProps {
open: boolean;
onOpenChange: (open: boolean) => void;
record: GeneralJournalRecord;
onSuccess: () => void;
}
function createEmptyRow(): JournalEntryRow {
return {
id: crypto.randomUUID(),
side: 'debit',
accountSubjectId: '',
accountSubjectName: '',
vendorId: '',
vendorName: '',
debitAmount: 0,
creditAmount: 0,
memo: '',
};
}
export function JournalEditModal({
open,
onOpenChange,
record,
onSuccess,
}: JournalEditModalProps) {
// 전표 적요
const [journalMemo, setJournalMemo] = useState('');
// 분개 행
const [rows, setRows] = useState<JournalEntryRow[]>([createEmptyRow()]);
const [isLoading, setIsLoading] = useState(false);
const [isSaving, setIsSaving] = useState(false);
const [showDeleteConfirm, setShowDeleteConfirm] = useState(false);
// 거래 정보 (읽기전용)
const [bankName, setBankName] = useState('');
const [accountNumber, setAccountNumber] = useState('');
// 옵션 데이터
const [vendors, setVendors] = useState<VendorOption[]>([]);
// 데이터 로드
useEffect(() => {
if (!open || !record) return;
const loadData = async () => {
setIsLoading(true);
try {
const [detailRes, vendorsRes] = await Promise.all([
getJournalDetail(record.id),
getVendorList(),
]);
if (vendorsRes.success && vendorsRes.data) {
setVendors(vendorsRes.data);
}
if (detailRes.success && detailRes.data) {
const detail = detailRes.data;
setBankName(detail.bank_name || '');
setAccountNumber(detail.account_number || '');
setJournalMemo(detail.journal_memo || '');
if (detail.rows && detail.rows.length > 0) {
setRows(
detail.rows.map((r) => ({
id: crypto.randomUUID(),
side: r.side as JournalSide,
accountSubjectId: String(r.account_subject_id),
accountSubjectName: r.account_subject_name,
vendorId: r.vendor_id ? String(r.vendor_id) : '',
vendorName: r.vendor_name || '',
debitAmount: r.debit_amount,
creditAmount: r.credit_amount,
memo: r.memo || '',
}))
);
} else {
setRows([
{ ...createEmptyRow(), side: 'debit', debitAmount: record.amount },
{ ...createEmptyRow(), side: 'credit', creditAmount: record.amount },
]);
}
} else {
setRows([
{ ...createEmptyRow(), side: 'debit', debitAmount: record.amount },
{ ...createEmptyRow(), side: 'credit', creditAmount: record.amount },
]);
}
} catch {
setRows([
{ ...createEmptyRow(), side: 'debit', debitAmount: record.amount },
{ ...createEmptyRow(), side: 'credit', creditAmount: record.amount },
]);
} finally {
setIsLoading(false);
}
};
loadData();
}, [open, record]);
// 행 추가
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);
const isBalanced = debitTotal === creditTotal;
const difference = Math.abs(debitTotal - creditTotal);
return { debitTotal, creditTotal, isBalanced, difference };
}, [rows]);
// 분개 수정
const handleSave = useCallback(async () => {
const hasEmptyAccount = rows.some((r) => !r.accountSubjectId);
if (hasEmptyAccount) {
toast.warning('모든 행의 계정과목을 선택해주세요.');
return;
}
if (!totals.isBalanced) {
toast.warning('차변 합계와 대변 합계가 일치하지 않습니다.');
return;
}
setIsSaving(true);
try {
const result = await updateJournalDetail(record.id, {
journalMemo,
rows,
});
if (result.success) {
toast.success('분개가 수정되었습니다.');
onSuccess();
} else {
toast.error(result.error || '분개 수정에 실패했습니다.');
}
} catch {
toast.error('서버 오류가 발생했습니다.');
} finally {
setIsSaving(false);
}
}, [record.id, journalMemo, rows, totals, onSuccess]);
// 분개 삭제
const handleDelete = useCallback(async () => {
setShowDeleteConfirm(false);
try {
const result = await deleteJournalDetail(record.id);
if (result.success) {
toast.success('분개가 삭제되었습니다.');
onSuccess();
} else {
toast.error(result.error || '분개 삭제에 실패했습니다.');
}
} catch {
toast.error('서버 오류가 발생했습니다.');
}
}, [record.id, 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-2 sm:grid-cols-5 gap-3 p-3 bg-muted/50 rounded-lg text-sm">
<div>
<Label className="text-xs text-muted-foreground"></Label>
<div className="font-medium">{record.date}</div>
</div>
<div>
<Label className="text-xs text-muted-foreground"></Label>
<div className="font-medium">
{JOURNAL_DIVISION_LABELS[record.division] || record.division}
</div>
</div>
<div>
<Label className="text-xs text-muted-foreground"></Label>
<div className="font-medium">{formatNumber(record.amount)}</div>
</div>
<div>
<Label className="text-xs text-muted-foreground"></Label>
<div className="font-medium truncate">{record.description || '-'}</div>
</div>
<div>
<Label className="text-xs text-muted-foreground"></Label>
<div className="font-medium truncate">
{bankName && accountNumber ? `${bankName} ${accountNumber}` : '-'}
</div>
</div>
</div>
{/* 전표 적요 */}
<FormField
label="전표 적요"
value={journalMemo}
onChange={setJournalMemo}
placeholder="적요 입력"
/>
{/* 분개 내역 헤더 */}
<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">
{isLoading ? (
<div className="flex items-center justify-center h-[150px] text-sm text-muted-foreground">
...
</div>
) : (
<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} className="text-center text-sm">
{totals.isBalanced ? (
<span className="text-green-600 font-medium"> </span>
) : (
<span className="text-red-500 font-medium">
: {formatNumber(totals.difference)}
</span>
)}
</TableCell>
</TableRow>
</TableFooter>
</Table>
)}
</div>
<DialogFooter className="gap-3">
<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>
</>
);
}