feat(WEB): 회계/설정/카드 관리 페이지 대규모 기능 추가 및 리팩토링
- 일반전표입력, 상품권관리, 세금계산서 발행/조회 신규 페이지 추가 - 바로빌 연동 설정 페이지 추가 - 카드관리/계좌관리 리스트 UniversalListPage 공통 구조로 전환 - 카드거래조회/은행거래조회 리팩토링 (모달 분리, 액션 확장) - 계좌 상세 폼(AccountDetailForm) 신규 구현 - 카드 상세(CardDetail) 신규 구현 + CardNumberInput 적용 - DateRangeSelector, StatCards, IntegratedListTemplateV2 공통 컴포넌트 개선 - 레거시 파일 정리 (CardManagementUnified, cardConfig, _legacy 등) Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,514 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 분개 수정 팝업
|
||||
*
|
||||
* - 거래 정보 (읽기전용): 날짜, 구분, 금액, 적요, 계좌(은행명+계좌번호)
|
||||
* - 전표 적요: 적요 Input
|
||||
* - 분개 내역 테이블: 구분(차변/대변), 계정과목, 거래처, 차변 금액, 대변 금액, 적요, 삭제
|
||||
* - 합계 행 + 대차 균형 표시
|
||||
* - 버튼: 분개 삭제(왼쪽), 취소, 분개 수정
|
||||
*/
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import { toast } from 'sonner';
|
||||
import { Plus, Trash2 } from 'lucide-react';
|
||||
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 {
|
||||
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,
|
||||
getAccountSubjects,
|
||||
getVendorList,
|
||||
} from './actions';
|
||||
import type {
|
||||
GeneralJournalRecord,
|
||||
JournalEntryRow,
|
||||
JournalSide,
|
||||
AccountSubject,
|
||||
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 [accountSubjects, setAccountSubjects] = useState<AccountSubject[]>([]);
|
||||
const [vendors, setVendors] = useState<VendorOption[]>([]);
|
||||
|
||||
// 데이터 로드
|
||||
useEffect(() => {
|
||||
if (!open || !record) return;
|
||||
|
||||
const loadData = async () => {
|
||||
setIsLoading(true);
|
||||
try {
|
||||
const [detailRes, subjectsRes, vendorsRes] = await Promise.all([
|
||||
getJournalDetail(record.id),
|
||||
getAccountSubjects({ category: 'all' }),
|
||||
getVendorList(),
|
||||
]);
|
||||
|
||||
if (subjectsRes.success && subjectsRes.data) {
|
||||
setAccountSubjects(subjectsRes.data.filter((s) => s.isActive));
|
||||
}
|
||||
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-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">{record.amount.toLocaleString()}원</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 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-[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">
|
||||
<Select
|
||||
value={row.accountSubjectId || 'none'}
|
||||
onValueChange={(v) =>
|
||||
handleRowChange(row.id, 'accountSubjectId', v === 'none' ? '' : v)
|
||||
}
|
||||
>
|
||||
<SelectTrigger className="h-8 text-sm">
|
||||
<SelectValue placeholder="선택" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="none">선택</SelectItem>
|
||||
{accountSubjects.map((s) => (
|
||||
<SelectItem key={s.id} value={s.id}>
|
||||
{s.name}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</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">
|
||||
{totals.debitTotal.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right text-sm font-bold">
|
||||
{totals.creditTotal.toLocaleString()}
|
||||
</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">
|
||||
차이: {totals.difference.toLocaleString()}원
|
||||
</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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user