From 1b6c7211ea045a712a495d8648f5b194e67fc0b0 Mon Sep 17 00:00:00 2001 From: TolaMironcenko Date: Thu, 7 Aug 2025 16:18:17 +0700 Subject: [PATCH] feat(auth): implement JWT authentication flow This commit implements a full-stack authentication system using JWT. On the backend, a new login endpoint `/api/token` is created. It validates credentials and issues a signed JWT. A JWT middleware is added to the server configuration to protect sensitive API endpoints like `/api/category` and `/api/operation`. Configuration is now managed via a `wallet.conf` file, loaded at startup. On the frontend, this enables a complete authentication lifecycle: - A new `/login` page allows users to enter their credentials. - A `PrivateRoute` component wraps protected pages, redirecting unauthenticated users to the login page. - API calls to protected resources now include the `Authorization: Bearer ` header. - A logout function is added to clear user session data. refactor(ui): enhance history and category views This commit also introduces major UI improvements. The History modal has been completely redesigned. Operations are no longer displayed as a flat list but are now grouped into tabs for Daily, Weekly, Monthly, and Yearly views, providing a much clearer overview of transactions. The Categories modal now separates categories into "Incomes" and "Expenses" tabs, reflecting the changes in the backend data model. --- backend/api/Accessible.go | 17 + backend/api/Login.go | 36 + backend/go.mod | 8 +- backend/internal/category/GetAllCategories.go | 15 + backend/internal/operation/GetAllOperation.go | 45 +- backend/main.go | 21 +- backend/model/category.go | 1 + backend/model/user.go | 14 + backend/server/configure.go | 19 + backend/utils/LoadEnv.go | 14 + backend/wallet.conf | 5 + frontend/src/App.jsx | 12 +- frontend/src/category/GetAllCategories.js | 14 - frontend/src/components/DayTitle.jsx | 29 + frontend/src/components/InputTitle.jsx | 12 + frontend/src/components/PrivateRoute.jsx | 7 + .../src/logic/category/GetAllCategories.js | 18 + frontend/src/logic/isAuthenticated.js | 7 + frontend/src/logic/login.js | 33 + frontend/src/logic/logout.js | 6 + .../src/logic/operation/GetAllOperation.js | 13 + .../AddCategoryModal/AddCategoryModal.jsx | 2 +- .../EmojiPickerButton/EmojiPickerButton.jsx | 2 +- .../CategoriesModal/CategoriesModal.jsx | 51 +- .../components/CategorySign/CategorySign.jsx | 13 +- .../CategorySign/CategorySign.module.css | 0 .../src/modals/HistoryModal/HistoryModal.jsx | 754 ++++-------------- .../OperationSign/OperationSign.jsx | 8 +- .../OperationSign/OperationSign.module.css | 0 .../HistoryModal/logic/groupOperations.js | 175 ++++ .../src/modals/HistoryModal/logic/week.js | 29 + .../modals/SettingsModal/SettingsModal.jsx | 15 + frontend/src/operation/GetAllOperation.js | 9 - frontend/src/pages/Login.jsx | 58 ++ frontend/src/routes.js | 1 + 35 files changed, 789 insertions(+), 674 deletions(-) create mode 100644 backend/api/Accessible.go create mode 100644 backend/api/Login.go create mode 100644 backend/model/user.go create mode 100644 backend/utils/LoadEnv.go create mode 100644 backend/wallet.conf delete mode 100644 frontend/src/category/GetAllCategories.js create mode 100644 frontend/src/components/DayTitle.jsx create mode 100644 frontend/src/components/InputTitle.jsx create mode 100644 frontend/src/components/PrivateRoute.jsx create mode 100644 frontend/src/logic/category/GetAllCategories.js create mode 100644 frontend/src/logic/isAuthenticated.js create mode 100644 frontend/src/logic/login.js create mode 100644 frontend/src/logic/logout.js create mode 100644 frontend/src/logic/operation/GetAllOperation.js rename frontend/src/{ => modals/AddCategoryModal}/components/EmojiPickerButton/EmojiPickerButton.jsx (96%) rename frontend/src/{ => modals/CategoriesModal}/components/CategorySign/CategorySign.jsx (85%) rename frontend/src/{ => modals/CategoriesModal}/components/CategorySign/CategorySign.module.css (100%) rename frontend/src/{ => modals/HistoryModal}/components/OperationSign/OperationSign.jsx (89%) rename frontend/src/{ => modals/HistoryModal}/components/OperationSign/OperationSign.module.css (100%) create mode 100644 frontend/src/modals/HistoryModal/logic/groupOperations.js create mode 100644 frontend/src/modals/HistoryModal/logic/week.js delete mode 100644 frontend/src/operation/GetAllOperation.js create mode 100644 frontend/src/pages/Login.jsx diff --git a/backend/api/Accessible.go b/backend/api/Accessible.go new file mode 100644 index 0000000..a3f8e86 --- /dev/null +++ b/backend/api/Accessible.go @@ -0,0 +1,17 @@ +package api + +import ( + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt" +) + +func Accessible(c *fiber.Ctx) error { + return c.SendString("Accessible") +} + +func Restricted(c *fiber.Ctx) error { + user := c.Locals("user").(*jwt.Token) + claims := user.Claims.(jwt.MapClaims) + name := claims["name"].(string) + return c.SendString("Welcome " + name) +} diff --git a/backend/api/Login.go b/backend/api/Login.go new file mode 100644 index 0000000..e5e20d5 --- /dev/null +++ b/backend/api/Login.go @@ -0,0 +1,36 @@ +package api + +import ( + "backend/model" + "fmt" + "os" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/golang-jwt/jwt" +) + +func Login(c *fiber.Ctx) error { + var requestdata model.AuthRequest + err := c.BodyParser(&requestdata) + if err != nil { + return fmt.Errorf("Error parse body, %s", err) + } + if requestdata.Username != "tola" || requestdata.Password != "2808" { + return c.SendStatus(fiber.StatusUnauthorized) + } + + claims := jwt.MapClaims{ + "username": "tola", + "is_superuser": true, + "exp": time.Now().Add(time.Hour * 72).Unix(), + } + + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + + t, err := token.SignedString([]byte(os.Getenv("JWT_SECRET_KEY"))) + if err != nil { + return c.SendStatus(fiber.StatusInternalServerError) + } + return c.JSON(fiber.Map{"token": t}) +} diff --git a/backend/go.mod b/backend/go.mod index a2b0a9e..1ca3944 100644 --- a/backend/go.mod +++ b/backend/go.mod @@ -3,15 +3,17 @@ module backend go 1.24.5 require ( - github.com/gofiber/fiber v1.14.6 + github.com/gofiber/contrib/jwt v1.1.2 github.com/gofiber/fiber/v2 v2.52.9 + github.com/golang-jwt/jwt v3.2.2+incompatible + github.com/joho/godotenv v1.5.1 ) require ( + github.com/MicahParks/keyfunc/v2 v2.1.0 // indirect github.com/andybalholm/brotli v1.1.0 // indirect - github.com/gofiber/utils v0.0.10 // indirect + github.com/golang-jwt/jwt/v5 v5.2.2 // indirect github.com/google/uuid v1.6.0 // indirect - github.com/gorilla/schema v1.1.0 // indirect github.com/klauspost/compress v1.17.9 // indirect github.com/mattn/go-colorable v0.1.13 // indirect github.com/mattn/go-isatty v0.0.20 // indirect diff --git a/backend/internal/category/GetAllCategories.go b/backend/internal/category/GetAllCategories.go index 1d5127b..e58bff1 100644 --- a/backend/internal/category/GetAllCategories.go +++ b/backend/internal/category/GetAllCategories.go @@ -8,11 +8,13 @@ func GetAllCategories() ([]model.Category, error) { ID: "1", Title: "Electronics", Icon: "😀", + Type: "expense", }, { ID: "2", Title: "Clothing", Icon: "😁", + Type: "expense", }, { ID: "3", @@ -28,6 +30,19 @@ func GetAllCategories() ([]model.Category, error) { Title: "Non-Fiction", }, }, + Type: "expense", + }, + { + ID: "4", + Title: "Salary", + Icon: "😀", + Type: "income", + }, + { + ID: "5", + Title: "asdf", + Icon: "😀", + Type: "income", }, }, nil } diff --git a/backend/internal/operation/GetAllOperation.go b/backend/internal/operation/GetAllOperation.go index 423d68e..152f404 100644 --- a/backend/internal/operation/GetAllOperation.go +++ b/backend/internal/operation/GetAllOperation.go @@ -1,6 +1,9 @@ package operation -import "backend/model" +import ( + "backend/model" + "time" +) func GetAllOperation() ([]model.Operation, error) { return []model.Operation{ @@ -26,5 +29,45 @@ func GetAllOperation() ([]model.Operation, error) { Type: "expense", Icon: "😂", }, + { + ID: "2", + Title: "Operation 2", + Amount: 200, + Category: "Category 2", + Date: time.Now().Format(time.RFC3339), + Type: "expense", + Icon: "😂", + }, + { + ID: "1", + Title: "Operation 1", + Amount: 100, + Category: "Category 1", + SubCategory: model.Subcategory{ + ID: "1", + Title: "subcategory", + }, + Date: "2022-01-02T00:00:00Z", + Type: "income", + Icon: "😀", + }, + { + ID: "2", + Title: "Operation 2", + Amount: 200, + Category: "Category 2", + Date: "2022-06-02T00:00:00Z", + Type: "expense", + Icon: "😂", + }, + { + ID: "2", + Title: "Operation 2", + Amount: 200, + Category: "Category 2", + Date: "2022-06-22T00:00:00Z", + Type: "expense", + Icon: "😂", + }, }, nil } diff --git a/backend/main.go b/backend/main.go index 0bfabba..1c8cffc 100644 --- a/backend/main.go +++ b/backend/main.go @@ -2,6 +2,9 @@ package main import ( "backend/server" + "log" + "os" + "strconv" "github.com/gofiber/fiber/v2" ) @@ -11,5 +14,21 @@ func main() { server.Configure(app) - app.Listen(":3000") + enableTls, err := strconv.ParseBool(os.Getenv("ENABLE_TLS")) + if err != nil { + log.Fatalf("error read tls configuration, %v", err) + } + if enableTls { + log.Fatal( + app.ListenTLS( + os.Getenv("HTTP_ADDRESS"), + os.Getenv("CERT_FILE"), + os.Getenv("KEY_FILE"), + ), + ) + } else { + log.Fatal( + app.Listen(os.Getenv("HTTP_ADDRESS")), + ) + } } diff --git a/backend/model/category.go b/backend/model/category.go index d468b0d..52abbfa 100644 --- a/backend/model/category.go +++ b/backend/model/category.go @@ -5,4 +5,5 @@ type Category struct { Icon string `json:"icon"` Title string `json:"title"` Subcategories []Subcategory `json:"subcategories"` + Type string `json:"type"` } diff --git a/backend/model/user.go b/backend/model/user.go new file mode 100644 index 0000000..bedcc61 --- /dev/null +++ b/backend/model/user.go @@ -0,0 +1,14 @@ +package model + +type AuthRequest struct { + Username string `json:"username"` + Password string `json:"password"` +} + +type User struct { + Username string `json:"username"` + Password string `json:"password"` + Email string `json:"email"` + Group string `json:"group"` + IsSuperuser bool `json:"is_superuser"` +} diff --git a/backend/server/configure.go b/backend/server/configure.go index ab5c4c7..d4b462a 100644 --- a/backend/server/configure.go +++ b/backend/server/configure.go @@ -2,14 +2,33 @@ package server import ( "backend/api" + "backend/utils" + "os" + jwtware "github.com/gofiber/contrib/jwt" "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/log" + "github.com/gofiber/fiber/v2/middleware/cors" ) func Configure(app *fiber.App) { + utils.LoadEnv("wallet.conf") + log.SetLevel(log.LevelDebug) + app.Use(cors.New(cors.Config{ + AllowOrigins: "http://localhost:3000", + AllowHeaders: "*", + })) app.Static("/", "../frontend/build") apiGroup := app.Group("/api") + apiGroup.Post("/token", api.Login) + apiGroup.Get("/accessible", api.Accessible) + app.Use(jwtware.New(jwtware.Config{ + SigningKey: jwtware.SigningKey{ + Key: []byte(os.Getenv("JWT_SECRET_KEY")), + }, + })) + apiGroup.Get("/rescticted", api.Restricted) operationGroup := apiGroup.Group("/operation") operationGroup.Get("/", api.GetAllOperation) diff --git a/backend/utils/LoadEnv.go b/backend/utils/LoadEnv.go new file mode 100644 index 0000000..8a3820a --- /dev/null +++ b/backend/utils/LoadEnv.go @@ -0,0 +1,14 @@ +package utils + +import ( + "log" + + "github.com/joho/godotenv" +) + +func LoadEnv(filename string) { + err := godotenv.Load(filename) + if err != nil { + log.Fatal("Error loading .env file") + } +} diff --git a/backend/wallet.conf b/backend/wallet.conf new file mode 100644 index 0000000..c9dd73b --- /dev/null +++ b/backend/wallet.conf @@ -0,0 +1,5 @@ +JWT_SECRET_KEY=secret +HTTP_ADDRESS=0.0.0.0:43243 +ENABLE_TLS=true +CERT_FILE=/home/tola/certs/localhost/localhost.crt +KEY_FILE=/home/tola/certs/localhost/localhost.key diff --git a/frontend/src/App.jsx b/frontend/src/App.jsx index 8aece72..59886a1 100644 --- a/frontend/src/App.jsx +++ b/frontend/src/App.jsx @@ -3,6 +3,7 @@ import NavigationBar from "./components/NavigationBar/NavigationBar"; import { useTheme } from "./contexts/ThemeContext"; import { useEffect } from "react"; import Home from "./pages/Home"; +import Login from "./pages/Login"; import { BrowserRouter as Router, Routes, @@ -10,6 +11,7 @@ import { Navigate, } from "react-router-dom"; import NotFound from "./pages/NotFound"; +import PrivateRoute from "./components/PrivateRoute"; function App() { const { theme } = useTheme(); @@ -23,7 +25,15 @@ function App() { <> - } /> + + + + } + /> + } /> } /> } /> diff --git a/frontend/src/category/GetAllCategories.js b/frontend/src/category/GetAllCategories.js deleted file mode 100644 index b58a726..0000000 --- a/frontend/src/category/GetAllCategories.js +++ /dev/null @@ -1,14 +0,0 @@ -import axios from "axios"; -import routes from "../routes"; - -const fetchCategories = async () => { - try { - const response = await axios.get(routes.categories); - return response.data; - } catch (error) { - console.error("Error fetching categories:", error); - return []; - } -}; - -export default fetchCategories; diff --git a/frontend/src/components/DayTitle.jsx b/frontend/src/components/DayTitle.jsx new file mode 100644 index 0000000..11e37e7 --- /dev/null +++ b/frontend/src/components/DayTitle.jsx @@ -0,0 +1,29 @@ +const DayTitle = ({ date, income, expense, ...props }) => { + return ( +
+

+ {/* 25 Jul 2025*/} + {date} +

+
+
+ Income: {income} ₽ +
+
+ Expense: {expense} ₽ +
+
+
+ ); +}; + +export default DayTitle; diff --git a/frontend/src/components/InputTitle.jsx b/frontend/src/components/InputTitle.jsx new file mode 100644 index 0000000..dc59e50 --- /dev/null +++ b/frontend/src/components/InputTitle.jsx @@ -0,0 +1,12 @@ +const InputTitle = ({ text, ...props }) => { + return ( +

+ {text} +

+ ); +}; +export default InputTitle; diff --git a/frontend/src/components/PrivateRoute.jsx b/frontend/src/components/PrivateRoute.jsx new file mode 100644 index 0000000..db0fd2a --- /dev/null +++ b/frontend/src/components/PrivateRoute.jsx @@ -0,0 +1,7 @@ +import isAuthenticated from "../logic/isAuthenticated"; +import Login from "../pages/Login"; + +const PrivateRoute = ({ children }) => { + return isAuthenticated() ? children : ; +}; +export default PrivateRoute; diff --git a/frontend/src/logic/category/GetAllCategories.js b/frontend/src/logic/category/GetAllCategories.js new file mode 100644 index 0000000..f960ced --- /dev/null +++ b/frontend/src/logic/category/GetAllCategories.js @@ -0,0 +1,18 @@ +import routes from "../../routes"; + +const fetchCategories = async () => { + try { + const response = await fetch(routes.categories, { + headers: { + Authorization: `Bearer ${localStorage.getItem("token")}`, + }, + }); + const data = await response.json(); + return data; + } catch (error) { + console.error("Error fetching categories:", error); + return []; + } +}; + +export default fetchCategories; diff --git a/frontend/src/logic/isAuthenticated.js b/frontend/src/logic/isAuthenticated.js new file mode 100644 index 0000000..9fcb178 --- /dev/null +++ b/frontend/src/logic/isAuthenticated.js @@ -0,0 +1,7 @@ +const isAuthenticated = () => { + if (localStorage.getItem("token") === null) { + return false; + } + return true; +}; +export default isAuthenticated; diff --git a/frontend/src/logic/login.js b/frontend/src/logic/login.js new file mode 100644 index 0000000..997c809 --- /dev/null +++ b/frontend/src/logic/login.js @@ -0,0 +1,33 @@ +import routes from "../routes"; + +const login = async (userdata, e) => { + e.preventDefault(); + console.log({ + username: userdata.username, + password: userdata.password, + }); + const response = await fetch(routes.token, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + username: userdata.username, + password: userdata.password, + }), + }); + if (response.status === 200) { + const data = await response.json(); + localStorage.setItem("token", data.token); + window.location.reload(); + return true; + } + if (response.status === 401) { + return false; + } + if (response.status === 500) { + return false; + } +}; + +export default login; diff --git a/frontend/src/logic/logout.js b/frontend/src/logic/logout.js new file mode 100644 index 0000000..43872e2 --- /dev/null +++ b/frontend/src/logic/logout.js @@ -0,0 +1,6 @@ +const logout = () => { + localStorage.clear(); + window.location.reload(); +}; + +export default logout; diff --git a/frontend/src/logic/operation/GetAllOperation.js b/frontend/src/logic/operation/GetAllOperation.js new file mode 100644 index 0000000..41b44b5 --- /dev/null +++ b/frontend/src/logic/operation/GetAllOperation.js @@ -0,0 +1,13 @@ +import routes from "../../routes"; + +const GetAllOperation = async () => { + const response = await fetch(routes.operations, { + headers: { + Authorization: `Bearer ${localStorage.getItem("token")}`, + }, + }); + const data = await response.json(); + return data; +}; + +export default GetAllOperation; diff --git a/frontend/src/modals/AddCategoryModal/AddCategoryModal.jsx b/frontend/src/modals/AddCategoryModal/AddCategoryModal.jsx index 5932bf8..439cd2b 100644 --- a/frontend/src/modals/AddCategoryModal/AddCategoryModal.jsx +++ b/frontend/src/modals/AddCategoryModal/AddCategoryModal.jsx @@ -1,7 +1,7 @@ import { useState } from "react"; import { useTheme } from "../../contexts/ThemeContext"; import { Modal, Button, Card, Form } from "react-bootstrap"; -import EmojiPickerButton from "../../components/EmojiPickerButton/EmojiPickerButton"; +import EmojiPickerButton from "./components/EmojiPickerButton/EmojiPickerButton"; const AddCategoryModal = ({ variant, ...props }) => { const { theme } = useTheme(); diff --git a/frontend/src/components/EmojiPickerButton/EmojiPickerButton.jsx b/frontend/src/modals/AddCategoryModal/components/EmojiPickerButton/EmojiPickerButton.jsx similarity index 96% rename from frontend/src/components/EmojiPickerButton/EmojiPickerButton.jsx rename to frontend/src/modals/AddCategoryModal/components/EmojiPickerButton/EmojiPickerButton.jsx index cf7b193..7e5c241 100644 --- a/frontend/src/components/EmojiPickerButton/EmojiPickerButton.jsx +++ b/frontend/src/modals/AddCategoryModal/components/EmojiPickerButton/EmojiPickerButton.jsx @@ -1,7 +1,7 @@ import React, { useState, useRef } from "react"; import { Button, Overlay, Popover, InputGroup } from "react-bootstrap"; import EmojiPicker from "emoji-picker-react"; -import { useTheme } from "../../contexts/ThemeContext"; +import { useTheme } from "../../../../contexts/ThemeContext"; const EmojiPickerButton = () => { const { theme } = useTheme(); diff --git a/frontend/src/modals/CategoriesModal/CategoriesModal.jsx b/frontend/src/modals/CategoriesModal/CategoriesModal.jsx index 9cbf164..1e3ef48 100644 --- a/frontend/src/modals/CategoriesModal/CategoriesModal.jsx +++ b/frontend/src/modals/CategoriesModal/CategoriesModal.jsx @@ -11,12 +11,13 @@ import { DropdownButton, } from "react-bootstrap"; import styles from "./CategoriesModal.module.css"; -import CategorySign from "../../components/CategorySign/CategorySign"; +import CategorySign from "./components/CategorySign/CategorySign"; import AddCategoryModal from "../../modals/AddCategoryModal/AddCategoryModal"; -import fetchCategories from "../../category/GetAllCategories"; +import fetchCategories from "../../logic/category/GetAllCategories"; const CategoriesModal = ({ variant }) => { - let [categories, setCategories] = useState([]); + let [expenseCategories, setExpenseCategories] = useState([]); + const [incomeCategories, setIncomeCategories] = useState([]); const { theme } = useTheme(); const [show, setShow] = useState(false); const [searchTerm, setSearchTerm] = useState(""); @@ -24,17 +25,29 @@ const CategoriesModal = ({ variant }) => { const handleShow = () => setShow(true); useEffect(() => { fetchCategories().then((data) => { - setCategories(data); + setExpenseCategories( + data.filter((category) => category.type === "expense"), + ); + setIncomeCategories( + data.filter((category) => category.type === "income"), + ); console.log(data); }); }, []); - const filteredCategories = useMemo(() => { - if (!searchTerm) return categories; + const filteredExpenseCategories = useMemo(() => { + if (!searchTerm) return expenseCategories; const term = searchTerm.toLowerCase(); - return categories.filter((category) => + return expenseCategories.filter((category) => category.title.toLowerCase().includes(term), ); - }, [categories, searchTerm]); + }, [expenseCategories, searchTerm]); + const filteredIncomeCategories = useMemo(() => { + if (!searchTerm) return incomeCategories; + const term = searchTerm.toLowerCase(); + return incomeCategories.filter((category) => + category.title.toLowerCase().includes(term), + ); + }, [incomeCategories, searchTerm]); const handleSearch = useCallback((e) => { setSearchTerm(e.target.value); }, []); @@ -61,7 +74,7 @@ const CategoriesModal = ({ variant }) => {
Settings
Categories - +
{ className="nav nav-tabs" > - asdf +
+ {filteredIncomeCategories.map((category) => ( + + ))} +
- {filteredCategories.map((category) => ( + {filteredExpenseCategories.map((category) => ( { >
➞
-
+
{subcategories.map((subcategory) => ( - +
))}
diff --git a/frontend/src/components/CategorySign/CategorySign.module.css b/frontend/src/modals/CategoriesModal/components/CategorySign/CategorySign.module.css similarity index 100% rename from frontend/src/components/CategorySign/CategorySign.module.css rename to frontend/src/modals/CategoriesModal/components/CategorySign/CategorySign.module.css diff --git a/frontend/src/modals/HistoryModal/HistoryModal.jsx b/frontend/src/modals/HistoryModal/HistoryModal.jsx index 2d8a3ef..c2a7a85 100644 --- a/frontend/src/modals/HistoryModal/HistoryModal.jsx +++ b/frontend/src/modals/HistoryModal/HistoryModal.jsx @@ -3,19 +3,49 @@ import { useTheme } from "../../contexts/ThemeContext"; import { ListGroup, Tabs, Tab, Modal, Button } from "react-bootstrap"; import styles from "./HistoryModal.module.css"; import AddOperationModal from "../AddOperationModal/AddOperationModal"; -import GetAllOperation from "../../operation/GetAllOperation"; -import OperationSign from "../../components/OperationSign/OperationSign"; +import GetAllOperation from "../../logic/operation/GetAllOperation"; +import OperationSign from "./components/OperationSign/OperationSign"; +import DayTitle from "../../components/DayTitle"; +import { + processMonthlyOperations, + processOperations, + processWeeklyOperations, + processYearlyOperations, +} from "./logic/groupOperations"; +import { formatWeekRange } from "./logic/week"; const HistoryModal = () => { const { theme } = useTheme(); const [show, setShow] = useState(false); const handleClose = () => setShow(false); const handleShow = () => setShow(true); - const [operations, setOperations] = useState([]); + const [grouped, setGrouped] = useState({}); + const [sortedGroupKeys, setSortedGroupKeys] = useState([]); + const [groupedWeek, setGroupedWeek] = useState({}); + const [sortedGroupKeysWeek, setSortedGroupKeysWeek] = useState([]); + const [groupedMonth, setGroupedMonth] = useState({}); + const [sortedGroupKeysMonth, setSortedGroupKeysMonth] = useState([]); + const [groupedYear, setGroupedYear] = useState({}); + const [sortedGroupKeysYear, setSortedGroupKeysYear] = useState([]); useEffect(() => { GetAllOperation().then((data) => { console.log(data); - setOperations(data); + const processop = processOperations(data); + setGrouped(processop.grouped); + setSortedGroupKeys(processop.sortedGroupKeys); + console.log(processop, processOperations(data)); + const processopWeek = processWeeklyOperations(data); + setGroupedWeek(processopWeek.grouped); + setSortedGroupKeysWeek(processopWeek.sortedKeys); + console.log(processopWeek, processWeeklyOperations(data)); + const processopMonth = processMonthlyOperations(data); + setGroupedMonth(processopMonth.grouped); + setSortedGroupKeysMonth(processopMonth.sortedKeys); + console.log(processopMonth, processMonthlyOperations(data)); + const processopYear = processYearlyOperations(data); + setGroupedYear(processopYear.grouped); + setSortedGroupKeysYear(processopYear.sortedKeys); + console.log(processopYear, processYearlyOperations(data)); }); }, []); return ( @@ -40,627 +70,113 @@ const HistoryModal = () => { - +
- {operations.map((operation) => { + {sortedGroupKeys.map((dateKey) => { + const group = grouped[dateKey]; return ( - - - - - +
+ + + {group.operations.map((operation) => ( + + + + ))} + +
+ ); + })} +
+
+ +
+ {sortedGroupKeysWeek.map((weekKey) => { + const group = groupedWeek[weekKey]; + return ( +
+ + + {group.operations.map((operation) => ( + + + + ))} + +
+ ); + })} +
+
+ +
+ {sortedGroupKeysMonth.map((monthKey) => { + const group = groupedMonth[monthKey]; + return ( +
+ + + {group.operations.map((operation) => ( + + + + ))} + +
+ ); + })} +
+
+ +
+ {sortedGroupKeysYear.map((yearKey) => { + const group = groupedYear[yearKey]; + return ( +
+ + + {group.operations.map((operation) => ( + + + + ))} + +
); })} - {/*
-
-

- 25 Jul 2025 -

-
-
- Income: 0,00 ₽ -
-
- Expense: 0,00 ₽ -
-
-
-
- - -
-
-
😀
-
-
-

- -63.00 ₽ -

-
- Transport -
-
- Note -
-
-
-
- 09:14 -
-
-
-
- -
-
-
🗼
-
-
-

- 18000 ₽ -

-
- Transport -
-
- Note -
-
-
-
- 09:14 -
-
-
-
- -
-
-
😀
-
-
-

- -63.00 ₽ -

-
- Transport -
-
- Note -
-
-
-
- 09:14 -
-
-
-
-
-
-
-

- 25 Jul 2025 -

-
-
- Income: 0,00 ₽ -
-
- Expense: 0,00 ₽ -
-
-
-
- - -
-
-
😀
-
-
-

- -63.00 ₽ -

-
- Transport -
-
- Note -
-
-
-
- 09:14 -
-
-
-
- -
-
-
🗼
-
-
-

- 18000 ₽ -

-
- Transport -
-
- Note -
-
-
-
- 09:14 -
-
-
-
- -
-
-
😀
-
-
-

- -63.00 ₽ -

-
- Transport -
-
- Note -
-
-
-
- 09:14 -
-
-
-
-
-
-
-

- 25 Jul 2025 -

-
-
- Income: 0,00 ₽ -
-
- Expense: 0,00 ₽ -
-
-
-
- - -
-
-
😀
-
-
-

- -63.00 ₽ -

-
- Transport -
-
- Note -
-
-
-
- 09:14 -
-
-
-
- -
-
-
🗼
-
-
-

- 18000 ₽ -

-
- Transport -
-
- Note -
-
-
-
- 09:14 -
-
-
-
- -
-
-
😀
-
-
-

- -63.00 ₽ -

-
- Transport -
-
- Note -
-
-
-
- 09:14 -
-
-
-
-
-
-
-

- 25 Jul 2025 -

-
-
- Income: 0,00 ₽ -
-
- Expense: 0,00 ₽ -
-
-
-
- - -
-
-
😀
-
-
-

- -63.00 ₽ -

-
- Transport -
-
- Note -
-
-
-
- 09:14 -
-
-
-
- -
-
-
🗼
-
-
-

- 18000 ₽ -

-
- Transport -
-
- Note -
-
-
-
- 09:14 -
-
-
-
- -
-
-
😀
-
-
-

- -63.00 ₽ -

-
- Transport -
-
- Note -
-
-
-
- 09:14 -
-
-
-
*/} - {/*
*/}
- - -
diff --git a/frontend/src/components/OperationSign/OperationSign.jsx b/frontend/src/modals/HistoryModal/components/OperationSign/OperationSign.jsx similarity index 89% rename from frontend/src/components/OperationSign/OperationSign.jsx rename to frontend/src/modals/HistoryModal/components/OperationSign/OperationSign.jsx index 20ab8a2..330ad3f 100644 --- a/frontend/src/components/OperationSign/OperationSign.jsx +++ b/frontend/src/modals/HistoryModal/components/OperationSign/OperationSign.jsx @@ -1,7 +1,7 @@ -import getDateString from "../../logic/getDateString"; -import { Button } from "react-bootstrap"; -import classNames from "classnames"; -import styles from "./OperationSign.module.css"; +import getDateString from "../../../../logic/getDateString"; +// import { Button } from "react-bootstrap"; +// import classNames from "classnames"; +// import styles from "./OperationSign.module.css"; const OperationSign = ({ operation }) => { return ( diff --git a/frontend/src/components/OperationSign/OperationSign.module.css b/frontend/src/modals/HistoryModal/components/OperationSign/OperationSign.module.css similarity index 100% rename from frontend/src/components/OperationSign/OperationSign.module.css rename to frontend/src/modals/HistoryModal/components/OperationSign/OperationSign.module.css diff --git a/frontend/src/modals/HistoryModal/logic/groupOperations.js b/frontend/src/modals/HistoryModal/logic/groupOperations.js new file mode 100644 index 0000000..28bdc5d --- /dev/null +++ b/frontend/src/modals/HistoryModal/logic/groupOperations.js @@ -0,0 +1,175 @@ +import { getMonday } from "./week"; + +const groupOperationsByDate = (operations) => { + const groups = {}; + operations.forEach((operation) => { + const date = new Date(operation.date); + + const dateKey = [ + date.getFullYear(), + String(date.getMonth() + 1).padStart(2, "0"), + String(date.getDate()).padStart(2, "0"), + ].join("-"); + + if (!groups[dateKey]) { + groups[dateKey] = { + dateObj: new Date(date.getFullYear(), date.getMonth(), date.getDate()), + operations: [], + income: 0, + expense: 0, + }; + } + groups[dateKey].operations.push(operation); + operation.type === "income" + ? (groups[dateKey].income += operation.amount) + : (groups[dateKey].expense += operation.amount); + }); + return groups; +}; + +const processOperations = (operations) => { + const grouped = groupOperationsByDate(operations); + const sortedGroupKeys = Object.keys(grouped).sort( + (a, b) => new Date(b) - new Date(a), + ); + + sortedGroupKeys.forEach((key) => { + grouped[key].operations.sort((a, b) => new Date(b.date) - new Date(a.date)); + }); + return { grouped, sortedGroupKeys }; +}; + +const groupOperationsByWeek = (operations) => { + const groups = {}; + + operations.forEach((operation) => { + const date = new Date(operation.date); + const monday = getMonday(date); + const weekKey = monday.toISOString().split("T")[0]; // YYYY-MM-DD + + if (!groups[weekKey]) { + groups[weekKey] = { + monday: new Date(monday), // Сохраняем объект Date + operations: [], + income: 0, + expense: 0, + }; + } + + groups[weekKey].operations.push(operation); + operation.type === "income" + ? (groups[weekKey].income += operation.amount) + : (groups[weekKey].expense += operation.amount); + }); + + return groups; +}; + +const processWeeklyOperations = (operations) => { + const grouped = groupOperationsByWeek(operations); + + // Сортировка недель (от новых к старым) + const sortedKeys = Object.keys(grouped).sort( + (a, b) => new Date(b) - new Date(a), + ); + + // Сортировка транзакций внутри недель (от новых к старым) + sortedKeys.forEach((key) => { + grouped[key].operations.sort((a, b) => new Date(b.date) - new Date(a.date)); + }); + + return { grouped, sortedKeys }; +}; + +// Группировка транзакций по месяцу и году +const groupOperationsByMonth = (operations) => { + const groups = {}; + + operations.forEach((operation) => { + const date = new Date(operation.date); + // Формируем ключ в формате "Год-Месяц" (например: "2025-08") + const monthKey = `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, "0")}`; + + if (!groups[monthKey]) { + groups[monthKey] = { + monthDate: new Date(date.getFullYear(), date.getMonth(), 1), // Первый день месяца + operations: [], + income: 0, + expense: 0, + }; + } + + groups[monthKey].operations.push(operation); + operation.type === "income" + ? (groups[monthKey].income += operation.amount) + : (groups[monthKey].expense += operation.amount); + }); + + return groups; +}; + +// Обработка и сортировка транзакций +const processMonthlyOperations = (operations) => { + const grouped = groupOperationsByMonth(operations); + + // Сортировка месяцев (от новых к старым) + const sortedKeys = Object.keys(grouped).sort( + (a, b) => new Date(b) - new Date(a), + ); + + // Сортировка транзакций внутри месяцев (от новых к старым) + sortedKeys.forEach((key) => { + grouped[key].operations.sort((a, b) => new Date(b.date) - new Date(a.date)); + }); + + return { grouped, sortedKeys }; +}; + +// Группировка транзакций по году +const groupOperationsByYear = (operations) => { + const groups = {}; + + operations.forEach((operation) => { + const date = new Date(operation.date); + const year = date.getFullYear(); + const yearKey = String(year); + + if (!groups[yearKey]) { + groups[yearKey] = { + year, + operations: [], + income: 0, + expense: 0, + }; + } + + groups[yearKey].operations.push(operation); + operation.type === "income" + ? (groups[yearKey].income += operation.amount) + : (groups[yearKey].expense += operation.amount); + }); + + return groups; +}; + +// Обработка и сортировка транзакций +const processYearlyOperations = (operations) => { + const grouped = groupOperationsByYear(operations); + + // Сортировка годов (от новых к старым) + const sortedKeys = Object.keys(grouped).sort((a, b) => b - a); + + // Сортировка транзакций внутри годов (от новых к старым) + sortedKeys.forEach((key) => { + grouped[key].operations.sort((a, b) => new Date(b.date) - new Date(a.date)); + }); + + return { grouped, sortedKeys }; +}; + +export { + processOperations, + processWeeklyOperations, + processMonthlyOperations, + processYearlyOperations, +}; diff --git a/frontend/src/modals/HistoryModal/logic/week.js b/frontend/src/modals/HistoryModal/logic/week.js new file mode 100644 index 0000000..da67bbd --- /dev/null +++ b/frontend/src/modals/HistoryModal/logic/week.js @@ -0,0 +1,29 @@ +// Получение даты понедельника для любой даты +const getMonday = (date) => { + const d = new Date(date); + const day = d.getDay(); // 0 (воскресенье) - 6 (суббота) + const diff = d.getDate() - day + (day === 0 ? -6 : 1); // Коррекция для воскресенья + return new Date(d.setDate(diff)); +}; + +// Форматирование диапазона недели +const formatWeekRange = (monday) => { + const start = new Date(monday); + const end = new Date(monday); + end.setDate(start.getDate() + 6); + + const startStr = start.toLocaleDateString("ru-RU", { + day: "numeric", + month: "short", + }); + + const endStr = end.toLocaleDateString("ru-RU", { + day: "numeric", + month: "short", + year: "numeric", + }); + + return `${startStr} - ${endStr}`; +}; + +export { getMonday, formatWeekRange }; diff --git a/frontend/src/modals/SettingsModal/SettingsModal.jsx b/frontend/src/modals/SettingsModal/SettingsModal.jsx index 78aea82..00f346a 100644 --- a/frontend/src/modals/SettingsModal/SettingsModal.jsx +++ b/frontend/src/modals/SettingsModal/SettingsModal.jsx @@ -3,6 +3,7 @@ import { useTheme } from "../../contexts/ThemeContext"; import { ListGroup, Modal, Button } from "react-bootstrap"; import ThemeToggle from "../../components/ThemeToggle"; import CategoriesModal from "../../modals/CategoriesModal/CategoriesModal"; +import logout from "../../logic/logout"; const SettingsModal = () => { const { theme } = useTheme(); @@ -56,6 +57,20 @@ const SettingsModal = () => { +

+ ACCOUNT +

+ + + + + diff --git a/frontend/src/operation/GetAllOperation.js b/frontend/src/operation/GetAllOperation.js deleted file mode 100644 index b06f6fc..0000000 --- a/frontend/src/operation/GetAllOperation.js +++ /dev/null @@ -1,9 +0,0 @@ -import routes from "../routes"; - -const GetAllOperation = async () => { - const response = await fetch(routes.operations); - const data = await response.json(); - return data; -}; - -export default GetAllOperation; diff --git a/frontend/src/pages/Login.jsx b/frontend/src/pages/Login.jsx new file mode 100644 index 0000000..caf73ac --- /dev/null +++ b/frontend/src/pages/Login.jsx @@ -0,0 +1,58 @@ +import { useState } from "react"; +import { Modal, Card, Button, Form } from "react-bootstrap"; +import InputTitle from "../components/InputTitle"; +import login from "../logic/login"; + +const Login = () => { + const [show, setShow] = useState(true); + // const handleClose = () => setShow(false); + const [userdata, setUserdata] = useState({ username: "", password: "" }); + return ( + + + Login + + + + +
{ + if (await login(userdata, e)) { + setShow(false); + } + }} + > + + + setUserdata({ ...userdata, username: e.target.value }) + } + className="mb-3" + required + /> + + + setUserdata({ ...userdata, password: e.target.value }) + } + className="mb-3" + required + /> + + +
+
+
+
+ ); +}; + +export default Login; diff --git a/frontend/src/routes.js b/frontend/src/routes.js index f7855f2..b68cdac 100644 --- a/frontend/src/routes.js +++ b/frontend/src/routes.js @@ -1,6 +1,7 @@ const host = window.location.origin; const prefix = "/api"; const routes = { + token: `${host}${prefix}/token`, categories: `${host}${prefix}/category`, operations: `${host}${prefix}/operation`, };