diff --git a/app/controllers/api/v1/budgets_controller.rb b/app/controllers/api/v1/budgets_controller.rb index f2816a3..fc0a157 100644 --- a/app/controllers/api/v1/budgets_controller.rb +++ b/app/controllers/api/v1/budgets_controller.rb @@ -41,7 +41,7 @@ def show # PATCH/PUT /api/v1/budgets/:id def update if @budget.update(budget_params) - render json: @budget, status: :ok + render json: BudgetSerializer.new(@budget).serializable_hash[:data], status: :ok else render json: { errors: @budget.errors.full_messages }, status: :unprocessable_entity end diff --git a/app/models/budget.rb b/app/models/budget.rb index 5bca706..4731cea 100644 --- a/app/models/budget.rb +++ b/app/models/budget.rb @@ -4,4 +4,12 @@ class Budget < ApplicationRecord validates :name, presence: true validates :financial_goal, presence: true, numericality: { greater_than: 0 } + + def total_spent + transactions.sum(:amount) + end + + def remaining_amount + financial_goal - total_spent + end end diff --git a/app/serializers/budget_serializer.rb b/app/serializers/budget_serializer.rb index f0d7e2f..c1ce8d9 100644 --- a/app/serializers/budget_serializer.rb +++ b/app/serializers/budget_serializer.rb @@ -3,6 +3,14 @@ class BudgetSerializer attributes :id, :name, :financial_goal, :created_at, :updated_at + attribute :spent do |budget| + budget.total_spent + end + + attribute :remaining do |budget| + budget.remaining_amount + end + has_many :transactions belongs_to :user end diff --git a/client/src/App.js b/client/src/App.js index 40a8a5b..5bdcf99 100644 --- a/client/src/App.js +++ b/client/src/App.js @@ -4,7 +4,7 @@ import './App.css'; import { AuthProvider } from './contexts/AuthContext'; import Layout from './components/Layout'; import Dashboard from './components/Dashboard'; -import Budgets from './components/Budgets'; +import Budgets from './components/Budgets/Budgets'; import Transactions from './components/Transactions/Transactions'; import Categories from './components/Categories'; import Analytics from './components/Analytics'; diff --git a/client/src/components/Budgets.js b/client/src/components/Budgets.js deleted file mode 100644 index 8592775..0000000 --- a/client/src/components/Budgets.js +++ /dev/null @@ -1,253 +0,0 @@ -import React, { useState, useEffect } from 'react'; -import { useForm } from 'react-hook-form'; -import { PlusCircle, Edit, Trash2, Eye, DollarSign } from 'lucide-react'; - -const Budgets = () => { - const [budgets, setBudgets] = useState([]); - const [showForm, setShowForm] = useState(false); - const [editingBudget, setEditingBudget] = useState(null); - const [loading, setLoading] = useState(true); - - const { register, handleSubmit, reset, formState: { errors } } = useForm(); - - useEffect(() => { - // Mock data - will be replaced with API calls - setTimeout(() => { - setBudgets([ - { - id: 1, - name: 'Monthly Budget', - financial_goal: 3000, - spent: 1850, - remaining: 1150, - created_at: '2024-01-01' - }, - { - id: 2, - name: 'Vacation Fund', - financial_goal: 5000, - spent: 1200, - remaining: 3800, - created_at: '2024-01-15' - }, - { - id: 3, - name: 'Emergency Fund', - financial_goal: 10000, - spent: 0, - remaining: 10000, - created_at: '2024-01-20' - } - ]); - setLoading(false); - }, 1000); - }, []); - - const onSubmit = (data) => { - const budgetData = { - ...data, - financial_goal: parseFloat(data.financial_goal), - spent: 0, - remaining: parseFloat(data.financial_goal) - }; - - if (editingBudget) { - // Update existing budget - setBudgets(budgets.map(budget => - budget.id === editingBudget.id - ? { ...budget, ...budgetData } - : budget - )); - } else { - // Add new budget - const newBudget = { - ...budgetData, - id: Date.now(), - created_at: new Date().toISOString().split('T')[0] - }; - setBudgets([...budgets, newBudget]); - } - - reset(); - setShowForm(false); - setEditingBudget(null); - }; - - const handleEdit = (budget) => { - setEditingBudget(budget); - reset({ - name: budget.name, - financial_goal: budget.financial_goal - }); - setShowForm(true); - }; - - const handleDelete = (budgetId) => { - if (window.confirm('Are you sure you want to delete this budget?')) { - setBudgets(budgets.filter(budget => budget.id !== budgetId)); - } - }; - - const handleCancel = () => { - setShowForm(false); - setEditingBudget(null); - reset(); - }; - - if (loading) { - return ( -
-
-

Loading budgets...

-
- ); - } - - return ( -
-
-
-

Budgets

-

Manage your financial goals and track your spending

-
- -
- - {/* Budget Form */} - {showForm && ( -
-
-

{editingBudget ? 'Edit Budget' : 'Create New Budget'}

-
- -
-
- - - {errors.name && {errors.name.message}} -
- -
- - - {errors.financial_goal && {errors.financial_goal.message}} -
- -
- - -
-
-
- )} - - {/* Budgets Grid */} -
- {budgets.length === 0 ? ( -
- -

No budgets yet

-

Create your first budget to start tracking your spending

- -
- ) : ( - budgets.map((budget) => ( -
-
-
-

{budget.name}

- Created {budget.created_at} -
-
- - -
-
- -
-
- Goal - ${budget.financial_goal.toLocaleString()} -
-
- Spent - ${budget.spent.toLocaleString()} -
-
- Remaining - ${budget.remaining.toLocaleString()} -
-
- -
-
-
-
-
- {((budget.spent / budget.financial_goal) * 100).toFixed(1)}% used -
-
- -
- -
-
- )) - )} -
-
- ); -}; - -export default Budgets; diff --git a/client/src/components/Budgets/BudgetCard.js b/client/src/components/Budgets/BudgetCard.js new file mode 100644 index 0000000..da208ee --- /dev/null +++ b/client/src/components/Budgets/BudgetCard.js @@ -0,0 +1,56 @@ +import React from 'react'; +import { Edit, Trash2, Eye } from 'lucide-react'; + +const BudgetCard = ({ budget, onEdit, onDelete }) => { + const progressPercent = Math.min((budget.spent / budget.financial_goal) * 100, 100); + + return ( +
+
+
+

{budget.name}

+ Created {budget.created_at} +
+
+ + +
+
+ +
+
+ Goal + ${budget.financial_goal.toLocaleString()} +
+
+ Spent + ${budget.spent.toLocaleString()} +
+
+ Remaining + ${budget.remaining.toLocaleString()} +
+
+ +
+
+
+
+
{progressPercent.toFixed(1)}% used
+
+ +
+ +
+
+ ); +}; + +export default BudgetCard; diff --git a/client/src/components/Budgets/BudgetForm.js b/client/src/components/Budgets/BudgetForm.js new file mode 100644 index 0000000..21d04ad --- /dev/null +++ b/client/src/components/Budgets/BudgetForm.js @@ -0,0 +1,121 @@ +import React, { useEffect } from 'react'; +import { useForm } from 'react-hook-form'; +import { budgetsAPI } from '../../services/api'; + +const BudgetForm = ({ editingBudget, onSubmit, onCancel, budgets }) => { + const { + register, + handleSubmit, + reset, + formState: { errors }, + } = useForm({ + defaultValues: { name: '', financial_goal: 0 }, + }); + + useEffect(() => { + if (editingBudget) { + reset({ + name: editingBudget.name, + financial_goal: editingBudget.financial_goal, + }); + } else { + reset({ name: '', financial_goal: 0 }); + } + }, [editingBudget, reset]); + + const submitHandler = async (data) => { + const payload = { + name: data.name, + financial_goal: parseFloat(data.financial_goal), + }; + + try { + if (editingBudget) { + const res = await budgetsAPI.update(editingBudget.id, { budget: payload }); + const returned = res.data?.data || res.data; + const attrs = returned?.attributes || payload; + + const updatedBudgets = budgets.map((b) => + String(b.id) === String(returned?.id || editingBudget.id) + ? { + ...b, + name: attrs.name, + financial_goal: parseFloat(attrs.financial_goal || b.financial_goal), + spent: parseFloat(attrs.spent || b.spent), + remaining: parseFloat(attrs.remaining || b.remaining), + created_at: attrs.created_at || b.created_at, + } + : b + ); + onSubmit(updatedBudgets); + } else { + const res = await budgetsAPI.create({ budget: payload }); + const returned = res.data?.data || res.data; + const attrs = returned?.attributes || payload; + + const newBudget = { + id: returned?.id || Date.now(), + name: attrs.name, + financial_goal: parseFloat(attrs.financial_goal || 0), + spent: 0, + remaining: parseFloat(attrs.financial_goal || 0), + created_at: attrs.created_at || new Date().toISOString(), + }; + + onSubmit([newBudget, ...budgets]); + } + } catch (err) { + console.error('Budget save failed', err); + alert('Failed to save budget'); + } + }; + + return ( +
+
+

{editingBudget ? 'Edit Budget' : 'Create New Budget'}

+
+ +
+
+ + + {errors.name && {errors.name.message}} +
+ +
+ + + {errors.financial_goal && {errors.financial_goal.message}} +
+ +
+ + +
+
+
+ ); +}; + +export default BudgetForm; diff --git a/client/src/components/Budgets/Budgets.js b/client/src/components/Budgets/Budgets.js new file mode 100644 index 0000000..230352f --- /dev/null +++ b/client/src/components/Budgets/Budgets.js @@ -0,0 +1,102 @@ +import React, { useState, useEffect } from 'react'; +import { PlusCircle } from 'lucide-react'; +import { budgetsAPI } from '../../services/api'; +import LoadingSpinner from './LoadingSpinner'; +import BudgetForm from './BudgetForm'; +import BudgetsGrid from './BudgetsGrid'; + +const Budgets = () => { + const [budgets, setBudgets] = useState([]); + const [showForm, setShowForm] = useState(false); + const [editingBudget, setEditingBudget] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchBudgets = async () => { + setLoading(true); + try { + const res = await budgetsAPI.getAll(); + const items = res.data?.data || []; + + const mapped = items.map((b) => { + const attrs = b.attributes || {}; + return { + id: b.id || attrs.id, + name: attrs.name || b.name, + financial_goal: parseFloat(attrs.financial_goal || 0), + spent: parseFloat(attrs.spent || 0), + remaining: parseFloat(attrs.remaining || 0), + created_at: attrs.created_at ? new Date(attrs.created_at).toLocaleString() : '', + }; + }); + setBudgets(mapped); + } catch (err) { + console.error('Failed to load budgets', err); + } finally { + setLoading(false); + } + }; + + fetchBudgets(); + }, []); + + const handleEdit = (budget) => { + setEditingBudget(budget); + setShowForm(true); + }; + + const handleDelete = async (budgetId) => { + if (!window.confirm('Are you sure you want to delete this budget?')) return; + + setLoading(true); + try { + await budgetsAPI.delete(budgetId); + setBudgets((prev) => prev.filter((b) => String(b.id) !== String(budgetId))); + } catch (err) { + console.error('Delete budget failed', err); + alert('Failed to delete budget'); + } finally { + setLoading(false); + } + }; + + const handleFormSubmit = (updatedBudgets) => { + setBudgets(updatedBudgets); + setShowForm(false); + setEditingBudget(null); + }; + + const handleCancel = () => { + setShowForm(false); + setEditingBudget(null); + }; + + if (loading) return ; + + return ( +
+
+
+

Budgets

+

Manage your financial goals and track your spending

+
+ +
+ + {showForm && ( + + )} + + +
+ ); +}; + +export default Budgets; diff --git a/client/src/components/Budgets/BudgetsGrid.js b/client/src/components/Budgets/BudgetsGrid.js new file mode 100644 index 0000000..f952105 --- /dev/null +++ b/client/src/components/Budgets/BudgetsGrid.js @@ -0,0 +1,28 @@ +import React from 'react'; +import { Edit, Trash2, Eye, DollarSign, PlusCircle } from 'lucide-react'; +import BudgetCard from './BudgetCard'; + +const BudgetsGrid = ({ budgets, onEdit, onDelete }) => { + if (budgets.length === 0) { + return ( +
+ +

No budgets yet

+

Create your first budget to start tracking your spending

+ +
+ ); + } + + return ( +
+ {budgets.map((budget) => ( + + ))} +
+ ); +}; + +export default BudgetsGrid; diff --git a/client/src/components/Budgets/LoadingSpinner.js b/client/src/components/Budgets/LoadingSpinner.js new file mode 100644 index 0000000..5b05023 --- /dev/null +++ b/client/src/components/Budgets/LoadingSpinner.js @@ -0,0 +1,10 @@ +import React from 'react'; + +const LoadingSpinner = ({ message }) => ( +
+
+

{message}

+
+); + +export default LoadingSpinner; diff --git a/client/src/components/Transactions/TransactionForm.js b/client/src/components/Transactions/TransactionForm.js index 707061c..bb1ece9 100644 --- a/client/src/components/Transactions/TransactionForm.js +++ b/client/src/components/Transactions/TransactionForm.js @@ -41,7 +41,11 @@ const TransactionForm = ({ budgets, categories, onSubmit, editingTransaction, on type="number" step="0.01" min="0" - {...register('amount', { required: 'Amount is required', min: { value: 0.01, message: 'Amount must be greater than 0' } })} + {...register('amount', { + required: 'Amount is required', + min: { value: 0.01, message: 'Amount must be greater than 0' }, + max: { value: 1000000, message: 'Amount must not be greater than 1,000,000' }, + })} placeholder="0.00" /> {errors.amount && {errors.amount.message}} @@ -55,18 +59,20 @@ const TransactionForm = ({ budgets, categories, onSubmit, editingTransaction, on {errors.date && {errors.date.message}} -
- - - {errors.budget_id && {errors.budget_id.message}} -
+ {!editingTransaction && ( +
+ + + {errors.budget_id && {errors.budget_id.message}} +
+ )}
diff --git a/spec/controllers/budget_controller_spec.rb b/spec/controllers/budget_controller_spec.rb index 940347b..2d16154 100644 --- a/spec/controllers/budget_controller_spec.rb +++ b/spec/controllers/budget_controller_spec.rb @@ -9,7 +9,6 @@ end describe 'GET #index' do - # Create data inside a before block for this specific action before do create_list(:budget, 2, user: user) end @@ -62,13 +61,25 @@ describe 'PUT #update' do let(:budget) { create(:budget, user: user) } - it 'updates the budget' do + it 'updates the budget and returns serialized data' do put :update, params: { id: budget.id, budget: { name: 'Updated Budget Name' } } - expect(response).to have_http_status(:success) + + expect(response).to have_http_status(:ok) + expect(budget.reload.name).to eq('Updated Budget Name') + + json = JSON.parse(response.body) + + expect(json).to have_key('id').or have_key('data') + if json.key?('data') + expect(json['data']['id'].to_s).to eq(budget.id.to_s) + expect(json['data']['attributes']['name']).to eq('Updated Budget Name') + else + expect(json['id'].to_s).to eq(budget.id.to_s) + end end end diff --git a/spec/models/budget_spec.rb b/spec/models/budget_spec.rb index db21e6e..4ad17a0 100644 --- a/spec/models/budget_spec.rb +++ b/spec/models/budget_spec.rb @@ -11,4 +11,50 @@ it { should belong_to(:user) } it { should have_many(:transactions) } end + + describe 'calculations' do + let(:user) { create(:user) } + let(:budget) { create(:budget, user: user, financial_goal: 1000.0) } + + context 'total_spent' do + it 'returns 0 when there are no transactions' do + expect(budget.total_spent.to_f).to eq(0.0) + end + + it 'sums transaction amounts for the budget' do + create(:transaction, budget: budget, amount: 150.0) + create(:transaction, budget: budget, amount: 75.5) + create(:transaction, budget: budget, amount: 24.5) + + expect(budget.total_spent.to_f).to eq(250.0) + end + + it 'does not include transactions from other budgets' do + other_budget = create(:budget, user: user) + create(:transaction, budget: other_budget, amount: 500.0) + create(:transaction, budget: budget, amount: 50.0) + + expect(budget.total_spent.to_f).to eq(50.0) + end + end + + context 'remaining_amount' do + it 'returns financial_goal when no transactions exist' do + expect(budget.remaining_amount.to_f).to eq(1000.0) + end + + it 'returns financial_goal minus total_spent' do + create(:transaction, budget: budget, amount: 200.0) + create(:transaction, budget: budget, amount: 100.0) + + expect(budget.remaining_amount.to_f).to eq(700.0) + end + + it 'can be negative when spent exceeds goal' do + create(:transaction, budget: budget, amount: 1200.0) + + expect(budget.remaining_amount.to_f).to eq(-200.0) + end + end + end end