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 <token>` 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.
This commit is contained in:
wt committed 2025-08-07 16:18:17 +07:00
1 parent ca99cfcf6d
commit 1b6c7211ea
35 files changed
+789 -674

No files matched your search

+17
View File
@@ -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)
}
+36
View File
@@ -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})
}
+5 -3
View File
@@ -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
@@ -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
}
+44 -1
View File
@@ -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
}
+20 -1
View File
@@ -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")),
)
}
}
+1
View File
@@ -5,4 +5,5 @@ type Category struct {
Icon string `json:"icon"`
Title string `json:"title"`
Subcategories []Subcategory `json:"subcategories"`
Type string `json:"type"`
}
+14
View File
@@ -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"`
}
+19
View File
@@ -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)
+14
View File
@@ -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")
}
}
+5
View File
@@ -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
+11 -1
View File
@@ -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() {
<NavigationBar />
<>
<Routes>
<Route path="/" element={<Home />} />
<Route
path="/"
element={
<PrivateRoute>
<Home />
</PrivateRoute>
}
/>
<Route path="/login" element={<Login />} />
<Route path="/not-found" element={<NotFound />} />
<Route path="*" element={<Navigate to="/not-found" replace />} />
</Routes>
-14
View File
@@ -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;
+29
View File
@@ -0,0 +1,29 @@
const DayTitle = ({ date, income, expense, ...props }) => {
return (
<div
className="d-flex flex-column p-0 justify-content-start align-items-start w-100"
{...props}
>
<h3
className="mb-0 fw-bolder"
style={{ fontSize: "1.1rem", fontWeight: 700 }}
>
{/* 25 Jul 2025*/}
{date}
</h3>
<div
className="d-flex text-muted small mb-1 w-100"
style={{ fontSize: "0.8rem" }}
>
<div className="w-100">
Income: <b>{income} ₽</b>
</div>
<div className="w-100 text-end">
Expense: <b>{expense} ₽</b>
</div>
</div>
</div>
);
};
export default DayTitle;
+12
View File
@@ -0,0 +1,12 @@
const InputTitle = ({ text, ...props }) => {
return (
<h3
className="mb-0 fw-bolder text-start w-100"
style={{ fontSize: "1.1rem", fontWeight: 700 }}
{...props}
>
{text}
</h3>
);
};
export default InputTitle;
+7
View File
@@ -0,0 +1,7 @@
import isAuthenticated from "../logic/isAuthenticated";
import Login from "../pages/Login";
const PrivateRoute = ({ children }) => {
return isAuthenticated() ? children : <Login />;
};
export default PrivateRoute;
@@ -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;
+7
View File
@@ -0,0 +1,7 @@
const isAuthenticated = () => {
if (localStorage.getItem("token") === null) {
return false;
}
return true;
};
export default isAuthenticated;
+33
View File
@@ -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;
+6
View File
@@ -0,0 +1,6 @@
const logout = () => {
localStorage.clear();
window.location.reload();
};
export default logout;
@@ -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;
@@ -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();
@@ -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();
@@ -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 }) => {
<div>Settings</div>
</Button>
<Modal.Title>Categories</Modal.Title>
<spacer />
<div></div>
<DropdownButton title="&#9776;" variant={theme} drop="start">
<Dropdown.Item className="p-0">
<AddCategoryModal
@@ -93,16 +106,30 @@ const CategoriesModal = ({ variant }) => {
className="nav nav-tabs"
>
<Tab eventKey="Incomes" title="Incomes">
asdf
<div
className={styles.scrollableContent}
style={{ padding: "10px" }}
>
{filteredIncomeCategories.map((category) => (
<CategorySign
key={`${category.id}/expense`}
icon={category.icon}
title={category.title}
subcategories={
category.subcategories ? category.subcategories : []
}
/>
))}
</div>
</Tab>
<Tab eventKey="Expenses" title="Expenses">
<div
className={styles.scrollableContent}
style={{ padding: "10px" }}
>
{filteredCategories.map((category) => (
{filteredExpenseCategories.map((category) => (
<CategorySign
key={category.id}
key={`${category.id}/income`}
icon={category.icon}
title={category.title}
subcategories={
@@ -1,4 +1,4 @@
import { useTheme } from "../../contexts/ThemeContext";
import { useTheme } from "../../../../contexts/ThemeContext";
import { Button } from "react-bootstrap";
import styles from "./CategorySign.module.css";
import classNames from "classnames";
@@ -45,18 +45,15 @@ const CategorySign = ({ icon, title, subcategories }) => {
>
<div>&#10142;</div>
</div>
<div
className={classNames("d-flex gap-3", styles.hideScrollbar)}
style={{ width: "100%", overflowX: "auto" }}
>
<div className={classNames("d-flex gap-3", styles.hideScrollbar)}>
{subcategories.map((subcategory) => (
<Button
variant="secondary"
<div
className="btn btn-secondary"
style={{ borderRadius: "25px", padding: "0 15px" }}
key={subcategory.id}
>
{subcategory.title}
</Button>
</div>
))}
</div>
</div>
+135 -619
View File
@@ -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 = () => {
</Modal.Header>
<Modal.Body>
<Tabs defaultActiveKey="D" id="history-tabs">
<Tab
eventKey="D"
title="D"
className="pt-2 pb-2"
// style={{ maxHeight: "calc(100vh - 200px)", overflowY: "auto" }}
>
<Tab eventKey="D" title="D" className="pt-2 pb-2">
<div className={styles.scrollableContent}>
{operations.map((operation) => {
{sortedGroupKeys.map((dateKey) => {
const group = grouped[dateKey];
return (
<ListGroup className="mb-3 mt-0">
<ListGroup.Item className="p-0">
<OperationSign operation={operation} />
</ListGroup.Item>
</ListGroup>
<div key={dateKey}>
<DayTitle
date={group.dateObj.toDateString()}
income={group.income}
expense={group.expense}
/>
<ListGroup className="mb-3 mt-0">
{group.operations.map((operation) => (
<ListGroup.Item
className="p-0"
key={`${operation.id}/${operation.date}/day`}
>
<OperationSign operation={operation} />
</ListGroup.Item>
))}
</ListGroup>
</div>
);
})}
</div>
</Tab>
<Tab eventKey="W" title="W" className="pt-2 pb-2">
<div className={styles.scrollableContent}>
{sortedGroupKeysWeek.map((weekKey) => {
const group = groupedWeek[weekKey];
return (
<div key={weekKey}>
<DayTitle
date={formatWeekRange(group.monday)}
income={group.income}
expense={group.expense}
/>
<ListGroup className="mb-3 mt-0">
{group.operations.map((operation) => (
<ListGroup.Item
className="p-0"
key={`${operation.id}/${operation.date}/week`}
>
<OperationSign operation={operation} />
</ListGroup.Item>
))}
</ListGroup>
</div>
);
})}
</div>
</Tab>
<Tab eventKey="M" title="M" className="pt-2 pb-2">
<div className={styles.scrollableContent}>
{sortedGroupKeysMonth.map((monthKey) => {
const group = groupedMonth[monthKey];
return (
<div key={monthKey}>
<DayTitle
date={group.monthDate.toLocaleDateString("ru-RU", {
month: "long",
year: "numeric",
})}
income={group.income}
expense={group.expense}
/>
<ListGroup className="mb-3 mt-0">
{group.operations.map((operation) => (
<ListGroup.Item
className="p-0"
key={`${operation.id}/${operation.date}/month`}
>
<OperationSign operation={operation} />
</ListGroup.Item>
))}
</ListGroup>
</div>
);
})}
</div>
</Tab>
<Tab eventKey="Y" title="Y" className="pt-2 pb-2">
<div className={styles.scrollableContent}>
{sortedGroupKeysYear.map((yearKey) => {
const group = groupedYear[yearKey];
return (
<div key={yearKey}>
<DayTitle
date={group.year}
income={group.income}
expense={group.expense}
/>
<ListGroup className="mb-3 mt-0">
{group.operations.map((operation) => (
<ListGroup.Item
className="p-0"
key={`${operation.id}/${operation.date}/year`}
>
<OperationSign operation={operation} />
</ListGroup.Item>
))}
</ListGroup>
</div>
);
})}
{/* <div className="d-flex m-1">
<div className="d-flex flex-column p-0 justify-content-start align-items-start w-100">
<h3
className="mb-0 fw-bolder"
style={{ fontSize: "1.1rem", fontWeight: 700 }}
>
25 Jul 2025
</h3>
<div
className="d-flex text-muted small mb-1 w-100"
style={{ fontSize: "0.8rem" }}
>
<div className="w-100">
Income: <b>0,00 ₽</b>
</div>
<div className="w-100 text-end">
Expense: <b>0,00 ₽</b>
</div>
</div>
</div>
</div>
<ListGroup className="mb-3 mt-0">
<ListGroup.Item className="p-0">
<div className="d-flex flex-row gap-3 p-2">
<div
className="p-2 justify-content-start align-items-start d-flex flex-column"
style={{ fontSize: "30px" }}
>
<div>&#128512;</div>
</div>
<div className="d-flex flex-column p-0 justify-content-start align-items-start w-100">
<h3
className="mb-0 fw-bolder"
style={{ fontSize: "1.1rem", fontWeight: 700 }}
>
-63.00 ₽
</h3>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Transport
</div>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Note
</div>
</div>
<div
className="p-0 m-0"
style={{ width: "100%", height: "100%" }}
>
<div
className="text-muted small mb-1 text-end"
style={{ fontSize: "0.8rem" }}
>
09:14
</div>
</div>
</div>
</ListGroup.Item>
<ListGroup.Item className="p-0">
<div className="d-flex flex-row gap-3 p-2">
<div
className="p-2 justify-content-start align-items-start d-flex flex-column"
style={{ fontSize: "30px" }}
>
<div>&#128508;</div>
</div>
<div className="d-flex flex-column p-0 justify-content-start align-items-start w-100">
<h3
className="mb-0 fw-bolder"
style={{
fontSize: "1.1rem",
fontWeight: 700,
color: "#2DBB4D",
}}
>
18000 ₽
</h3>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Transport
</div>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Note
</div>
</div>
<div
className="p-0 m-0"
style={{ width: "100%", height: "100%" }}
>
<div
className="text-muted small mb-1 text-end"
style={{ fontSize: "0.8rem" }}
>
09:14
</div>
</div>
</div>
</ListGroup.Item>
<ListGroup.Item className="p-0">
<div className="d-flex flex-row gap-3 p-2">
<div
className="p-2 justify-content-start align-items-start d-flex flex-column"
style={{ fontSize: "30px" }}
>
<div>&#128512;</div>
</div>
<div className="d-flex flex-column p-0 justify-content-start align-items-start w-100">
<h3
className="mb-0 fw-bolder"
style={{ fontSize: "1.1rem", fontWeight: 700 }}
>
-63.00 ₽
</h3>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Transport
</div>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Note
</div>
</div>
<div
className="p-0 m-0"
style={{ width: "100%", height: "100%" }}
>
<div
className="text-muted small mb-1 text-end"
style={{ fontSize: "0.8rem" }}
>
09:14
</div>
</div>
</div>
</ListGroup.Item>
</ListGroup>
<div className="d-flex m-1">
<div className="d-flex flex-column p-0 justify-content-start align-items-start w-100">
<h3
className="mb-0 fw-bolder"
style={{ fontSize: "1.1rem", fontWeight: 700 }}
>
25 Jul 2025
</h3>
<div
className="d-flex text-muted small mb-1 w-100"
style={{ fontSize: "0.8rem" }}
>
<div className="w-100">
Income: <b>0,00 ₽</b>
</div>
<div className="w-100 text-end">
Expense: <b>0,00 ₽</b>
</div>
</div>
</div>
</div>
<ListGroup className="mb-3 mt-0">
<ListGroup.Item className="p-0">
<div className="d-flex flex-row gap-3 p-2">
<div
className="p-2 justify-content-start align-items-start d-flex flex-column"
style={{ fontSize: "30px" }}
>
<div>&#128512;</div>
</div>
<div className="d-flex flex-column p-0 justify-content-start align-items-start w-100">
<h3
className="mb-0 fw-bolder"
style={{ fontSize: "1.1rem", fontWeight: 700 }}
>
-63.00 ₽
</h3>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Transport
</div>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Note
</div>
</div>
<div
className="p-0 m-0"
style={{ width: "100%", height: "100%" }}
>
<div
className="text-muted small mb-1 text-end"
style={{ fontSize: "0.8rem" }}
>
09:14
</div>
</div>
</div>
</ListGroup.Item>
<ListGroup.Item className="p-0">
<div className="d-flex flex-row gap-3 p-2">
<div
className="p-2 justify-content-start align-items-start d-flex flex-column"
style={{ fontSize: "30px" }}
>
<div>&#128508;</div>
</div>
<div className="d-flex flex-column p-0 justify-content-start align-items-start w-100">
<h3
className="mb-0 fw-bolder"
style={{
fontSize: "1.1rem",
fontWeight: 700,
color: "#2DBB4D",
}}
>
18000 ₽
</h3>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Transport
</div>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Note
</div>
</div>
<div
className="p-0 m-0"
style={{ width: "100%", height: "100%" }}
>
<div
className="text-muted small mb-1 text-end"
style={{ fontSize: "0.8rem" }}
>
09:14
</div>
</div>
</div>
</ListGroup.Item>
<ListGroup.Item className="p-0">
<div className="d-flex flex-row gap-3 p-2">
<div
className="p-2 justify-content-start align-items-start d-flex flex-column"
style={{ fontSize: "30px" }}
>
<div>&#128512;</div>
</div>
<div className="d-flex flex-column p-0 justify-content-start align-items-start w-100">
<h3
className="mb-0 fw-bolder"
style={{ fontSize: "1.1rem", fontWeight: 700 }}
>
-63.00 ₽
</h3>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Transport
</div>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Note
</div>
</div>
<div
className="p-0 m-0"
style={{ width: "100%", height: "100%" }}
>
<div
className="text-muted small mb-1 text-end"
style={{ fontSize: "0.8rem" }}
>
09:14
</div>
</div>
</div>
</ListGroup.Item>
</ListGroup>
<div className="d-flex m-1">
<div className="d-flex flex-column p-0 justify-content-start align-items-start w-100">
<h3
className="mb-0 fw-bolder"
style={{ fontSize: "1.1rem", fontWeight: 700 }}
>
25 Jul 2025
</h3>
<div
className="d-flex text-muted small mb-1 w-100"
style={{ fontSize: "0.8rem" }}
>
<div className="w-100">
Income: <b>0,00 ₽</b>
</div>
<div className="w-100 text-end">
Expense: <b>0,00 ₽</b>
</div>
</div>
</div>
</div>
<ListGroup className="mb-3 mt-0">
<ListGroup.Item className="p-0">
<div className="d-flex flex-row gap-3 p-2">
<div
className="p-2 justify-content-start align-items-start d-flex flex-column"
style={{ fontSize: "30px" }}
>
<div>&#128512;</div>
</div>
<div className="d-flex flex-column p-0 justify-content-start align-items-start w-100">
<h3
className="mb-0 fw-bolder"
style={{ fontSize: "1.1rem", fontWeight: 700 }}
>
-63.00 ₽
</h3>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Transport
</div>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Note
</div>
</div>
<div
className="p-0 m-0"
style={{ width: "100%", height: "100%" }}
>
<div
className="text-muted small mb-1 text-end"
style={{ fontSize: "0.8rem" }}
>
09:14
</div>
</div>
</div>
</ListGroup.Item>
<ListGroup.Item className="p-0">
<div className="d-flex flex-row gap-3 p-2">
<div
className="p-2 justify-content-start align-items-start d-flex flex-column"
style={{ fontSize: "30px" }}
>
<div>&#128508;</div>
</div>
<div className="d-flex flex-column p-0 justify-content-start align-items-start w-100">
<h3
className="mb-0 fw-bolder"
style={{
fontSize: "1.1rem",
fontWeight: 700,
color: "#2DBB4D",
}}
>
18000 ₽
</h3>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Transport
</div>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Note
</div>
</div>
<div
className="p-0 m-0"
style={{ width: "100%", height: "100%" }}
>
<div
className="text-muted small mb-1 text-end"
style={{ fontSize: "0.8rem" }}
>
09:14
</div>
</div>
</div>
</ListGroup.Item>
<ListGroup.Item className="p-0">
<div className="d-flex flex-row gap-3 p-2">
<div
className="p-2 justify-content-start align-items-start d-flex flex-column"
style={{ fontSize: "30px" }}
>
<div>&#128512;</div>
</div>
<div className="d-flex flex-column p-0 justify-content-start align-items-start w-100">
<h3
className="mb-0 fw-bolder"
style={{ fontSize: "1.1rem", fontWeight: 700 }}
>
-63.00 ₽
</h3>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Transport
</div>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Note
</div>
</div>
<div
className="p-0 m-0"
style={{ width: "100%", height: "100%" }}
>
<div
className="text-muted small mb-1 text-end"
style={{ fontSize: "0.8rem" }}
>
09:14
</div>
</div>
</div>
</ListGroup.Item>
</ListGroup>
<div className="d-flex m-1">
<div className="d-flex flex-column p-0 justify-content-start align-items-start w-100">
<h3
className="mb-0 fw-bolder"
style={{ fontSize: "1.1rem", fontWeight: 700 }}
>
25 Jul 2025
</h3>
<div
className="d-flex text-muted small mb-1 w-100"
style={{ fontSize: "0.8rem" }}
>
<div className="w-100">
Income: <b>0,00 ₽</b>
</div>
<div className="w-100 text-end">
Expense: <b>0,00 ₽</b>
</div>
</div>
</div>
</div>
<ListGroup className="mb-3 mt-0">
<ListGroup.Item className="p-0">
<div className="d-flex flex-row gap-3 p-2">
<div
className="p-2 justify-content-start align-items-start d-flex flex-column"
style={{ fontSize: "30px" }}
>
<div>&#128512;</div>
</div>
<div className="d-flex flex-column p-0 justify-content-start align-items-start w-100">
<h3
className="mb-0 fw-bolder"
style={{ fontSize: "1.1rem", fontWeight: 700 }}
>
-63.00 ₽
</h3>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Transport
</div>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Note
</div>
</div>
<div
className="p-0 m-0"
style={{ width: "100%", height: "100%" }}
>
<div
className="text-muted small mb-1 text-end"
style={{ fontSize: "0.8rem" }}
>
09:14
</div>
</div>
</div>
</ListGroup.Item>
<ListGroup.Item className="p-0">
<div className="d-flex flex-row gap-3 p-2">
<div
className="p-2 justify-content-start align-items-start d-flex flex-column"
style={{ fontSize: "30px" }}
>
<div>&#128508;</div>
</div>
<div className="d-flex flex-column p-0 justify-content-start align-items-start w-100">
<h3
className="mb-0 fw-bolder"
style={{
fontSize: "1.1rem",
fontWeight: 700,
color: "#2DBB4D",
}}
>
18000 ₽
</h3>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Transport
</div>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Note
</div>
</div>
<div
className="p-0 m-0"
style={{ width: "100%", height: "100%" }}
>
<div
className="text-muted small mb-1 text-end"
style={{ fontSize: "0.8rem" }}
>
09:14
</div>
</div>
</div>
</ListGroup.Item>
<ListGroup.Item className="p-0">
<div className="d-flex flex-row gap-3 p-2">
<div
className="p-2 justify-content-start align-items-start d-flex flex-column"
style={{ fontSize: "30px" }}
>
<div>&#128512;</div>
</div>
<div className="d-flex flex-column p-0 justify-content-start align-items-start w-100">
<h3
className="mb-0 fw-bolder"
style={{ fontSize: "1.1rem", fontWeight: 700 }}
>
-63.00 ₽
</h3>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Transport
</div>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
Note
</div>
</div>
<div
className="p-0 m-0"
style={{ width: "100%", height: "100%" }}
>
<div
className="text-muted small mb-1 text-end"
style={{ fontSize: "0.8rem" }}
>
09:14
</div>
</div>
</div>
</ListGroup.Item> */}
{/* </ListGroup> */}
</div>
</Tab>
<Tab eventKey="W" title="W" className="pt-2 pb-2"></Tab>
<Tab eventKey="M" title="M" className="pt-2 pb-2"></Tab>
<Tab eventKey="Y" title="Y" className="pt-2 pb-2"></Tab>
</Tabs>
</Modal.Body>
</Modal>
@@ -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 (
@@ -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,
};
@@ -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 };
@@ -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 = () => {
<ThemeToggle />
</ListGroup.Item>
</ListGroup>
<h3 className="text-muted small m-1" style={{ fontSize: "0.8rem" }}>
ACCOUNT
</h3>
<ListGroup>
<ListGroup.Item className="p-0">
<Button
variant={theme}
className="w-100 p-1 m-0"
onClick={logout}
>
Logout
</Button>
</ListGroup.Item>
</ListGroup>
</Modal.Body>
</Modal>
</>
@@ -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;
+58
View File
@@ -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 (
<Modal show={show} fullscreen={true}>
<Modal.Header className="d-flex align-items-center justify-content-between">
<Modal.Title className="w-100 text-center">Login</Modal.Title>
</Modal.Header>
<Modal.Body className="p-3">
<Card>
<Card.Body>
<Form
onSubmit={async (e) => {
if (await login(userdata, e)) {
setShow(false);
}
}}
>
<InputTitle text="Username" />
<Form.Control
type="text"
placeholder="Title"
value={userdata.username}
onChange={(e) =>
setUserdata({ ...userdata, username: e.target.value })
}
className="mb-3"
required
/>
<InputTitle text="Password" />
<Form.Control
type="password"
placeholder="password"
value={userdata.password}
onChange={(e) =>
setUserdata({ ...userdata, password: e.target.value })
}
className="mb-3"
required
/>
<Button type="submit" className="w-100">
Login
</Button>
</Form>
</Card.Body>
</Card>
</Modal.Body>
</Modal>
);
};
export default Login;
+1
View File
@@ -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`,
};