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:
유병철
2026-02-15 23:18:45 +09:00
parent 7ce4efa146
commit 7f39f3066f
81 changed files with 12848 additions and 2749 deletions

View File

@@ -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>
);
}

View File

@@ -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: '엑셀 다운로드에 실패했습니다.',
});
}

View File

@@ -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}
/>
</>
);
}
}

View File

@@ -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: '기타' },
];
];