diff --git a/client/src/App.tsx b/client/src/App.tsx index a6f0c02..7125d72 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -5,6 +5,8 @@ import Sidebar from "./components/common/Sidebar"; import Dashboard from "./pages/Dashboard"; import DashboardHeader from "./components/common/Header"; import BackgroundImage from "./components/common/BackgroundImage"; +import { CreateIncomePage } from "./pages/CreateIncomePage"; +import { EditIncomePage } from "./pages/EditIncomePage"; function App() { /* @@ -12,7 +14,6 @@ function App() { TODO: use different layout for auth page and dashboard page */ const location = useLocation(); - return ( } /> } /> + } /> + } /> diff --git a/client/src/api/services/DefaultService.ts b/client/src/api/services/DefaultService.ts index 3f044c2..4ceef98 100644 --- a/client/src/api/services/DefaultService.ts +++ b/client/src/api/services/DefaultService.ts @@ -2,490 +2,464 @@ /* 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'; +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', - }); + /** + * 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; } - /** - * 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', - }); + ): 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 putIncomesCustomCategories( + id: string, + requestBody: { + category_name: string; + icon_url?: string; } - /** - * 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, - }, - }); + ): CancelablePromise { + return __request(OpenAPI, { + method: "PUT", + url: "/incomes/custom-categories/{id}", + path: { + id: id, + }, + body: requestBody, + mediaType: "application/json", + }); + } + /** + * Delete an income category + * @param id + * @returns void + * @throws ApiError + */ + public static deleteIncomesCustomCategories( + id: string + ): CancelablePromise { + return __request(OpenAPI, { + method: "DELETE", + url: "/incomes/custom-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; } - /** - * 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 putIncomesCustomCategories( - id: string, - requestBody: { - category_name: string; - icon_url?: string; - }, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'PUT', - url: '/incomes/custom-categories/{id}', - path: { - 'id': id, - }, - body: requestBody, - mediaType: 'application/json', - }); - } - /** - * Delete an income category - * @param id - * @returns void - * @throws ApiError - */ - public static deleteIncomesCustomCategories( - id: string, - ): CancelablePromise { - return __request(OpenAPI, { - method: 'DELETE', - url: '/incomes/custom-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', - }); + ): 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/components/IncomeForm.tsx b/client/src/components/IncomeForm.tsx index efa83d9..61dad99 100644 --- a/client/src/components/IncomeForm.tsx +++ b/client/src/components/IncomeForm.tsx @@ -1,16 +1,15 @@ import React, { useState, useEffect } from "react"; import type { Income, - CreateIncomeRequest, - UpdateIncomeRequest, + IncomeFormData } 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; + onSave: (formData: IncomeFormData) => Promise; onCancel: () => void; open: boolean; } @@ -21,22 +20,20 @@ export const IncomeForm: React.FC = ({ onCancel, open, }) => { - const [formData, setFormData] = useState< - CreateIncomeRequest | UpdateIncomeRequest - >({ + const { categories, loading: categoriesLoading } = useIncomeCategories(); + const [saving, setSaving] = useState(false); + const toast = useToast(); + + const [formData, setFormData] = useState({ 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, + category_id: income?.category_id || 1, }); - const { categories, loading: categoriesLoading } = useIncomeCategories(); - const [saving, setSaving] = useState(false); - const toast = useToast(); - useEffect(() => { if (income) { setFormData({ @@ -52,7 +49,7 @@ export const IncomeForm: React.FC = ({ date: new Date().toISOString().split("T")[0], source: "", description: "", - category_id: 0, + category_id: 1, }); } }, [income, open]); @@ -62,14 +59,7 @@ export const IncomeForm: React.FC = ({ 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(); + await onSave(formData); } catch (error) { const message = error instanceof Error ? error.message : "Failed to save income"; @@ -80,7 +70,7 @@ export const IncomeForm: React.FC = ({ }; const handleChange = ( - field: keyof typeof formData, + field: keyof IncomeFormData, value: string | number ) => { setFormData((prev) => ({ @@ -111,7 +101,6 @@ export const IncomeForm: React.FC = ({ type="date" value={formData.date ?? ""} onChange={(e) => handleChange("date", e.target.value)} - required fullWidth /> @@ -119,7 +108,6 @@ export const IncomeForm: React.FC = ({ label="Source" value={formData.source ?? ""} onChange={(e) => handleChange("source", e.target.value)} - required fullWidth /> @@ -164,4 +152,4 @@ export const IncomeForm: React.FC = ({ ); -}; +}; \ No newline at end of file diff --git a/client/src/components/IncomeList.tsx b/client/src/components/IncomeList.tsx index 7baef49..1adfadf 100644 --- a/client/src/components/IncomeList.tsx +++ b/client/src/components/IncomeList.tsx @@ -1,7 +1,7 @@ -import React from "react"; +import { forwardRef, useImperativeHandle } from "react"; import type { Income } from "../types/Income"; import { useIncomes } from "../hooks/useIncomes"; -import { Button, Skeleton, Chip } from "../ui"; +import { Button, Chip } from "../ui"; interface IncomeListProps { startDate?: string; @@ -10,98 +10,101 @@ interface IncomeListProps { onDelete: (income: Income) => void; } -export const IncomeList: React.FC = ({ - startDate, - endDate, - onEdit, - onDelete, -}) => { - const { incomes, loading, error, refetch } = useIncomes(startDate, endDate); +export interface IncomeListRef { + refetch: () => void; +} - if (loading) { - return ( -
- {[...Array(5)].map((_, i) => ( - - ))} -
- ); - } +export const IncomeList = forwardRef( + ({ startDate, endDate, onEdit, onDelete }, ref) => { + const { incomes, loading, error, refetch } = useIncomes(startDate, endDate); - if (error) { - return ( -
-
Error: {error}
- -
- ); - } + useImperativeHandle(ref, () => ({ + refetch, + })); - return ( -
-
-

Incomes

- -
+ if (error) { + return ( +
+
Error: {error}
+ +
+ ); + } - {incomes.length === 0 ? ( -
- No incomes found for the selected period + return ( +
+
+

Incomes

+
- ) : ( -
- {incomes.map((income) => ( + {loading && ( +
-
-
-
-

- ${income.amount.toFixed(2)} -

- {income.category && ( - + className="size-10 mx-auto animate-spin rounded-full border-4 border-primary-light border-t-transparent" + role="status" + aria-label="Loading" + >
+
+ )} + {incomes.length === 0 ? ( +
+ No incomes found for the selected period +
+ ) : ( +
+ {incomes.map((income) => ( +
+
+
+
+

+ {income.amount.toFixed(2)} MGA +

+ {income.category && ( + + )} +
+

{income.source}

+ {income.description && ( +

+ {income.description} +

)} -
-

{income.source}

- {income.description && ( -

- {income.description} +

+ {new Date(income.date).toLocaleDateString()}

- )} -

- {new Date(income.date).toLocaleDateString()} -

-
-
- - +
+
+ + +
-
- ))} -
- )} -
- ); -}; + ))} +
+ )} +
+ ); + } +); diff --git a/client/src/components/common/BackgroundImage.tsx b/client/src/components/common/BackgroundImage.tsx index 221ea11..fcf87fb 100644 --- a/client/src/components/common/BackgroundImage.tsx +++ b/client/src/components/common/BackgroundImage.tsx @@ -2,7 +2,7 @@ import { assets } from "../../assets/images"; function BackgroundImage() { return ( -
+
Bg image
); diff --git a/client/src/components/common/Sidebar.tsx b/client/src/components/common/Sidebar.tsx index 1bb51c4..c4db1fd 100644 --- a/client/src/components/common/Sidebar.tsx +++ b/client/src/components/common/Sidebar.tsx @@ -18,7 +18,7 @@ const items = [ export default function Sidebar() { return ( -
+
diff --git a/client/src/pages/CreateIncomePage.tsx b/client/src/pages/CreateIncomePage.tsx new file mode 100644 index 0000000..301fd2c --- /dev/null +++ b/client/src/pages/CreateIncomePage.tsx @@ -0,0 +1,46 @@ +import { useNavigate } from "react-router-dom"; +import { IncomeForm } from "../components/IncomeForm"; +import { Button, useToast } from "../ui"; +import { IncomeService } from "../services/IncomeService"; +import type { CreateIncomeRequest } from "../types/Income"; + +export const CreateIncomePage = () => { + const navigate = useNavigate(); + const toast = useToast(); + + const handleSave = async (formData: CreateIncomeRequest) => { + try { + await IncomeService.createIncome(formData); + toast.success("Income created successfully"); + navigate("/incomes"); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to create income"; + toast.error(message); + throw error; + } + }; + + const handleCancel = () => { + navigate("/incomes"); + }; + + return ( +
+
+ +

Create New Income

+
+ +
+ +
+
+ ); +}; diff --git a/client/src/pages/EditIncomePage.tsx b/client/src/pages/EditIncomePage.tsx new file mode 100644 index 0000000..3b2dd01 --- /dev/null +++ b/client/src/pages/EditIncomePage.tsx @@ -0,0 +1,89 @@ +import { useState, useEffect } from "react"; +import { useNavigate, useParams } from "react-router-dom"; +import { IncomeForm } from "../components/IncomeForm"; +import { Button, useToast, Skeleton } from "../ui"; +import { IncomeService } from "../services/IncomeService"; +import type { Income, UpdateIncomeRequest } from "../types/Income"; + +export const EditIncomePage = () => { + const { id } = useParams<{ id: string }>(); + const navigate = useNavigate(); + const toast = useToast(); + const [income, setIncome] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchIncome = async () => { + if (!id) return; + try { + const incomeData = await IncomeService.getIncomeById(id); + setIncome(incomeData); + } catch { + toast.error("Failed to load income"); + navigate("/incomes"); + } finally { + setLoading(false); + } + }; + + fetchIncome(); + }, [id, navigate, toast]); + + const handleSave = async (formData: UpdateIncomeRequest) => { + if (!id) return; + try { + await IncomeService.updateIncome(id, formData); + toast.success("Income updated successfully"); + navigate("/incomes"); + } catch (error) { + const message = + error instanceof Error ? error.message : "Failed to update income"; + toast.error(message); + throw error; + } + }; + + const handleCancel = () => { + navigate("/incomes"); + }; + + if (loading) { + return ( +
+ +
+ ); + } + + if (!income) { + return ( +
+
Income not found
+
+ ); + } + + return ( +
+
+ +

Edit Income

+
+ +
+ +
+
+ ); +}; diff --git a/client/src/pages/IncomesPage.tsx b/client/src/pages/IncomesPage.tsx index a81c2fd..bc88eeb 100644 --- a/client/src/pages/IncomesPage.tsx +++ b/client/src/pages/IncomesPage.tsx @@ -1,21 +1,23 @@ -import React, { useState } from 'react'; -import type { Income } from '../types/Income'; -import { IncomeList } from '../components/IncomeList'; -import { IncomeForm } from '../components/IncomeForm'; -import { Button, TextField, Dialog, useToast } from '../ui'; -import { IncomeService } from '../services/IncomeService'; +import { useState, useRef } from "react"; +import type { Income } from "../types/Income"; +import { IncomeList } from "../components/IncomeList"; +import { Button, TextField, Dialog, useToast } from "../ui"; +import { IncomeService } from "../services/IncomeService"; +import { useNavigate } from "react-router-dom"; -export const IncomesPage: React.FC = () => { - const [editingIncome, setEditingIncome] = useState(); - const [showForm, setShowForm] = useState(false); - const [dateFilter, setDateFilter] = useState<{ start?: string; end?: string }>({}); +export const IncomesPage = () => { + const [dateFilter, setDateFilter] = useState<{ + start?: string; + end?: string; + }>({}); const [deleteConfirmOpen, setDeleteConfirmOpen] = useState(false); const [incomeToDelete, setIncomeToDelete] = useState(null); const toast = useToast(); + const incomeListRef = useRef<{ refetch: () => void }>(null); + const navigate = useNavigate(); const handleEdit = (income: Income) => { - setEditingIncome(income); - setShowForm(true); + navigate(`/incomes/${income.income_id}/edit`); }; const handleDeleteClick = (income: Income) => { @@ -28,35 +30,30 @@ export const IncomesPage: React.FC = () => { try { await IncomeService.deleteIncome(incomeToDelete.income_id.toString()); - toast.success('Income deleted successfully'); + toast.success("Income deleted successfully"); setDeleteConfirmOpen(false); setIncomeToDelete(null); + //refresh after every operations + incomeListRef.current?.refetch(); } catch (error) { - const message = error instanceof Error ? error.message : 'Failed to delete income'; + const message = + error instanceof Error ? error.message : "Failed to delete income"; toast.error(message); } }; - const handleSave = () => { - setEditingIncome(undefined); - setShowForm(false); - }; - - const handleCancel = () => { - setEditingIncome(undefined); - setShowForm(false); - }; - const handleNewIncome = () => { - setEditingIncome(undefined); - setShowForm(true); + navigate("/incomes/new"); }; return ( -
-
-

Income Management

-
@@ -66,18 +63,29 @@ export const IncomesPage: React.FC = () => { setDateFilter(prev => ({ ...prev, start: e.target.value || undefined }))} + value={dateFilter.start || ""} + onChange={(e) => + setDateFilter((prev) => ({ + ...prev, + start: e.target.value || undefined, + })) + } /> setDateFilter(prev => ({ ...prev, end: e.target.value || undefined }))} + value={dateFilter.end || ""} + onChange={(e) => + setDateFilter((prev) => ({ + ...prev, + end: e.target.value || undefined, + })) + } />
{ />
- - setDeleteConfirmOpen(false)} title="Confirm Delete" footer={ <> - - @@ -110,12 +117,19 @@ export const IncomesPage: React.FC = () => {

Are you sure you want to delete this income?

{incomeToDelete && (
-

Amount: ${incomeToDelete.amount.toFixed(2)}

-

Source: {incomeToDelete.source}

-

Date: {new Date(incomeToDelete.date).toLocaleDateString()}

+

+ Amount: ${incomeToDelete.amount.toFixed(2)} +

+

+ Source: {incomeToDelete.source} +

+

+ Date:{" "} + {new Date(incomeToDelete.date).toLocaleDateString()} +

)}
); -}; \ No newline at end of file +}; diff --git a/client/src/types/Income.ts b/client/src/types/Income.ts index dcc94db..1a11bb1 100644 --- a/client/src/types/Income.ts +++ b/client/src/types/Income.ts @@ -10,6 +10,14 @@ export interface Income { category?: IncomeCategory; } +export interface IncomeFormData { + amount: number; + date: string; + source: string; + description: string; + category_id: number; +} + export interface IncomeCategory { category_id: number; category_name: string; @@ -34,4 +42,4 @@ export interface UpdateIncomeRequest { source?: string; description?: string; category_id?: number; -} \ No newline at end of file +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..04141b7 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "expense-tracker", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/server/controllers/income.controller.js b/server/controllers/income.controller.js index f7cf0c9..2226afa 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 || !category_id) { + if (!amount) { return res.status(400).json({ error: "Missing required fields" }); } @@ -14,52 +14,131 @@ export const createIncome = asyncHandler(async (req, res) => { amount: parseFloat(amount), source: source || "", description: description || "", - category: { connect: { category_id: parseInt(category_id) } }, }; + if (category_id) { + incomeData.category = { connect: { category_id: parseInt(category_id) } }; + } else { + incomeData.category = { connect: { category_id: 1 } }; + } + if (date) { const parsedDate = new Date(date); if (!isNaN(parsedDate.getTime())) { incomeData.date = parsedDate; + } else { + return res.status(400).json({ error: "Invalid date format" }); } + } else { + incomeData.date = new Date(); } - const income = await incomeService.createIncome(req.user.user_id, incomeData); - res.status(201).json(income); + try { + const income = await incomeService.createIncome( + req.user.user_id, + incomeData + ); + res.status(201).json(income); + } catch (error) { + console.error("Error creating income:", error); + + if ( + error.message.includes("category") || + error.message.includes("Category") + ) { + return res.status(400).json({ error: "Invalid category specified" }); + } + + res.status(500).json({ error: "Failed to create income" }); + } }); export 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); + if (start) { + const parsedStart = new Date(start); + if (!isNaN(parsedStart.getTime())) { + filters.start = parsedStart; + } else { + return res.status(400).json({ error: "Invalid start date format" }); + } + } - const incomes = await incomeService.getIncomes(req.user.user_id, filters); - res.json(incomes); + if (end) { + const parsedEnd = new Date(end); + if (!isNaN(parsedEnd.getTime())) { + filters.end = parsedEnd; + } else { + return res.status(400).json({ error: "Invalid end date format" }); + } + } + + try { + const incomes = await incomeService.getIncomes(req.user.user_id, filters); + res.json(incomes); + } catch (error) { + console.error("Error fetching incomes:", error); + res.status(500).json({ error: "Failed to fetch incomes" }); + } }); export 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" }); + if (!id || isNaN(parseInt(id))) { + return res.status(400).json({ error: "Invalid income ID" }); } - res.json(income); + try { + const income = await incomeService.getIncomeById(id, req.user.user_id); + + 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: "Failed to fetch income" }); + } }); export const updateIncome = asyncHandler(async (req, res) => { const { id } = req.params; + + if (!id || isNaN(parseInt(id))) { + return res.status(400).json({ error: "Invalid income ID" }); + } + 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) }, - }; + if (updates.amount !== undefined) { + if (isNaN(parseFloat(updates.amount))) { + return res.status(400).json({ error: "Invalid amount" }); + } + updates.amount = parseFloat(updates.amount); + } + + if (updates.date) { + const parsedDate = new Date(updates.date); + if (isNaN(parsedDate.getTime())) { + return res.status(400).json({ error: "Invalid date format" }); + } + updates.date = parsedDate; + } + + if (updates.category_id !== undefined) { + if (updates.category_id === null || updates.category_id === "") { + updates.category = { connect: { category_id: 1 } }; + } else { + const categoryId = parseInt(updates.category_id); + if (isNaN(categoryId)) { + return res.status(400).json({ error: "Invalid category ID" }); + } + updates.category = { connect: { category_id: categoryId } }; + } delete updates.category_id; } @@ -71,43 +150,88 @@ export const updateIncome = asyncHandler(async (req, res) => { ); res.json(income); } catch (error) { - res.status(404).json({ error: error.message }); + console.error("Error updating income:", error); + + if ( + error.message.includes("not found") || + error.message.includes("not authorized") + ) { + return res + .status(404) + .json({ error: "Income not found or not authorized" }); + } + + if ( + error.message.includes("category") || + error.message.includes("Category") + ) { + return res.status(400).json({ error: "Invalid category specified" }); + } + + res.status(500).json({ error: "Failed to update income" }); } }); export const deleteIncome = asyncHandler(async (req, res) => { const { id } = req.params; + if (!id || isNaN(parseInt(id))) { + return res.status(400).json({ error: "Invalid income ID" }); + } + try { await incomeService.deleteIncome(id, req.user.user_id); res.status(204).send(); } catch (error) { - res.status(404).json({ error: error.message }); + console.error("Error deleting income:", error); + + if ( + error.message.includes("not found") || + error.message.includes("not authorized") + ) { + return res + .status(404) + .json({ error: "Income not found or not authorized" }); + } + + res.status(500).json({ error: "Failed to delete income" }); } }); //---------------INCOME CATEGORIES---------------// export const getIncomeCategories = asyncHandler(async (req, res) => { - const categories = await incomeService.getIncomeCategories(req.user.user_id); - res.json(categories); + try { + const categories = await incomeService.getIncomeCategories( + req.user.user_id + ); + res.json(categories); + } catch (error) { + console.error("Error fetching income categories:", error); + res.status(500).json({ error: "Failed to fetch income categories" }); + } }); export const getIncomeCategoriesByUser = asyncHandler(async (req, res) => { - const categories = await incomeService.getIncomeCategoriesByUser( - req.user.user_id - ); - res.json(categories); + try { + const categories = await incomeService.getIncomeCategoriesByUser( + req.user.user_id + ); + res.json(categories); + } catch (error) { + console.error("Error fetching user income categories:", error); + res.status(500).json({ error: "Failed to fetch user income categories" }); + } }); export const createIncomeCategory = asyncHandler(async (req, res) => { - const { category_name, icon_url, icon_emoji } = req.body; + try { + const { category_name, icon_url, icon_emoji } = req.body; - if (!category_name) { - return res.status(400).json({ error: "Category name is required" }); - } + if (!category_name) { + return res.status(400).json({ error: "Category name is required" }); + } - try { const category = await incomeService.createIncomeCategory( req.user.user_id, { @@ -118,19 +242,29 @@ export const createIncomeCategory = asyncHandler(async (req, res) => { ); res.status(201).json(category); } catch (error) { - res.status(400).json({ error: error.message }); + console.error("Error creating income category:", error); + + if (error.message.includes("already exists")) { + return res.status(409).json({ error: error.message }); + } + + res.status(500).json({ error: "Failed to create income category" }); } }); export const updateIncomeCategory = asyncHandler(async (req, res) => { - const { id } = req.params; - const { category_name, icon_url, icon_emoji } = req.body; + try { + 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" }); - } + if (!category_name) { + return res.status(400).json({ error: "Category name is required" }); + } + + if (!id || isNaN(parseInt(id))) { + return res.status(400).json({ error: "Invalid category ID" }); + } - try { const category = await incomeService.updateIncomeCategory( id, req.user.user_id, @@ -138,17 +272,47 @@ export const updateIncomeCategory = asyncHandler(async (req, res) => { ); res.json(category); } catch (error) { - res.status(400).json({ error: error.message }); + console.error("Error updating income category:", error); + + if ( + error.message.includes("not found") || + error.message.includes("not authorized") + ) { + return res.status(404).json({ error: error.message }); + } + + if (error.message.includes("already exists")) { + return res.status(409).json({ error: error.message }); + } + + res.status(500).json({ error: "Failed to update income category" }); } }); export const deleteIncomeCategory = asyncHandler(async (req, res) => { - const { id } = req.params; - try { + const { id } = req.params; + + if (!id || isNaN(parseInt(id))) { + return res.status(400).json({ error: "Invalid category ID" }); + } + await incomeService.deleteIncomeCategory(id, req.user.user_id); res.status(204).send(); } catch (error) { - res.status(400).json({ error: error.message }); + console.error("Error deleting income category:", error); + + if ( + error.message.includes("not found") || + error.message.includes("not authorized") + ) { + return res.status(404).json({ error: error.message }); + } + + if (error.message.includes("being used")) { + return res.status(409).json({ error: error.message }); + } + + res.status(500).json({ error: "Failed to delete income category" }); } }); diff --git a/server/docs/Expense Tracker API.yaml b/server/docs/Expense Tracker API.yaml index ce5bf0d..48843a0 100644 --- a/server/docs/Expense Tracker API.yaml +++ b/server/docs/Expense Tracker API.yaml @@ -22,6 +22,7 @@ paths: password: type: string responses: + "201": "201": description: User created /auth/login: @@ -40,6 +41,7 @@ paths: password: type: string responses: + "200": "200": description: Token returned /expenses: @@ -66,6 +68,7 @@ paths: type: string enum: [recurring, one-time] responses: + "200": "200": description: List of expenses post: @@ -96,6 +99,7 @@ paths: type: string format: binary responses: + "201": "201": description: Expense created /expenses/{id}: @@ -105,9 +109,15 @@ paths: 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: @@ -137,11 +147,13 @@ paths: type: string format: binary responses: + "200": "200": description: Expense updated delete: summary: Delete an expense responses: + "204": "204": description: Expense deleted /incomes/categories: @@ -202,6 +214,64 @@ paths: responses: "204": description: Income category 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/custom-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 @@ -215,6 +285,7 @@ paths: schema: type: string responses: + "200": "200": description: List of incomes post: @@ -224,7 +295,7 @@ paths: application/json: schema: type: object - required: [amount, category_id] + required: [amount] properties: amount: type: number @@ -236,7 +307,10 @@ paths: type: string category_id: type: number + category_id: + type: number responses: + "201": "201": description: Income created /incomes/{id}: @@ -246,9 +320,15 @@ paths: 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: @@ -268,17 +348,20 @@ 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: @@ -293,6 +376,7 @@ paths: name: type: string responses: + "201": "201": description: Category created /categories/{id}: @@ -313,6 +397,7 @@ paths: name: type: string responses: + "200": "200": description: Category updated delete: @@ -324,6 +409,7 @@ paths: schema: type: string responses: + "204": "204": description: Category deleted /summary/monthly: @@ -335,6 +421,7 @@ paths: schema: type: string responses: + "200": "200": description: Monthly summary /summary: @@ -350,12 +437,14 @@ paths: schema: type: string responses: + "200": "200": description: Summary /summary/alerts: get: summary: Budget overrun alert responses: + "200": "200": description: Alert info /receipts/{id}: @@ -368,11 +457,14 @@ paths: schema: type: string responses: + "200": "200": description: Receipt file /user/profile: get: summary: Get user profile responses: + "200": "200": description: Profile info + diff --git a/server/services/income.service.js b/server/services/income.service.js index 10c959e..d285487 100644 --- a/server/services/income.service.js +++ b/server/services/income.service.js @@ -26,7 +26,10 @@ const getIncomes = async (userId, filters = {}) => { return await prisma.income.findMany({ where, include: { category: true }, - orderBy: { date: "desc" }, + orderBy: [ + { date: "desc" }, + { creation_date: "desc" }, // might change { income_id: "desc" } if that doesnt work + ], }); };