From d2caefcae7fc2d84cf7bc48b5ee61bbfe7015886 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Sat, 23 Aug 2025 19:25:02 +0300 Subject: [PATCH 01/25] remove: prisma output custom path --- server/prisma/schema.prisma | 1 - 1 file changed, 1 deletion(-) diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 1c871fb..369f692 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -6,7 +6,6 @@ generator client { provider = "prisma-client-js" - output = "../generated/prisma" } datasource db { From 0cc1484faecac2d1dc8d374b1f07c631174e7a18 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Sat, 23 Aug 2025 20:07:08 +0300 Subject: [PATCH 02/25] chore: move lib folder in server root --- server/{src => }/lib/prisma.js | 0 server/package.json | 3 +-- 2 files changed, 1 insertion(+), 2 deletions(-) rename server/{src => }/lib/prisma.js (100%) diff --git a/server/src/lib/prisma.js b/server/lib/prisma.js similarity index 100% rename from server/src/lib/prisma.js rename to server/lib/prisma.js diff --git a/server/package.json b/server/package.json index 8fe6e2f..d79bff0 100644 --- a/server/package.json +++ b/server/package.json @@ -27,8 +27,7 @@ "express": "^4.18.2", "helmet": "^7.1.0", "morgan": "^1.10.0", - "pg": "^8.11.3", - "@prisma/client": "^5.18.0" + "pg": "^8.11.3" }, "devDependencies": { "nodemon": "^3.0.2", From 7af62267a92cdf8d28aed5fdb9b4426682c0c92b Mon Sep 17 00:00:00 2001 From: colombefioren Date: Sat, 23 Aug 2025 20:10:30 +0300 Subject: [PATCH 03/25] chore(prisma): remove custom generator output and drop icon_emoji attribute from IncomeCategory model --- server/prisma/schema.prisma | 2 -- 1 file changed, 2 deletions(-) diff --git a/server/prisma/schema.prisma b/server/prisma/schema.prisma index 1c871fb..7f9dd94 100644 --- a/server/prisma/schema.prisma +++ b/server/prisma/schema.prisma @@ -6,7 +6,6 @@ generator client { provider = "prisma-client-js" - output = "../generated/prisma" } datasource db { @@ -49,7 +48,6 @@ model IncomeCategory { category_id Int @id @default(autoincrement()) category_name String @db.VarChar(50) icon_url String? @db.VarChar(500) - icon_emoji String? @db.VarChar(10) is_custom Boolean @default(false) user_id Int? user User? @relation(fields: [user_id], references: [user_id], onDelete: Cascade) From b0aff8aa0d00847044bcb029f31706f68934c11f Mon Sep 17 00:00:00 2001 From: colombefioren Date: Sat, 23 Aug 2025 20:17:44 +0300 Subject: [PATCH 04/25] feat(client): add income models --- client/src/types/Income.ts | 37 +++++++++++++++++++++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 client/src/types/Income.ts diff --git a/client/src/types/Income.ts b/client/src/types/Income.ts new file mode 100644 index 0000000..14aeec4 --- /dev/null +++ b/client/src/types/Income.ts @@ -0,0 +1,37 @@ +export interface Income { + income_id: number; + amount: number; + date: string; + source: string; + description?: string; + creation_date: string; + user_id: number; + category_id: number; + category?: IncomeCategory; +} + +export interface IncomeCategory { + category_id: number; + category_name: string; + icon_url?: string; + is_custom: boolean; + user_id?: number; + created_at: string; + updated_at: string; +} + +export interface CreateIncomeRequest { + amount: number; + date: string; + source: string; + description?: string; + category_id: number; +} + +export interface UpdateIncomeRequest { + amount?: number; + date?: string; + source?: string; + description?: string; + category_id?: number; +} \ No newline at end of file From 08dcacc0b462ab75211a7e95d80fe476bf754b11 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Sat, 23 Aug 2025 20:26:40 +0300 Subject: [PATCH 05/25] feat(server): implement income controller with full CRUD operations --- server/controllers/income.controller.js | 191 ++++++++++++++++++++++++ 1 file changed, 191 insertions(+) create mode 100644 server/controllers/income.controller.js diff --git a/server/controllers/income.controller.js b/server/controllers/income.controller.js new file mode 100644 index 0000000..c4c769a --- /dev/null +++ b/server/controllers/income.controller.js @@ -0,0 +1,191 @@ +import { prisma } from "../lib/prisma"; + +//GET ALL INCOMES +export const getIncomes = async (req, res) => { + try { + const { start, end } = req.query; + const userId = req.user.user_id; + + let whereClause = { user_id: userId }; + + if (start || end) { + whereClause.date = {}; + if (start) whereClause.date.gte = new Date(start); + if (end) whereClause.date.lte = new Date(end); + } + + const incomes = await prisma.income.findMany({ + where: whereClause, + include: { + category: true + }, + orderBy: { + date: 'desc' + } + }); + + res.json(incomes); + } catch (error) { + console.error('Error fetching incomes:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +//GET BY ID +export const getIncomeById = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user.user_id; + + const income = await prisma.income.findFirst({ + where: { + income_id: parseInt(id), + user_id: userId + }, + include: { + category: true + } + }); + + if (!income) { + return res.status(404).json({ error: 'Income not found' }); + } + + res.json(income); + } catch (error) { + console.error('Error fetching income:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +//POST +export const createIncome = async (req, res) => { + try { + const { amount, date, source, description, category_id } = req.body; + const userId = req.user.user_id; + + if (!amount || !date || !source || !category_id) { + return res.status(400).json({ error: 'Missing required fields' }); + } + + const category = await prisma.incomeCategory.findFirst({ + where: { + category_id: parseInt(category_id), + OR: [ + { user_id: userId }, + { user_id: null } + ] + } + }); + + if (!category) { + return res.status(400).json({ error: 'Invalid category' }); + } + + const income = await prisma.income.create({ + data: { + amount: parseFloat(amount), + date: new Date(date), + source, + description, + user_id: userId, + category_id: parseInt(category_id) + }, + include: { + category: true + } + }); + + res.status(201).json(income); + } catch (error) { + console.error('Error creating income:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +//PUT - PATCH +export const updateIncome = async (req, res) => { + try { + const { id } = req.params; + const { amount, date, source, description, category_id } = req.body; + const userId = req.user.user_id; + + const existingIncome = await prisma.income.findFirst({ + where: { + income_id: parseInt(id), + user_id: userId + } + }); + + if (!existingIncome) { + return res.status(404).json({ error: 'Income not found' }); + } + + if (category_id) { + const category = await prisma.incomeCategory.findFirst({ + where: { + category_id: parseInt(category_id), + OR: [ + { user_id: userId }, + { user_id: null } + ] + } + }); + + if (!category) { + return res.status(400).json({ error: 'Invalid category' }); + } + } + + const income = await prisma.income.update({ + where: { + income_id: parseInt(id) + }, + data: { + ...(amount && { amount: parseFloat(amount) }), + ...(date && { date: new Date(date) }), + ...(source && { source }), + ...(description !== undefined && { description }), + ...(category_id && { category_id: parseInt(category_id) }) + }, + include: { + category: true + } + }); + + res.json(income); + } catch (error) { + console.error('Error updating income:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +//DELETE +export const deleteIncome = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user.user_id; + + const income = await prisma.income.findFirst({ + where: { + income_id: parseInt(id), + user_id: userId + } + }); + + if (!income) { + return res.status(404).json({ error: 'Income not found' }); + } + + await prisma.income.delete({ + where: { + income_id: parseInt(id) + } + }); + + res.status(204).send(); + } catch (error) { + console.error('Error deleting income:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; \ No newline at end of file From 3567d5e7a57c5694a3ce98b8790a4e665165d615 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Sat, 23 Aug 2025 20:30:18 +0300 Subject: [PATCH 06/25] feat(server): implement income category controller --- .../controllers/incomeCategory.controller.js | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 server/controllers/incomeCategory.controller.js diff --git a/server/controllers/incomeCategory.controller.js b/server/controllers/incomeCategory.controller.js new file mode 100644 index 0000000..d0835cd --- /dev/null +++ b/server/controllers/incomeCategory.controller.js @@ -0,0 +1,160 @@ +import { prisma } from "../lib/prisma"; + +//GET ALL INCOME CATEGORIES +export const getIncomeCategories = async (req, res) => { + try { + const userId = req.user.user_id; + + const categories = await prisma.incomeCategory.findMany({ + where: { + OR: [ + { user_id: null }, + { user_id: userId } + ] + }, + orderBy: [ + { is_custom: 'asc' }, //system categories are displayed first + { category_name: 'asc' } + ] + }); + + res.json(categories); + } catch (error) { + console.error('Error fetching income categories:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +//POST +export const createIncomeCategory = async (req, res) => { + try { + const { category_name, icon_url, icon_emoji } = req.body; + const userId = req.user.user_id; + + if (!category_name) { + return res.status(400).json({ error: 'Category name is required' }); + } + + const existingCategory = await prisma.incomeCategory.findFirst({ + where: { + category_name, + user_id: userId + } + }); + + if (existingCategory) { + return res.status(400).json({ error: 'Category already exists' }); + } + + const category = await prisma.incomeCategory.create({ + data: { + category_name, + icon_url, + icon_emoji, + is_custom: true, + user_id: userId + } + }); + + res.status(201).json(category); + } catch (error) { + console.error('Error creating income category:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +//PUT - PATCH +export const updateIncomeCategory = async (req, res) => { + try { + const { id } = req.params; + const { category_name, icon_url, icon_emoji } = req.body; + const userId = req.user.user_id; + + if (!category_name) { + return res.status(400).json({ error: 'Category name is required' }); + } + + const existingCategory = await prisma.incomeCategory.findFirst({ + where: { + category_id: parseInt(id), + user_id: userId + } + }); + + if (!existingCategory) { + return res.status(404).json({ error: 'Category not found' }); + } + + const duplicateCategory = await prisma.incomeCategory.findFirst({ + where: { + category_name, + user_id: userId, + NOT: { + category_id: parseInt(id) + } + } + }); + + if (duplicateCategory) { + return res.status(400).json({ error: 'Category name already exists' }); + } + + const category = await prisma.incomeCategory.update({ + where: { + category_id: parseInt(id) + }, + data: { + category_name, + icon_url, + icon_emoji + } + }); + + res.json(category); + } catch (error) { + console.error('Error updating income category:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; + +//DELETE +export const deleteIncomeCategory = async (req, res) => { + try { + const { id } = req.params; + const userId = req.user.user_id; + + const category = await prisma.incomeCategory.findFirst({ + where: { + category_id: parseInt(id), + user_id: userId + } + }); + + if (!category) { + return res.status(404).json({ error: 'Category not found' }); + } + + const incomesUsingCategory = await prisma.income.count({ + where: { + category_id: parseInt(id) + } + }); + + if (incomesUsingCategory > 0) { + return res.status(400).json({ + error: 'Cannot delete category that is being used by incomes' + }); + } + + await prisma.incomeCategory.delete({ + where: { + category_id: parseInt(id) + } + }); + + res.status(204).send(); + } catch (error) { + console.error('Error deleting income category:', error); + res.status(500).json({ error: 'Internal server error' }); + } +}; \ No newline at end of file From 8acfe4d305454af4ba65ed2f8210cb9dcc880325 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Sat, 23 Aug 2025 20:36:46 +0300 Subject: [PATCH 07/25] feat(server): asyncHandler utility --- server/controllers/income.controller.js | 132 +++++++++--------- .../controllers/incomeCategory.controller.js | 121 ++++++++-------- server/{lib => db}/prisma.js | 0 server/utils/asyncHandler.js | 3 + 4 files changed, 125 insertions(+), 131 deletions(-) rename server/{lib => db}/prisma.js (100%) create mode 100644 server/utils/asyncHandler.js diff --git a/server/controllers/income.controller.js b/server/controllers/income.controller.js index c4c769a..0d893c9 100644 --- a/server/controllers/income.controller.js +++ b/server/controllers/income.controller.js @@ -1,33 +1,33 @@ -import { prisma } from "../lib/prisma"; +import { prisma } from "../db/prisma"; //GET ALL INCOMES export const getIncomes = async (req, res) => { try { const { start, end } = req.query; const userId = req.user.user_id; - + let whereClause = { user_id: userId }; - + if (start || end) { whereClause.date = {}; if (start) whereClause.date.gte = new Date(start); if (end) whereClause.date.lte = new Date(end); } - + const incomes = await prisma.income.findMany({ where: whereClause, include: { - category: true + category: true, }, orderBy: { - date: 'desc' - } + date: "desc", + }, }); - + res.json(incomes); } catch (error) { - console.error('Error fetching incomes:', error); - res.status(500).json({ error: 'Internal server error' }); + console.error("Error fetching incomes:", error); + res.status(500).json({ error: "Internal server error" }); } }; @@ -36,25 +36,25 @@ export const getIncomeById = async (req, res) => { try { const { id } = req.params; const userId = req.user.user_id; - + const income = await prisma.income.findFirst({ where: { income_id: parseInt(id), - user_id: userId + user_id: userId, }, include: { - category: true - } + category: true, + }, }); - + if (!income) { - return res.status(404).json({ error: 'Income not found' }); + return res.status(404).json({ error: "Income not found" }); } - + res.json(income); } catch (error) { - console.error('Error fetching income:', error); - res.status(500).json({ error: 'Internal server error' }); + console.error("Error fetching income:", error); + res.status(500).json({ error: "Internal server error" }); } }; @@ -63,25 +63,22 @@ export const createIncome = async (req, res) => { try { const { amount, date, source, description, category_id } = req.body; const userId = req.user.user_id; - + if (!amount || !date || !source || !category_id) { - return res.status(400).json({ error: 'Missing required fields' }); + return res.status(400).json({ error: "Missing required fields" }); } - + const category = await prisma.incomeCategory.findFirst({ where: { category_id: parseInt(category_id), - OR: [ - { user_id: userId }, - { user_id: null } - ] - } + OR: [{ user_id: userId }, { user_id: null }], + }, }); - + if (!category) { - return res.status(400).json({ error: 'Invalid category' }); + return res.status(400).json({ error: "Invalid category" }); } - + const income = await prisma.income.create({ data: { amount: parseFloat(amount), @@ -89,17 +86,17 @@ export const createIncome = async (req, res) => { source, description, user_id: userId, - category_id: parseInt(category_id) + category_id: parseInt(category_id), }, include: { - category: true - } + category: true, + }, }); - + res.status(201).json(income); } catch (error) { - console.error('Error creating income:', error); - res.status(500).json({ error: 'Internal server error' }); + console.error("Error creating income:", error); + res.status(500).json({ error: "Internal server error" }); } }; @@ -109,54 +106,51 @@ export const updateIncome = async (req, res) => { const { id } = req.params; const { amount, date, source, description, category_id } = req.body; const userId = req.user.user_id; - + const existingIncome = await prisma.income.findFirst({ where: { income_id: parseInt(id), - user_id: userId - } + user_id: userId, + }, }); - + if (!existingIncome) { - return res.status(404).json({ error: 'Income not found' }); + return res.status(404).json({ error: "Income not found" }); } - + if (category_id) { const category = await prisma.incomeCategory.findFirst({ where: { category_id: parseInt(category_id), - OR: [ - { user_id: userId }, - { user_id: null } - ] - } + OR: [{ user_id: userId }, { user_id: null }], + }, }); - + if (!category) { - return res.status(400).json({ error: 'Invalid category' }); + return res.status(400).json({ error: "Invalid category" }); } } - + const income = await prisma.income.update({ where: { - income_id: parseInt(id) + income_id: parseInt(id), }, data: { ...(amount && { amount: parseFloat(amount) }), ...(date && { date: new Date(date) }), ...(source && { source }), ...(description !== undefined && { description }), - ...(category_id && { category_id: parseInt(category_id) }) + ...(category_id && { category_id: parseInt(category_id) }), }, include: { - category: true - } + category: true, + }, }); - + res.json(income); } catch (error) { - console.error('Error updating income:', error); - res.status(500).json({ error: 'Internal server error' }); + console.error("Error updating income:", error); + res.status(500).json({ error: "Internal server error" }); } }; @@ -165,27 +159,27 @@ export const deleteIncome = async (req, res) => { try { const { id } = req.params; const userId = req.user.user_id; - + const income = await prisma.income.findFirst({ where: { income_id: parseInt(id), - user_id: userId - } + user_id: userId, + }, }); - + if (!income) { - return res.status(404).json({ error: 'Income not found' }); + return res.status(404).json({ error: "Income not found" }); } - + await prisma.income.delete({ where: { - income_id: parseInt(id) - } + income_id: parseInt(id), + }, }); - + res.status(204).send(); } catch (error) { - console.error('Error deleting income:', error); - res.status(500).json({ error: 'Internal server error' }); + console.error("Error deleting income:", error); + res.status(500).json({ error: "Internal server error" }); } -}; \ No newline at end of file +}; diff --git a/server/controllers/incomeCategory.controller.js b/server/controllers/incomeCategory.controller.js index d0835cd..d87b553 100644 --- a/server/controllers/incomeCategory.controller.js +++ b/server/controllers/incomeCategory.controller.js @@ -1,27 +1,24 @@ -import { prisma } from "../lib/prisma"; +import { prisma } from "../db/prisma"; //GET ALL INCOME CATEGORIES export const getIncomeCategories = async (req, res) => { try { const userId = req.user.user_id; - + const categories = await prisma.incomeCategory.findMany({ where: { - OR: [ - { user_id: null }, - { user_id: userId } - ] + OR: [{ user_id: null }, { user_id: userId }], }, orderBy: [ - { is_custom: 'asc' }, //system categories are displayed first - { category_name: 'asc' } - ] + { is_custom: "asc" }, //system categories are displayed first + { category_name: "asc" }, + ], }); - + res.json(categories); } catch (error) { - console.error('Error fetching income categories:', error); - res.status(500).json({ error: 'Internal server error' }); + console.error("Error fetching income categories:", error); + res.status(500).json({ error: "Internal server error" }); } }; @@ -30,36 +27,36 @@ export const createIncomeCategory = async (req, res) => { try { const { category_name, icon_url, icon_emoji } = req.body; const userId = req.user.user_id; - + if (!category_name) { - return res.status(400).json({ error: 'Category name is required' }); + return res.status(400).json({ error: "Category name is required" }); } - + const existingCategory = await prisma.incomeCategory.findFirst({ where: { category_name, - user_id: userId - } + user_id: userId, + }, }); - + if (existingCategory) { - return res.status(400).json({ error: 'Category already exists' }); + return res.status(400).json({ error: "Category already exists" }); } - + const category = await prisma.incomeCategory.create({ data: { category_name, icon_url, icon_emoji, is_custom: true, - user_id: userId - } + user_id: userId, + }, }); - + res.status(201).json(category); } catch (error) { - console.error('Error creating income category:', error); - res.status(500).json({ error: 'Internal server error' }); + console.error("Error creating income category:", error); + res.status(500).json({ error: "Internal server error" }); } }; @@ -69,20 +66,20 @@ export const updateIncomeCategory = async (req, res) => { const { id } = req.params; const { category_name, icon_url, icon_emoji } = req.body; const userId = req.user.user_id; - + if (!category_name) { - return res.status(400).json({ error: 'Category name is required' }); + return res.status(400).json({ error: "Category name is required" }); } - + const existingCategory = await prisma.incomeCategory.findFirst({ where: { category_id: parseInt(id), - user_id: userId - } + user_id: userId, + }, }); - + if (!existingCategory) { - return res.status(404).json({ error: 'Category not found' }); + return res.status(404).json({ error: "Category not found" }); } const duplicateCategory = await prisma.incomeCategory.findFirst({ @@ -90,30 +87,30 @@ export const updateIncomeCategory = async (req, res) => { category_name, user_id: userId, NOT: { - category_id: parseInt(id) - } - } + category_id: parseInt(id), + }, + }, }); - + if (duplicateCategory) { - return res.status(400).json({ error: 'Category name already exists' }); + return res.status(400).json({ error: "Category name already exists" }); } - + const category = await prisma.incomeCategory.update({ where: { - category_id: parseInt(id) + category_id: parseInt(id), }, data: { category_name, icon_url, - icon_emoji - } + icon_emoji, + }, }); - + res.json(category); } catch (error) { - console.error('Error updating income category:', error); - res.status(500).json({ error: 'Internal server error' }); + console.error("Error updating income category:", error); + res.status(500).json({ error: "Internal server error" }); } }; @@ -122,39 +119,39 @@ export const deleteIncomeCategory = async (req, res) => { try { const { id } = req.params; const userId = req.user.user_id; - + const category = await prisma.incomeCategory.findFirst({ where: { category_id: parseInt(id), - user_id: userId - } + user_id: userId, + }, }); - + if (!category) { - return res.status(404).json({ error: 'Category not found' }); + return res.status(404).json({ error: "Category not found" }); } - + const incomesUsingCategory = await prisma.income.count({ where: { - category_id: parseInt(id) - } + category_id: parseInt(id), + }, }); - + if (incomesUsingCategory > 0) { - return res.status(400).json({ - error: 'Cannot delete category that is being used by incomes' + return res.status(400).json({ + error: "Cannot delete category that is being used by incomes", }); } - + await prisma.incomeCategory.delete({ where: { - category_id: parseInt(id) - } + category_id: parseInt(id), + }, }); - + res.status(204).send(); } catch (error) { - console.error('Error deleting income category:', error); - res.status(500).json({ error: 'Internal server error' }); + console.error("Error deleting income category:", error); + res.status(500).json({ error: "Internal server error" }); } -}; \ No newline at end of file +}; diff --git a/server/lib/prisma.js b/server/db/prisma.js similarity index 100% rename from server/lib/prisma.js rename to server/db/prisma.js diff --git a/server/utils/asyncHandler.js b/server/utils/asyncHandler.js new file mode 100644 index 0000000..bb1b35f --- /dev/null +++ b/server/utils/asyncHandler.js @@ -0,0 +1,3 @@ +export const asyncHandler = (fn) => (req, res, next) => { + Promise.resolve(fn(req, res, next)).catch(next); +}; From 39282033b6e8295414f40bbf945cd3831a730db0 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Sat, 23 Aug 2025 20:47:56 +0300 Subject: [PATCH 08/25] refactor: separate controller and service logic for cleaner architecture --- server/controllers/income.controller.js | 185 ------------------ .../controllers/income/income.controller.js | 86 ++++++++ .../income/incomeCategory.controller.js | 67 +++++++ .../controllers/incomeCategory.controller.js | 157 --------------- server/services/income/income.service.js | 170 ++++++++++++++++ 5 files changed, 323 insertions(+), 342 deletions(-) delete mode 100644 server/controllers/income.controller.js create mode 100644 server/controllers/income/income.controller.js create mode 100644 server/controllers/income/incomeCategory.controller.js delete mode 100644 server/controllers/incomeCategory.controller.js create mode 100644 server/services/income/income.service.js diff --git a/server/controllers/income.controller.js b/server/controllers/income.controller.js deleted file mode 100644 index 0d893c9..0000000 --- a/server/controllers/income.controller.js +++ /dev/null @@ -1,185 +0,0 @@ -import { prisma } from "../db/prisma"; - -//GET ALL INCOMES -export const getIncomes = async (req, res) => { - try { - const { start, end } = req.query; - const userId = req.user.user_id; - - let whereClause = { user_id: userId }; - - if (start || end) { - whereClause.date = {}; - if (start) whereClause.date.gte = new Date(start); - if (end) whereClause.date.lte = new Date(end); - } - - const incomes = await prisma.income.findMany({ - where: whereClause, - include: { - category: true, - }, - orderBy: { - date: "desc", - }, - }); - - res.json(incomes); - } catch (error) { - console.error("Error fetching incomes:", error); - res.status(500).json({ error: "Internal server error" }); - } -}; - -//GET BY ID -export const getIncomeById = async (req, res) => { - try { - const { id } = req.params; - const userId = req.user.user_id; - - const income = await prisma.income.findFirst({ - where: { - income_id: parseInt(id), - user_id: userId, - }, - include: { - category: true, - }, - }); - - if (!income) { - return res.status(404).json({ error: "Income not found" }); - } - - res.json(income); - } catch (error) { - console.error("Error fetching income:", error); - res.status(500).json({ error: "Internal server error" }); - } -}; - -//POST -export const createIncome = async (req, res) => { - try { - const { amount, date, source, description, category_id } = req.body; - const userId = req.user.user_id; - - if (!amount || !date || !source || !category_id) { - return res.status(400).json({ error: "Missing required fields" }); - } - - const category = await prisma.incomeCategory.findFirst({ - where: { - category_id: parseInt(category_id), - OR: [{ user_id: userId }, { user_id: null }], - }, - }); - - if (!category) { - return res.status(400).json({ error: "Invalid category" }); - } - - const income = await prisma.income.create({ - data: { - amount: parseFloat(amount), - date: new Date(date), - source, - description, - user_id: userId, - category_id: parseInt(category_id), - }, - include: { - category: true, - }, - }); - - res.status(201).json(income); - } catch (error) { - console.error("Error creating income:", error); - res.status(500).json({ error: "Internal server error" }); - } -}; - -//PUT - PATCH -export const updateIncome = async (req, res) => { - try { - const { id } = req.params; - const { amount, date, source, description, category_id } = req.body; - const userId = req.user.user_id; - - const existingIncome = await prisma.income.findFirst({ - where: { - income_id: parseInt(id), - user_id: userId, - }, - }); - - if (!existingIncome) { - return res.status(404).json({ error: "Income not found" }); - } - - if (category_id) { - const category = await prisma.incomeCategory.findFirst({ - where: { - category_id: parseInt(category_id), - OR: [{ user_id: userId }, { user_id: null }], - }, - }); - - if (!category) { - return res.status(400).json({ error: "Invalid category" }); - } - } - - const income = await prisma.income.update({ - where: { - income_id: parseInt(id), - }, - data: { - ...(amount && { amount: parseFloat(amount) }), - ...(date && { date: new Date(date) }), - ...(source && { source }), - ...(description !== undefined && { description }), - ...(category_id && { category_id: parseInt(category_id) }), - }, - include: { - category: true, - }, - }); - - res.json(income); - } catch (error) { - console.error("Error updating income:", error); - res.status(500).json({ error: "Internal server error" }); - } -}; - -//DELETE -export const deleteIncome = async (req, res) => { - try { - const { id } = req.params; - const userId = req.user.user_id; - - const income = await prisma.income.findFirst({ - where: { - income_id: parseInt(id), - user_id: userId, - }, - }); - - if (!income) { - return res.status(404).json({ error: "Income not found" }); - } - - await prisma.income.delete({ - where: { - income_id: parseInt(id), - }, - }); - - res.status(204).send(); - } catch (error) { - console.error("Error deleting income:", error); - res.status(500).json({ error: "Internal server error" }); - } -}; diff --git a/server/controllers/income/income.controller.js b/server/controllers/income/income.controller.js new file mode 100644 index 0000000..3a0346f --- /dev/null +++ b/server/controllers/income/income.controller.js @@ -0,0 +1,86 @@ +import { incomeService } from "../../services/income/income.service"; +import { asyncHandler } from "../../utils/asyncHandler"; + +const createIncome = asyncHandler(async (req, res) => { + const { amount, date, source, description, category_id } = req.body; + + if (!amount || !date || !source || !category_id) { + return res.status(400).json({ error: "Missing required fields" }); + } + + const income = await incomeService.createIncome(req.user.user_id, { + amount: parseFloat(amount), + date: new Date(date), + source, + description, + category: { connect: { category_id: parseInt(category_id) } }, + }); + + res.status(201).json(income); +}); + +const getIncomes = asyncHandler(async (req, res) => { + const { start, end } = req.query; + const filters = {}; + + if (start) filters.start = new Date(start); + if (end) filters.end = new Date(end); + + const incomes = await incomeService.getIncomes(req.user.user_id, filters); + res.json(incomes); +}); + +const getIncome = asyncHandler(async (req, res) => { + const { id } = req.params; + const income = await incomeService.getIncomeById(id, req.user.user_id); + + if (!income) { + return res.status(404).json({ error: "Income not found" }); + } + + res.json(income); +}); + +const updateIncome = asyncHandler(async (req, res) => { + const { id } = req.params; + const updates = req.body; + + if (updates.date) updates.date = new Date(updates.date); + if (updates.amount) updates.amount = parseFloat(updates.amount); + if (updates.category_id) { + updates.category = { + connect: { category_id: parseInt(updates.category_id) }, + }; + delete updates.category_id; + } + + try { + const income = await incomeService.updateIncome( + id, + req.user.user_id, + updates + ); + res.json(income); + } catch (error) { + res.status(404).json({ error: error.message }); + } +}); + +const deleteIncome = asyncHandler(async (req, res) => { + const { id } = req.params; + + try { + await incomeService.deleteIncome(id, req.user.user_id); + res.status(204).send(); + } catch (error) { + res.status(404).json({ error: error.message }); + } +}); + +export default { + createIncome, + getIncomes, + getIncome, + updateIncome, + deleteIncome, +}; diff --git a/server/controllers/income/incomeCategory.controller.js b/server/controllers/income/incomeCategory.controller.js new file mode 100644 index 0000000..0d40659 --- /dev/null +++ b/server/controllers/income/incomeCategory.controller.js @@ -0,0 +1,67 @@ +import { incomeService } from "../../services/income/income.service"; +import { asyncHandler } from "../../utils/asyncHandler"; + +const getIncomeCategories = asyncHandler(async (req, res) => { + const categories = await incomeService.getIncomeCategories(req.user.user_id); + res.json(categories); +}); + +const createIncomeCategory = asyncHandler(async (req, res) => { + const { category_name, icon_url, icon_emoji } = req.body; + + if (!category_name) { + return res.status(400).json({ error: "Category name is required" }); + } + + try { + const category = await incomeService.createIncomeCategory( + req.user.user_id, + { + category_name, + icon_url, + icon_emoji, + } + ); + res.status(201).json(category); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +const updateIncomeCategory = asyncHandler(async (req, res) => { + const { id } = req.params; + const { category_name, icon_url, icon_emoji } = req.body; + + if (!category_name) { + return res.status(400).json({ error: "Category name is required" }); + } + + try { + const category = await incomeService.updateIncomeCategory( + id, + req.user.user_id, + { category_name, icon_url, icon_emoji } + ); + res.json(category); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +const deleteIncomeCategory = asyncHandler(async (req, res) => { + const { id } = req.params; + + try { + await incomeService.deleteIncomeCategory(id, req.user.user_id); + res.status(204).send(); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +export default { + getIncomeCategories, + createIncomeCategory, + updateIncomeCategory, + deleteIncomeCategory, +}; diff --git a/server/controllers/incomeCategory.controller.js b/server/controllers/incomeCategory.controller.js deleted file mode 100644 index d87b553..0000000 --- a/server/controllers/incomeCategory.controller.js +++ /dev/null @@ -1,157 +0,0 @@ -import { prisma } from "../db/prisma"; - -//GET ALL INCOME CATEGORIES -export const getIncomeCategories = async (req, res) => { - try { - const userId = req.user.user_id; - - const categories = await prisma.incomeCategory.findMany({ - where: { - OR: [{ user_id: null }, { user_id: userId }], - }, - orderBy: [ - { is_custom: "asc" }, //system categories are displayed first - { category_name: "asc" }, - ], - }); - - res.json(categories); - } catch (error) { - console.error("Error fetching income categories:", error); - res.status(500).json({ error: "Internal server error" }); - } -}; - -//POST -export const createIncomeCategory = async (req, res) => { - try { - const { category_name, icon_url, icon_emoji } = req.body; - const userId = req.user.user_id; - - if (!category_name) { - return res.status(400).json({ error: "Category name is required" }); - } - - const existingCategory = await prisma.incomeCategory.findFirst({ - where: { - category_name, - user_id: userId, - }, - }); - - if (existingCategory) { - return res.status(400).json({ error: "Category already exists" }); - } - - const category = await prisma.incomeCategory.create({ - data: { - category_name, - icon_url, - icon_emoji, - is_custom: true, - user_id: userId, - }, - }); - - res.status(201).json(category); - } catch (error) { - console.error("Error creating income category:", error); - res.status(500).json({ error: "Internal server error" }); - } -}; - -//PUT - PATCH -export const updateIncomeCategory = async (req, res) => { - try { - const { id } = req.params; - const { category_name, icon_url, icon_emoji } = req.body; - const userId = req.user.user_id; - - if (!category_name) { - return res.status(400).json({ error: "Category name is required" }); - } - - const existingCategory = await prisma.incomeCategory.findFirst({ - where: { - category_id: parseInt(id), - user_id: userId, - }, - }); - - if (!existingCategory) { - return res.status(404).json({ error: "Category not found" }); - } - - const duplicateCategory = await prisma.incomeCategory.findFirst({ - where: { - category_name, - user_id: userId, - NOT: { - category_id: parseInt(id), - }, - }, - }); - - if (duplicateCategory) { - return res.status(400).json({ error: "Category name already exists" }); - } - - const category = await prisma.incomeCategory.update({ - where: { - category_id: parseInt(id), - }, - data: { - category_name, - icon_url, - icon_emoji, - }, - }); - - res.json(category); - } catch (error) { - console.error("Error updating income category:", error); - res.status(500).json({ error: "Internal server error" }); - } -}; - -//DELETE -export const deleteIncomeCategory = async (req, res) => { - try { - const { id } = req.params; - const userId = req.user.user_id; - - const category = await prisma.incomeCategory.findFirst({ - where: { - category_id: parseInt(id), - user_id: userId, - }, - }); - - if (!category) { - return res.status(404).json({ error: "Category not found" }); - } - - const incomesUsingCategory = await prisma.income.count({ - where: { - category_id: parseInt(id), - }, - }); - - if (incomesUsingCategory > 0) { - return res.status(400).json({ - error: "Cannot delete category that is being used by incomes", - }); - } - - await prisma.incomeCategory.delete({ - where: { - category_id: parseInt(id), - }, - }); - - res.status(204).send(); - } catch (error) { - console.error("Error deleting income category:", error); - res.status(500).json({ error: "Internal server error" }); - } -}; diff --git a/server/services/income/income.service.js b/server/services/income/income.service.js new file mode 100644 index 0000000..df197f0 --- /dev/null +++ b/server/services/income/income.service.js @@ -0,0 +1,170 @@ +import { prisma } from "../../db/prisma"; + +//---------------INCOME---------------// + +//POST +const createIncome = async (userId, data) => { + return await prisma.income.create({ + data: { + ...data, + user: { connect: { user_id: userId } }, + }, + include: { category: true }, + }); +}; + +//GET ALL (with start and end date query params) +const getIncomes = async (userId, filters = {}) => { + const where = { user_id: userId }; + + if (filters.start || filters.end) { + where.date = {}; + if (filters.start) where.date.gte = new Date(filters.start); + if (filters.end) where.date.lte = new Date(filters.end); + } + + return await prisma.income.findMany({ + where, + include: { category: true }, + orderBy: { date: "desc" }, + }); +}; + +//GET BY ID +const getIncomeById = async (id, userId) => { + return await prisma.income.findFirst({ + where: { income_id: parseInt(id), user_id: userId }, + include: { category: true }, + }); +}; + +//UPDATE +const updateIncome = async (id, userId, data) => { + const income = await prisma.income.findFirst({ + where: { income_id: parseInt(id), user_id: userId }, + }); + + if (!income) { + throw new Error("Income not found or not authorized"); + } + + return await prisma.income.update({ + where: { income_id: parseInt(id) }, + data, + include: { category: true }, + }); +}; + +//DELETE +const deleteIncome = async (id, userId) => { + const income = await prisma.income.findFirst({ + where: { income_id: parseInt(id), user_id: userId }, + }); + + if (!income) { + throw new Error("Income not found or not authorized"); + } + + await prisma.income.delete({ + where: { income_id: parseInt(id) }, + }); +}; + +//---------------INCOME CATEGORIES---------------// + +//GET ALL +const getIncomeCategories = async (userId) => { + return await prisma.incomeCategory.findMany({ + where: { + OR: [{ user_id: null }, { user_id: userId }], + }, + orderBy: [{ is_custom: "asc" }, { category_name: "asc" }], + }); +}; + +//POST +const createIncomeCategory = async (userId, data) => { + const existingCategory = await prisma.incomeCategory.findFirst({ + where: { + category_name: data.category_name, + user_id: userId, + }, + }); + + if (existingCategory) { + throw new Error("Category already exists"); + } + + return await prisma.incomeCategory.create({ + data: { + ...data, + is_custom: true, + user: { connect: { user_id: userId } }, + }, + }); +}; + +//UPDATE +const updateIncomeCategory = async (id, userId, data) => { + const category = await prisma.incomeCategory.findFirst({ + where: { category_id: parseInt(id), user_id: userId }, + }); + + if (!category) { + throw new Error("Category not found or not authorized"); + } + + if (data.category_name) { + const duplicateCategory = await prisma.incomeCategory.findFirst({ + where: { + category_name: data.category_name, + user_id: userId, + NOT: { category_id: parseInt(id) }, + }, + }); + + if (duplicateCategory) { + throw new Error("Category name already exists"); + } + } + + return await prisma.incomeCategory.update({ + where: { category_id: parseInt(id) }, + data, + }); +}; + +//DELETE +const deleteIncomeCategory = async (id, userId) => { + const category = await prisma.incomeCategory.findFirst({ + where: { category_id: parseInt(id), user_id: userId }, + }); + + if (!category) { + throw new Error("Category not found or not authorized"); + } + + const incomesUsingCategory = await prisma.income.count({ + where: { category_id: parseInt(id) }, + }); + + if (incomesUsingCategory > 0) { + throw new Error("Cannot delete category that is being used by incomes"); + } + + await prisma.incomeCategory.delete({ + where: { category_id: parseInt(id) }, + }); +}; + +export default { + createIncome, + getIncomes, + getIncomeById, + updateIncome, + deleteIncome, + getIncomeCategories, + createIncomeCategory, + updateIncomeCategory, + deleteIncomeCategory, +}; From ad7d22d51818babb832ea8832cd5aceb8e98c748 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Sat, 23 Aug 2025 20:53:54 +0300 Subject: [PATCH 09/25] refactor: unify income and income-category controllers --- .../{income => }/income.controller.js | 66 ++++++++++++++++++ .../income/incomeCategory.controller.js | 67 ------------------- .../services/{income => }/income.service.js | 2 +- 3 files changed, 67 insertions(+), 68 deletions(-) rename server/controllers/{income => }/income.controller.js (54%) delete mode 100644 server/controllers/income/incomeCategory.controller.js rename server/services/{income => }/income.service.js (94%) diff --git a/server/controllers/income/income.controller.js b/server/controllers/income.controller.js similarity index 54% rename from server/controllers/income/income.controller.js rename to server/controllers/income.controller.js index 3a0346f..ab144fa 100644 --- a/server/controllers/income/income.controller.js +++ b/server/controllers/income.controller.js @@ -1,6 +1,8 @@ import { incomeService } from "../../services/income/income.service"; import { asyncHandler } from "../../utils/asyncHandler"; +//---------------INCOME---------------// + const createIncome = asyncHandler(async (req, res) => { const { amount, date, source, description, category_id } = req.body; @@ -77,10 +79,74 @@ const deleteIncome = asyncHandler(async (req, res) => { } }); +//---------------INCOME CATEGORIES---------------// + +const getIncomeCategories = asyncHandler(async (req, res) => { + const categories = await incomeService.getIncomeCategories(req.user.user_id); + res.json(categories); +}); + +const createIncomeCategory = asyncHandler(async (req, res) => { + const { category_name, icon_url, icon_emoji } = req.body; + + if (!category_name) { + return res.status(400).json({ error: "Category name is required" }); + } + + try { + const category = await incomeService.createIncomeCategory( + req.user.user_id, + { + category_name, + icon_url, + icon_emoji, + } + ); + res.status(201).json(category); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +const updateIncomeCategory = asyncHandler(async (req, res) => { + const { id } = req.params; + const { category_name, icon_url, icon_emoji } = req.body; + + if (!category_name) { + return res.status(400).json({ error: "Category name is required" }); + } + + try { + const category = await incomeService.updateIncomeCategory( + id, + req.user.user_id, + { category_name, icon_url, icon_emoji } + ); + res.json(category); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + +const deleteIncomeCategory = asyncHandler(async (req, res) => { + const { id } = req.params; + + try { + await incomeService.deleteIncomeCategory(id, req.user.user_id); + res.status(204).send(); + } catch (error) { + res.status(400).json({ error: error.message }); + } +}); + export default { createIncome, getIncomes, getIncome, updateIncome, deleteIncome, + getIncomeCategories, + createIncomeCategory, + updateIncomeCategory, + deleteIncomeCategory, }; diff --git a/server/controllers/income/incomeCategory.controller.js b/server/controllers/income/incomeCategory.controller.js deleted file mode 100644 index 0d40659..0000000 --- a/server/controllers/income/incomeCategory.controller.js +++ /dev/null @@ -1,67 +0,0 @@ -import { incomeService } from "../../services/income/income.service"; -import { asyncHandler } from "../../utils/asyncHandler"; - -const getIncomeCategories = asyncHandler(async (req, res) => { - const categories = await incomeService.getIncomeCategories(req.user.user_id); - res.json(categories); -}); - -const createIncomeCategory = asyncHandler(async (req, res) => { - const { category_name, icon_url, icon_emoji } = req.body; - - if (!category_name) { - return res.status(400).json({ error: "Category name is required" }); - } - - try { - const category = await incomeService.createIncomeCategory( - req.user.user_id, - { - category_name, - icon_url, - icon_emoji, - } - ); - res.status(201).json(category); - } catch (error) { - res.status(400).json({ error: error.message }); - } -}); - -const updateIncomeCategory = asyncHandler(async (req, res) => { - const { id } = req.params; - const { category_name, icon_url, icon_emoji } = req.body; - - if (!category_name) { - return res.status(400).json({ error: "Category name is required" }); - } - - try { - const category = await incomeService.updateIncomeCategory( - id, - req.user.user_id, - { category_name, icon_url, icon_emoji } - ); - res.json(category); - } catch (error) { - res.status(400).json({ error: error.message }); - } -}); - -const deleteIncomeCategory = asyncHandler(async (req, res) => { - const { id } = req.params; - - try { - await incomeService.deleteIncomeCategory(id, req.user.user_id); - res.status(204).send(); - } catch (error) { - res.status(400).json({ error: error.message }); - } -}); - -export default { - getIncomeCategories, - createIncomeCategory, - updateIncomeCategory, - deleteIncomeCategory, -}; diff --git a/server/services/income/income.service.js b/server/services/income.service.js similarity index 94% rename from server/services/income/income.service.js rename to server/services/income.service.js index df197f0..f1a5a65 100644 --- a/server/services/income/income.service.js +++ b/server/services/income.service.js @@ -70,7 +70,7 @@ const deleteIncome = async (id, userId) => { }); }; -//---------------INCOME CATEGORIES---------------// +//---------------INCOME CATEGORY---------------// //GET ALL const getIncomeCategories = async (userId) => { From 867f846c413cbf16cf687ed76df975eef47a1e0c Mon Sep 17 00:00:00 2001 From: colombefioren Date: Sat, 23 Aug 2025 20:59:45 +0300 Subject: [PATCH 10/25] feat: income routes --- server/routes/income.route.js | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) create mode 100644 server/routes/income.route.js diff --git a/server/routes/income.route.js b/server/routes/income.route.js new file mode 100644 index 0000000..a9c7a13 --- /dev/null +++ b/server/routes/income.route.js @@ -0,0 +1,19 @@ +import { Router } from 'express'; +import { createIncome, getIncomes, getIncome, updateIncome, deleteIncome, getIncomeCategories, createIncomeCategory, updateIncomeCategory, deleteIncomeCategory } from '../controllers/incomeController'; + +const router = Router(); + +//INCOME +router.get('/', getIncomes); +router.get('/:id', getIncome); +router.post('/', createIncome); +router.put('/:id', updateIncome); +router.delete('/:id', deleteIncome); + +//INCOME CATEGORY +router.get('/income-categories/list', getIncomeCategories); +router.post('/income-categories', createIncomeCategory); +router.put('/income-categories/:id', updateIncomeCategory); +router.delete('/income-categories/:id', deleteIncomeCategory); + +export default router; \ No newline at end of file From 87d9b4c76f72e4f59482febd452d95883fdb577d Mon Sep 17 00:00:00 2001 From: colombefioren Date: Sat, 23 Aug 2025 21:04:17 +0300 Subject: [PATCH 11/25] feat: GET /custom-categories endpoint to fetch categories created by the user --- server/controllers/income.controller.js | 5 +++++ server/routes/income.route.js | 5 +++-- server/services/income.service.js | 10 ++++++++++ 3 files changed, 18 insertions(+), 2 deletions(-) diff --git a/server/controllers/income.controller.js b/server/controllers/income.controller.js index ab144fa..6c0357e 100644 --- a/server/controllers/income.controller.js +++ b/server/controllers/income.controller.js @@ -86,6 +86,11 @@ const getIncomeCategories = asyncHandler(async (req, res) => { res.json(categories); }); +const getIncomeCategoriesByUser = asyncHandler(async (req, res) => { + const categories = await incomeService.getIncomeCategoriesByUser(req.user.user_id); + res.json(categories); +}); + const createIncomeCategory = asyncHandler(async (req, res) => { const { category_name, icon_url, icon_emoji } = req.body; diff --git a/server/routes/income.route.js b/server/routes/income.route.js index a9c7a13..bb6395a 100644 --- a/server/routes/income.route.js +++ b/server/routes/income.route.js @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { createIncome, getIncomes, getIncome, updateIncome, deleteIncome, getIncomeCategories, createIncomeCategory, updateIncomeCategory, deleteIncomeCategory } from '../controllers/incomeController'; +import { createIncome, getIncomes, getIncome, updateIncome, deleteIncome, getIncomeCategories, getIncomeCategoriesByUser,createIncomeCategory, updateIncomeCategory, deleteIncomeCategory } from '../controllers/incomeController'; const router = Router(); @@ -11,7 +11,8 @@ router.put('/:id', updateIncome); router.delete('/:id', deleteIncome); //INCOME CATEGORY -router.get('/income-categories/list', getIncomeCategories); +router.get('/income-categories', getIncomeCategories); +router.get('/custom-income-categories', getIncomeCategoriesByUser) router.post('/income-categories', createIncomeCategory); router.put('/income-categories/:id', updateIncomeCategory); router.delete('/income-categories/:id', deleteIncomeCategory); diff --git a/server/services/income.service.js b/server/services/income.service.js index f1a5a65..e3b759a 100644 --- a/server/services/income.service.js +++ b/server/services/income.service.js @@ -82,6 +82,16 @@ const getIncomeCategories = async (userId) => { }); }; +//GET ALL BY USER +const getIncomeCategoriesByUser = async (userId) => { + return await prisma.incomeCategory.findMany({ + where: { + user_id: userId, + }, + orderBy: [{ is_custom: "asc" }, { category_name: "asc" }], + }); +}; + //POST const createIncomeCategory = async (userId, data) => { const existingCategory = await prisma.incomeCategory.findFirst({ From c094fd01c079eb6fab9b7bbc84d8e906ff265ab3 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Sat, 23 Aug 2025 21:27:03 +0300 Subject: [PATCH 12/25] fix: correct income controller exports --- server/controllers/income.controller.js | 36 +++++++++---------------- server/routes/income.route.js | 12 ++++----- server/server.js | 4 +++ server/services/income.service.js | 2 +- 4 files changed, 24 insertions(+), 30 deletions(-) diff --git a/server/controllers/income.controller.js b/server/controllers/income.controller.js index 6c0357e..a2becc9 100644 --- a/server/controllers/income.controller.js +++ b/server/controllers/income.controller.js @@ -1,9 +1,9 @@ -import { incomeService } from "../../services/income/income.service"; -import { asyncHandler } from "../../utils/asyncHandler"; +import incomeService from "../services/income.service.js"; +import { asyncHandler } from "../utils/asyncHandler.js"; //---------------INCOME---------------// -const createIncome = asyncHandler(async (req, res) => { +export const createIncome = asyncHandler(async (req, res) => { const { amount, date, source, description, category_id } = req.body; if (!amount || !date || !source || !category_id) { @@ -21,7 +21,7 @@ const createIncome = asyncHandler(async (req, res) => { res.status(201).json(income); }); -const getIncomes = asyncHandler(async (req, res) => { +export const getIncomes = asyncHandler(async (req, res) => { const { start, end } = req.query; const filters = {}; @@ -32,7 +32,7 @@ const getIncomes = asyncHandler(async (req, res) => { res.json(incomes); }); -const getIncome = asyncHandler(async (req, res) => { +export const getIncome = asyncHandler(async (req, res) => { const { id } = req.params; const income = await incomeService.getIncomeById(id, req.user.user_id); @@ -43,7 +43,7 @@ const getIncome = asyncHandler(async (req, res) => { res.json(income); }); -const updateIncome = asyncHandler(async (req, res) => { +export const updateIncome = asyncHandler(async (req, res) => { const { id } = req.params; const updates = req.body; @@ -68,7 +68,7 @@ const updateIncome = asyncHandler(async (req, res) => { } }); -const deleteIncome = asyncHandler(async (req, res) => { +export const deleteIncome = asyncHandler(async (req, res) => { const { id } = req.params; try { @@ -81,17 +81,17 @@ const deleteIncome = asyncHandler(async (req, res) => { //---------------INCOME CATEGORIES---------------// -const getIncomeCategories = asyncHandler(async (req, res) => { +export const getIncomeCategories = asyncHandler(async (req, res) => { const categories = await incomeService.getIncomeCategories(req.user.user_id); res.json(categories); }); -const getIncomeCategoriesByUser = asyncHandler(async (req, res) => { +export const getIncomeCategoriesByUser = asyncHandler(async (req, res) => { const categories = await incomeService.getIncomeCategoriesByUser(req.user.user_id); res.json(categories); }); -const createIncomeCategory = asyncHandler(async (req, res) => { +export const createIncomeCategory = asyncHandler(async (req, res) => { const { category_name, icon_url, icon_emoji } = req.body; if (!category_name) { @@ -113,7 +113,7 @@ const createIncomeCategory = asyncHandler(async (req, res) => { } }); -const updateIncomeCategory = asyncHandler(async (req, res) => { +export const updateIncomeCategory = asyncHandler(async (req, res) => { const { id } = req.params; const { category_name, icon_url, icon_emoji } = req.body; @@ -133,7 +133,7 @@ const updateIncomeCategory = asyncHandler(async (req, res) => { } }); -const deleteIncomeCategory = asyncHandler(async (req, res) => { +export const deleteIncomeCategory = asyncHandler(async (req, res) => { const { id } = req.params; try { @@ -144,14 +144,4 @@ const deleteIncomeCategory = asyncHandler(async (req, res) => { } }); -export default { - createIncome, - getIncomes, - getIncome, - updateIncome, - deleteIncome, - getIncomeCategories, - createIncomeCategory, - updateIncomeCategory, - deleteIncomeCategory, -}; + diff --git a/server/routes/income.route.js b/server/routes/income.route.js index bb6395a..65c5d3c 100644 --- a/server/routes/income.route.js +++ b/server/routes/income.route.js @@ -1,5 +1,5 @@ import { Router } from 'express'; -import { createIncome, getIncomes, getIncome, updateIncome, deleteIncome, getIncomeCategories, getIncomeCategoriesByUser,createIncomeCategory, updateIncomeCategory, deleteIncomeCategory } from '../controllers/incomeController'; +import { createIncome, getIncomes, getIncome, updateIncome, deleteIncome, getIncomeCategories, getIncomeCategoriesByUser,createIncomeCategory, updateIncomeCategory, deleteIncomeCategory } from '../controllers/income.controller.js'; const router = Router(); @@ -11,10 +11,10 @@ router.put('/:id', updateIncome); router.delete('/:id', deleteIncome); //INCOME CATEGORY -router.get('/income-categories', getIncomeCategories); -router.get('/custom-income-categories', getIncomeCategoriesByUser) -router.post('/income-categories', createIncomeCategory); -router.put('/income-categories/:id', updateIncomeCategory); -router.delete('/income-categories/:id', deleteIncomeCategory); +router.get('/categories', getIncomeCategories); +router.get('/custom-categories', getIncomeCategoriesByUser) +router.post('/categories', createIncomeCategory); +router.put('/categories/:id', updateIncomeCategory); +router.delete('/categories/:id', deleteIncomeCategory); export default router; \ No newline at end of file diff --git a/server/server.js b/server/server.js index dd6bced..89927e1 100644 --- a/server/server.js +++ b/server/server.js @@ -2,6 +2,7 @@ import express from 'express'; import cors from 'cors'; import dotenv from 'dotenv'; import { PrismaClient } from '@prisma/client'; +import incomeRoutes from './routes/income.route.js'; dotenv.config(); @@ -11,6 +12,9 @@ const PORT = process.env.PORT || 8080; app.use(cors()); app.use(express.json()); +//Routes +app.use('/api/incomes', incomeRoutes); + // Initialize a single Prisma client instance const prisma = new PrismaClient(); diff --git a/server/services/income.service.js b/server/services/income.service.js index e3b759a..8efc7b2 100644 --- a/server/services/income.service.js +++ b/server/services/income.service.js @@ -1,4 +1,4 @@ -import { prisma } from "../../db/prisma"; +import { prisma } from "../db/prisma.js"; //---------------INCOME---------------// From 34ce5bb6f61afc747835de8d71957db03cba0937 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Sat, 23 Aug 2025 21:30:51 +0300 Subject: [PATCH 13/25] chore: add temporary middleware to mock user (id: 4) for testing --- server/server.js | 6 ++++++ 1 file changed, 6 insertions(+) diff --git a/server/server.js b/server/server.js index 89927e1..476fb04 100644 --- a/server/server.js +++ b/server/server.js @@ -12,6 +12,12 @@ const PORT = process.env.PORT || 8080; app.use(cors()); app.use(express.json()); +//temporary middleware for testing +app.use((req, res, next) => { + req.user = { user_id: 4 }; // fake user, but you have to create a fake user with id 4 as well in your local database + next(); +}); + //Routes app.use('/api/incomes', incomeRoutes); From 01853eea02b1f6f83c6cf1575d5ed99e972a87d4 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Sat, 23 Aug 2025 21:46:35 +0300 Subject: [PATCH 14/25] fix(server): handle missing date field in income creation --- server/controllers/income.controller.js | 2 +- server/routes/income.route.js | 14 ++++++++------ server/services/income.service.js | 1 + 3 files changed, 10 insertions(+), 7 deletions(-) diff --git a/server/controllers/income.controller.js b/server/controllers/income.controller.js index a2becc9..f6a3213 100644 --- a/server/controllers/income.controller.js +++ b/server/controllers/income.controller.js @@ -6,7 +6,7 @@ import { asyncHandler } from "../utils/asyncHandler.js"; export const createIncome = asyncHandler(async (req, res) => { const { amount, date, source, description, category_id } = req.body; - if (!amount || !date || !source || !category_id) { + if (!amount || !source || !category_id) { return res.status(400).json({ error: "Missing required fields" }); } diff --git a/server/routes/income.route.js b/server/routes/income.route.js index 65c5d3c..bf73523 100644 --- a/server/routes/income.route.js +++ b/server/routes/income.route.js @@ -3,6 +3,13 @@ import { createIncome, getIncomes, getIncome, updateIncome, deleteIncome, getInc const router = Router(); +//INCOME CATEGORY +router.get('/categories', getIncomeCategories); +router.get('/custom-categories', getIncomeCategoriesByUser) +router.post('/categories', createIncomeCategory); +router.put('/categories/:id', updateIncomeCategory); +router.delete('/categories/:id', deleteIncomeCategory); + //INCOME router.get('/', getIncomes); router.get('/:id', getIncome); @@ -10,11 +17,6 @@ router.post('/', createIncome); router.put('/:id', updateIncome); router.delete('/:id', deleteIncome); -//INCOME CATEGORY -router.get('/categories', getIncomeCategories); -router.get('/custom-categories', getIncomeCategoriesByUser) -router.post('/categories', createIncomeCategory); -router.put('/categories/:id', updateIncomeCategory); -router.delete('/categories/:id', deleteIncomeCategory); + export default router; \ No newline at end of file diff --git a/server/services/income.service.js b/server/services/income.service.js index 8efc7b2..10c959e 100644 --- a/server/services/income.service.js +++ b/server/services/income.service.js @@ -175,6 +175,7 @@ export default { deleteIncome, getIncomeCategories, createIncomeCategory, + getIncomeCategoriesByUser, updateIncomeCategory, deleteIncomeCategory, }; From b2634856f0c4e6876f0157007c02e09da055df44 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Sat, 23 Aug 2025 21:51:35 +0300 Subject: [PATCH 15/25] fix(server): resolve invalid date type error --- server/controllers/income.controller.js | 19 +++++++++++++------ 1 file changed, 13 insertions(+), 6 deletions(-) diff --git a/server/controllers/income.controller.js b/server/controllers/income.controller.js index f6a3213..2b8dac4 100644 --- a/server/controllers/income.controller.js +++ b/server/controllers/income.controller.js @@ -10,14 +10,21 @@ export const createIncome = asyncHandler(async (req, res) => { return res.status(400).json({ error: "Missing required fields" }); } - const income = await incomeService.createIncome(req.user.user_id, { + const incomeData = { amount: parseFloat(amount), - date: new Date(date), source, description, category: { connect: { category_id: parseInt(category_id) } }, - }); + }; + if (date) { + const parsedDate = new Date(date); + if (!isNaN(parsedDate.getTime())) { + incomeData.date = parsedDate; + } + } + + const income = await incomeService.createIncome(req.user.user_id, incomeData); res.status(201).json(income); }); @@ -87,7 +94,9 @@ export const getIncomeCategories = asyncHandler(async (req, res) => { }); export const getIncomeCategoriesByUser = asyncHandler(async (req, res) => { - const categories = await incomeService.getIncomeCategoriesByUser(req.user.user_id); + const categories = await incomeService.getIncomeCategoriesByUser( + req.user.user_id + ); res.json(categories); }); @@ -143,5 +152,3 @@ export const deleteIncomeCategory = asyncHandler(async (req, res) => { res.status(400).json({ error: error.message }); } }); - - From 962ea93cbe810d25c135329d68838a98c47f786d Mon Sep 17 00:00:00 2001 From: colombefioren Date: Sat, 23 Aug 2025 22:53:59 +0300 Subject: [PATCH 16/25] chore(api): make income.date optional and require category_id in OpenAPI spec --- client/src/types/Income.ts | 2 +- server/controllers/income.controller.js | 6 +- server/docs/Expense Tracker API.yaml | 126 +++++++++++++++++------- 3 files changed, 97 insertions(+), 37 deletions(-) diff --git a/client/src/types/Income.ts b/client/src/types/Income.ts index 14aeec4..dcc94db 100644 --- a/client/src/types/Income.ts +++ b/client/src/types/Income.ts @@ -22,7 +22,7 @@ export interface IncomeCategory { export interface CreateIncomeRequest { amount: number; - date: string; + date?: string; source: string; description?: string; category_id: number; diff --git a/server/controllers/income.controller.js b/server/controllers/income.controller.js index 2b8dac4..f7cf0c9 100644 --- a/server/controllers/income.controller.js +++ b/server/controllers/income.controller.js @@ -6,14 +6,14 @@ import { asyncHandler } from "../utils/asyncHandler.js"; export const createIncome = asyncHandler(async (req, res) => { const { amount, date, source, description, category_id } = req.body; - if (!amount || !source || !category_id) { + if (!amount || !category_id) { return res.status(400).json({ error: "Missing required fields" }); } const incomeData = { amount: parseFloat(amount), - source, - description, + source: source || "", + description: description || "", category: { connect: { category_id: parseInt(category_id) } }, }; diff --git a/server/docs/Expense Tracker API.yaml b/server/docs/Expense Tracker API.yaml index 10fe973..069b93d 100644 --- a/server/docs/Expense Tracker API.yaml +++ b/server/docs/Expense Tracker API.yaml @@ -22,7 +22,7 @@ paths: password: type: string responses: - '201': + "201": description: User created /auth/login: post: @@ -40,7 +40,7 @@ paths: password: type: string responses: - '200': + "200": description: Token returned /expenses: get: @@ -66,7 +66,7 @@ paths: type: string enum: [recurring, one-time] responses: - '200': + "200": description: List of expenses post: summary: Create a new expense @@ -96,19 +96,19 @@ paths: type: string format: binary responses: - '201': + "201": description: Expense created /expenses/{id}: parameters: - - in: path - name: id - required: true - schema: - type: string + - in: path + name: id + required: true + schema: + type: string get: summary: Get a single expense responses: - '200': + "200": description: Expense data put: summary: Update an expense @@ -137,13 +137,71 @@ paths: type: string format: binary responses: - '200': + "200": description: Expense updated delete: summary: Delete an expense responses: - '204': + "204": description: Expense deleted + /incomes/categories: + get: + summary: Get system income categories + responses: + "200": + description: List of system income categories" + /incomes/custom-categories: + get: + summary: Get user's custom income categories + responses: + "200": + description: List of user's custom income categories + post: + summary: Create a new custom income category + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [category_name] + properties: + category_name: + type: string + icon_url: + type: string + responses: + "201": + description: Income category created + /incomes/categories/{id}: + parameters: + - in: path + name: id + required: true + schema: + type: string + put: + summary: Update an income category + requestBody: + required: true + content: + application/json: + schema: + type: object + required: [category_name] + properties: + category_name: + type: string + icon_url: + type: string + responses: + "200": + description: Income category updated + delete: + summary: Delete an income category + responses: + "204": + description: Income category deleted /incomes: get: summary: List all incomes @@ -157,7 +215,7 @@ paths: schema: type: string responses: - '200': + "200": description: List of incomes post: summary: Create new income @@ -166,7 +224,7 @@ paths: application/json: schema: type: object - required: [amount, date] + required: [amount, category_id] properties: amount: type: number @@ -176,20 +234,22 @@ paths: type: string description: type: string + category_id: + type: number responses: - '201': + "201": description: Income created /incomes/{id}: parameters: - - in: path - name: id - required: true - schema: - type: string + - in: path + name: id + required: true + schema: + type: string get: summary: Get an income entry responses: - '200': + "200": description: Income data put: summary: Update an income entry @@ -208,18 +268,18 @@ paths: description: type: string responses: - '200': + "200": description: Income updated delete: summary: Delete an income entry responses: - '204': + "204": description: Income deleted /categories: get: summary: List user categories responses: - '200': + "200": description: List of categories post: summary: Create new category @@ -233,7 +293,7 @@ paths: name: type: string responses: - '201': + "201": description: Category created /categories/{id}: put: @@ -253,7 +313,7 @@ paths: name: type: string responses: - '200': + "200": description: Category updated delete: summary: Delete a category @@ -264,7 +324,7 @@ paths: schema: type: string responses: - '204': + "204": description: Category deleted /summary/monthly: get: @@ -275,7 +335,7 @@ paths: schema: type: string responses: - '200': + "200": description: Monthly summary /summary: get: @@ -290,13 +350,13 @@ paths: schema: type: string responses: - '200': + "200": description: Summary /summary/alerts: get: summary: Budget overrun alert responses: - '200': + "200": description: Alert info /receipts/{id}: get: @@ -308,11 +368,11 @@ paths: schema: type: string responses: - '200': + "200": description: Receipt file /user/profile: get: summary: Get user profile responses: - '200': - description: Profile info \ No newline at end of file + "200": + description: Profile info From ea517fa9f91e9f57b48256a796fbc094d73ef00c Mon Sep 17 00:00:00 2001 From: colombefioren Date: Sat, 23 Aug 2025 23:09:23 +0300 Subject: [PATCH 17/25] feat(client): implement income service --- client/src/api/core/ApiError.ts | 2 +- client/src/api/core/ApiRequestOptions.ts | 2 +- client/src/api/core/ApiResult.ts | 2 +- client/src/api/core/CancelablePromise.ts | 2 +- client/src/api/core/OpenAPI.ts | 2 +- client/src/api/core/request.ts | 2 +- client/src/api/services/DefaultService.ts | 899 ++++++++++++---------- client/src/services/IncomeService.ts | 137 ++++ 8 files changed, 634 insertions(+), 414 deletions(-) create mode 100644 client/src/services/IncomeService.ts diff --git a/client/src/api/core/ApiError.ts b/client/src/api/core/ApiError.ts index ec7b16a..df9f43e 100644 --- a/client/src/api/core/ApiError.ts +++ b/client/src/api/core/ApiError.ts @@ -22,4 +22,4 @@ export class ApiError extends Error { this.body = response.body; this.request = request; } -} +} diff --git a/client/src/api/core/ApiRequestOptions.ts b/client/src/api/core/ApiRequestOptions.ts index 93143c3..a93ee2a 100644 --- a/client/src/api/core/ApiRequestOptions.ts +++ b/client/src/api/core/ApiRequestOptions.ts @@ -14,4 +14,4 @@ export type ApiRequestOptions = { readonly mediaType?: string; readonly responseHeader?: string; readonly errors?: Record; -}; +}; diff --git a/client/src/api/core/ApiResult.ts b/client/src/api/core/ApiResult.ts index ee1126e..e23d6c8 100644 --- a/client/src/api/core/ApiResult.ts +++ b/client/src/api/core/ApiResult.ts @@ -8,4 +8,4 @@ export type ApiResult = { readonly status: number; readonly statusText: string; readonly body: any; -}; +}; diff --git a/client/src/api/core/CancelablePromise.ts b/client/src/api/core/CancelablePromise.ts index d70de92..9ee9569 100644 --- a/client/src/api/core/CancelablePromise.ts +++ b/client/src/api/core/CancelablePromise.ts @@ -128,4 +128,4 @@ export class CancelablePromise implements Promise { public get isCancelled(): boolean { return this.#isCancelled; } -} +} diff --git a/client/src/api/core/OpenAPI.ts b/client/src/api/core/OpenAPI.ts index 03da729..77fa96a 100644 --- a/client/src/api/core/OpenAPI.ts +++ b/client/src/api/core/OpenAPI.ts @@ -29,4 +29,4 @@ export const OpenAPI: OpenAPIConfig = { PASSWORD: undefined, HEADERS: undefined, ENCODE_PATH: undefined, -}; +}; diff --git a/client/src/api/core/request.ts b/client/src/api/core/request.ts index 1dc6fef..319b00a 100644 --- a/client/src/api/core/request.ts +++ b/client/src/api/core/request.ts @@ -320,4 +320,4 @@ export const request = (config: OpenAPIConfig, options: ApiRequestOptions, ax reject(error); } }); -}; +}; diff --git a/client/src/api/services/DefaultService.ts b/client/src/api/services/DefaultService.ts index 50ff9f3..e868a20 100644 --- a/client/src/api/services/DefaultService.ts +++ b/client/src/api/services/DefaultService.ts @@ -1,408 +1,491 @@ -/* generated using openapi-typescript-codegen -- do not edit */ -/* istanbul ignore file */ -/* tslint:disable */ -/* eslint-disable */ -import type { CancelablePromise } from '../core/CancelablePromise'; -import { OpenAPI } from '../core/OpenAPI'; -import { request as __request } from '../core/request'; -export class DefaultService { - /** - * Register a new user - * @param requestBody - * @returns any User created - * @throws ApiError - */ - public static postAuthSignup( - requestBody: { - email: string; - password: string; - }, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/auth/signup', - body: requestBody, - mediaType: 'application/json', - }); - } - /** - * Login and receive JWT token - * @param requestBody - * @returns any Token returned - * @throws ApiError - */ - public static postAuthLogin( - requestBody: { - email: string; - password: string; - }, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/auth/login', - body: requestBody, - mediaType: 'application/json', - }); - } - /** - * List all user expenses - * @param start Start date - * @param end End date - * @param category - * @param type - * @returns any List of expenses - * @throws ApiError - */ - public static getExpenses( - start?: string, - end?: string, - category?: string, - type?: 'recurring' | 'one-time', - ): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/expenses', - query: { - 'start': start, - 'end': end, - 'category': category, - 'type': type, - }, - }); - } - /** - * Create a new expense - * @param formData - * @returns any Expense created - * @throws ApiError - */ - public static postExpenses( - formData?: { - amount: number; - date: string; - categoryId: string; - description?: string; - type?: 'one-time' | 'recurring'; - startDate?: string; - endDate?: string; - receipt?: Blob; - }, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/expenses', - formData: formData, - mediaType: 'multipart/form-data', - }); - } - /** - * Get a single expense - * @param id - * @returns any Expense data - * @throws ApiError - */ - public static getExpenses1( - id: string, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/expenses/{id}', - path: { - 'id': id, - }, - }); - } - /** - * Update an expense - * @param id - * @param formData - * @returns any Expense updated - * @throws ApiError - */ - public static putExpenses( - id: string, - formData?: { - amount?: number; - date?: string; - categoryId?: string; - description?: string; - type?: 'one-time' | 'recurring'; - startDate?: string; - endDate?: string; - receipt?: Blob; - }, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'PUT', - url: '/expenses/{id}', - path: { - 'id': id, - }, - formData: formData, - mediaType: 'multipart/form-data', - }); - } - /** - * Delete an expense - * @param id - * @returns void - * @throws ApiError - */ - public static deleteExpenses( - id: string, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'DELETE', - url: '/expenses/{id}', - path: { - 'id': id, - }, - }); - } - /** - * List all incomes - * @param start - * @param end - * @returns any List of incomes - * @throws ApiError - */ - public static getIncomes( - start?: string, - end?: string, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/incomes', - query: { - 'start': start, - 'end': end, - }, - }); - } - /** - * Create new income - * @param requestBody - * @returns any Income created - * @throws ApiError - */ - public static postIncomes( - requestBody?: { - amount: number; - date: string; - source?: string; - description?: string; - }, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/incomes', - body: requestBody, - mediaType: 'application/json', - }); - } - /** - * Get an income entry - * @param id - * @returns any Income data - * @throws ApiError - */ - public static getIncomes1( - id: string, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/incomes/{id}', - path: { - 'id': id, - }, - }); - } - /** - * Update an income entry - * @param id - * @param requestBody - * @returns any Income updated - * @throws ApiError - */ - public static putIncomes( - id: string, - requestBody?: { - amount?: number; - date?: string; - source?: string; - description?: string; - }, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'PUT', - url: '/incomes/{id}', - path: { - 'id': id, - }, - body: requestBody, - mediaType: 'application/json', - }); - } - /** - * Delete an income entry - * @param id - * @returns void - * @throws ApiError - */ - public static deleteIncomes( - id: string, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'DELETE', - url: '/incomes/{id}', - path: { - 'id': id, - }, - }); - } - /** - * List user categories - * @returns any List of categories - * @throws ApiError - */ - public static getCategories(): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/categories', - }); - } - /** - * Create new category - * @param requestBody - * @returns any Category created - * @throws ApiError - */ - public static postCategories( - requestBody?: { - name: string; - }, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'POST', - url: '/categories', - body: requestBody, - mediaType: 'application/json', - }); - } - /** - * Rename a category - * @param id - * @param requestBody - * @returns any Category updated - * @throws ApiError - */ - public static putCategories( - id: string, - requestBody?: { - name?: string; - }, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'PUT', - url: '/categories/{id}', - path: { - 'id': id, - }, - body: requestBody, - mediaType: 'application/json', - }); - } - /** - * Delete a category - * @param id - * @returns void - * @throws ApiError - */ - public static deleteCategories( - id: string, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'DELETE', - url: '/categories/{id}', - path: { - 'id': id, - }, - }); - } - /** - * Get current month summary - * @param month - * @returns any Monthly summary - * @throws ApiError - */ - public static getSummaryMonthly( - month?: string, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/summary/monthly', - query: { - 'month': month, - }, - }); - } - /** - * Get summary for custom range - * @param start - * @param end - * @returns any Summary - * @throws ApiError - */ - public static getSummary( - start?: string, - end?: string, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/summary', - query: { - 'start': start, - 'end': end, - }, - }); - } - /** - * Budget overrun alert - * @returns any Alert info - * @throws ApiError - */ - public static getSummaryAlerts(): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/summary/alerts', - }); - } - /** - * Download/view receipt - * @param id - * @returns any Receipt file - * @throws ApiError - */ - public static getReceipts( - id: string, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/receipts/{id}', - path: { - 'id': id, - }, - }); - } - /** - * Get user profile - * @returns any Profile info - * @throws ApiError - */ - public static getUserProfile(): CancelablePromise { - return __request(OpenAPI, { - method: 'GET', - url: '/user/profile', - }); - } -} +/* generated using openapi-typescript-codegen -- do not edit */ +/* istanbul ignore file */ +/* tslint:disable */ +/* eslint-disable */ +import type { CancelablePromise } from '../core/CancelablePromise'; +import { OpenAPI } from '../core/OpenAPI'; +import { request as __request } from '../core/request'; +export class DefaultService { + /** + * Register a new user + * @param requestBody + * @returns any User created + * @throws ApiError + */ + public static postAuthSignup( + requestBody: { + email: string; + password: string; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/auth/signup', + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Login and receive JWT token + * @param requestBody + * @returns any Token returned + * @throws ApiError + */ + public static postAuthLogin( + requestBody: { + email: string; + password: string; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/auth/login', + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * List all user expenses + * @param start Start date + * @param end End date + * @param category + * @param type + * @returns any List of expenses + * @throws ApiError + */ + public static getExpenses( + start?: string, + end?: string, + category?: string, + type?: 'recurring' | 'one-time', + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/expenses', + query: { + 'start': start, + 'end': end, + 'category': category, + 'type': type, + }, + }); + } + /** + * Create a new expense + * @param formData + * @returns any Expense created + * @throws ApiError + */ + public static postExpenses( + formData?: { + amount: number; + date: string; + categoryId: string; + description?: string; + type?: 'one-time' | 'recurring'; + startDate?: string; + endDate?: string; + receipt?: Blob; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/expenses', + formData: formData, + mediaType: 'multipart/form-data', + }); + } + /** + * Get a single expense + * @param id + * @returns any Expense data + * @throws ApiError + */ + public static getExpenses1( + id: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/expenses/{id}', + path: { + 'id': id, + }, + }); + } + /** + * Update an expense + * @param id + * @param formData + * @returns any Expense updated + * @throws ApiError + */ + public static putExpenses( + id: string, + formData?: { + amount?: number; + date?: string; + categoryId?: string; + description?: string; + type?: 'one-time' | 'recurring'; + startDate?: string; + endDate?: string; + receipt?: Blob; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/expenses/{id}', + path: { + 'id': id, + }, + formData: formData, + mediaType: 'multipart/form-data', + }); + } + /** + * Delete an expense + * @param id + * @returns void + * @throws ApiError + */ + public static deleteExpenses( + id: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/expenses/{id}', + path: { + 'id': id, + }, + }); + } + /** + * Get system income categories + * @returns any List of system income categories" + * @throws ApiError + */ + public static getIncomesCategories(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/incomes/categories', + }); + } + /** + * Get user's custom income categories + * @returns any List of user's custom income categories + * @throws ApiError + */ + public static getIncomesCustomCategories(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/incomes/custom-categories', + }); + } + /** + * Create a new custom income category + * @param requestBody + * @returns any Income category created + * @throws ApiError + */ + public static postIncomesCustomCategories( + requestBody: { + category_name: string; + icon_url?: string; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/incomes/custom-categories', + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Update an income category + * @param id + * @param requestBody + * @returns any Income category updated + * @throws ApiError + */ + public static putIncomesCategories( + id: string, + requestBody: { + category_name: string; + icon_url?: string; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/incomes/categories/{id}', + path: { + 'id': id, + }, + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Delete an income category + * @param id + * @returns void + * @throws ApiError + */ + public static deleteIncomesCategories( + id: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/incomes/categories/{id}', + path: { + 'id': id, + }, + }); + } + /** + * List all incomes + * @param start + * @param end + * @returns any List of incomes + * @throws ApiError + */ + public static getIncomes( + start?: string, + end?: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/incomes', + query: { + 'start': start, + 'end': end, + }, + }); + } + /** + * Create new income + * @param requestBody + * @returns any Income created + * @throws ApiError + */ + public static postIncomes( + requestBody?: { + amount: number; + date?: string; + source?: string; + description?: string; + category_id: number; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/incomes', + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Get an income entry + * @param id + * @returns any Income data + * @throws ApiError + */ + public static getIncomes1( + id: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/incomes/{id}', + path: { + 'id': id, + }, + }); + } + /** + * Update an income entry + * @param id + * @param requestBody + * @returns any Income updated + * @throws ApiError + */ + public static putIncomes( + id: string, + requestBody?: { + amount?: number; + date?: string; + source?: string; + description?: string; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/incomes/{id}', + path: { + 'id': id, + }, + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Delete an income entry + * @param id + * @returns void + * @throws ApiError + */ + public static deleteIncomes( + id: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/incomes/{id}', + path: { + 'id': id, + }, + }); + } + /** + * List user categories + * @returns any List of categories + * @throws ApiError + */ + public static getCategories(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/categories', + }); + } + /** + * Create new category + * @param requestBody + * @returns any Category created + * @throws ApiError + */ + public static postCategories( + requestBody?: { + name: string; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'POST', + url: '/categories', + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Rename a category + * @param id + * @param requestBody + * @returns any Category updated + * @throws ApiError + */ + public static putCategories( + id: string, + requestBody?: { + name?: string; + }, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'PUT', + url: '/categories/{id}', + path: { + 'id': id, + }, + body: requestBody, + mediaType: 'application/json', + }); + } + /** + * Delete a category + * @param id + * @returns void + * @throws ApiError + */ + public static deleteCategories( + id: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'DELETE', + url: '/categories/{id}', + path: { + 'id': id, + }, + }); + } + /** + * Get current month summary + * @param month + * @returns any Monthly summary + * @throws ApiError + */ + public static getSummaryMonthly( + month?: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/summary/monthly', + query: { + 'month': month, + }, + }); + } + /** + * Get summary for custom range + * @param start + * @param end + * @returns any Summary + * @throws ApiError + */ + public static getSummary( + start?: string, + end?: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/summary', + query: { + 'start': start, + 'end': end, + }, + }); + } + /** + * Budget overrun alert + * @returns any Alert info + * @throws ApiError + */ + public static getSummaryAlerts(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/summary/alerts', + }); + } + /** + * Download/view receipt + * @param id + * @returns any Receipt file + * @throws ApiError + */ + public static getReceipts( + id: string, + ): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/receipts/{id}', + path: { + 'id': id, + }, + }); + } + /** + * Get user profile + * @returns any Profile info + * @throws ApiError + */ + public static getUserProfile(): CancelablePromise { + return __request(OpenAPI, { + method: 'GET', + url: '/user/profile', + }); + } +} diff --git a/client/src/services/IncomeService.ts b/client/src/services/IncomeService.ts new file mode 100644 index 0000000..f73d6ea --- /dev/null +++ b/client/src/services/IncomeService.ts @@ -0,0 +1,137 @@ +import { DefaultService } from "../api/services/DefaultService"; +import type { + Income, + IncomeCategory, + CreateIncomeRequest, + UpdateIncomeRequest, +} from "../types/Income"; + +export class IncomeService { + //GET all incomes with optional filtering + static async getIncomes(start?: string, end?: string): Promise { + try { + const response = await DefaultService.getIncomes(start, end); + return response as Income[]; + } catch (error) { + console.error("Error fetching incomes:", error); + throw new Error("Failed to fetch incomes"); + } + } + + //GET income by id + static async getIncomeById(id: string): Promise { + try { + const response = await DefaultService.getIncomes1(id); + return response as Income; + } catch (error) { + console.error(`Error fetching income ${id}:`, error); + throw new Error("Failed to fetch income"); + } + } + + //POST income + static async createIncome(incomeData: CreateIncomeRequest): Promise { + try { + const requestData = { + ...incomeData, + amount: Number(incomeData.amount), + }; + + if (incomeData.date) { + requestData.date = incomeData.date; + } + const response = await DefaultService.postIncomes(requestData); + return response as Income; + } catch (error) { + console.error("Error creating income:", error); + throw new Error("Failed to create income"); + } + } + + //UPDATE income + static async updateIncome( + id: string, + incomeData: UpdateIncomeRequest + ): Promise { + try { + const requestData = { ...incomeData }; + if (incomeData.amount !== undefined) { + requestData.amount = Number(incomeData.amount); + } + + const response = await DefaultService.putIncomes(id, requestData); + return response as Income; + } catch (error) { + console.error(`Error updating income ${id}:`, error); + throw new Error("Failed to update income"); + } + } + + //DELETE income + static async deleteIncome(id: string): Promise { + try { + await DefaultService.deleteIncomes(id); + } catch (error) { + console.error(`Error deleting income ${id}:`, error); + throw new Error("Failed to delete income"); + } + } + + //GET ALL income categories (custom and system) + static async getIncomeCategories(): Promise { + try { + const response = await DefaultService.getCategories(); + + return response as IncomeCategory[]; + } catch (error) { + console.error("Error fetching income categories:", error); + throw new Error("Failed to fetch income categories"); + } + } + + //GET custom categories + static async getCustomIncomeCategories(): Promise { + try { + const allCategories = await this.getIncomeCategories(); + return allCategories.filter((category) => category.is_custom); + } catch (error) { + console.error("Error fetching custom income categories:", error); + throw new Error("Failed to fetch custom income categories"); + } + } + + //POST new category + static async createIncomeCategory(name: string): Promise { + try { + const response = await DefaultService.postCategories({ name }); + return response as IncomeCategory; + } catch (error) { + console.error("Error creating income category:", error); + throw new Error("Failed to create income category"); + } + } + + //UPDATE a category + static async updateIncomeCategory( + id: string, + name: string + ): Promise { + try { + const response = await DefaultService.putCategories(id, { name }); + return response as IncomeCategory; + } catch (error) { + console.error(`Error updating income category ${id}:`, error); + throw new Error("Failed to update income category"); + } + } + + //DELETE category + static async deleteIncomeCategory(id: string): Promise { + try { + await DefaultService.deleteCategories(id); + } catch (error) { + console.error(`Error deleting income category ${id}:`, error); + throw new Error("Failed to delete income category"); + } + } +} From 825f96dd38da306be9f59670c1b9d8d0fe7264ba Mon Sep 17 00:00:00 2001 From: colombefioren Date: Sat, 23 Aug 2025 23:14:23 +0300 Subject: [PATCH 18/25] feat(client): custom react hooks for income data --- client/src/hooks/useIncomeCategories.ts | 45 +++++++++++++++++++++++ client/src/hooks/useIncomes.ts | 47 +++++++++++++++++++++++++ 2 files changed, 92 insertions(+) create mode 100644 client/src/hooks/useIncomeCategories.ts create mode 100644 client/src/hooks/useIncomes.ts diff --git a/client/src/hooks/useIncomeCategories.ts b/client/src/hooks/useIncomeCategories.ts new file mode 100644 index 0000000..7684f7e --- /dev/null +++ b/client/src/hooks/useIncomeCategories.ts @@ -0,0 +1,45 @@ +import type { IncomeCategory } from "../types/Income"; +import { IncomeService } from "../services/IncomeService"; +import { useState, useEffect } from "react"; + +export const useIncomeCategories = () => { + const [categories, setCategories] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchCategories = async () => { + try { + setLoading(true); + setError(null); + const data = await IncomeService.getIncomeCategories(); + setCategories(data); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to fetch categories" + ); + } finally { + setLoading(false); + } + }; + + fetchCategories(); + }, []); + + const refetch = async () => { + try { + setLoading(true); + setError(null); + const data = await IncomeService.getIncomeCategories(); + setCategories(data); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to refetch categories" + ); + } finally { + setLoading(false); + } + }; + + return { categories, loading, error, refetch }; +}; diff --git a/client/src/hooks/useIncomes.ts b/client/src/hooks/useIncomes.ts new file mode 100644 index 0000000..fa86774 --- /dev/null +++ b/client/src/hooks/useIncomes.ts @@ -0,0 +1,47 @@ +import { useState, useEffect } from "react"; +import type { Income } from "../types/Income"; +import { IncomeService } from "../services/IncomeService"; + +export const useIncomes = (startDate?: string, endDate?: string) => { + const [incomes, setIncomes] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + useEffect(() => { + const fetchIncomes = async () => { + try { + setLoading(true); + setError(null); + const data = await IncomeService.getIncomes(startDate, endDate); + setIncomes(data); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to fetch incomes" + ); + } finally { + setLoading(false); + } + }; + + fetchIncomes(); + }, [startDate, endDate]); + + const refetch = async () => { + try { + setLoading(true); + setError(null); + const data = await IncomeService.getIncomes(startDate, endDate); + setIncomes(data); + } catch (err) { + setError( + err instanceof Error ? err.message : "Failed to refetch incomes" + ); + } finally { + setLoading(false); + } + }; + + return { incomes, loading, error, refetch }; +}; + + From 66b57773cca6b730cce05ebce4fe59a581c5e815 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Sun, 24 Aug 2025 09:14:52 +0300 Subject: [PATCH 19/25] feat: react-router-dom and setup initial routes for home and incomes --- client/package-lock.json | 54 ++++++++++++++++++++++++++++ client/package.json | 11 +++--- client/src/App.tsx | 40 ++++++--------------- client/src/main.tsx | 6 ++-- client/src/services/IncomeService.ts | 21 +++++------ 5 files changed, 82 insertions(+), 50 deletions(-) diff --git a/client/package-lock.json b/client/package-lock.json index 59efc7a..93548ee 100644 --- a/client/package-lock.json +++ b/client/package-lock.json @@ -15,6 +15,7 @@ "axios": "^1.7.7", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-router-dom": "^7.8.2", "tailwindcss": "^4.1.11" }, "devDependencies": { @@ -3293,6 +3294,15 @@ "dev": true, "license": "MIT" }, + "node_modules/cookie": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz", + "integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==", + "license": "MIT", + "engines": { + "node": ">=18" + } + }, "node_modules/cross-spawn": { "version": "7.0.6", "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", @@ -5535,6 +5545,44 @@ "node": ">=0.10.0" } }, + "node_modules/react-router": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.8.2.tgz", + "integrity": "sha512-7M2fR1JbIZ/jFWqelpvSZx+7vd7UlBTfdZqf6OSdF9g6+sfdqJDAWcak6ervbHph200ePlu+7G8LdoiC3ReyAQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-router-dom": { + "version": "7.8.2", + "resolved": "https://registry.npmjs.org/react-router-dom/-/react-router-dom-7.8.2.tgz", + "integrity": "sha512-Z4VM5mKDipal2jQ385H6UBhiiEDlnJPx6jyWsTYoZQdl5TrjxEV2a9yl3Fi60NBJxYzOTGTTHXPi0pdizvTwow==", + "license": "MIT", + "dependencies": { + "react-router": "7.8.2" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + } + }, "node_modules/recast": { "version": "0.23.11", "resolved": "https://registry.npmjs.org/recast/-/recast-0.23.11.tgz", @@ -5700,6 +5748,12 @@ "semver": "bin/semver.js" } }, + "node_modules/set-cookie-parser": { + "version": "2.7.1", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.1.tgz", + "integrity": "sha512-IOc8uWeOZgnb3ptbCURJWNjWUPcO3ZnTTdzsurqERrP6nPyv+paC55vJM0LpOlT2ne+Ix+9+CRG1MNLlyZ4GjQ==", + "license": "MIT" + }, "node_modules/shebang-command": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", diff --git a/client/package.json b/client/package.json index 7edf5c5..81f59b7 100644 --- a/client/package.json +++ b/client/package.json @@ -20,6 +20,7 @@ "axios": "^1.7.7", "react": "^19.1.1", "react-dom": "^19.1.1", + "react-router-dom": "^7.8.2", "tailwindcss": "^4.1.11" }, "devDependencies": { @@ -33,19 +34,19 @@ "@types/react": "^19.1.9", "@types/react-dom": "^19.1.7", "@vitejs/plugin-react": "^4.7.0", + "@vitest/browser": "^3.2.4", + "@vitest/coverage-v8": "^3.2.4", "eslint": "^9.32.0", "eslint-plugin-react-hooks": "^5.2.0", "eslint-plugin-react-refresh": "^0.4.20", "eslint-plugin-storybook": "^9.1.2", "globals": "^16.3.0", + "openapi-typescript-codegen": "^0.29.0", + "playwright": "^1.54.2", "storybook": "^9.1.2", "typescript": "~5.8.3", "typescript-eslint": "^8.39.0", "vite": "^7.1.0", - "vitest": "^3.2.4", - "@vitest/browser": "^3.2.4", - "playwright": "^1.54.2", - "@vitest/coverage-v8": "^3.2.4", - "openapi-typescript-codegen": "^0.29.0" + "vitest": "^3.2.4" } } diff --git a/client/src/App.tsx b/client/src/App.tsx index eef9bad..a914da7 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,39 +1,19 @@ -import { useState } from 'react' -import reactLogo from './assets/react.svg' -import viteLogo from '/vite.svg' -import './App.css' -import { SpeedInsights } from '@vercel/speed-insights/react' -import { Analytics } from '@vercel/analytics/react' +import "./App.css"; +import { SpeedInsights } from "@vercel/speed-insights/react"; +import { Analytics } from "@vercel/analytics/react"; +import { Route, Routes } from "react-router-dom"; function App() { - const [count, setCount] = useState(0) - return ( <> - -

Vite + React

-
- -

- Edit src/App.tsx and save to test HMR -

-
-

- Click on the Vite and React logos to learn more -

+ + Income Page} /> + Heyyyy there} /> + - ) + ); } -export default App +export default App; diff --git a/client/src/main.tsx b/client/src/main.tsx index bef5202..c3cbdd0 100644 --- a/client/src/main.tsx +++ b/client/src/main.tsx @@ -1,10 +1,10 @@ -import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' import './index.css' import App from './App.tsx' +import { BrowserRouter } from 'react-router-dom' createRoot(document.getElementById('root')!).render( - + - , + , ) diff --git a/client/src/services/IncomeService.ts b/client/src/services/IncomeService.ts index f73d6ea..fe476f7 100644 --- a/client/src/services/IncomeService.ts +++ b/client/src/services/IncomeService.ts @@ -8,7 +8,7 @@ import type { export class IncomeService { //GET all incomes with optional filtering - static async getIncomes(start?: string, end?: string): Promise { + static async getIncomes(start?: string, end?: string) { try { const response = await DefaultService.getIncomes(start, end); return response as Income[]; @@ -19,7 +19,7 @@ export class IncomeService { } //GET income by id - static async getIncomeById(id: string): Promise { + static async getIncomeById(id: string) { try { const response = await DefaultService.getIncomes1(id); return response as Income; @@ -30,7 +30,7 @@ export class IncomeService { } //POST income - static async createIncome(incomeData: CreateIncomeRequest): Promise { + static async createIncome(incomeData: CreateIncomeRequest) { try { const requestData = { ...incomeData, @@ -49,10 +49,7 @@ export class IncomeService { } //UPDATE income - static async updateIncome( - id: string, - incomeData: UpdateIncomeRequest - ): Promise { + static async updateIncome(id: string, incomeData: UpdateIncomeRequest) { try { const requestData = { ...incomeData }; if (incomeData.amount !== undefined) { @@ -68,7 +65,7 @@ export class IncomeService { } //DELETE income - static async deleteIncome(id: string): Promise { + static async deleteIncome(id: string) { try { await DefaultService.deleteIncomes(id); } catch (error) { @@ -78,7 +75,7 @@ export class IncomeService { } //GET ALL income categories (custom and system) - static async getIncomeCategories(): Promise { + static async getIncomeCategories() { try { const response = await DefaultService.getCategories(); @@ -90,7 +87,7 @@ export class IncomeService { } //GET custom categories - static async getCustomIncomeCategories(): Promise { + static async getCustomIncomeCategories() { try { const allCategories = await this.getIncomeCategories(); return allCategories.filter((category) => category.is_custom); @@ -101,7 +98,7 @@ export class IncomeService { } //POST new category - static async createIncomeCategory(name: string): Promise { + static async createIncomeCategory(name: string) { try { const response = await DefaultService.postCategories({ name }); return response as IncomeCategory; @@ -126,7 +123,7 @@ export class IncomeService { } //DELETE category - static async deleteIncomeCategory(id: string): Promise { + static async deleteIncomeCategory(id: string) { try { await DefaultService.deleteCategories(id); } catch (error) { From 59b4a60637f424665c09d13e11fcc1e505e49f09 Mon Sep 17 00:00:00 2001 From: colombefioren Date: Sun, 24 Aug 2025 10:03:54 +0300 Subject: [PATCH 20/25] feat(client): income component and incomes page with full functionality --- client/src/App.tsx | 28 +++-- client/src/components/IncomeForm.tsx | 167 +++++++++++++++++++++++++++ client/src/components/IncomeList.tsx | 107 +++++++++++++++++ client/src/pages/IncomesPage.tsx | 121 +++++++++++++++++++ client/src/services/IncomeService.ts | 10 +- 5 files changed, 416 insertions(+), 17 deletions(-) create mode 100644 client/src/components/IncomeForm.tsx create mode 100644 client/src/components/IncomeList.tsx create mode 100644 client/src/pages/IncomesPage.tsx diff --git a/client/src/App.tsx b/client/src/App.tsx index a914da7..f378eb3 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,18 +1,22 @@ -import "./App.css"; -import { SpeedInsights } from "@vercel/speed-insights/react"; -import { Analytics } from "@vercel/analytics/react"; -import { Route, Routes } from "react-router-dom"; +import { Routes, Route, Navigate } from "react-router-dom"; +import { ToastProvider } from "./ui"; +import { IncomesPage } from "./pages/IncomesPage"; function App() { return ( - <> - - - - Income Page} /> - Heyyyy there} /> - - + +
+ + } /> + } /> + +
+
); } diff --git a/client/src/components/IncomeForm.tsx b/client/src/components/IncomeForm.tsx new file mode 100644 index 0000000..efa83d9 --- /dev/null +++ b/client/src/components/IncomeForm.tsx @@ -0,0 +1,167 @@ +import React, { useState, useEffect } from "react"; +import type { + Income, + CreateIncomeRequest, + UpdateIncomeRequest, +} from "../types/Income"; +import { IncomeService } from "../services/IncomeService"; +import { useIncomeCategories } from "../hooks/useIncomeCategories"; +import { Button, TextField, Select, Dialog, useToast, Skeleton } from "../ui"; + +interface IncomeFormProps { + income?: Income; + onSave: () => void; + onCancel: () => void; + open: boolean; +} + +export const IncomeForm: React.FC = ({ + income, + onSave, + onCancel, + open, +}) => { + const [formData, setFormData] = useState< + CreateIncomeRequest | UpdateIncomeRequest + >({ + amount: income?.amount || 0, + date: income?.date + ? new Date(income.date).toISOString().split("T")[0] + : new Date().toISOString().split("T")[0], + source: income?.source || "", + description: income?.description || "", + category_id: income?.category_id || 0, + }); + + const { categories, loading: categoriesLoading } = useIncomeCategories(); + const [saving, setSaving] = useState(false); + const toast = useToast(); + + useEffect(() => { + if (income) { + setFormData({ + amount: income.amount, + date: new Date(income.date).toISOString().split("T")[0], + source: income.source, + description: income.description || "", + category_id: income.category_id, + }); + } else { + setFormData({ + amount: 0, + date: new Date().toISOString().split("T")[0], + source: "", + description: "", + category_id: 0, + }); + } + }, [income, open]); + + const handleSubmit = async (e: React.FormEvent) => { + e.preventDefault(); + setSaving(true); + + try { + if (income) { + await IncomeService.updateIncome(income.income_id.toString(), formData); + toast.success("Income updated successfully"); + } else { + await IncomeService.createIncome(formData as CreateIncomeRequest); + toast.success("Income created successfully"); + } + onSave(); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to save income"; + toast.error(message); + } finally { + setSaving(false); + } + }; + + const handleChange = ( + field: keyof typeof formData, + value: string | number + ) => { + setFormData((prev) => ({ + ...prev, + [field]: + field === "amount" || field === "category_id" ? Number(value) : value, + })); + }; + + return ( + +
+ handleChange("amount", e.target.value)} + required + fullWidth + /> + + handleChange("date", e.target.value)} + required + fullWidth + /> + + handleChange("source", e.target.value)} + required + fullWidth + /> + + {categoriesLoading ? ( + + ) : ( +
+ +