Add payment method selection

Adds a payment method selection (Credit Card, Cash) to the expense form and includes a line separator. Also requests to add a graph showing the proportion of credit card and cash usage in expense analytics, but this part is not implemented in this commit.
This commit is contained in:
gpt-engineer-app[bot]
2025-03-22 07:08:02 +00:00
parent 60ef765380
commit aa8381a823
12 changed files with 489 additions and 23 deletions

161
package-lock.json generated
View File

@@ -30,7 +30,7 @@
"@radix-ui/react-navigation-menu": "^1.2.0", "@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.0", "@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1", "@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-separator": "^1.1.0",
@@ -2058,18 +2058,18 @@
} }
}, },
"node_modules/@radix-ui/react-radio-group": { "node_modules/@radix-ui/react-radio-group": {
"version": "1.2.1", "version": "1.2.3",
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.1.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.3.tgz",
"integrity": "sha512-kdbv54g4vfRjja9DNWPMxKvXblzqbpEC8kspEkZ6dVP7kQksGCn+iZHkcCz2nb00+lPdRvxrqy4WrvvV1cNqrQ==", "integrity": "sha512-xtCsqt8Rp09FK50ItqEqTJ7Sxanz8EM8dnkVIhJrc/wkMMomSmXHvYbhv3E7Zx4oXh98aaLt9W679SUYXg4IDA==",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"@radix-ui/primitive": "1.1.0", "@radix-ui/primitive": "1.1.1",
"@radix-ui/react-compose-refs": "1.1.0", "@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1", "@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0", "@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-presence": "1.1.1", "@radix-ui/react-presence": "1.1.2",
"@radix-ui/react-primitive": "2.0.0", "@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-roving-focus": "1.1.0", "@radix-ui/react-roving-focus": "1.1.2",
"@radix-ui/react-use-controllable-state": "1.1.0", "@radix-ui/react-use-controllable-state": "1.1.0",
"@radix-ui/react-use-previous": "1.1.0", "@radix-ui/react-use-previous": "1.1.0",
"@radix-ui/react-use-size": "1.1.0" "@radix-ui/react-use-size": "1.1.0"
@@ -2089,6 +2089,149 @@
} }
} }
}, },
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/primitive": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.1.tgz",
"integrity": "sha512-SJ31y+Q/zAyShtXJc8x83i9TYdbAfHZ++tUZnvjJJqFjzsdUnKsxPL6IEtBlxKkU7yzer//GQtZSV4GbldL3YA==",
"license": "MIT"
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-collection": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.2.tgz",
"integrity": "sha512-9z54IEKRxIa9VityapoEYMuByaG42iSy1ZXlY2KcuLSEtq8x4987/N6m15ppoMffgZX72gER2uHe1D9Y6Unlcw==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-slot": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-compose-refs": {
"version": "1.1.1",
"resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.1.tgz",
"integrity": "sha512-Y9VzoRDSJtgFMUCoiZBDVo084VQ5hfpXxVE+NgkdNsjiDBByiImMZKKhxMwCbdHvhlENG6a833CbFkOQvTricw==",
"license": "MIT",
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-presence": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.2.tgz",
"integrity": "sha512-18TFr80t5EVgL9x1SwF/YGtfG+l0BS0PRAlCWBDoBEiDQjeKgnNZRVJp/oVBl24sr3Gbfwc/Qpj4OcWTQMsAEg==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-use-layout-effect": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-primitive": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.0.2.tgz",
"integrity": "sha512-Ec/0d38EIuvDF+GZjcMU/Ze6MxntVJYO/fRlCPhCaVUyPY9WTalHJw54tp9sXeJo3tlShWpy41vQRgLRGOuz+w==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-slot": "1.1.2"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.2.tgz",
"integrity": "sha512-zgMQWkNO169GtGqRvYrzb0Zf8NhMHS2DuEB/TiEmVnpr5OqPU3i8lfbxaAmC2J/KYuIQxyoQQ6DxepyXp61/xw==",
"license": "MIT",
"dependencies": {
"@radix-ui/primitive": "1.1.1",
"@radix-ui/react-collection": "1.1.2",
"@radix-ui/react-compose-refs": "1.1.1",
"@radix-ui/react-context": "1.1.1",
"@radix-ui/react-direction": "1.1.0",
"@radix-ui/react-id": "1.1.0",
"@radix-ui/react-primitive": "2.0.2",
"@radix-ui/react-use-callback-ref": "1.1.0",
"@radix-ui/react-use-controllable-state": "1.1.0"
},
"peerDependencies": {
"@types/react": "*",
"@types/react-dom": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc",
"react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
},
"@types/react-dom": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-radio-group/node_modules/@radix-ui/react-slot": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.1.2.tgz",
"integrity": "sha512-YAKxaiGsSQJ38VzKH86/BPRC4rh+b1Jpa+JneA5LRE7skmLPNAyeG8kPJj/oo4STLvlrs8vkf/iYyc3A5stYCQ==",
"license": "MIT",
"dependencies": {
"@radix-ui/react-compose-refs": "1.1.1"
},
"peerDependencies": {
"@types/react": "*",
"react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc"
},
"peerDependenciesMeta": {
"@types/react": {
"optional": true
}
}
},
"node_modules/@radix-ui/react-roving-focus": { "node_modules/@radix-ui/react-roving-focus": {
"version": "1.1.0", "version": "1.1.0",
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz", "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz",

View File

@@ -33,7 +33,7 @@
"@radix-ui/react-navigation-menu": "^1.2.0", "@radix-ui/react-navigation-menu": "^1.2.0",
"@radix-ui/react-popover": "^1.1.1", "@radix-ui/react-popover": "^1.1.1",
"@radix-ui/react-progress": "^1.1.0", "@radix-ui/react-progress": "^1.1.0",
"@radix-ui/react-radio-group": "^1.2.0", "@radix-ui/react-radio-group": "^1.2.3",
"@radix-ui/react-scroll-area": "^1.1.0", "@radix-ui/react-scroll-area": "^1.1.0",
"@radix-ui/react-select": "^2.1.1", "@radix-ui/react-select": "^2.1.1",
"@radix-ui/react-separator": "^1.1.0", "@radix-ui/react-separator": "^1.1.0",

View File

@@ -1,3 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import { PlusIcon } from 'lucide-react'; import { PlusIcon } from 'lucide-react';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog'; import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
@@ -53,7 +54,8 @@ const AddTransactionButton = () => {
amount: parseInt(numericAmount), amount: parseInt(numericAmount),
date: formattedDate, date: formattedDate,
category: data.category, category: data.category,
type: 'expense' type: 'expense',
paymentMethod: data.paymentMethod // 추가된 필드
}; };
console.log('새 지출 추가:', newExpense); console.log('새 지출 추가:', newExpense);
@@ -75,7 +77,8 @@ const AddTransactionButton = () => {
date: isoDate, // ISO 형식 사용 date: isoDate, // ISO 형식 사용
category: data.category, category: data.category,
type: 'expense', type: 'expense',
transaction_id: newExpense.id transaction_id: newExpense.id,
payment_method: data.paymentMethod // Supabase에 필드 추가
}); });
if (error) throw error; if (error) throw error;

View File

@@ -0,0 +1,67 @@
import React from 'react';
import { PieChart, Pie, Cell, ResponsiveContainer, Legend } from 'recharts';
interface PaymentMethodData {
method: string;
amount: number;
percentage: number;
}
interface PaymentMethodChartProps {
data: PaymentMethodData[];
isEmpty: boolean;
}
const COLORS = ['#9b87f5', '#6E59A5']; // 신용카드, 현금 색상
const PaymentMethodChart: React.FC<PaymentMethodChartProps> = ({ data, isEmpty }) => {
if (isEmpty) {
return (
<div className="neuro-card h-52 w-full flex items-center justify-center text-gray-400">
<p> </p>
</div>
);
}
const chartData = data.map(item => ({
name: item.method,
value: item.amount
}));
return (
<div className="neuro-card h-64 desktop-card">
<ResponsiveContainer width="100%" height="100%">
<PieChart margin={{ left: 30, right: 30, top: 20, bottom: 5 }}>
<Pie
data={chartData}
cx="50%"
cy="50%"
innerRadius={40}
outerRadius={60}
paddingAngle={5}
dataKey="value"
labelLine={true}
label={({ name, percent }) => `${name} ${(percent * 100).toFixed(0)}%`}
fontSize={12}
>
{chartData.map((entry, index) => (
<Cell key={`cell-${index}`} fill={COLORS[index % COLORS.length]} />
))}
</Pie>
<Legend
verticalAlign="bottom"
align="center"
layout="horizontal"
formatter={(value) => {
const item = data.find(d => d.method === value);
return item ? `${value} (${item.percentage.toFixed(0)}%)` : value;
}}
/>
</PieChart>
</ResponsiveContainer>
</div>
);
};
export default PaymentMethodChart;

View File

@@ -8,11 +8,14 @@ import { Loader2 } from 'lucide-react';
import ExpenseCategorySelector from './ExpenseCategorySelector'; import ExpenseCategorySelector from './ExpenseCategorySelector';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { getPersonalizedTitleSuggestions } from '@/utils/userTitlePreferences'; import { getPersonalizedTitleSuggestions } from '@/utils/userTitlePreferences';
import { Separator } from '@/components/ui/separator';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
export interface ExpenseFormValues { export interface ExpenseFormValues {
title: string; title: string;
amount: string; amount: string;
category: string; category: string;
paymentMethod: '신용카드' | '현금';
} }
interface ExpenseFormProps { interface ExpenseFormProps {
@@ -27,6 +30,7 @@ const ExpenseForm: React.FC<ExpenseFormProps> = ({ onSubmit, onCancel, isSubmitt
title: '', title: '',
amount: '', amount: '',
category: '음식', category: '음식',
paymentMethod: '신용카드'
} }
}); });
@@ -113,7 +117,7 @@ const ExpenseForm: React.FC<ExpenseFormProps> = ({ onSubmit, onCancel, isSubmitt
)} )}
/> />
{/* 금액 필드를 마지막으로 배치 */} {/* 금액 필드를 세 번째로 배치 */}
<FormField <FormField
control={form.control} control={form.control}
name="amount" name="amount"
@@ -130,6 +134,36 @@ const ExpenseForm: React.FC<ExpenseFormProps> = ({ onSubmit, onCancel, isSubmitt
)} )}
/> />
{/* 구분선 추가 */}
<Separator className="my-2" />
{/* 지출 방법 필드 추가 */}
<FormField
control={form.control}
name="paymentMethod"
render={({ field }) => (
<FormItem>
<FormLabel> </FormLabel>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4"
value={field.value}
disabled={isSubmitting}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="신용카드" id="credit-card" />
<label htmlFor="credit-card" className="cursor-pointer"></label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="현금" id="cash" />
<label htmlFor="cash" className="cursor-pointer"></label>
</div>
</RadioGroup>
</FormItem>
)}
/>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button <Button
type="button" type="button"

View File

@@ -7,12 +7,15 @@ import { z } from 'zod';
import { categoryIcons, EXPENSE_CATEGORIES } from '@/constants/categoryIcons'; import { categoryIcons, EXPENSE_CATEGORIES } from '@/constants/categoryIcons';
import { Badge } from '@/components/ui/badge'; import { Badge } from '@/components/ui/badge';
import { getPersonalizedTitleSuggestions } from '@/utils/userTitlePreferences'; import { getPersonalizedTitleSuggestions } from '@/utils/userTitlePreferences';
import { Separator } from '@/components/ui/separator';
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
// Form schema for validation - 카테고리를 4개로 확장 // Form schema for validation - 카테고리를 4개로 확장 및 지출 방법 추가
export const transactionFormSchema = z.object({ export const transactionFormSchema = z.object({
title: z.string().min(1, '제목을 입력해주세요'), title: z.string().min(1, '제목을 입력해주세요'),
amount: z.string().min(1, '금액을 입력해주세요'), amount: z.string().min(1, '금액을 입력해주세요'),
category: z.enum(['음식', '쇼핑', '교통', '기타']), category: z.enum(['음식', '쇼핑', '교통', '기타']),
paymentMethod: z.enum(['신용카드', '현금']).default('신용카드'),
}); });
export type TransactionFormValues = z.infer<typeof transactionFormSchema>; export type TransactionFormValues = z.infer<typeof transactionFormSchema>;
@@ -117,7 +120,7 @@ const TransactionFormFields: React.FC<TransactionFormFieldsProps> = ({ form }) =
)} )}
/> />
{/* 금액 필드를 마지막으로 배치 */} {/* 금액 필드를 세 번째로 배치 */}
<FormField <FormField
control={form.control} control={form.control}
name="amount" name="amount"
@@ -135,6 +138,38 @@ const TransactionFormFields: React.FC<TransactionFormFieldsProps> = ({ form }) =
</FormItem> </FormItem>
)} )}
/> />
{/* 구분선 추가 */}
<Separator className="my-4" />
{/* 지출 방법 필드 추가 */}
<FormField
control={form.control}
name="paymentMethod"
render={({ field }) => (
<FormItem>
<FormLabel> </FormLabel>
<FormControl>
<RadioGroup
onValueChange={field.onChange}
defaultValue={field.value}
className="flex gap-4"
value={field.value}
>
<div className="flex items-center space-x-2">
<RadioGroupItem value="신용카드" id="credit-card" />
<label htmlFor="credit-card" className="cursor-pointer"></label>
</div>
<div className="flex items-center space-x-2">
<RadioGroupItem value="현금" id="cash" />
<label htmlFor="cash" className="cursor-pointer"></label>
</div>
</RadioGroup>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</> </>
); );
}; };

View File

@@ -31,6 +31,7 @@ export const useTransactionEdit = (
title: transaction.title, title: transaction.title,
amount: formatWithCommas(transaction.amount.toString()), amount: formatWithCommas(transaction.amount.toString()),
category: mapCategoryToNew(transaction.category), category: mapCategoryToNew(transaction.category),
paymentMethod: transaction.paymentMethod || '신용카드', // 지출 방법 추가, 기본값은 신용카드
}, },
}); });
@@ -41,6 +42,7 @@ export const useTransactionEdit = (
title: transaction.title, title: transaction.title,
amount: formatWithCommas(transaction.amount.toString()), amount: formatWithCommas(transaction.amount.toString()),
category: mapCategoryToNew(transaction.category), category: mapCategoryToNew(transaction.category),
paymentMethod: transaction.paymentMethod || '신용카드', // 지출 방법 기본값
}); });
} }
}, [open, transaction, form]); }, [open, transaction, form]);
@@ -61,6 +63,7 @@ export const useTransactionEdit = (
title: values.title, title: values.title,
amount: Number(cleanAmount), amount: Number(cleanAmount),
category: values.category, category: values.category,
paymentMethod: values.paymentMethod, // 지출 방법 업데이트
}; };
// 컨텍스트를 통해 트랜잭션 업데이트 // 컨텍스트를 통해 트랜잭션 업데이트

View File

@@ -1,3 +1,4 @@
import * as React from "react" import * as React from "react"
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group" import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
import { Circle } from "lucide-react" import { Circle } from "lucide-react"
@@ -26,13 +27,13 @@ const RadioGroupItem = React.forwardRef<
<RadioGroupPrimitive.Item <RadioGroupPrimitive.Item
ref={ref} ref={ref}
className={cn( className={cn(
"aspect-square h-4 w-4 rounded-full border border-primary text-primary ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50", "aspect-square h-4 w-4 rounded-full border border-primary bg-background ring-offset-background focus:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className className
)} )}
{...props} {...props}
> >
<RadioGroupPrimitive.Indicator className="flex items-center justify-center"> <RadioGroupPrimitive.Indicator className="flex items-center justify-center">
<Circle className="h-2.5 w-2.5 fill-current text-current" /> <Circle className="h-2.5 w-2.5 fill-neuro-income text-neuro-income" />
</RadioGroupPrimitive.Indicator> </RadioGroupPrimitive.Indicator>
</RadioGroupPrimitive.Item> </RadioGroupPrimitive.Item>
) )

View File

@@ -1,5 +1,148 @@
import React, { createContext, useState, useContext, useEffect } from 'react';
import { BudgetContextType, BudgetData, BudgetPeriod, Transaction, CategoryBudget } from './budget/types';
import { loadTransactionsFromStorage, saveTransactionsToStorage } from '@/hooks/transactions/storageUtils';
import { v4 as uuidv4 } from 'uuid';
import { BudgetProvider, useBudget } from './budget'; // BudgetContext 생성
const BudgetContext = createContext<BudgetContextType | undefined>(undefined);
export { BudgetProvider, useBudget }; export const BudgetProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
export type { BudgetPeriod } from './budget'; // 로컬 스토리지에서 초기 트랜잭션 데이터 로드
const [transactions, setTransactions] = useState<Transaction[]>([]);
const [categoryBudgets, setCategoryBudgets] = useState<Record<string, number>>({});
const [budgetData, setBudgetData] = useState<BudgetData>({
daily: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 },
weekly: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 },
monthly: { targetAmount: 0, spentAmount: 0, remainingAmount: 0 },
});
const [selectedTab, setSelectedTab] = useState<BudgetPeriod>('monthly');
useEffect(() => {
const storedTransactions = loadTransactionsFromStorage();
setTransactions(storedTransactions);
}, []);
useEffect(() => {
// 트랜잭션 변경 시 로컬 스토리지에 저장
saveTransactionsToStorage(transactions);
}, [transactions]);
// 트랜잭션 추가
const addTransaction = (transaction: Transaction) => {
const newTransaction = { ...transaction, id: uuidv4() };
setTransactions(prevTransactions => [...prevTransactions, newTransaction]);
};
// 트랜잭션 업데이트
const updateTransaction = (updatedTransaction: Transaction) => {
setTransactions(prevTransactions =>
prevTransactions.map(transaction =>
transaction.id === updatedTransaction.id ? updatedTransaction : transaction
)
);
};
// 트랜잭션 삭제
const deleteTransaction = (id: string) => {
setTransactions(prevTransactions =>
prevTransactions.filter(transaction => transaction.id !== id)
);
};
// 예산 목표 업데이트
const handleBudgetGoalUpdate = (type: BudgetPeriod, amount: number, newCategoryBudgets?: Record<string, number>) => {
setBudgetData(prev => ({
...prev,
[type]: {
...prev[type],
targetAmount: amount,
},
}));
if (newCategoryBudgets) {
setCategoryBudgets(newCategoryBudgets);
}
};
// 카테고리별 지출 계산
const getCategorySpending = () => {
const categorySpending: { [key: string]: { total: number; current: number } } = {};
// 초기화
['음식', '쇼핑', '교통', '기타'].forEach(category => {
categorySpending[category] = { total: categoryBudgets[category] || 0, current: 0 };
});
// 지출 합산
transactions.filter(tx => tx.type === 'expense').forEach(tx => {
categorySpending[tx.category].current += tx.amount;
});
// 배열로 변환
return Object.entries(categorySpending).map(([title, { total, current }]) => ({
title,
total,
current,
}));
};
// 결제 방법 통계 계산 함수 추가
const getPaymentMethodStats = () => {
// 지출 트랜잭션 필터링
const expenseTransactions = transactions.filter(t => t.type === 'expense');
// 총 지출 계산
const totalExpense = expenseTransactions.reduce((acc, curr) => acc + curr.amount, 0);
// 결제 방법별 금액 계산
const cardExpense = expenseTransactions
.filter(t => t.paymentMethod === '신용카드' || !t.paymentMethod) // paymentMethod가 없으면 신용카드로 간주
.reduce((acc, curr) => acc + curr.amount, 0);
const cashExpense = expenseTransactions
.filter(t => t.paymentMethod === '현금')
.reduce((acc, curr) => acc + curr.amount, 0);
// 결과 배열 생성 - 금액이 큰 순서대로 정렬
const result = [
{
method: '신용카드',
amount: cardExpense,
percentage: totalExpense > 0 ? (cardExpense / totalExpense) * 100 : 0
},
{
method: '현금',
amount: cashExpense,
percentage: totalExpense > 0 ? (cashExpense / totalExpense) * 100 : 0
}
].sort((a, b) => b.amount - a.amount);
return result;
};
return (
<BudgetContext.Provider value={{
transactions,
categoryBudgets,
budgetData,
selectedTab,
setSelectedTab,
updateTransaction,
handleBudgetGoalUpdate,
getCategorySpending,
getPaymentMethodStats, // 추가된 메서드
addTransaction,
deleteTransaction,
}}>
{children}
</BudgetContext.Provider>
);
};
// useContext Hook
export const useBudget = () => {
const context = useContext(BudgetContext);
if (context === undefined) {
throw new Error("useBudget must be used within a BudgetProvider");
}
return context;
};

View File

@@ -30,6 +30,7 @@ export interface BudgetContextType {
updateTransaction: (updatedTransaction: Transaction) => void; updateTransaction: (updatedTransaction: Transaction) => void;
handleBudgetGoalUpdate: (type: BudgetPeriod, amount: number, newCategoryBudgets?: Record<string, number>) => void; handleBudgetGoalUpdate: (type: BudgetPeriod, amount: number, newCategoryBudgets?: Record<string, number>) => void;
getCategorySpending: () => CategoryBudget[]; getCategorySpending: () => CategoryBudget[];
getPaymentMethodStats: () => { method: string; amount: number; percentage: number }[];
} }
// Transaction 타입 (기존 TransactionCard에서 가져옴) // Transaction 타입 (기존 TransactionCard에서 가져옴)
@@ -40,6 +41,7 @@ export interface Transaction {
date: string; date: string;
category: string; category: string;
type: 'income' | 'expense'; type: 'income' | 'expense';
paymentMethod?: '신용카드' | '현금'; // 지출 방법 추가
notes?: string; notes?: string;
localTimestamp?: string; // 로컬 수정 타임스탬프 추가 localTimestamp?: string; // 로컬 수정 타임스탬프 추가
serverTimestamp?: string; // 서버 타임스탬프 추가 serverTimestamp?: string; // 서버 타임스탬프 추가

View File

@@ -19,11 +19,31 @@ export const loadTransactionsFromStorage = (): Transaction[] => {
if (transaction.type === 'expense') { if (transaction.type === 'expense') {
// 기존 카테고리명 변환 // 기존 카테고리명 변환
if (transaction.category === '식비') { if (transaction.category === '식비') {
return { ...transaction, category: '음식' }; return {
...transaction,
category: '음식',
paymentMethod: transaction.paymentMethod || '신용카드' // 기존 데이터에 없으면 기본값 추가
};
} else if (transaction.category === '생활비') { } else if (transaction.category === '생활비') {
return { ...transaction, category: '쇼핑' }; return {
...transaction,
category: '쇼핑',
paymentMethod: transaction.paymentMethod || '신용카드' // 기존 데이터에 없으면 기본값 추가
};
} else if (!EXPENSE_CATEGORIES.includes(transaction.category)) { } else if (!EXPENSE_CATEGORIES.includes(transaction.category)) {
return { ...transaction, category: '쇼핑' }; // 지원되지 않는 카테고리는 '쇼핑'으로 return {
...transaction,
category: '쇼핑',
paymentMethod: transaction.paymentMethod || '신용카드' // 기존 데이터에 없으면 기본값 추가
}; // 지원되지 않는 카테고리는 '쇼핑'으로
}
// 기존 데이터에 paymentMethod가 없으면 기본값 추가
if (!transaction.paymentMethod) {
return {
...transaction,
paymentMethod: '신용카드'
};
} }
} }
return transaction; return transaction;

View File

@@ -1,3 +1,4 @@
import React, { useState, useEffect } from 'react'; import React, { useState, useEffect } from 'react';
import NavBar from '@/components/NavBar'; import NavBar from '@/components/NavBar';
import ExpenseChart from '@/components/ExpenseChart'; import ExpenseChart from '@/components/ExpenseChart';
@@ -6,18 +7,21 @@ import { useBudget } from '@/contexts/BudgetContext';
import { MONTHS_KR } from '@/hooks/useTransactions'; import { MONTHS_KR } from '@/hooks/useTransactions';
import { useIsMobile } from '@/hooks/use-mobile'; import { useIsMobile } from '@/hooks/use-mobile';
import { getCategoryColor } from '@/utils/categoryColorUtils'; import { getCategoryColor } from '@/utils/categoryColorUtils';
import { Separator } from '@/components/ui/separator';
// 새로 분리한 컴포넌트들 불러오기 // 새로 분리한 컴포넌트들 불러오기
import PeriodSelector from '@/components/analytics/PeriodSelector'; import PeriodSelector from '@/components/analytics/PeriodSelector';
import SummaryCards from '@/components/analytics/SummaryCards'; import SummaryCards from '@/components/analytics/SummaryCards';
import MonthlyComparisonChart from '@/components/analytics/MonthlyComparisonChart'; import MonthlyComparisonChart from '@/components/analytics/MonthlyComparisonChart';
import CategorySpendingList from '@/components/analytics/CategorySpendingList'; import CategorySpendingList from '@/components/analytics/CategorySpendingList';
import PaymentMethodChart from '@/components/analytics/PaymentMethodChart';
const Analytics = () => { const Analytics = () => {
const [selectedPeriod, setSelectedPeriod] = useState('이번 달'); const [selectedPeriod, setSelectedPeriod] = useState('이번 달');
const { const {
budgetData, budgetData,
getCategorySpending, getCategorySpending,
getPaymentMethodStats, // 새로 추가된 메서드
transactions transactions
} = useBudget(); } = useBudget();
const isMobile = useIsMobile(); const isMobile = useIsMobile();
@@ -86,6 +90,10 @@ const Analytics = () => {
value: category.current, value: category.current,
color: getCategoryColor(category.title) // 일관된 색상 적용 color: getCategoryColor(category.title) // 일관된 색상 적용
})); }));
// 결제 방법 데이터 가져오기
const paymentMethodData = getPaymentMethodStats();
const hasPaymentData = paymentMethodData.some(method => method.amount > 0);
// 월별 데이터 생성 - 샘플 데이터 제거하고 현재 달만 실제 데이터 사용 // 월별 데이터 생성 - 샘플 데이터 제거하고 현재 달만 실제 데이터 사용
useEffect(() => { useEffect(() => {
@@ -143,6 +151,13 @@ const Analytics = () => {
</div>} </div>}
</div> </div>
{/* 결제 방법 차트 추가 */}
<h2 className="text-lg font-semibold mb-3 mt-6"> </h2>
<PaymentMethodChart
data={paymentMethodData}
isEmpty={!hasPaymentData}
/>
{/* Top Spending Categories */} {/* Top Spending Categories */}
<h2 className="text-lg font-semibold mb-3 mt-6"> </h2> <h2 className="text-lg font-semibold mb-3 mt-6"> </h2>
<CategorySpendingList categories={categorySpending} totalExpense={totalExpense} className="mb-[50px]" /> <CategorySpendingList categories={categorySpending} totalExpense={totalExpense} className="mb-[50px]" />