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,445 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useMemo } from 'react';
|
||||
import { format } from 'date-fns';
|
||||
import { Loader2, RotateCcw } from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Input } from '@/components/ui/input';
|
||||
import { Label } from '@/components/ui/label';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from '@/components/ui/dialog';
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
SelectItem,
|
||||
SelectTrigger,
|
||||
SelectValue,
|
||||
} from '@/components/ui/select';
|
||||
import { DatePicker } from '@/components/ui/date-picker';
|
||||
import { TimePicker } from '@/components/ui/time-picker';
|
||||
import type { BankTransaction, TransactionKind, TransactionFormData } from './types';
|
||||
import {
|
||||
createManualTransaction,
|
||||
updateTransaction,
|
||||
deleteTransaction,
|
||||
restoreTransaction,
|
||||
} from './actions';
|
||||
|
||||
// ===== Props =====
|
||||
interface TransactionFormModalProps {
|
||||
open: boolean;
|
||||
onOpenChange: (open: boolean) => void;
|
||||
mode: 'create' | 'edit';
|
||||
transaction?: BankTransaction | null;
|
||||
accountOptions: { id: number; label: string }[];
|
||||
onSuccess: () => void;
|
||||
}
|
||||
|
||||
// ===== 시간 포맷 변환 (API: HHMMSS ↔ TimePicker: HH:mm:ss) =====
|
||||
function apiTimeToPickerFormat(hhmmss: string): string {
|
||||
if (!hhmmss) return '';
|
||||
const clean = hhmmss.replace(/[^0-9]/g, '');
|
||||
if (clean.length < 4) return '';
|
||||
const h = clean.slice(0, 2);
|
||||
const m = clean.slice(2, 4);
|
||||
const s = clean.slice(4, 6) || '00';
|
||||
return `${h}:${m}:${s}`;
|
||||
}
|
||||
|
||||
function pickerTimeToApiFormat(hhmm_ss: string): string {
|
||||
if (!hhmm_ss) return '';
|
||||
return hhmm_ss.replace(/:/g, '');
|
||||
}
|
||||
|
||||
// ===== 초기 폼 데이터 =====
|
||||
function getInitialFormData(transaction?: BankTransaction | null): TransactionFormData {
|
||||
if (transaction) {
|
||||
const amount = transaction.type === 'deposit'
|
||||
? transaction.depositAmount
|
||||
: transaction.withdrawalAmount;
|
||||
return {
|
||||
bankAccountId: transaction.bankAccountId ?? null,
|
||||
transactionDate: transaction.transactionDate?.split(' ')[0] || format(new Date(), 'yyyy-MM-dd'),
|
||||
transactionTime: apiTimeToPickerFormat(transaction.transactionTime || ''),
|
||||
type: transaction.type,
|
||||
amount,
|
||||
note: transaction.note || '',
|
||||
depositorName: transaction.depositorName || '',
|
||||
memo: transaction.memo || '',
|
||||
branch: transaction.branch || '',
|
||||
};
|
||||
}
|
||||
return {
|
||||
bankAccountId: null,
|
||||
transactionDate: format(new Date(), 'yyyy-MM-dd'),
|
||||
transactionTime: '',
|
||||
type: 'deposit',
|
||||
amount: 0,
|
||||
note: '',
|
||||
depositorName: '',
|
||||
memo: '',
|
||||
branch: '',
|
||||
};
|
||||
}
|
||||
|
||||
export function TransactionFormModal({
|
||||
open,
|
||||
onOpenChange,
|
||||
mode,
|
||||
transaction,
|
||||
accountOptions,
|
||||
onSuccess,
|
||||
}: TransactionFormModalProps) {
|
||||
const [formData, setFormData] = useState<TransactionFormData>(() =>
|
||||
getInitialFormData(transaction)
|
||||
);
|
||||
const [isSaving, setIsSaving] = useState(false);
|
||||
const [isDeleting, setIsDeleting] = useState(false);
|
||||
const [isRestoring, setIsRestoring] = useState(false);
|
||||
|
||||
// 원본 데이터 (수정 감지용)
|
||||
const [originalFormData, setOriginalFormData] = useState<TransactionFormData>(() =>
|
||||
getInitialFormData(transaction)
|
||||
);
|
||||
|
||||
// transaction 변경 시 폼 데이터 리셋
|
||||
useEffect(() => {
|
||||
if (open) {
|
||||
const data = getInitialFormData(transaction);
|
||||
setFormData(data);
|
||||
setOriginalFormData(data);
|
||||
}
|
||||
}, [open, transaction]);
|
||||
|
||||
// 수정된 필드 감지
|
||||
const modifiedFields = useMemo(() => {
|
||||
if (mode === 'create') return [];
|
||||
const fields: string[] = [];
|
||||
const keys: (keyof TransactionFormData)[] = [
|
||||
'bankAccountId', 'transactionDate', 'transactionTime', 'type',
|
||||
'amount', 'note', 'depositorName', 'memo', 'branch',
|
||||
];
|
||||
for (const key of keys) {
|
||||
if (String(formData[key]) !== String(originalFormData[key])) {
|
||||
fields.push(key);
|
||||
}
|
||||
}
|
||||
return fields;
|
||||
}, [formData, originalFormData, mode]);
|
||||
|
||||
const isFieldModified = useCallback(
|
||||
(field: keyof TransactionFormData) => modifiedFields.includes(field),
|
||||
[modifiedFields]
|
||||
);
|
||||
|
||||
// 필드 변경 핸들러
|
||||
const handleChange = useCallback(
|
||||
<K extends keyof TransactionFormData>(key: K, value: TransactionFormData[K]) => {
|
||||
setFormData((prev) => ({ ...prev, [key]: value }));
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// 잔액 자동계산 (표시용)
|
||||
const calculatedBalance = useMemo(() => {
|
||||
if (!transaction) return formData.amount;
|
||||
const prevBalance = transaction.balance;
|
||||
if (formData.type === 'deposit') {
|
||||
return prevBalance + formData.amount;
|
||||
}
|
||||
return prevBalance - formData.amount;
|
||||
}, [formData.amount, formData.type, transaction]);
|
||||
|
||||
// 저장/수정
|
||||
const handleSubmit = useCallback(async () => {
|
||||
if (!formData.bankAccountId) {
|
||||
toast.error('계좌를 선택해주세요.');
|
||||
return;
|
||||
}
|
||||
if (!formData.transactionDate) {
|
||||
toast.error('거래일을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
if (formData.amount <= 0) {
|
||||
toast.error('금액을 입력해주세요.');
|
||||
return;
|
||||
}
|
||||
|
||||
setIsSaving(true);
|
||||
try {
|
||||
// TimePicker HH:mm:ss → API HHMMSS 변환
|
||||
const apiFormData = {
|
||||
...formData,
|
||||
transactionTime: pickerTimeToApiFormat(formData.transactionTime),
|
||||
};
|
||||
|
||||
if (mode === 'create') {
|
||||
const result = await createManualTransaction(apiFormData);
|
||||
if (result.success) {
|
||||
toast.success('수기 입력이 완료되었습니다.');
|
||||
onOpenChange(false);
|
||||
onSuccess();
|
||||
} else {
|
||||
toast.error(result.error || '수기 입력에 실패했습니다.');
|
||||
}
|
||||
} else if (transaction) {
|
||||
const result = await updateTransaction(transaction.sourceId, apiFormData);
|
||||
if (result.success) {
|
||||
toast.success('수정이 완료되었습니다.');
|
||||
onOpenChange(false);
|
||||
onSuccess();
|
||||
} else {
|
||||
toast.error(result.error || '수정에 실패했습니다.');
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
toast.error('처리 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsSaving(false);
|
||||
}
|
||||
}, [formData, mode, transaction, onOpenChange, onSuccess]);
|
||||
|
||||
// 삭제
|
||||
const handleDelete = useCallback(async () => {
|
||||
if (!transaction) return;
|
||||
setIsDeleting(true);
|
||||
try {
|
||||
const result = await deleteTransaction(transaction.sourceId);
|
||||
if (result.success) {
|
||||
toast.success('삭제가 완료되었습니다.');
|
||||
onOpenChange(false);
|
||||
onSuccess();
|
||||
} else {
|
||||
toast.error(result.error || '삭제에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('삭제 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsDeleting(false);
|
||||
}
|
||||
}, [transaction, onOpenChange, onSuccess]);
|
||||
|
||||
// 원본으로 복원 (③)
|
||||
const handleRestore = useCallback(async () => {
|
||||
if (!transaction) return;
|
||||
setIsRestoring(true);
|
||||
try {
|
||||
const result = await restoreTransaction(transaction.sourceId);
|
||||
if (result.success) {
|
||||
toast.success('원본으로 복원되었습니다.');
|
||||
onOpenChange(false);
|
||||
onSuccess();
|
||||
} else {
|
||||
toast.error(result.error || '복원에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('복원 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsRestoring(false);
|
||||
}
|
||||
}, [transaction, onOpenChange, onSuccess]);
|
||||
|
||||
const isProcessing = isSaving || isDeleting || isRestoring;
|
||||
|
||||
return (
|
||||
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||
<DialogContent className="sm:max-w-[560px] max-h-[90vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>입출금 수기 입력</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5 py-2">
|
||||
{/* 계좌 * (①) */}
|
||||
<div className="space-y-2">
|
||||
<Label className="font-medium">
|
||||
계좌 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Select
|
||||
key={`account-${formData.bankAccountId}`}
|
||||
value={formData.bankAccountId ? String(formData.bankAccountId) : ''}
|
||||
onValueChange={(v) => handleChange('bankAccountId', parseInt(v, 10))}
|
||||
>
|
||||
<SelectTrigger>
|
||||
<SelectValue placeholder="계좌를 선택하세요" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{accountOptions.map((opt) => (
|
||||
<SelectItem key={opt.id} value={String(opt.id)}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
{/* 거래일 * / 거래시간 */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="font-medium">
|
||||
거래일 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<DatePicker
|
||||
value={formData.transactionDate}
|
||||
onChange={(date) => handleChange('transactionDate', date)}
|
||||
placeholder="날짜 선택"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="font-medium">거래시간</Label>
|
||||
<TimePicker
|
||||
value={formData.transactionTime || undefined}
|
||||
onChange={(time) => handleChange('transactionTime', time)}
|
||||
placeholder="시간 선택"
|
||||
showSeconds
|
||||
secondStep={1}
|
||||
minuteStep={1}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 거래유형 * */}
|
||||
<div className="space-y-2">
|
||||
<Label className="font-medium">
|
||||
거래유형 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<RadioGroup
|
||||
value={formData.type}
|
||||
onValueChange={(v) => handleChange('type', v as TransactionKind)}
|
||||
className="flex gap-6"
|
||||
>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="deposit" id="type-deposit" />
|
||||
<Label htmlFor="type-deposit" className="cursor-pointer">입금</Label>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<RadioGroupItem value="withdrawal" id="type-withdrawal" />
|
||||
<Label htmlFor="type-withdrawal" className="cursor-pointer">출금</Label>
|
||||
</div>
|
||||
</RadioGroup>
|
||||
</div>
|
||||
|
||||
{/* 금액 * / 잔액 (자동계산) */}
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div className="space-y-2">
|
||||
<Label className="font-medium">
|
||||
금액 <span className="text-red-500">*</span>
|
||||
</Label>
|
||||
<Input
|
||||
type="text"
|
||||
inputMode="numeric"
|
||||
value={formData.amount ? formData.amount.toLocaleString() : ''}
|
||||
onChange={(e) => {
|
||||
const raw = e.target.value.replace(/[^0-9]/g, '');
|
||||
handleChange('amount', raw ? parseInt(raw, 10) : 0);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<Label className="font-medium text-gray-500">잔액 (자동계산)</Label>
|
||||
<Input
|
||||
value={calculatedBalance.toLocaleString()}
|
||||
disabled
|
||||
className="bg-gray-50"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 적요 + 수정 스티커 (②) */}
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center gap-2">
|
||||
<Label className="font-medium">적요</Label>
|
||||
{mode === 'edit' && isFieldModified('note') && (
|
||||
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-300 text-xs">
|
||||
수정
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
<Input
|
||||
value={formData.note}
|
||||
onChange={(e) => handleChange('note', e.target.value)}
|
||||
placeholder="내용"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 상대계좌 예금주명 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="font-medium">상대계좌 예금주명</Label>
|
||||
<Input
|
||||
value={formData.depositorName}
|
||||
onChange={(e) => handleChange('depositorName', e.target.value)}
|
||||
placeholder="예금주명"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 메모 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="font-medium">메모</Label>
|
||||
<Input
|
||||
value={formData.memo}
|
||||
onChange={(e) => handleChange('memo', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* 취급점 */}
|
||||
<div className="space-y-2">
|
||||
<Label className="font-medium">취급점</Label>
|
||||
<Input
|
||||
value={formData.branch}
|
||||
onChange={(e) => handleChange('branch', e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 하단 버튼 */}
|
||||
<div className="flex items-center justify-between pt-4 border-t">
|
||||
{/* 좌측: 원본으로 복원 (③) - 수정 모드에서만 */}
|
||||
<div>
|
||||
{mode === 'edit' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleRestore}
|
||||
disabled={isProcessing}
|
||||
className="gap-1"
|
||||
>
|
||||
{isRestoring ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RotateCcw className="h-4 w-4" />
|
||||
)}
|
||||
원본으로 복원
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 우측: 삭제 + 수정/등록 */}
|
||||
<div className="flex gap-2">
|
||||
{mode === 'edit' && (
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleDelete}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isDeleting ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
|
||||
삭제
|
||||
</Button>
|
||||
)}
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={isProcessing}
|
||||
>
|
||||
{isSaving ? <Loader2 className="h-4 w-4 mr-1 animate-spin" /> : null}
|
||||
{mode === 'create' ? '등록' : '수정'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
@@ -4,16 +4,18 @@
|
||||
import { executeServerAction, type ActionResult } from '@/lib/api/execute-server-action';
|
||||
import { executePaginatedAction } from '@/lib/api/execute-paginated-action';
|
||||
import { buildApiUrl } from '@/lib/api/query-params';
|
||||
import type { BankTransaction, TransactionKind } from './types';
|
||||
import type { BankTransaction, TransactionKind, TransactionFormData, AccountCategoryFilter } from './types';
|
||||
|
||||
// ===== API 응답 타입 =====
|
||||
interface BankTransactionApiItem {
|
||||
id: number;
|
||||
type: 'deposit' | 'withdrawal';
|
||||
transaction_date: string;
|
||||
transaction_time?: string | null;
|
||||
bank_account_id: number;
|
||||
bank_name: string;
|
||||
account_name: string;
|
||||
account_number?: string | null;
|
||||
note: string | null;
|
||||
vendor_id: number | null;
|
||||
vendor_name: string | null;
|
||||
@@ -23,6 +25,10 @@ interface BankTransactionApiItem {
|
||||
balance: number | string;
|
||||
transaction_type: string | null;
|
||||
source_id: string;
|
||||
memo?: string | null;
|
||||
branch?: string | null;
|
||||
is_manual?: boolean;
|
||||
modified_fields?: string[] | null;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
@@ -30,6 +36,9 @@ interface BankTransactionApiItem {
|
||||
interface BankTransactionApiSummary {
|
||||
total_deposit: number;
|
||||
total_withdrawal: number;
|
||||
total_balance: number;
|
||||
account_count: number;
|
||||
unset_count: number;
|
||||
deposit_unset_count: number;
|
||||
withdrawal_unset_count: number;
|
||||
}
|
||||
@@ -40,7 +49,9 @@ function transformItem(item: BankTransactionApiItem): BankTransaction {
|
||||
id: `${item.type}-${item.id}`,
|
||||
bankName: item.bank_name,
|
||||
accountName: item.account_name,
|
||||
accountNumber: item.account_number || undefined,
|
||||
transactionDate: item.transaction_date,
|
||||
transactionTime: item.transaction_time || undefined,
|
||||
type: item.type as TransactionKind,
|
||||
note: item.note || undefined,
|
||||
vendorId: item.vendor_id ? String(item.vendor_id) : undefined,
|
||||
@@ -51,6 +62,11 @@ function transformItem(item: BankTransactionApiItem): BankTransaction {
|
||||
balance: typeof item.balance === 'string' ? parseFloat(item.balance) : item.balance,
|
||||
transactionType: item.transaction_type || undefined,
|
||||
sourceId: item.source_id,
|
||||
bankAccountId: item.bank_account_id,
|
||||
memo: item.memo || undefined,
|
||||
branch: item.branch || undefined,
|
||||
isManual: !!item.is_manual,
|
||||
modifiedFields: item.modified_fields || undefined,
|
||||
createdAt: item.created_at,
|
||||
updatedAt: item.updated_at,
|
||||
};
|
||||
@@ -61,6 +77,7 @@ export async function getBankTransactionList(params?: {
|
||||
page?: number; perPage?: number; startDate?: string; endDate?: string;
|
||||
bankAccountId?: number; transactionType?: string; search?: string;
|
||||
sortBy?: string; sortDir?: 'asc' | 'desc';
|
||||
accountCategory?: AccountCategoryFilter; financialInstitution?: string;
|
||||
}) {
|
||||
return executePaginatedAction<BankTransactionApiItem, BankTransaction>({
|
||||
url: buildApiUrl('/api/v1/bank-transactions', {
|
||||
@@ -73,6 +90,8 @@ export async function getBankTransactionList(params?: {
|
||||
search: params?.search,
|
||||
sort_by: params?.sortBy,
|
||||
sort_dir: params?.sortDir,
|
||||
account_category: params?.accountCategory !== 'all' ? params?.accountCategory : undefined,
|
||||
financial_institution: params?.financialInstitution !== 'all' ? params?.financialInstitution : undefined,
|
||||
}),
|
||||
transform: transformItem,
|
||||
errorMessage: '은행 거래 조회에 실패했습니다.',
|
||||
@@ -80,9 +99,17 @@ export async function getBankTransactionList(params?: {
|
||||
}
|
||||
|
||||
// ===== 입출금 요약 통계 =====
|
||||
export interface BankTransactionSummaryData {
|
||||
totalDeposit: number;
|
||||
totalWithdrawal: number;
|
||||
totalBalance: number;
|
||||
accountCount: number;
|
||||
unsetCount: number;
|
||||
}
|
||||
|
||||
export async function getBankTransactionSummary(params?: {
|
||||
startDate?: string; endDate?: string;
|
||||
}): Promise<ActionResult<{ totalDeposit: number; totalWithdrawal: number; depositUnsetCount: number; withdrawalUnsetCount: number }>> {
|
||||
}): Promise<ActionResult<BankTransactionSummaryData>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/bank-transactions/summary', {
|
||||
start_date: params?.startDate,
|
||||
@@ -91,14 +118,15 @@ export async function getBankTransactionSummary(params?: {
|
||||
transform: (data: BankTransactionApiSummary) => ({
|
||||
totalDeposit: data.total_deposit,
|
||||
totalWithdrawal: data.total_withdrawal,
|
||||
depositUnsetCount: data.deposit_unset_count,
|
||||
withdrawalUnsetCount: data.withdrawal_unset_count,
|
||||
totalBalance: data.total_balance ?? 0,
|
||||
accountCount: data.account_count ?? 0,
|
||||
unsetCount: data.unset_count ?? (data.deposit_unset_count + data.withdrawal_unset_count),
|
||||
}),
|
||||
errorMessage: '요약 조회에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 계좌 목록 조회 (필터용) =====
|
||||
// ===== 계좌 목록 조회 (필터 + 모달 Select용) =====
|
||||
export async function getBankAccountOptions(): Promise<{
|
||||
success: boolean; data: { id: number; label: string }[]; error?: string;
|
||||
}> {
|
||||
@@ -109,3 +137,118 @@ export async function getBankAccountOptions(): Promise<{
|
||||
});
|
||||
return { success: result.success, data: result.data || [], error: result.error };
|
||||
}
|
||||
|
||||
// ===== 금융기관 목록 조회 (⑤ 필터용) =====
|
||||
export async function getFinancialInstitutions(): Promise<{
|
||||
success: boolean; data: { value: string; label: string }[]; error?: string;
|
||||
}> {
|
||||
const result = await executeServerAction({
|
||||
url: buildApiUrl('/api/v1/bank-transactions/financial-institutions'),
|
||||
transform: (data: { code: string; name: string }[]) =>
|
||||
data.map((item) => ({ value: item.code, label: item.name })),
|
||||
errorMessage: '금융기관 목록 조회에 실패했습니다.',
|
||||
});
|
||||
return { success: result.success, data: result.data || [], error: result.error };
|
||||
}
|
||||
|
||||
// ===== 수기 입력 (신규 생성) =====
|
||||
export async function createManualTransaction(
|
||||
formData: TransactionFormData
|
||||
): Promise<ActionResult<BankTransaction>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/bank-transactions/manual'),
|
||||
method: 'POST',
|
||||
body: {
|
||||
bank_account_id: formData.bankAccountId,
|
||||
transaction_date: formData.transactionDate,
|
||||
transaction_time: formData.transactionTime || undefined,
|
||||
type: formData.type,
|
||||
amount: formData.amount,
|
||||
note: formData.note || undefined,
|
||||
depositor_name: formData.depositorName || undefined,
|
||||
memo: formData.memo || undefined,
|
||||
branch: formData.branch || undefined,
|
||||
},
|
||||
transform: transformItem,
|
||||
errorMessage: '수기 입력에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 거래 수정 =====
|
||||
export async function updateTransaction(
|
||||
id: string,
|
||||
formData: Partial<TransactionFormData>
|
||||
): Promise<ActionResult<BankTransaction>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/bank-transactions/${id}`),
|
||||
method: 'PUT',
|
||||
body: {
|
||||
bank_account_id: formData.bankAccountId,
|
||||
transaction_date: formData.transactionDate,
|
||||
transaction_time: formData.transactionTime || undefined,
|
||||
type: formData.type,
|
||||
amount: formData.amount,
|
||||
note: formData.note ?? undefined,
|
||||
depositor_name: formData.depositorName ?? undefined,
|
||||
memo: formData.memo ?? undefined,
|
||||
branch: formData.branch ?? undefined,
|
||||
},
|
||||
transform: transformItem,
|
||||
errorMessage: '거래 수정에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 거래 삭제 =====
|
||||
export async function deleteTransaction(id: string): Promise<ActionResult<null>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/bank-transactions/${id}`),
|
||||
method: 'DELETE',
|
||||
errorMessage: '거래 삭제에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 원본으로 복원 =====
|
||||
export async function restoreTransaction(id: string): Promise<ActionResult<BankTransaction>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl(`/api/v1/bank-transactions/${id}/restore`),
|
||||
method: 'POST',
|
||||
transform: transformItem,
|
||||
errorMessage: '원본 복원에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 변경사항 일괄 저장 =====
|
||||
export async function batchSaveTransactions(
|
||||
changes: { id: string; data: Partial<TransactionFormData> }[]
|
||||
): Promise<ActionResult<null>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/bank-transactions/batch-save'),
|
||||
method: 'POST',
|
||||
body: {
|
||||
transactions: changes.map((c) => ({
|
||||
id: c.id,
|
||||
...c.data,
|
||||
})),
|
||||
},
|
||||
errorMessage: '일괄 저장에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 엑셀 다운로드 =====
|
||||
export async function exportBankTransactionsExcel(params?: {
|
||||
startDate?: string; endDate?: string;
|
||||
accountCategory?: string; financialInstitution?: string;
|
||||
}): Promise<ActionResult<{ downloadUrl: string }>> {
|
||||
return executeServerAction({
|
||||
url: buildApiUrl('/api/v1/bank-transactions/export', {
|
||||
start_date: params?.startDate,
|
||||
end_date: params?.endDate,
|
||||
account_category: params?.accountCategory,
|
||||
financial_institution: params?.financialInstitution,
|
||||
}),
|
||||
transform: (data: { download_url: string }) => ({
|
||||
downloadUrl: data.download_url,
|
||||
}),
|
||||
errorMessage: '엑셀 다운로드에 실패했습니다.',
|
||||
});
|
||||
}
|
||||
|
||||
@@ -1,24 +1,28 @@
|
||||
'use client';
|
||||
|
||||
/**
|
||||
* 입출금 계좌조회 - UniversalListPage 마이그레이션
|
||||
* 계좌 입출금 내역
|
||||
*
|
||||
* 기존 IntegratedListTemplateV2 → UniversalListPage config 기반으로 변환
|
||||
* - 서버 사이드 필터링/페이지네이션
|
||||
* - dateRangeSelector (헤더 액션)
|
||||
* - beforeTableContent: 새로고침 버튼
|
||||
* - tableHeaderActions: 3개 Select 필터 (결제계좌, 입출금유형, 정렬)
|
||||
* - tableFooter: 합계 행
|
||||
* - 수정 버튼 (입금/출금 상세 페이지 이동)
|
||||
* 기획서 기준:
|
||||
* - 통계 5개: 입금, 출금, 잔고, 계좌, 거래
|
||||
* - 테이블 11컬럼: 체크박스, No., 거래일시, 구분, 계좌정보, 적요/내용, 입금, 출금, 잔액, 취급점, 상대계좌예금주명
|
||||
* - 필터: ④구분(은행계좌/대출계좌/증권계좌/보험계좌), ⑤금융기관
|
||||
* - 액션: ①저장, 엑셀 다운로드, ②입출금 수기 입력
|
||||
* - 행 클릭 → 수기 입력/수정 모달
|
||||
* - ③수정 영역 하이라이트, ⑥수정 스티커
|
||||
* - 범례: 수기 계좌(🟠) / 연동 계좌(🔵)
|
||||
* - 날짜 프리셋: 이번달, 지난달, D-2월~D-5월
|
||||
*/
|
||||
|
||||
import { useState, useMemo, useCallback, useEffect } from 'react';
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { format, startOfMonth, endOfMonth } from 'date-fns';
|
||||
import { Building2, Pencil, RefreshCw, Loader2 } from 'lucide-react';
|
||||
import {
|
||||
Building2, Save, Download, Plus, RefreshCw, Loader2,
|
||||
} from 'lucide-react';
|
||||
import { toast } from 'sonner';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { Checkbox } from '@/components/ui/checkbox';
|
||||
import { TableRow, TableCell } from '@/components/ui/table';
|
||||
import {
|
||||
Select,
|
||||
@@ -35,85 +39,82 @@ import {
|
||||
type RowClickHandlers,
|
||||
type StatCard,
|
||||
} from '@/components/templates/UniversalListPage';
|
||||
import type { BankTransaction, SortOption } from './types';
|
||||
import type { BankTransaction, AccountCategoryFilter, SortOption } from './types';
|
||||
import {
|
||||
TRANSACTION_KIND_LABELS,
|
||||
DEPOSIT_TYPE_LABELS,
|
||||
WITHDRAWAL_TYPE_LABELS,
|
||||
SORT_OPTIONS,
|
||||
TRANSACTION_TYPE_FILTER_OPTIONS,
|
||||
ACCOUNT_CATEGORY_OPTIONS,
|
||||
ACCOUNT_CATEGORY_LABELS,
|
||||
} from './types';
|
||||
import {
|
||||
getBankTransactionList,
|
||||
getBankTransactionSummary,
|
||||
getBankAccountOptions,
|
||||
getFinancialInstitutions,
|
||||
batchSaveTransactions,
|
||||
exportBankTransactionsExcel,
|
||||
type BankTransactionSummaryData,
|
||||
} from './actions';
|
||||
import { TransactionFormModal } from './TransactionFormModal';
|
||||
import { isNextRedirectError } from '@/lib/utils/redirect-error';
|
||||
|
||||
// ===== 테이블 컬럼 정의 =====
|
||||
// ===== 테이블 컬럼 정의 (체크박스 제외 10개) =====
|
||||
const tableColumns = [
|
||||
{ key: 'bankName', label: '은행명' },
|
||||
{ key: 'accountName', label: '계좌명' },
|
||||
{ key: 'rowNumber', label: 'No.', className: 'text-center w-[50px]' },
|
||||
{ key: 'transactionDate', label: '거래일시' },
|
||||
{ key: 'type', label: '구분', className: 'text-center' },
|
||||
{ key: 'note', label: '적요' },
|
||||
{ key: 'vendorName', label: '거래처' },
|
||||
{ key: 'depositorName', label: '입금자/수취인' },
|
||||
{ key: 'accountInfo', label: '계좌정보' },
|
||||
{ key: 'note', label: '적요/내용' },
|
||||
{ key: 'depositAmount', label: '입금', className: 'text-right' },
|
||||
{ key: 'withdrawalAmount', label: '출금', className: 'text-right' },
|
||||
{ key: 'balance', label: '잔액', className: 'text-right' },
|
||||
{ key: 'transactionType', label: '입출금 유형', className: 'text-center' },
|
||||
{ key: 'actions', label: '작업', className: 'text-center w-[80px]' },
|
||||
{ key: 'branch', label: '취급점', className: 'text-center' },
|
||||
{ key: 'depositorName', label: '상대계좌예금주명' },
|
||||
];
|
||||
|
||||
// ===== Props =====
|
||||
interface BankTransactionInquiryProps {
|
||||
initialData?: BankTransaction[];
|
||||
initialSummary?: {
|
||||
totalDeposit: number;
|
||||
totalWithdrawal: number;
|
||||
depositUnsetCount: number;
|
||||
withdrawalUnsetCount: number;
|
||||
};
|
||||
initialPagination?: {
|
||||
currentPage: number;
|
||||
lastPage: number;
|
||||
perPage: number;
|
||||
total: number;
|
||||
};
|
||||
}
|
||||
// ===== 기본 Summary =====
|
||||
const DEFAULT_SUMMARY: BankTransactionSummaryData = {
|
||||
totalDeposit: 0,
|
||||
totalWithdrawal: 0,
|
||||
totalBalance: 0,
|
||||
accountCount: 0,
|
||||
unsetCount: 0,
|
||||
};
|
||||
|
||||
export function BankTransactionInquiry({
|
||||
initialData = [],
|
||||
initialSummary,
|
||||
initialPagination,
|
||||
}: BankTransactionInquiryProps) {
|
||||
const router = useRouter();
|
||||
export function BankTransactionInquiry() {
|
||||
// ===== 데이터 상태 =====
|
||||
const [data, setData] = useState<BankTransaction[]>([]);
|
||||
const [summary, setSummary] = useState<BankTransactionSummaryData>(DEFAULT_SUMMARY);
|
||||
const [pagination, setPagination] = useState({
|
||||
currentPage: 1, lastPage: 1, perPage: 20, total: 0,
|
||||
});
|
||||
|
||||
// ===== 외부 상태 (UniversalListPage 외부에서 관리) =====
|
||||
const [data, setData] = useState<BankTransaction[]>(initialData);
|
||||
const [summary, setSummary] = useState(
|
||||
initialSummary || { totalDeposit: 0, totalWithdrawal: 0, depositUnsetCount: 0, withdrawalUnsetCount: 0 }
|
||||
);
|
||||
const [pagination, setPagination] = useState(
|
||||
initialPagination || { currentPage: 1, lastPage: 1, perPage: 20, total: 0 }
|
||||
);
|
||||
const [accountOptions, setAccountOptions] = useState<{ value: string; label: string }[]>([
|
||||
{ value: 'all', label: '전체' },
|
||||
]);
|
||||
// 계좌/금융기관 옵션
|
||||
const [accountOptions, setAccountOptions] = useState<{ id: number; label: string }[]>([]);
|
||||
const [financialInstitutionOptions, setFinancialInstitutionOptions] = useState<
|
||||
{ value: string; label: string }[]
|
||||
>([{ value: 'all', label: '전체' }]);
|
||||
|
||||
// 필터 상태
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [sortOption, setSortOption] = useState<SortOption>('latest');
|
||||
const [accountFilter, setAccountFilter] = useState<string>('all');
|
||||
const [transactionTypeFilter, setTransactionTypeFilter] = useState<string>('all');
|
||||
const [currentPage, setCurrentPage] = useState(initialPagination?.currentPage || 1);
|
||||
const [isLoading, setIsLoading] = useState(!initialData.length);
|
||||
const [accountCategoryFilter, setAccountCategoryFilter] = useState<AccountCategoryFilter>('all');
|
||||
const [financialInstitutionFilter, setFinancialInstitutionFilter] = useState('all');
|
||||
const [currentPage, setCurrentPage] = useState(1);
|
||||
const [isLoading, setIsLoading] = useState(true);
|
||||
|
||||
// 날짜 범위 상태
|
||||
// 날짜 범위
|
||||
const [startDate, setStartDate] = useState(() => format(startOfMonth(new Date()), 'yyyy-MM-dd'));
|
||||
const [endDate, setEndDate] = useState(() => format(endOfMonth(new Date()), 'yyyy-MM-dd'));
|
||||
|
||||
// 모달 상태
|
||||
const [modalOpen, setModalOpen] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<'create' | 'edit'>('create');
|
||||
const [selectedTransaction, setSelectedTransaction] = useState<BankTransaction | null>(null);
|
||||
|
||||
// 수정 추적 (로컬 변경사항)
|
||||
const [localChanges, setLocalChanges] = useState<Map<string, Partial<BankTransaction>>>(new Map());
|
||||
const [isBatchSaving, setIsBatchSaving] = useState(false);
|
||||
|
||||
// ===== 데이터 로드 =====
|
||||
const loadData = useCallback(async () => {
|
||||
setIsLoading(true);
|
||||
@@ -126,35 +127,37 @@ export function BankTransactionInquiry({
|
||||
};
|
||||
const sortParams = sortMapping[sortOption];
|
||||
|
||||
const [listResult, summaryResult, accountsResult] = await Promise.all([
|
||||
const [listResult, summaryResult, accountsResult, fiResult] = await Promise.all([
|
||||
getBankTransactionList({
|
||||
page: currentPage,
|
||||
perPage: 20,
|
||||
startDate,
|
||||
endDate,
|
||||
bankAccountId: accountFilter !== 'all' ? parseInt(accountFilter, 10) : undefined,
|
||||
transactionType: transactionTypeFilter !== 'all' ? transactionTypeFilter : undefined,
|
||||
search: searchQuery || undefined,
|
||||
sortBy: sortParams.sortBy,
|
||||
sortDir: sortParams.sortDir,
|
||||
accountCategory: accountCategoryFilter,
|
||||
financialInstitution: financialInstitutionFilter,
|
||||
}),
|
||||
getBankTransactionSummary({ startDate, endDate }),
|
||||
getBankAccountOptions(),
|
||||
getFinancialInstitutions(),
|
||||
]);
|
||||
|
||||
if (listResult.success) {
|
||||
setData(listResult.data);
|
||||
setPagination(listResult.pagination);
|
||||
}
|
||||
|
||||
if (summaryResult.success && summaryResult.data) {
|
||||
setSummary(summaryResult.data);
|
||||
}
|
||||
|
||||
if (accountsResult.success) {
|
||||
setAccountOptions([
|
||||
setAccountOptions(accountsResult.data);
|
||||
}
|
||||
if (fiResult.success) {
|
||||
setFinancialInstitutionOptions([
|
||||
{ value: 'all', label: '전체' },
|
||||
...accountsResult.data.map((acc) => ({ value: String(acc.id), label: acc.label })),
|
||||
...fiResult.data,
|
||||
]);
|
||||
}
|
||||
} catch (error) {
|
||||
@@ -163,184 +166,236 @@ export function BankTransactionInquiry({
|
||||
} finally {
|
||||
setIsLoading(false);
|
||||
}
|
||||
}, [currentPage, startDate, endDate, accountFilter, transactionTypeFilter, searchQuery, sortOption]);
|
||||
}, [currentPage, startDate, endDate, searchQuery, sortOption, accountCategoryFilter, financialInstitutionFilter]);
|
||||
|
||||
// 데이터 로드 (필터 변경 시)
|
||||
useEffect(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// ===== 핸들러 =====
|
||||
const handleEditClick = useCallback(
|
||||
(item: BankTransaction) => {
|
||||
if (item.type === 'deposit') {
|
||||
router.push(`/ko/accounting/deposits/${item.sourceId}?mode=edit`);
|
||||
} else {
|
||||
router.push(`/ko/accounting/withdrawals/${item.sourceId}?mode=edit`);
|
||||
}
|
||||
},
|
||||
[router]
|
||||
);
|
||||
const handleRowClick = useCallback((item: BankTransaction) => {
|
||||
setSelectedTransaction(item);
|
||||
setModalMode('edit');
|
||||
setModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleRefresh = useCallback(() => {
|
||||
const handleCreateClick = useCallback(() => {
|
||||
setSelectedTransaction(null);
|
||||
setModalMode('create');
|
||||
setModalOpen(true);
|
||||
}, []);
|
||||
|
||||
const handleModalSuccess = useCallback(() => {
|
||||
loadData();
|
||||
}, [loadData]);
|
||||
|
||||
// ===== 유형 라벨 가져오기 =====
|
||||
const getTransactionTypeLabel = useCallback((item: BankTransaction) => {
|
||||
if (!item.transactionType) return '미설정';
|
||||
if (item.type === 'deposit') {
|
||||
return DEPOSIT_TYPE_LABELS[item.transactionType as keyof typeof DEPOSIT_TYPE_LABELS] || item.transactionType;
|
||||
// ① 저장 버튼 (변경사항 일괄 저장)
|
||||
const handleBatchSave = useCallback(async () => {
|
||||
if (localChanges.size === 0) {
|
||||
toast.info('변경된 내용이 없습니다.');
|
||||
return;
|
||||
}
|
||||
return WITHDRAWAL_TYPE_LABELS[item.transactionType as keyof typeof WITHDRAWAL_TYPE_LABELS] || item.transactionType;
|
||||
}, []);
|
||||
setIsBatchSaving(true);
|
||||
try {
|
||||
const changes = Array.from(localChanges.entries()).map(([id, data]) => ({
|
||||
id,
|
||||
data: {
|
||||
bankAccountId: data.bankAccountId ?? undefined,
|
||||
transactionDate: data.transactionDate,
|
||||
type: data.type,
|
||||
amount: data.depositAmount || data.withdrawalAmount,
|
||||
note: data.note ?? '',
|
||||
depositorName: data.depositorName ?? '',
|
||||
memo: data.memo ?? '',
|
||||
branch: data.branch ?? '',
|
||||
},
|
||||
}));
|
||||
const result = await batchSaveTransactions(changes);
|
||||
if (result.success) {
|
||||
toast.success('저장이 완료되었습니다.');
|
||||
setLocalChanges(new Map());
|
||||
loadData();
|
||||
} else {
|
||||
toast.error(result.error || '저장에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('저장 중 오류가 발생했습니다.');
|
||||
} finally {
|
||||
setIsBatchSaving(false);
|
||||
}
|
||||
}, [localChanges, loadData]);
|
||||
|
||||
// ===== 테이블 합계 계산 =====
|
||||
// 엑셀 다운로드
|
||||
const handleExcelDownload = useCallback(async () => {
|
||||
try {
|
||||
const result = await exportBankTransactionsExcel({
|
||||
startDate,
|
||||
endDate,
|
||||
accountCategory: accountCategoryFilter,
|
||||
financialInstitution: financialInstitutionFilter,
|
||||
});
|
||||
if (result.success && result.data) {
|
||||
window.open(result.data.downloadUrl, '_blank');
|
||||
} else {
|
||||
toast.error(result.error || '엑셀 다운로드에 실패했습니다.');
|
||||
}
|
||||
} catch {
|
||||
toast.error('엑셀 다운로드 중 오류가 발생했습니다.');
|
||||
}
|
||||
}, [startDate, endDate, accountCategoryFilter, financialInstitutionFilter]);
|
||||
|
||||
// ===== 합계 계산 =====
|
||||
const tableTotals = useMemo(() => {
|
||||
const totalDeposit = data.reduce((sum, item) => sum + item.depositAmount, 0);
|
||||
const totalWithdrawal = data.reduce((sum, item) => sum + item.withdrawalAmount, 0);
|
||||
return { totalDeposit, totalWithdrawal };
|
||||
}, [data]);
|
||||
|
||||
// 행이 수정되었는지 확인
|
||||
const isRowModified = useCallback(
|
||||
(id: string) => localChanges.has(id) || false,
|
||||
[localChanges]
|
||||
);
|
||||
|
||||
// 셀이 수정되었는지 확인 (서버에서 온 modifiedFields)
|
||||
const isCellModified = useCallback(
|
||||
(item: BankTransaction, field: string) => {
|
||||
return item.modifiedFields?.includes(field) || false;
|
||||
},
|
||||
[]
|
||||
);
|
||||
|
||||
// ===== UniversalListPage Config =====
|
||||
const config: UniversalListConfig<BankTransaction> = useMemo(
|
||||
() => ({
|
||||
// 페이지 기본 정보
|
||||
title: '입출금 계좌조회',
|
||||
description: '은행 계좌 정보와 입출금 내역을 조회할 수 있습니다',
|
||||
title: '계좌 입출금 내역',
|
||||
description: '은행 계좌의 입출금 내역을 조회하고 관리합니다',
|
||||
icon: Building2,
|
||||
basePath: '/accounting/bank-transactions',
|
||||
|
||||
// ID 추출
|
||||
idField: 'id',
|
||||
|
||||
// API 액션
|
||||
actions: {
|
||||
getList: async () => {
|
||||
return {
|
||||
success: true,
|
||||
data: data,
|
||||
totalCount: pagination.total,
|
||||
};
|
||||
},
|
||||
getList: async () => ({
|
||||
success: true,
|
||||
data,
|
||||
totalCount: pagination.total,
|
||||
}),
|
||||
},
|
||||
|
||||
// 테이블 컬럼
|
||||
columns: tableColumns,
|
||||
|
||||
// 서버 사이드 필터링 (클라이언트 사이드 아님)
|
||||
clientSideFiltering: false,
|
||||
itemsPerPage: 20,
|
||||
showCheckbox: true,
|
||||
showRowNumber: true,
|
||||
|
||||
// 검색
|
||||
searchPlaceholder: '은행명, 계좌명, 거래처, 입금자/수취인 검색...',
|
||||
searchPlaceholder: '계좌명, 적요, 예금주명 검색...',
|
||||
onSearchChange: setSearchQuery,
|
||||
searchFilter: (item: BankTransaction, search: string) => {
|
||||
const s = search.toLowerCase();
|
||||
return (
|
||||
item.bankName?.toLowerCase().includes(s) ||
|
||||
item.accountName?.toLowerCase().includes(s) ||
|
||||
item.vendorName?.toLowerCase().includes(s) ||
|
||||
item.note?.toLowerCase().includes(s) ||
|
||||
item.depositorName?.toLowerCase().includes(s) ||
|
||||
false
|
||||
);
|
||||
},
|
||||
|
||||
// 필터 설정 (모바일용)
|
||||
filterConfig: [
|
||||
{
|
||||
key: 'account',
|
||||
label: '결제계좌',
|
||||
type: 'single',
|
||||
options: accountOptions.filter((o) => o.value !== 'all'),
|
||||
},
|
||||
{
|
||||
key: 'transactionType',
|
||||
label: '입출금유형',
|
||||
type: 'single',
|
||||
options: TRANSACTION_TYPE_FILTER_OPTIONS.filter((o) => o.value !== 'all').map((o) => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
{
|
||||
key: 'sortBy',
|
||||
label: '정렬',
|
||||
type: 'single',
|
||||
options: SORT_OPTIONS.map((o) => ({
|
||||
value: o.value,
|
||||
label: o.label,
|
||||
})),
|
||||
},
|
||||
],
|
||||
initialFilters: {
|
||||
account: 'all',
|
||||
transactionType: 'all',
|
||||
sortBy: 'latest',
|
||||
},
|
||||
filterTitle: '계좌 필터',
|
||||
|
||||
// 날짜 선택기 (헤더 액션)
|
||||
// 날짜 선택기 (이번달~D-5월 프리셋)
|
||||
dateRangeSelector: {
|
||||
enabled: true,
|
||||
showPresets: true,
|
||||
presets: ['thisMonth', 'lastMonth', 'twoMonthsAgo', 'threeMonthsAgo', 'fourMonthsAgo', 'fiveMonthsAgo'],
|
||||
presetLabels: {
|
||||
thisMonth: '이번달',
|
||||
lastMonth: '지난달',
|
||||
twoMonthsAgo: 'D-2월',
|
||||
threeMonthsAgo: 'D-3월',
|
||||
fourMonthsAgo: 'D-4월',
|
||||
fiveMonthsAgo: 'D-5월',
|
||||
},
|
||||
startDate,
|
||||
endDate,
|
||||
onStartDateChange: setStartDate,
|
||||
onEndDateChange: setEndDate,
|
||||
},
|
||||
|
||||
// 헤더 액션: 새로고침 버튼
|
||||
// 헤더 액션: ①저장 + 엑셀 다운로드 + ②수기 입력
|
||||
headerActions: () => (
|
||||
<Button variant="outline" size="sm" onClick={handleRefresh} disabled={isLoading}>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
{isLoading ? '조회중...' : '새로고침'}
|
||||
</Button>
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
size="sm"
|
||||
onClick={handleBatchSave}
|
||||
disabled={isBatchSaving || localChanges.size === 0}
|
||||
className="bg-orange-500 hover:bg-orange-600 text-white"
|
||||
>
|
||||
{isBatchSaving ? (
|
||||
<Loader2 className="h-4 w-4 mr-1 animate-spin" />
|
||||
) : (
|
||||
<Save className="h-4 w-4 mr-1" />
|
||||
)}
|
||||
저장
|
||||
</Button>
|
||||
<Button variant="outline" size="sm" onClick={handleExcelDownload}>
|
||||
<Download className="h-4 w-4 mr-1" />
|
||||
엑셀 다운로드
|
||||
</Button>
|
||||
<Button size="sm" onClick={handleCreateClick}>
|
||||
<Plus className="h-4 w-4 mr-1" />
|
||||
입출금 수기 입력
|
||||
</Button>
|
||||
</div>
|
||||
),
|
||||
|
||||
// 테이블 헤더 액션 (3개 필터)
|
||||
// 테이블 헤더 액션: 총 N건 + ④구분 + ⑤금융기관
|
||||
tableHeaderActions: () => (
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
{/* 결제계좌 필터 */}
|
||||
<Select value={accountFilter} onValueChange={setAccountFilter}>
|
||||
<SelectTrigger className="w-[180px]">
|
||||
<SelectValue placeholder="결제계좌" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{accountOptions.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<span className="text-sm text-muted-foreground whitespace-nowrap">
|
||||
총 {pagination.total}건
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={() => loadData()}
|
||||
disabled={isLoading}
|
||||
>
|
||||
{isLoading ? (
|
||||
<Loader2 className="h-4 w-4 animate-spin" />
|
||||
) : (
|
||||
<RefreshCw className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
{/* 입출금유형 필터 */}
|
||||
<Select value={transactionTypeFilter} onValueChange={setTransactionTypeFilter}>
|
||||
<SelectTrigger className="w-[130px]">
|
||||
<SelectValue placeholder="입출금유형" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TRANSACTION_TYPE_FILTER_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* 정렬 */}
|
||||
<Select value={sortOption} onValueChange={(value) => setSortOption(value as SortOption)}>
|
||||
{/* ④ 구분 */}
|
||||
<Select
|
||||
value={accountCategoryFilter}
|
||||
onValueChange={(v) => setAccountCategoryFilter(v as AccountCategoryFilter)}
|
||||
>
|
||||
<SelectTrigger className="w-[120px]">
|
||||
<SelectValue placeholder="정렬" />
|
||||
<SelectValue placeholder="구분" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{SORT_OPTIONS.map((option) => (
|
||||
<SelectItem key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
{ACCOUNT_CATEGORY_OPTIONS.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{/* ⑤ 금융기관 */}
|
||||
<Select
|
||||
value={financialInstitutionFilter}
|
||||
onValueChange={setFinancialInstitutionFilter}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="금융기관" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{financialInstitutionOptions.map((opt) => (
|
||||
<SelectItem key={opt.value} value={opt.value}>
|
||||
{opt.label}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
@@ -348,31 +403,40 @@ export function BankTransactionInquiry({
|
||||
</div>
|
||||
),
|
||||
|
||||
// 테이블 푸터 (합계 행)
|
||||
// 합계 행 + 범례 (체크박스+No.+9데이터 = 12컬럼)
|
||||
tableFooter: (
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell className="text-center" />
|
||||
<TableCell className="font-bold">합계</TableCell>
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell className="text-right font-bold text-blue-600">
|
||||
{tableTotals.totalDeposit.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-bold text-red-600">
|
||||
{tableTotals.totalWithdrawal.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
<>
|
||||
<TableRow className="bg-muted/50 font-medium">
|
||||
<TableCell />
|
||||
<TableCell colSpan={5} className="text-right font-bold">합계</TableCell>
|
||||
<TableCell className="text-right font-bold text-blue-600">
|
||||
{tableTotals.totalDeposit.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell className="text-right font-bold text-red-600">
|
||||
{tableTotals.totalWithdrawal.toLocaleString()}
|
||||
</TableCell>
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
<TableCell />
|
||||
</TableRow>
|
||||
<TableRow>
|
||||
<TableCell colSpan={12} className="py-2">
|
||||
<div className="flex items-center gap-4 text-xs text-muted-foreground">
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block w-2.5 h-2.5 rounded-full bg-orange-400" />
|
||||
수기 계좌
|
||||
</span>
|
||||
<span className="flex items-center gap-1.5">
|
||||
<span className="inline-block w-2.5 h-2.5 rounded-full bg-blue-400" />
|
||||
연동 계좌
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
</>
|
||||
),
|
||||
|
||||
// Stats 카드
|
||||
// 통계 카드 5개
|
||||
computeStats: (): StatCard[] => [
|
||||
{
|
||||
label: '입금',
|
||||
@@ -387,14 +451,20 @@ export function BankTransactionInquiry({
|
||||
iconColor: 'text-red-500',
|
||||
},
|
||||
{
|
||||
label: '입금 유형 미설정',
|
||||
value: `${summary.depositUnsetCount}건`,
|
||||
label: '잔고',
|
||||
value: `${summary.totalBalance.toLocaleString()}원`,
|
||||
icon: Building2,
|
||||
iconColor: 'text-green-500',
|
||||
},
|
||||
{
|
||||
label: '출금 유형 미설정',
|
||||
value: `${summary.withdrawalUnsetCount}건`,
|
||||
label: '계좌',
|
||||
value: `${summary.accountCount}개`,
|
||||
icon: Building2,
|
||||
iconColor: 'text-gray-500',
|
||||
},
|
||||
{
|
||||
label: '거래',
|
||||
value: `${pagination.total}건`,
|
||||
icon: Building2,
|
||||
iconColor: 'text-orange-500',
|
||||
},
|
||||
@@ -403,76 +473,92 @@ export function BankTransactionInquiry({
|
||||
// 테이블 행 렌더링
|
||||
renderTableRow: (
|
||||
item: BankTransaction,
|
||||
index: number,
|
||||
_index: number,
|
||||
globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<BankTransaction>
|
||||
) => {
|
||||
const isTypeUnset = item.transactionType === 'unset';
|
||||
const rowModified = isRowModified(item.id);
|
||||
|
||||
return (
|
||||
<TableRow key={item.id} className="hover:bg-muted/50">
|
||||
<TableRow
|
||||
key={item.id}
|
||||
className={`cursor-pointer hover:bg-muted/50 ${rowModified ? 'bg-green-50' : ''}`}
|
||||
onClick={() => handleRowClick(item)}
|
||||
>
|
||||
{/* 체크박스 */}
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox checked={handlers.isSelected} onCheckedChange={handlers.onToggle} />
|
||||
<TableCell className="text-center w-[40px]" onClick={(e) => e.stopPropagation()}>
|
||||
<Checkbox
|
||||
checked={handlers.isSelected}
|
||||
onCheckedChange={() => handlers.onToggle()}
|
||||
/>
|
||||
</TableCell>
|
||||
{/* 번호 */}
|
||||
{/* No. */}
|
||||
<TableCell className="text-center">{globalIndex}</TableCell>
|
||||
{/* 은행명 */}
|
||||
<TableCell>{item.bankName}</TableCell>
|
||||
{/* 계좌명 */}
|
||||
<TableCell>{item.accountName}</TableCell>
|
||||
{/* 거래일시 */}
|
||||
<TableCell>{item.transactionDate}</TableCell>
|
||||
{/* 구분 */}
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={
|
||||
item.type === 'deposit'
|
||||
? 'border-blue-300 text-blue-600 bg-blue-50'
|
||||
: 'border-red-300 text-red-600 bg-red-50'
|
||||
}
|
||||
>
|
||||
{TRANSACTION_KIND_LABELS[item.type]}
|
||||
</Badge>
|
||||
<TableCell>
|
||||
<span className={isCellModified(item, 'transaction_date') ? 'bg-green-100 px-1 rounded' : ''}>
|
||||
{item.transactionDate}
|
||||
{item.transactionTime && (
|
||||
<span className="text-xs text-gray-400 ml-1">{item.transactionTime}</span>
|
||||
)}
|
||||
</span>
|
||||
</TableCell>
|
||||
{/* 구분 (계좌 카테고리) */}
|
||||
<TableCell className="text-center text-sm">
|
||||
{item.accountCategory
|
||||
? ACCOUNT_CATEGORY_LABELS[item.accountCategory]
|
||||
: '은행계좌'}
|
||||
</TableCell>
|
||||
{/* 계좌정보 (수기: 🟠, 연동: 🔵) - 은행명 + 마스킹 계좌번호 */}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`inline-block w-2.5 h-2.5 rounded-full shrink-0 ${
|
||||
item.isManual ? 'bg-orange-400' : 'bg-blue-400'
|
||||
}`}
|
||||
/>
|
||||
<span className="text-sm truncate">
|
||||
{item.bankName || item.accountName}
|
||||
{item.accountNumber && (
|
||||
<span className="text-muted-foreground ml-1">
|
||||
****{item.accountNumber.slice(-4)}
|
||||
</span>
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* 적요/내용 (수정 하이라이트) */}
|
||||
<TableCell>
|
||||
<div className="flex items-center gap-1">
|
||||
<span className={isCellModified(item, 'note') ? 'bg-green-100 px-1 rounded' : ''}>
|
||||
{item.note || '-'}
|
||||
</span>
|
||||
{isCellModified(item, 'note') && (
|
||||
<Badge variant="outline" className="bg-green-50 text-green-700 border-green-300 text-[10px] px-1 py-0">
|
||||
수정
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
{/* 적요 */}
|
||||
<TableCell className="text-gray-500">{item.note || '-'}</TableCell>
|
||||
{/* 거래처 */}
|
||||
<TableCell>{item.vendorName || '-'}</TableCell>
|
||||
{/* 입금자/수취인 */}
|
||||
<TableCell>{item.depositorName || '-'}</TableCell>
|
||||
{/* 입금 */}
|
||||
<TableCell className="text-right font-medium text-blue-600">
|
||||
<TableCell className={`text-right font-medium text-blue-600 ${isCellModified(item, 'deposit_amount') ? 'bg-green-100 rounded' : ''}`}>
|
||||
{item.depositAmount > 0 ? item.depositAmount.toLocaleString() : '-'}
|
||||
</TableCell>
|
||||
{/* 출금 */}
|
||||
<TableCell className="text-right font-medium text-red-600">
|
||||
<TableCell className={`text-right font-medium text-red-600 ${isCellModified(item, 'withdrawal_amount') ? 'bg-green-100 rounded' : ''}`}>
|
||||
{item.withdrawalAmount > 0 ? item.withdrawalAmount.toLocaleString() : '-'}
|
||||
</TableCell>
|
||||
{/* 잔액 */}
|
||||
<TableCell className="text-right font-medium">{item.balance.toLocaleString()}</TableCell>
|
||||
{/* 입출금 유형 */}
|
||||
<TableCell className="text-center">
|
||||
<Badge
|
||||
variant="outline"
|
||||
className={isTypeUnset ? 'border-red-300 text-red-500 bg-red-50' : ''}
|
||||
>
|
||||
{getTransactionTypeLabel(item)}
|
||||
</Badge>
|
||||
<TableCell className="text-right font-medium">
|
||||
{item.balance.toLocaleString()}
|
||||
</TableCell>
|
||||
{/* 작업 */}
|
||||
<TableCell className="text-center" onClick={(e) => e.stopPropagation()}>
|
||||
{handlers.isSelected && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8 text-gray-600 hover:text-gray-700 hover:bg-gray-50"
|
||||
onClick={() => handleEditClick(item)}
|
||||
>
|
||||
<Pencil className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
{/* 취급점 */}
|
||||
<TableCell className="text-center text-sm text-muted-foreground">
|
||||
{item.branch || '-'}
|
||||
</TableCell>
|
||||
{/* 상대계좌예금주명 */}
|
||||
<TableCell className="text-sm">
|
||||
{item.depositorName || '-'}
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
);
|
||||
@@ -481,75 +567,91 @@ export function BankTransactionInquiry({
|
||||
// 모바일 카드 렌더링
|
||||
renderMobileCard: (
|
||||
item: BankTransaction,
|
||||
index: number,
|
||||
globalIndex: number,
|
||||
_index: number,
|
||||
_globalIndex: number,
|
||||
handlers: SelectionHandlers & RowClickHandlers<BankTransaction>
|
||||
) => (
|
||||
<MobileCard
|
||||
key={item.id}
|
||||
title={`${item.bankName} - ${item.accountName}`}
|
||||
subtitle={item.transactionDate}
|
||||
badge={TRANSACTION_KIND_LABELS[item.type]}
|
||||
badgeVariant="outline"
|
||||
badgeClassName={
|
||||
item.type === 'deposit'
|
||||
? 'border-blue-300 text-blue-600 bg-blue-50'
|
||||
: 'border-red-300 text-red-600 bg-red-50'
|
||||
}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggle={handlers.onToggle}
|
||||
details={[
|
||||
{
|
||||
label: '입금',
|
||||
value: item.depositAmount > 0 ? `${item.depositAmount.toLocaleString()}원` : '-',
|
||||
},
|
||||
{
|
||||
label: '출금',
|
||||
value: item.withdrawalAmount > 0 ? `${item.withdrawalAmount.toLocaleString()}원` : '-',
|
||||
},
|
||||
{ label: '잔액', value: `${item.balance.toLocaleString()}원` },
|
||||
{ label: '거래처', value: item.vendorName || '-' },
|
||||
{ label: '입출금 유형', value: getTransactionTypeLabel(item) },
|
||||
]}
|
||||
actions={
|
||||
handlers.isSelected ? (
|
||||
<Button variant="outline" className="w-full" onClick={() => handleEditClick(item)}>
|
||||
<Pencil className="w-4 h-4 mr-2" /> 수정
|
||||
</Button>
|
||||
) : undefined
|
||||
}
|
||||
/>
|
||||
),
|
||||
) => {
|
||||
return (
|
||||
<MobileCard
|
||||
key={item.id}
|
||||
title={`${item.bankName || item.accountName}${item.accountNumber ? ` ****${item.accountNumber.slice(-4)}` : ''}`}
|
||||
subtitle={item.transactionDate}
|
||||
badge={TRANSACTION_KIND_LABELS[item.type]}
|
||||
badgeVariant="outline"
|
||||
badgeClassName={
|
||||
item.type === 'deposit'
|
||||
? 'border-blue-300 text-blue-600 bg-blue-50'
|
||||
: 'border-red-300 text-red-600 bg-red-50'
|
||||
}
|
||||
isSelected={handlers.isSelected}
|
||||
onToggle={handlers.onToggle}
|
||||
onClick={() => handleRowClick(item)}
|
||||
details={[
|
||||
{ label: '적요', value: item.note || '-' },
|
||||
{
|
||||
label: '입금',
|
||||
value: item.depositAmount > 0 ? `${item.depositAmount.toLocaleString()}원` : '-',
|
||||
},
|
||||
{
|
||||
label: '출금',
|
||||
value: item.withdrawalAmount > 0 ? `${item.withdrawalAmount.toLocaleString()}원` : '-',
|
||||
},
|
||||
{ label: '잔액', value: `${item.balance.toLocaleString()}원` },
|
||||
{ label: '취급점', value: item.branch || '-' },
|
||||
{ label: '예금주', value: item.depositorName || '-' },
|
||||
]}
|
||||
/>
|
||||
);
|
||||
},
|
||||
}),
|
||||
[
|
||||
data,
|
||||
pagination,
|
||||
summary,
|
||||
accountOptions,
|
||||
accountFilter,
|
||||
transactionTypeFilter,
|
||||
accountCategoryFilter,
|
||||
financialInstitutionFilter,
|
||||
financialInstitutionOptions,
|
||||
sortOption,
|
||||
startDate,
|
||||
endDate,
|
||||
tableTotals,
|
||||
isLoading,
|
||||
handleRefresh,
|
||||
handleEditClick,
|
||||
getTransactionTypeLabel,
|
||||
isBatchSaving,
|
||||
localChanges,
|
||||
handleBatchSave,
|
||||
handleExcelDownload,
|
||||
handleCreateClick,
|
||||
handleRowClick,
|
||||
isRowModified,
|
||||
isCellModified,
|
||||
loadData,
|
||||
]
|
||||
);
|
||||
|
||||
return (
|
||||
<UniversalListPage
|
||||
config={config}
|
||||
initialData={data}
|
||||
externalPagination={{
|
||||
currentPage,
|
||||
totalPages: pagination.lastPage,
|
||||
totalItems: pagination.total,
|
||||
itemsPerPage: 20,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
/>
|
||||
<>
|
||||
<UniversalListPage
|
||||
config={config}
|
||||
initialData={data}
|
||||
externalPagination={{
|
||||
currentPage,
|
||||
totalPages: pagination.lastPage,
|
||||
totalItems: pagination.total,
|
||||
itemsPerPage: 20,
|
||||
onPageChange: setCurrentPage,
|
||||
}}
|
||||
externalIsLoading={isLoading}
|
||||
/>
|
||||
|
||||
{/* 수기 입력/수정 모달 */}
|
||||
<TransactionFormModal
|
||||
open={modalOpen}
|
||||
onOpenChange={setModalOpen}
|
||||
mode={modalMode}
|
||||
transaction={selectedTransaction}
|
||||
accountOptions={accountOptions}
|
||||
onSuccess={handleModalSuccess}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
// ===== 입출금 계좌조회 타입 정의 =====
|
||||
// ===== 계좌 입출금 내역 타입 정의 =====
|
||||
|
||||
// 거래 구분
|
||||
export type TransactionKind = 'deposit' | 'withdrawal';
|
||||
@@ -30,28 +30,52 @@ export type WithdrawalTransactionType =
|
||||
| 'vatPayment' // 부가세납부
|
||||
| 'other'; // 기타
|
||||
|
||||
// 계좌 카테고리 타입 (구분 컬럼 표시용)
|
||||
export type AccountCategory = 'bank_account' | 'loan_account' | 'securities_account' | 'insurance_account';
|
||||
|
||||
// 입출금 거래 레코드
|
||||
export interface BankTransaction {
|
||||
id: string;
|
||||
bankName: string; // 은행명
|
||||
accountName: string; // 계좌명
|
||||
transactionDate: string; // 거래일시
|
||||
type: TransactionKind; // 구분 (입금/출금)
|
||||
note?: string; // 적요
|
||||
accountNumber?: string; // 계좌번호
|
||||
accountCategory?: AccountCategory; // 계좌 카테고리 (은행계좌/대출계좌/증권계좌/보험계좌)
|
||||
transactionDate: string; // 거래일
|
||||
transactionTime?: string; // 거래시간 (HHMMSS)
|
||||
type: TransactionKind; // 입금/출금
|
||||
note?: string; // 적요/내용
|
||||
vendorId?: string; // 거래처 ID
|
||||
vendorName?: string; // 거래처명
|
||||
depositorName?: string; // 입금자/수취인
|
||||
depositorName?: string; // 상대계좌 예금주명
|
||||
depositAmount: number; // 입금
|
||||
withdrawalAmount: number; // 출금
|
||||
balance: number; // 잔액
|
||||
transactionType?: string; // 입출금 유형
|
||||
sourceId: string; // 원본 입금/출금 ID (상세 이동용)
|
||||
sourceId: string; // 원본 입금/출금 ID
|
||||
bankAccountId?: number; // 계좌 ID
|
||||
memo?: string; // 메모
|
||||
branch?: string; // 취급점
|
||||
isManual: boolean; // 수기(true) / 연동(false)
|
||||
modifiedFields?: string[]; // 수정된 필드 목록
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
// 필터 타입
|
||||
export type TransactionFilter = 'all' | 'deposit' | 'withdrawal';
|
||||
// 수기 입력/수정 폼 데이터
|
||||
export interface TransactionFormData {
|
||||
bankAccountId: number | null; // 계좌 ID
|
||||
transactionDate: string; // 거래일
|
||||
transactionTime: string; // 거래시간
|
||||
type: TransactionKind; // 거래유형 (입금/출금)
|
||||
amount: number; // 금액
|
||||
note: string; // 적요
|
||||
depositorName: string; // 상대계좌 예금주명
|
||||
memo: string; // 메모
|
||||
branch: string; // 취급점
|
||||
}
|
||||
|
||||
// 계좌 카테고리 필터 (④ 구분)
|
||||
export type AccountCategoryFilter = 'all' | 'bank_account' | 'loan_account' | 'securities_account' | 'insurance_account';
|
||||
|
||||
// 정렬 옵션
|
||||
export type SortOption = 'latest' | 'oldest' | 'amountHigh' | 'amountLow';
|
||||
@@ -90,10 +114,21 @@ export const WITHDRAWAL_TYPE_LABELS: Record<WithdrawalTransactionType, string> =
|
||||
other: '기타',
|
||||
};
|
||||
|
||||
export const FILTER_OPTIONS: { value: TransactionFilter; label: string }[] = [
|
||||
{ value: 'all', label: '전체(선택)' },
|
||||
{ value: 'deposit', label: '입금/수입' },
|
||||
{ value: 'withdrawal', label: '출금' },
|
||||
// 계좌 카테고리 라벨 (구분 컬럼 표시용)
|
||||
export const ACCOUNT_CATEGORY_LABELS: Record<AccountCategory, string> = {
|
||||
bank_account: '은행계좌',
|
||||
loan_account: '대출계좌',
|
||||
securities_account: '증권계좌',
|
||||
insurance_account: '보험계좌',
|
||||
};
|
||||
|
||||
// ④ 구분 셀렉트 옵션
|
||||
export const ACCOUNT_CATEGORY_OPTIONS: { value: AccountCategoryFilter; label: string }[] = [
|
||||
{ value: 'all', label: '전체' },
|
||||
{ value: 'bank_account', label: '은행계좌' },
|
||||
{ value: 'loan_account', label: '대출계좌' },
|
||||
{ value: 'securities_account', label: '증권계좌' },
|
||||
{ value: 'insurance_account', label: '보험계좌' },
|
||||
];
|
||||
|
||||
export const SORT_OPTIONS: { value: SortOption; label: string }[] = [
|
||||
@@ -127,4 +162,4 @@ export const TRANSACTION_TYPE_FILTER_OPTIONS: { value: string; label: string }[]
|
||||
{ value: 'loanRepayment', label: '차입금상환' },
|
||||
{ value: 'vatPayment', label: '부가세납부' },
|
||||
{ value: 'other', label: '기타' },
|
||||
];
|
||||
];
|
||||
|
||||
Reference in New Issue
Block a user