- AddTransactionButton.tsx: useEffect import 제거 - BudgetProgressCard.tsx: localBudgetData를 _localBudgetData로 변경 - Header.tsx: isMobile을 _isMobile로 변경 - RecentTransactionsSection.tsx: isDeleting을 _isDeleting로 변경 - TransactionCard.tsx: cn import 제거 - ExpenseForm.tsx: useState import 제거 - cacheStrategies.ts: QueryClient, Transaction import 제거 - Analytics.tsx: Separator import 제거, 미사용 변수들에 underscore prefix 추가 - Index.tsx: useMemo import 제거 - Login.tsx: setLoginError를 _setLoginError로 변경 - Register.tsx: useEffect dependency 수정 및 useCallback 추가 - Settings.tsx: toast, handleClick에 underscore prefix 추가 - authStore.ts: setError, setAppwriteInitialized에 underscore prefix 추가 - budgetStore.ts: ranges를 _ranges로 변경 - BudgetProgressCard.test.tsx: waitFor import 제거 🤖 Generated with [Claude Code](https://claude.ai/code) Co-Authored-By: Claude <noreply@anthropic.com>
501 lines
15 KiB
TypeScript
501 lines
15 KiB
TypeScript
import { create } from "zustand";
|
|
import { devtools, persist } from "zustand/middleware";
|
|
import { v4 as uuidv4 } from "uuid";
|
|
import {
|
|
Transaction,
|
|
BudgetData,
|
|
BudgetPeriod,
|
|
BudgetPeriodData,
|
|
CategoryBudget,
|
|
PaymentMethodStats,
|
|
} from "@/contexts/budget/types";
|
|
import { getInitialBudgetData } from "@/contexts/budget/utils/constants";
|
|
import { EXPENSE_CATEGORIES } from "@/constants/categoryIcons";
|
|
import { syncLogger } from "@/utils/logger";
|
|
import type { PaymentMethod } from "@/types/common";
|
|
|
|
// 상수 정의
|
|
const CATEGORIES = EXPENSE_CATEGORIES;
|
|
const DEFAULT_BUDGET_DATA = getInitialBudgetData();
|
|
const PAYMENT_METHODS: PaymentMethod[] = [
|
|
"신용카드",
|
|
"현금",
|
|
"체크카드",
|
|
"간편결제",
|
|
];
|
|
|
|
/**
|
|
* Zustand 예산 스토어 상태 타입
|
|
*/
|
|
interface BudgetState {
|
|
// 상태
|
|
transactions: Transaction[];
|
|
categoryBudgets: Record<string, number>;
|
|
budgetData: BudgetData;
|
|
selectedTab: BudgetPeriod;
|
|
|
|
// 트랜잭션 관리 액션
|
|
addTransaction: (transaction: Omit<Transaction, "id">) => void;
|
|
updateTransaction: (updatedTransaction: Transaction) => void;
|
|
deleteTransaction: (id: string) => void;
|
|
setTransactions: (transactions: Transaction[]) => void;
|
|
|
|
// 예산 관리 액션
|
|
handleBudgetGoalUpdate: (
|
|
type: BudgetPeriod,
|
|
amount: number,
|
|
newCategoryBudgets?: Record<string, number>
|
|
) => void;
|
|
setCategoryBudgets: (budgets: Record<string, number>) => void;
|
|
updateCategoryBudget: (category: string, amount: number) => void;
|
|
|
|
// UI 상태 액션
|
|
setSelectedTab: (tab: BudgetPeriod) => void;
|
|
|
|
// 계산 및 분석 함수
|
|
getCategorySpending: () => CategoryBudget[];
|
|
getPaymentMethodStats: () => PaymentMethodStats[];
|
|
calculateBudgetData: () => BudgetData;
|
|
|
|
// 데이터 초기화
|
|
resetBudgetData: () => void;
|
|
|
|
// 내부 헬퍼 함수
|
|
recalculateBudgetData: () => void;
|
|
persistToLocalStorage: () => void;
|
|
}
|
|
|
|
/**
|
|
* 날짜 범위 계산 헬퍼 함수들
|
|
*/
|
|
const getDateRanges = () => {
|
|
const now = new Date();
|
|
const today = new Date(now.getFullYear(), now.getMonth(), now.getDate());
|
|
|
|
// 일일 범위 (오늘)
|
|
const dailyStart = today;
|
|
const dailyEnd = new Date(today.getTime() + 24 * 60 * 60 * 1000 - 1);
|
|
|
|
// 주간 범위 (이번 주 월요일부터 일요일까지)
|
|
const dayOfWeek = today.getDay();
|
|
const weekStart = new Date(today);
|
|
weekStart.setDate(today.getDate() - (dayOfWeek === 0 ? 6 : dayOfWeek - 1));
|
|
const weekEnd = new Date(weekStart.getTime() + 7 * 24 * 60 * 60 * 1000 - 1);
|
|
|
|
// 월간 범위 (이번 달 1일부터 마지막 날까지)
|
|
const monthStart = new Date(today.getFullYear(), today.getMonth(), 1);
|
|
const monthEnd = new Date(
|
|
today.getFullYear(),
|
|
today.getMonth() + 1,
|
|
0,
|
|
23,
|
|
59,
|
|
59,
|
|
999
|
|
);
|
|
|
|
const _ranges = {
|
|
daily: { start: dailyStart, end: dailyEnd },
|
|
weekly: { start: weekStart, end: weekEnd },
|
|
monthly: { start: monthStart, end: monthEnd },
|
|
};
|
|
|
|
return _ranges;
|
|
};
|
|
|
|
/**
|
|
* 트랜잭션 필터링 헬퍼
|
|
*/
|
|
const filterTransactionsByPeriod = (
|
|
transactions: Transaction[],
|
|
period: BudgetPeriod
|
|
): Transaction[] => {
|
|
const _ranges = getDateRanges();
|
|
const { start, end } = _ranges[period];
|
|
|
|
return transactions.filter((transaction) => {
|
|
const transactionDate = new Date(transaction.date);
|
|
return transactionDate >= start && transactionDate <= end;
|
|
});
|
|
};
|
|
|
|
/**
|
|
* 예산 스토어
|
|
*
|
|
* Context API의 복잡한 예산 관리를 Zustand로 단순화
|
|
* - 트랜잭션 CRUD 작업
|
|
* - 예산 목표 설정 및 추적
|
|
* - 카테고리별 지출 분석
|
|
* - 결제 방법별 통계
|
|
* - localStorage 영속성
|
|
*/
|
|
export const useBudgetStore = create<BudgetState>()(
|
|
devtools(
|
|
persist(
|
|
(set, get) => ({
|
|
// 초기 상태
|
|
transactions: [],
|
|
categoryBudgets: {},
|
|
budgetData: DEFAULT_BUDGET_DATA,
|
|
selectedTab: "monthly" as BudgetPeriod,
|
|
|
|
// 트랜잭션 추가
|
|
addTransaction: (transactionData: Omit<Transaction, "id">) => {
|
|
const newTransaction: Transaction = {
|
|
...transactionData,
|
|
id: uuidv4(),
|
|
localTimestamp: new Date().toISOString(),
|
|
};
|
|
|
|
set(
|
|
(state) => ({
|
|
transactions: [...state.transactions, newTransaction],
|
|
}),
|
|
false,
|
|
"addTransaction"
|
|
);
|
|
|
|
// 예산 데이터 재계산 및 이벤트 발생
|
|
get().recalculateBudgetData();
|
|
get().persistToLocalStorage();
|
|
window.dispatchEvent(new Event("budgetDataUpdated"));
|
|
|
|
syncLogger.info("트랜잭션 추가됨", {
|
|
id: newTransaction.id,
|
|
amount: newTransaction.amount,
|
|
category: newTransaction.category,
|
|
});
|
|
},
|
|
|
|
// 트랜잭션 업데이트
|
|
updateTransaction: (updatedTransaction: Transaction) => {
|
|
set(
|
|
(state) => ({
|
|
transactions: state.transactions.map((transaction) =>
|
|
transaction.id === updatedTransaction.id
|
|
? {
|
|
...updatedTransaction,
|
|
localTimestamp: new Date().toISOString(),
|
|
}
|
|
: transaction
|
|
),
|
|
}),
|
|
false,
|
|
"updateTransaction"
|
|
);
|
|
|
|
get().recalculateBudgetData();
|
|
get().persistToLocalStorage();
|
|
window.dispatchEvent(new Event("budgetDataUpdated"));
|
|
|
|
syncLogger.info("트랜잭션 업데이트됨", {
|
|
id: updatedTransaction.id,
|
|
amount: updatedTransaction.amount,
|
|
});
|
|
},
|
|
|
|
// 트랜잭션 삭제
|
|
deleteTransaction: (id: string) => {
|
|
set(
|
|
(state) => ({
|
|
transactions: state.transactions.filter(
|
|
(transaction) => transaction.id !== id
|
|
),
|
|
}),
|
|
false,
|
|
"deleteTransaction"
|
|
);
|
|
|
|
get().recalculateBudgetData();
|
|
get().persistToLocalStorage();
|
|
window.dispatchEvent(new Event("budgetDataUpdated"));
|
|
|
|
syncLogger.info("트랜잭션 삭제됨", { id });
|
|
},
|
|
|
|
// 트랜잭션 목록 설정 (동기화용)
|
|
setTransactions: (transactions: Transaction[]) => {
|
|
set({ transactions }, false, "setTransactions");
|
|
get().recalculateBudgetData();
|
|
get().persistToLocalStorage();
|
|
},
|
|
|
|
// 예산 목표 업데이트
|
|
handleBudgetGoalUpdate: (
|
|
type: BudgetPeriod,
|
|
amount: number,
|
|
newCategoryBudgets?: Record<string, number>
|
|
) => {
|
|
set(
|
|
(state) => {
|
|
const updatedBudgetData = { ...state.budgetData };
|
|
updatedBudgetData[type] = {
|
|
...updatedBudgetData[type],
|
|
targetAmount: amount,
|
|
};
|
|
|
|
const updatedState: Partial<BudgetState> = {
|
|
budgetData: updatedBudgetData,
|
|
};
|
|
|
|
if (newCategoryBudgets) {
|
|
updatedState.categoryBudgets = newCategoryBudgets;
|
|
}
|
|
|
|
return updatedState;
|
|
},
|
|
false,
|
|
"handleBudgetGoalUpdate"
|
|
);
|
|
|
|
get().recalculateBudgetData();
|
|
get().persistToLocalStorage();
|
|
window.dispatchEvent(new Event("budgetDataUpdated"));
|
|
|
|
syncLogger.info("예산 목표 업데이트됨", { type, amount });
|
|
},
|
|
|
|
// 카테고리 예산 설정
|
|
setCategoryBudgets: (budgets: Record<string, number>) => {
|
|
set({ categoryBudgets: budgets }, false, "setCategoryBudgets");
|
|
get().persistToLocalStorage();
|
|
},
|
|
|
|
// 개별 카테고리 예산 업데이트
|
|
updateCategoryBudget: (category: string, amount: number) => {
|
|
set(
|
|
(state) => ({
|
|
categoryBudgets: {
|
|
...state.categoryBudgets,
|
|
[category]: amount,
|
|
},
|
|
}),
|
|
false,
|
|
"updateCategoryBudget"
|
|
);
|
|
get().persistToLocalStorage();
|
|
},
|
|
|
|
// 선택된 탭 변경
|
|
setSelectedTab: (tab: BudgetPeriod) => {
|
|
set({ selectedTab: tab }, false, "setSelectedTab");
|
|
},
|
|
|
|
// 카테고리별 지출 계산
|
|
getCategorySpending: (): CategoryBudget[] => {
|
|
const { transactions, categoryBudgets, selectedTab } = get();
|
|
const filteredTransactions = filterTransactionsByPeriod(
|
|
transactions,
|
|
selectedTab
|
|
);
|
|
|
|
return CATEGORIES.map((category) => {
|
|
const spent = filteredTransactions
|
|
.filter((t) => t.category === category && t.type === "expense")
|
|
.reduce((sum, t) => sum + t.amount, 0);
|
|
|
|
const budget = categoryBudgets[category] || 0;
|
|
|
|
return {
|
|
title: category,
|
|
current: spent,
|
|
total: budget,
|
|
};
|
|
});
|
|
},
|
|
|
|
// 결제 방법별 통계 계산
|
|
getPaymentMethodStats: (): PaymentMethodStats[] => {
|
|
const { transactions, selectedTab } = get();
|
|
const filteredTransactions = filterTransactionsByPeriod(
|
|
transactions,
|
|
selectedTab
|
|
);
|
|
|
|
const expenseTransactions = filteredTransactions.filter(
|
|
(t) => t.type === "expense"
|
|
);
|
|
const totalAmount = expenseTransactions.reduce(
|
|
(sum, t) => sum + t.amount,
|
|
0
|
|
);
|
|
|
|
if (totalAmount === 0) {
|
|
return PAYMENT_METHODS.map((method) => ({
|
|
method,
|
|
amount: 0,
|
|
percentage: 0,
|
|
}));
|
|
}
|
|
|
|
return PAYMENT_METHODS.map((method) => {
|
|
const amount = expenseTransactions
|
|
.filter((t) => t.paymentMethod === method)
|
|
.reduce((sum, t) => sum + t.amount, 0);
|
|
|
|
return {
|
|
method,
|
|
amount,
|
|
percentage: Math.round((amount / totalAmount) * 100),
|
|
};
|
|
});
|
|
},
|
|
|
|
// 예산 데이터 계산
|
|
calculateBudgetData: (): BudgetData => {
|
|
const { transactions } = get();
|
|
const _ranges = getDateRanges();
|
|
|
|
const calculatePeriodData = (
|
|
period: BudgetPeriod
|
|
): BudgetPeriodData => {
|
|
const periodTransactions = filterTransactionsByPeriod(
|
|
transactions,
|
|
period
|
|
);
|
|
const expenses = periodTransactions.filter(
|
|
(t) => t.type === "expense"
|
|
);
|
|
const spentAmount = expenses.reduce((sum, t) => sum + t.amount, 0);
|
|
|
|
const currentBudget = get().budgetData[period];
|
|
const targetAmount = currentBudget?.targetAmount || 0;
|
|
const remainingAmount = Math.max(0, targetAmount - spentAmount);
|
|
|
|
return {
|
|
targetAmount,
|
|
spentAmount,
|
|
remainingAmount,
|
|
};
|
|
};
|
|
|
|
return {
|
|
daily: calculatePeriodData("daily"),
|
|
weekly: calculatePeriodData("weekly"),
|
|
monthly: calculatePeriodData("monthly"),
|
|
};
|
|
},
|
|
|
|
// 예산 데이터 재계산 (내부 헬퍼)
|
|
recalculateBudgetData: () => {
|
|
const newBudgetData = get().calculateBudgetData();
|
|
set({ budgetData: newBudgetData }, false, "recalculateBudgetData");
|
|
},
|
|
|
|
// localStorage 저장 (내부 헬퍼)
|
|
persistToLocalStorage: () => {
|
|
const { transactions, categoryBudgets, budgetData } = get();
|
|
try {
|
|
localStorage.setItem(
|
|
"budget-store-transactions",
|
|
JSON.stringify(transactions)
|
|
);
|
|
localStorage.setItem(
|
|
"budget-store-categoryBudgets",
|
|
JSON.stringify(categoryBudgets)
|
|
);
|
|
localStorage.setItem(
|
|
"budget-store-budgetData",
|
|
JSON.stringify(budgetData)
|
|
);
|
|
} catch (error) {
|
|
syncLogger.error("localStorage 저장 실패", error);
|
|
}
|
|
},
|
|
|
|
// 데이터 초기화
|
|
resetBudgetData: () => {
|
|
set(
|
|
{
|
|
transactions: [],
|
|
categoryBudgets: {},
|
|
budgetData: DEFAULT_BUDGET_DATA,
|
|
selectedTab: "monthly" as BudgetPeriod,
|
|
},
|
|
false,
|
|
"resetBudgetData"
|
|
);
|
|
|
|
// localStorage 초기화
|
|
try {
|
|
localStorage.removeItem("budget-store-transactions");
|
|
localStorage.removeItem("budget-store-categoryBudgets");
|
|
localStorage.removeItem("budget-store-budgetData");
|
|
} catch (error) {
|
|
syncLogger.error("localStorage 초기화 실패", error);
|
|
}
|
|
|
|
window.dispatchEvent(new Event("budgetDataUpdated"));
|
|
syncLogger.info("예산 데이터 초기화됨");
|
|
},
|
|
}),
|
|
{
|
|
name: "budget-store", // localStorage 키
|
|
partialize: (state) => ({
|
|
// localStorage에 저장할 상태만 선택
|
|
transactions: state.transactions,
|
|
categoryBudgets: state.categoryBudgets,
|
|
budgetData: state.budgetData,
|
|
selectedTab: state.selectedTab,
|
|
}),
|
|
}
|
|
),
|
|
{
|
|
name: "budget-store", // DevTools 이름
|
|
}
|
|
)
|
|
);
|
|
|
|
// 컴포넌트에서 사용할 편의 훅들
|
|
export const useBudget = () => {
|
|
const {
|
|
transactions,
|
|
categoryBudgets,
|
|
budgetData,
|
|
selectedTab,
|
|
addTransaction,
|
|
updateTransaction,
|
|
deleteTransaction,
|
|
handleBudgetGoalUpdate,
|
|
setSelectedTab,
|
|
getCategorySpending,
|
|
getPaymentMethodStats,
|
|
resetBudgetData,
|
|
} = useBudgetStore();
|
|
|
|
return {
|
|
transactions,
|
|
categoryBudgets,
|
|
budgetData,
|
|
selectedTab,
|
|
addTransaction,
|
|
updateTransaction,
|
|
deleteTransaction,
|
|
handleBudgetGoalUpdate,
|
|
setSelectedTab,
|
|
getCategorySpending,
|
|
getPaymentMethodStats,
|
|
resetBudgetData,
|
|
};
|
|
};
|
|
|
|
// 트랜잭션만 필요한 경우의 경량 훅
|
|
export const useTransactions = () => {
|
|
const { transactions, addTransaction, updateTransaction, deleteTransaction } =
|
|
useBudgetStore();
|
|
return { transactions, addTransaction, updateTransaction, deleteTransaction };
|
|
};
|
|
|
|
// 예산 데이터만 필요한 경우의 경량 훅
|
|
export const useBudgetData = () => {
|
|
const { budgetData, selectedTab, setSelectedTab, handleBudgetGoalUpdate } =
|
|
useBudgetStore();
|
|
return { budgetData, selectedTab, setSelectedTab, handleBudgetGoalUpdate };
|
|
};
|
|
|
|
// 분석 데이터만 필요한 경우의 경량 훅
|
|
export const useBudgetAnalytics = () => {
|
|
const { getCategorySpending, getPaymentMethodStats } = useBudgetStore();
|
|
return { getCategorySpending, getPaymentMethodStats };
|
|
};
|