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:
161
package-lock.json
generated
161
package-lock.json
generated
@@ -30,7 +30,7 @@
|
||||
"@radix-ui/react-navigation-menu": "^1.2.0",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@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-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
@@ -2058,18 +2058,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@radix-ui/react-radio-group": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.1.tgz",
|
||||
"integrity": "sha512-kdbv54g4vfRjja9DNWPMxKvXblzqbpEC8kspEkZ6dVP7kQksGCn+iZHkcCz2nb00+lPdRvxrqy4WrvvV1cNqrQ==",
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-radio-group/-/react-radio-group-1.2.3.tgz",
|
||||
"integrity": "sha512-xtCsqt8Rp09FK50ItqEqTJ7Sxanz8EM8dnkVIhJrc/wkMMomSmXHvYbhv3E7Zx4oXh98aaLt9W679SUYXg4IDA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@radix-ui/primitive": "1.1.0",
|
||||
"@radix-ui/react-compose-refs": "1.1.0",
|
||||
"@radix-ui/primitive": "1.1.1",
|
||||
"@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-presence": "1.1.1",
|
||||
"@radix-ui/react-primitive": "2.0.0",
|
||||
"@radix-ui/react-roving-focus": "1.1.0",
|
||||
"@radix-ui/react-presence": "1.1.2",
|
||||
"@radix-ui/react-primitive": "2.0.2",
|
||||
"@radix-ui/react-roving-focus": "1.1.2",
|
||||
"@radix-ui/react-use-controllable-state": "1.1.0",
|
||||
"@radix-ui/react-use-previous": "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": {
|
||||
"version": "1.1.0",
|
||||
"resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.0.tgz",
|
||||
|
||||
@@ -33,7 +33,7 @@
|
||||
"@radix-ui/react-navigation-menu": "^1.2.0",
|
||||
"@radix-ui/react-popover": "^1.1.1",
|
||||
"@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-select": "^2.1.1",
|
||||
"@radix-ui/react-separator": "^1.1.0",
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { PlusIcon } from 'lucide-react';
|
||||
import { Dialog, DialogContent, DialogHeader, DialogTitle } from './ui/dialog';
|
||||
@@ -53,7 +54,8 @@ const AddTransactionButton = () => {
|
||||
amount: parseInt(numericAmount),
|
||||
date: formattedDate,
|
||||
category: data.category,
|
||||
type: 'expense'
|
||||
type: 'expense',
|
||||
paymentMethod: data.paymentMethod // 추가된 필드
|
||||
};
|
||||
|
||||
console.log('새 지출 추가:', newExpense);
|
||||
@@ -75,7 +77,8 @@ const AddTransactionButton = () => {
|
||||
date: isoDate, // ISO 형식 사용
|
||||
category: data.category,
|
||||
type: 'expense',
|
||||
transaction_id: newExpense.id
|
||||
transaction_id: newExpense.id,
|
||||
payment_method: data.paymentMethod // Supabase에 필드 추가
|
||||
});
|
||||
|
||||
if (error) throw error;
|
||||
|
||||
67
src/components/analytics/PaymentMethodChart.tsx
Normal file
67
src/components/analytics/PaymentMethodChart.tsx
Normal 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;
|
||||
@@ -8,11 +8,14 @@ import { Loader2 } from 'lucide-react';
|
||||
import ExpenseCategorySelector from './ExpenseCategorySelector';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import { getPersonalizedTitleSuggestions } from '@/utils/userTitlePreferences';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
import { RadioGroup, RadioGroupItem } from '@/components/ui/radio-group';
|
||||
|
||||
export interface ExpenseFormValues {
|
||||
title: string;
|
||||
amount: string;
|
||||
category: string;
|
||||
paymentMethod: '신용카드' | '현금';
|
||||
}
|
||||
|
||||
interface ExpenseFormProps {
|
||||
@@ -27,6 +30,7 @@ const ExpenseForm: React.FC<ExpenseFormProps> = ({ onSubmit, onCancel, isSubmitt
|
||||
title: '',
|
||||
amount: '',
|
||||
category: '음식',
|
||||
paymentMethod: '신용카드'
|
||||
}
|
||||
});
|
||||
|
||||
@@ -113,7 +117,7 @@ const ExpenseForm: React.FC<ExpenseFormProps> = ({ onSubmit, onCancel, isSubmitt
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 금액 필드를 마지막으로 배치 */}
|
||||
{/* 금액 필드를 세 번째로 배치 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
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">
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -7,12 +7,15 @@ import { z } from 'zod';
|
||||
import { categoryIcons, EXPENSE_CATEGORIES } from '@/constants/categoryIcons';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
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({
|
||||
title: z.string().min(1, '제목을 입력해주세요'),
|
||||
amount: z.string().min(1, '금액을 입력해주세요'),
|
||||
category: z.enum(['음식', '쇼핑', '교통', '기타']),
|
||||
paymentMethod: z.enum(['신용카드', '현금']).default('신용카드'),
|
||||
});
|
||||
|
||||
export type TransactionFormValues = z.infer<typeof transactionFormSchema>;
|
||||
@@ -117,7 +120,7 @@ const TransactionFormFields: React.FC<TransactionFormFieldsProps> = ({ form }) =
|
||||
)}
|
||||
/>
|
||||
|
||||
{/* 금액 필드를 마지막으로 배치 */}
|
||||
{/* 금액 필드를 세 번째로 배치 */}
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="amount"
|
||||
@@ -135,6 +138,38 @@ const TransactionFormFields: React.FC<TransactionFormFieldsProps> = ({ form }) =
|
||||
</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>
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -31,6 +31,7 @@ export const useTransactionEdit = (
|
||||
title: transaction.title,
|
||||
amount: formatWithCommas(transaction.amount.toString()),
|
||||
category: mapCategoryToNew(transaction.category),
|
||||
paymentMethod: transaction.paymentMethod || '신용카드', // 지출 방법 추가, 기본값은 신용카드
|
||||
},
|
||||
});
|
||||
|
||||
@@ -41,6 +42,7 @@ export const useTransactionEdit = (
|
||||
title: transaction.title,
|
||||
amount: formatWithCommas(transaction.amount.toString()),
|
||||
category: mapCategoryToNew(transaction.category),
|
||||
paymentMethod: transaction.paymentMethod || '신용카드', // 지출 방법 기본값
|
||||
});
|
||||
}
|
||||
}, [open, transaction, form]);
|
||||
@@ -61,6 +63,7 @@ export const useTransactionEdit = (
|
||||
title: values.title,
|
||||
amount: Number(cleanAmount),
|
||||
category: values.category,
|
||||
paymentMethod: values.paymentMethod, // 지출 방법 업데이트
|
||||
};
|
||||
|
||||
// 컨텍스트를 통해 트랜잭션 업데이트
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
import * as React from "react"
|
||||
import * as RadioGroupPrimitive from "@radix-ui/react-radio-group"
|
||||
import { Circle } from "lucide-react"
|
||||
@@ -26,13 +27,13 @@ const RadioGroupItem = React.forwardRef<
|
||||
<RadioGroupPrimitive.Item
|
||||
ref={ref}
|
||||
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
|
||||
)}
|
||||
{...props}
|
||||
>
|
||||
<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.Item>
|
||||
)
|
||||
|
||||
@@ -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 type { BudgetPeriod } from './budget';
|
||||
export const BudgetProvider: React.FC<{ children: React.ReactNode }> = ({ children }) => {
|
||||
// 로컬 스토리지에서 초기 트랜잭션 데이터 로드
|
||||
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;
|
||||
};
|
||||
|
||||
@@ -30,6 +30,7 @@ export interface BudgetContextType {
|
||||
updateTransaction: (updatedTransaction: Transaction) => void;
|
||||
handleBudgetGoalUpdate: (type: BudgetPeriod, amount: number, newCategoryBudgets?: Record<string, number>) => void;
|
||||
getCategorySpending: () => CategoryBudget[];
|
||||
getPaymentMethodStats: () => { method: string; amount: number; percentage: number }[];
|
||||
}
|
||||
|
||||
// Transaction 타입 (기존 TransactionCard에서 가져옴)
|
||||
@@ -40,6 +41,7 @@ export interface Transaction {
|
||||
date: string;
|
||||
category: string;
|
||||
type: 'income' | 'expense';
|
||||
paymentMethod?: '신용카드' | '현금'; // 지출 방법 추가
|
||||
notes?: string;
|
||||
localTimestamp?: string; // 로컬 수정 타임스탬프 추가
|
||||
serverTimestamp?: string; // 서버 타임스탬프 추가
|
||||
|
||||
@@ -19,11 +19,31 @@ export const loadTransactionsFromStorage = (): Transaction[] => {
|
||||
if (transaction.type === 'expense') {
|
||||
// 기존 카테고리명 변환
|
||||
if (transaction.category === '식비') {
|
||||
return { ...transaction, category: '음식' };
|
||||
return {
|
||||
...transaction,
|
||||
category: '음식',
|
||||
paymentMethod: transaction.paymentMethod || '신용카드' // 기존 데이터에 없으면 기본값 추가
|
||||
};
|
||||
} else if (transaction.category === '생활비') {
|
||||
return { ...transaction, category: '쇼핑' };
|
||||
return {
|
||||
...transaction,
|
||||
category: '쇼핑',
|
||||
paymentMethod: transaction.paymentMethod || '신용카드' // 기존 데이터에 없으면 기본값 추가
|
||||
};
|
||||
} 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;
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import NavBar from '@/components/NavBar';
|
||||
import ExpenseChart from '@/components/ExpenseChart';
|
||||
@@ -6,18 +7,21 @@ import { useBudget } from '@/contexts/BudgetContext';
|
||||
import { MONTHS_KR } from '@/hooks/useTransactions';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { getCategoryColor } from '@/utils/categoryColorUtils';
|
||||
import { Separator } from '@/components/ui/separator';
|
||||
|
||||
// 새로 분리한 컴포넌트들 불러오기
|
||||
import PeriodSelector from '@/components/analytics/PeriodSelector';
|
||||
import SummaryCards from '@/components/analytics/SummaryCards';
|
||||
import MonthlyComparisonChart from '@/components/analytics/MonthlyComparisonChart';
|
||||
import CategorySpendingList from '@/components/analytics/CategorySpendingList';
|
||||
import PaymentMethodChart from '@/components/analytics/PaymentMethodChart';
|
||||
|
||||
const Analytics = () => {
|
||||
const [selectedPeriod, setSelectedPeriod] = useState('이번 달');
|
||||
const {
|
||||
budgetData,
|
||||
getCategorySpending,
|
||||
getPaymentMethodStats, // 새로 추가된 메서드
|
||||
transactions
|
||||
} = useBudget();
|
||||
const isMobile = useIsMobile();
|
||||
@@ -86,6 +90,10 @@ const Analytics = () => {
|
||||
value: category.current,
|
||||
color: getCategoryColor(category.title) // 일관된 색상 적용
|
||||
}));
|
||||
|
||||
// 결제 방법 데이터 가져오기
|
||||
const paymentMethodData = getPaymentMethodStats();
|
||||
const hasPaymentData = paymentMethodData.some(method => method.amount > 0);
|
||||
|
||||
// 월별 데이터 생성 - 샘플 데이터 제거하고 현재 달만 실제 데이터 사용
|
||||
useEffect(() => {
|
||||
@@ -143,6 +151,13 @@ const Analytics = () => {
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/* 결제 방법 차트 추가 */}
|
||||
<h2 className="text-lg font-semibold mb-3 mt-6">결제 방법별 지출</h2>
|
||||
<PaymentMethodChart
|
||||
data={paymentMethodData}
|
||||
isEmpty={!hasPaymentData}
|
||||
/>
|
||||
|
||||
{/* Top Spending Categories */}
|
||||
<h2 className="text-lg font-semibold mb-3 mt-6">주요 지출 카테고리</h2>
|
||||
<CategorySpendingList categories={categorySpending} totalExpense={totalExpense} className="mb-[50px]" />
|
||||
|
||||
Reference in New Issue
Block a user