Merge pull request 'develop' (#1) from develop into main
Build and Test / build-and-test-backend (push) Successful in 20s
Build and Test / build-and-test-frontend (push) Successful in 32s

Reviewed-on: https://gitea.home.com:3000/TolaMironcenko/wallet/pulls/1
This commit was merged in pull request #1.
This commit is contained in:
wt
2025-08-08 21:54:59 +07:00
73 changed files with 2038 additions and 805 deletions
+49
View File
@@ -0,0 +1,49 @@
name: Build and Test
on:
push:
branches:
- main
- develop
pull_request:
branches:
- main
- develop
jobs:
build-and-test-backend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: "1.24.5"
- name: Tidy and Vendor
run: cd backend && go mod tidy && go mod vendor
- name: build
run: cd backend && go build -v ./...
- name: test
run: cd backend && go test -v ./...
build-and-test-frontend:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Node
uses: actions/setup-node@v4
with:
node-verion: "24.1.0"
- name: Install dependencies
run: cd frontend && npm ci
- name: Build
run: cd frontend && npm run build
+2
View File
@@ -0,0 +1,2 @@
go.sum
backend
+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)
}
+17
View File
@@ -0,0 +1,17 @@
package api
import (
"backend/internal/category"
"github.com/gofiber/fiber/v2"
)
func GetAllCategories(c *fiber.Ctx) error {
categories, err := category.GetAllCategories()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
return c.JSON(categories)
}
+17
View File
@@ -0,0 +1,17 @@
package api
import (
"backend/internal/operation"
"github.com/gofiber/fiber/v2"
)
func GetAllOperation(c *fiber.Ctx) error {
operations, err := operation.GetAllOperation()
if err != nil {
return c.Status(fiber.StatusInternalServerError).JSON(fiber.Map{
"error": err.Error(),
})
}
return c.JSON(operations)
}
+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})
}
+26
View File
@@ -0,0 +1,26 @@
module backend
go 1.24.5
require (
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/golang-jwt/jwt/v5 v5.2.2 // indirect
github.com/google/uuid v1.6.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
github.com/mattn/go-runewidth v0.0.16 // indirect
github.com/rivo/uniseg v0.2.0 // indirect
github.com/valyala/bytebufferpool v1.0.0 // indirect
github.com/valyala/fasthttp v1.51.0 // indirect
github.com/valyala/tcplisten v1.0.0 // indirect
golang.org/x/sys v0.28.0 // indirect
)
@@ -0,0 +1,48 @@
package category
import "backend/model"
func GetAllCategories() ([]model.Category, error) {
return []model.Category{
{
ID: "1",
Title: "Electronics",
Icon: "😀",
Type: "expense",
},
{
ID: "2",
Title: "Clothing",
Icon: "😁",
Type: "expense",
},
{
ID: "3",
Title: "Books",
Icon: "😂",
Subcategories: []model.Subcategory{
{
ID: "1",
Title: "Fiction",
},
{
ID: "2",
Title: "Non-Fiction",
},
},
Type: "expense",
},
{
ID: "4",
Title: "Salary",
Icon: "😀",
Type: "income",
},
{
ID: "5",
Title: "asdf",
Icon: "😀",
Type: "income",
},
}, nil
}
@@ -0,0 +1,73 @@
package operation
import (
"backend/model"
"time"
)
func GetAllOperation() ([]model.Operation, error) {
return []model.Operation{
{
ID: "1",
Title: "Operation 1",
Amount: 100,
Category: "Category 1",
SubCategory: model.Subcategory{
ID: "1",
Title: "subcategory",
},
Date: "2022-01-01T00:00:00Z",
Type: "income",
Icon: "😀",
},
{
ID: "2",
Title: "Operation 2",
Amount: 200,
Category: "Category 2",
Date: "2022-01-02T00:00:00Z",
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
}
+34
View File
@@ -0,0 +1,34 @@
package main
import (
"backend/server"
"log"
"os"
"strconv"
"github.com/gofiber/fiber/v2"
)
func main() {
app := fiber.New()
server.Configure(app)
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")),
)
}
}
+9
View File
@@ -0,0 +1,9 @@
package model
type Category struct {
ID string `json:"id"`
Icon string `json:"icon"`
Title string `json:"title"`
Subcategories []Subcategory `json:"subcategories"`
Type string `json:"type"`
}
+13
View File
@@ -0,0 +1,13 @@
package model
type Operation struct {
ID string `json:"id"`
Title string `json:"title"`
Amount float64 `json:"amount"`
Category string `json:"category"`
SubCategory Subcategory `json:"subcategory,omitzero"`
Note string `json:"note,omitempty"`
Icon string `json:"icon"`
Type string `json:"type"`
Date string `json:"date"`
}
+6
View File
@@ -0,0 +1,6 @@
package model
type Subcategory struct {
ID string `json:"id"`
Title string `json:"title"`
}
+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"`
}
+37
View File
@@ -0,0 +1,37 @@
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)
categoryGroup := apiGroup.Group("/category")
categoryGroup.Get("/", api.GetAllCategories)
}
+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
+1
View File
@@ -23,3 +23,4 @@ yarn-debug.log*
yarn-error.log*
bun.lock
bun.lockb
deno.lock
View File
File diff suppressed because it is too large Load Diff
+3
View File
@@ -7,8 +7,11 @@
"@testing-library/jest-dom": "^6.6.3",
"@testing-library/react": "^16.3.0",
"@testing-library/user-event": "^13.5.0",
"axios": "^1.11.0",
"bootstrap": "^5.3.7",
"chart.js": "^4.5.0",
"classnames": "^2.5.1",
"emoji-picker-react": "^4.13.2",
"faker": "^6.6.6",
"react": "^19.1.0",
"react-bootstrap": "^2.10.10",

Before

Width:  |  Height:  |  Size: 3.8 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Before

Width:  |  Height:  |  Size: 9.4 KiB

After

Width:  |  Height:  |  Size: 9.4 KiB

+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>
+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;
@@ -4,14 +4,14 @@ import SettingsModal from "../../modals/SettingsModal/SettingsModal";
const NavigationBar = () => {
return (
<Navbar
<div
expand="lg"
className="justify-content-between"
className="d-flex align-items-center justify-content-between"
style={{ padding: "1rem 1rem 0 1rem" }}
>
<Navbar.Brand href="/">Wallet</Navbar.Brand>
<SettingsModal />
</Navbar>
</div>
);
};
+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;
+8
View File
@@ -0,0 +1,8 @@
const getDateString = (date) => {
const dateData = new Date(date);
return [
dateData.getHours().toString().padStart(2, "0"),
dateData.getMinutes().toString().padStart(2, "0"),
].join(":");
};
export default getDateString;
+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;
@@ -0,0 +1,70 @@
import { useState } from "react";
import { useTheme } from "../../contexts/ThemeContext";
import { Modal, Button, Card, Form } from "react-bootstrap";
import EmojiPickerButton from "./components/EmojiPickerButton/EmojiPickerButton";
const AddCategoryModal = ({ variant, ...props }) => {
const { theme } = useTheme();
const [show, setShow] = useState(false);
const handleClose = () => setShow(false);
const handleShow = () => setShow(true);
const [title, setTitle] = useState("");
return (
<>
<Button variant={theme} onClick={handleShow} className="m-0" {...props}>
New Category
</Button>
<Modal show={show} onHide={handleClose} fullscreen={true}>
<Modal.Header className="d-flex align-items-center justify-content-between">
<spacer />
<Modal.Title>New Category</Modal.Title>
<Button
variant={theme}
type="button"
onClick={handleClose}
style={{
borderRadius: "50%",
width: "30px",
height: "30px",
padding: "0",
}}
>
&#10005;
</Button>
</Modal.Header>
<Modal.Body className="p-3">
<div className="d-flex align-items-center justify-content-center">
<EmojiPickerButton />
</div>
<Form.Control
type="text"
placeholder="Title"
value={title}
onChange={(e) => setTitle(e.target.value)}
className="mb-3"
/>
<Card>
<Card.Body>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
SUBCATEGORIES
</div>
<Button
variant={"secondary"}
onClick={() => {}}
className="w-100 text-start mb-3"
>
Add subcategory
</Button>
<spacer />
<Button className="w-100">Add</Button>
</Card.Body>
</Card>
</Modal.Body>
</Modal>
</>
);
};
export default AddCategoryModal;
@@ -0,0 +1,58 @@
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";
const EmojiPickerButton = () => {
const { theme } = useTheme();
const [showPicker, setShowPicker] = useState(false);
const [inputValue, setInputValue] = useState("😀");
const target = useRef(null);
const handleEmojiSelect = (emoji) => {
setInputValue(emoji.emoji);
setShowPicker(false);
};
return (
<div className="p-3">
<InputGroup>
<Button
ref={target}
variant="secondary"
style={{
borderRadius: "50%",
width: "100px",
height: "100px",
fontSize: "45px",
}}
onClick={() => setShowPicker(!showPicker)}
>
{inputValue}
</Button>
<Overlay
show={showPicker}
target={target.current}
placement="bottom"
rootClose
onHide={() => setShowPicker(false)}
>
<Popover id="emoji-popover">
<Popover.Body className="p-0">
<EmojiPicker
onEmojiClick={handleEmojiSelect}
height={350}
width="100%"
previewConfig={{ showPreview: false }}
theme={theme}
/>
</Popover.Body>
</Popover>
</Overlay>
</InputGroup>
</div>
);
};
export default EmojiPickerButton;
@@ -145,7 +145,7 @@ const AnalyticsModal = () => {
<spacer />
</Modal.Header>
<Modal.Body style={{ paddingLeft: "0", paddingRight: "0" }}>
<Tabs defaultActiveKey="Expenses" id="history-tabs">
<Tabs defaultActiveKey="Expenses" id="analytics-tabs">
<Tab eventKey="Incomes" title="Incomes"></Tab>
<Tab eventKey="Expenses" title="Expenses" style={{ padding: "0" }}>
<div className="d-flex mt-2 justify-content-center">
@@ -0,0 +1,148 @@
import { useEffect, useState, useMemo, useCallback } from "react";
import { useTheme } from "../../contexts/ThemeContext";
import {
Modal,
Button,
Form,
InputGroup,
Tabs,
Tab,
Dropdown,
DropdownButton,
} from "react-bootstrap";
import styles from "./CategoriesModal.module.css";
import CategorySign from "./components/CategorySign/CategorySign";
import AddCategoryModal from "../../modals/AddCategoryModal/AddCategoryModal";
import fetchCategories from "../../logic/category/GetAllCategories";
const CategoriesModal = ({ variant }) => {
let [expenseCategories, setExpenseCategories] = useState([]);
const [incomeCategories, setIncomeCategories] = useState([]);
const { theme } = useTheme();
const [show, setShow] = useState(false);
const [searchTerm, setSearchTerm] = useState("");
const handleClose = () => setShow(false);
const handleShow = () => setShow(true);
useEffect(() => {
fetchCategories().then((data) => {
setExpenseCategories(
data.filter((category) => category.type === "expense"),
);
setIncomeCategories(
data.filter((category) => category.type === "income"),
);
console.log(data);
});
}, []);
const filteredExpenseCategories = useMemo(() => {
if (!searchTerm) return expenseCategories;
const term = searchTerm.toLowerCase();
return expenseCategories.filter((category) =>
category.title.toLowerCase().includes(term),
);
}, [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);
}, []);
return (
<>
<Button
variant={theme}
onClick={handleShow}
className="w-100 d-flex justify-content-between"
>
<div>&copy; Categories</div>
<div>&#10095;</div>
</Button>
<Modal show={show} onHide={handleClose} fullscreen={true}>
<Modal.Header>
<div className="d-flex flex-column w-100 gap-2">
<div className="d-flex align-items-center justify-content-between">
<Button
variant={theme}
onClick={handleClose}
className="d-flex justify-content-between gap-2"
>
<div style={{ transform: "rotate(180deg)" }}>&#10095;</div>
<div>Settings</div>
</Button>
<Modal.Title>Categories</Modal.Title>
<div></div>
<DropdownButton title="&#9776;" variant={theme} drop="start">
<Dropdown.Item className="p-0">
<AddCategoryModal
className="w-100"
style={{ paddingLeft: "0" }}
/>
</Dropdown.Item>
<Dropdown.Item>Reorder</Dropdown.Item>
</DropdownButton>
</div>
<InputGroup className="mb-3">
<InputGroup.Text id="basic-addon1">
<div style={{ transform: "rotate(90deg)" }}>&#9740;</div>
</InputGroup.Text>
<Form.Control
placeholder="Search"
aria-label="Search"
aria-describedby="basic-addon1"
value={searchTerm}
onChange={handleSearch}
/>
</InputGroup>
</div>
</Modal.Header>
<Modal.Body className="p-0">
<Tabs
defaultActiveKey="Expenses"
id="categories-tabs"
className="nav nav-tabs"
>
<Tab eventKey="Incomes" title="Incomes">
<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" }}
>
{filteredExpenseCategories.map((category) => (
<CategorySign
key={`${category.id}/income`}
icon={category.icon}
title={category.title}
subcategories={
category.subcategories ? category.subcategories : []
}
/>
))}
</div>
</Tab>
</Tabs>
</Modal.Body>
</Modal>
</>
);
};
export default CategoriesModal;
@@ -0,0 +1,11 @@
.scrollableContent {
height: calc(100vh - 220px);
bottom: 0;
overflow-y: auto;
}
@media (max-width: 768px) {
.scrollableContent {
height: calc(100vh - 280px);
}
}
@@ -0,0 +1,66 @@
import { useTheme } from "../../../../contexts/ThemeContext";
import { Button } from "react-bootstrap";
import styles from "./CategorySign.module.css";
import classNames from "classnames";
const CategorySign = ({ icon, title, subcategories }) => {
const { theme } = useTheme();
return (
<Button variant={theme} className="w-100 mt-3">
<div>
<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>{icon}</div>
</div>
<div className="d-flex flex-column p-0 justify-content-center align-items-start w-100">
<h3
className="mb-0 fw-bolder"
style={{ fontSize: "1.1rem", fontWeight: 700 }}
>
{title}
</h3>
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
{subcategories.length === 0
? "No subcategories"
: `Subcategories: ${subcategories.length}`}
</div>
</div>
</div>
{subcategories.length === 0 ? (
""
) : (
<div
className="d-flex align-items-center gap-3 p-0"
style={{ margin: "0 20px" }}
>
<div
className="p-2 justify-content-start align-items-start d-flex flex-column"
// style={{ fontSize: "30px" }}
>
<div>&#10142;</div>
</div>
<div className={classNames("d-flex gap-3", styles.hideScrollbar)}>
{subcategories.map((subcategory) => (
<div
className="btn btn-secondary"
style={{ borderRadius: "25px", padding: "0 15px" }}
key={subcategory.id}
>
{subcategory.title}
</div>
))}
</div>
</div>
)}
</div>
</Button>
);
};
export default CategorySign;
@@ -0,0 +1,7 @@
.hideScrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
}
.hideScrollbar::-webkit-scrollbar {
display: none;
}
@@ -0,0 +1,186 @@
import { useState, useEffect } from "react";
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 "../../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 [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);
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 (
<>
<Button
variant={theme}
className="shadow-sm p-2"
style={{ width: "100%", borderRadius: "15px" }}
onClick={handleShow}
>
History
</Button>
<Modal show={show} onHide={handleClose} fullscreen={true}>
<Modal.Header className="d-flex align-items-center justify-content-between">
<Button variant={theme} type="button" onClick={handleClose}>
Close
</Button>
<Modal.Title>History</Modal.Title>
<AddOperationModal variant={theme} />
{/* <Button variant={theme}>&#43;</Button> */}
</Modal.Header>
<Modal.Body>
<Tabs defaultActiveKey="D" id="history-tabs">
<Tab eventKey="D" title="D" className="pt-2 pb-2">
<div className={styles.scrollableContent}>
{sortedGroupKeys.map((dateKey) => {
const group = grouped[dateKey];
return (
<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>
</Tab>
</Tabs>
</Modal.Body>
</Modal>
</>
);
};
export default HistoryModal;
@@ -0,0 +1,60 @@
import getDateString from "../../../../logic/getDateString";
// import { Button } from "react-bootstrap";
// import classNames from "classnames";
// import styles from "./OperationSign.module.css";
const OperationSign = ({ operation }) => {
return (
<div className="gap-2">
<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={
operation.type === "income"
? { fontSize: "1.1rem", fontWeight: 700, color: "#2DBB4D" }
: { fontSize: "1.1rem", fontWeight: 700 }
}
>
{operation.type === "income" ? "" : "-"}
{operation.amount} ₽
</h3>
<div className="text-muted small mb-1" style={{ fontSize: "0.8rem" }}>
{operation.category}
</div>
{!operation.subcategory ? (
""
) : (
<div
className="text-muted small mb-1"
style={{ fontSize: "0.8rem" }}
>
{operation.subcategory.title}
</div>
)}
<div className="text-muted small mb-1" style={{ fontSize: "0.8rem" }}>
{operation.note && operation.note !== ""
? operation.note
: "No 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" }}
>
{getDateString(operation.date)}
</div>
</div>
</div>
</div>
);
};
export default OperationSign;
@@ -0,0 +1,7 @@
.hideScrollbar {
scrollbar-width: none;
-ms-overflow-style: none;
}
.hideScrollbar::-webkit-scrollbar {
display: none;
}
@@ -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 };
@@ -2,6 +2,8 @@ import { useState } from "react";
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();
@@ -39,11 +41,36 @@ const SettingsModal = () => {
</Button>
</Modal.Header>
<Modal.Body>
<h3 className="text-muted small m-1" style={{ fontSize: "0.8rem" }}>
DATA
</h3>
<ListGroup>
<ListGroup.Item className="p-0">
<CategoriesModal />
</ListGroup.Item>
</ListGroup>
<h3 className="text-muted small m-1" style={{ fontSize: "0.8rem" }}>
APPLICATION
</h3>
<ListGroup>
<ListGroup.Item>
<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>
</>
+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;
+8
View File
@@ -0,0 +1,8 @@
const host = window.location.origin;
const prefix = "/api";
const routes = {
token: `${host}${prefix}/token`,
categories: `${host}${prefix}/category`,
operations: `${host}${prefix}/operation`,
};
export default routes;
-8
View File
@@ -1,8 +0,0 @@
import { render, screen } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
render(<App />);
const linkElement = screen.getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});
-652
View File
@@ -1,652 +0,0 @@
import { useState } from "react";
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";
const HistoryModal = () => {
const { theme } = useTheme();
const [show, setShow] = useState(false);
const handleClose = () => setShow(false);
const handleShow = () => setShow(true);
return (
<>
<Button
variant={theme}
className="shadow-sm p-2"
style={{ width: "100%", borderRadius: "15px" }}
onClick={handleShow}
>
History
</Button>
<Modal show={show} onHide={handleClose} fullscreen={true}>
<Modal.Header className="d-flex align-items-center justify-content-between">
<Button variant={theme} type="button" onClick={handleClose}>
Close
</Button>
<Modal.Title>History</Modal.Title>
<AddOperationModal variant={theme} />
{/* <Button variant={theme}>&#43;</Button> */}
</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" }}
>
<div className={styles.scrollableContent}>
<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>
</>
);
};
export default HistoryModal;