diff --git a/client/index.html b/client/index.html index e4b78ea..9d68165 100644 --- a/client/index.html +++ b/client/index.html @@ -1,8 +1,12 @@ - + + Vite + React + TS 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..2a483fb 100644 --- a/client/src/App.tsx +++ b/client/src/App.tsx @@ -1,39 +1,23 @@ -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' - -function App() { - const [count, setCount] = useState(0) +import { Routes, Route, Navigate } from "react-router-dom"; +import { ToastProvider } from "./ui"; +import { IncomesPage } from "./pages/IncomesPage"; +const App = () => { return ( - <> - - -
- - Vite logo - - - React logo - -
-

Vite + React

-
- -

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

+ +
+ + } /> + } /> +
-

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

- - ) -} +
+ ); +}; -export default App +export default App; 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..4ceef98 100644 --- a/client/src/api/services/DefaultService.ts +++ b/client/src/api/services/DefaultService.ts @@ -1,408 +1,465 @@ -/* 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 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", + }); + } +} 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 ? ( + + ) : ( +
+ +