Merge pull request 'develop' (#1) from develop into main
Reviewed-on: https://gitea.home.com:3000/TolaMironcenko/wallet/pulls/1
This commit was merged in pull request #1.
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
go.sum
|
||||
backend
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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})
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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")),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
package model
|
||||
|
||||
type Subcategory struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
@@ -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"`
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
@@ -23,3 +23,4 @@ yarn-debug.log*
|
||||
yarn-error.log*
|
||||
bun.lock
|
||||
bun.lockb
|
||||
deno.lock
|
||||
+559
-140
File diff suppressed because it is too large
Load Diff
@@ -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 |
@@ -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>
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
+3
-3
@@ -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>
|
||||
);
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1,7 @@
|
||||
const isAuthenticated = () => {
|
||||
if (localStorage.getItem("token") === null) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
};
|
||||
export default isAuthenticated;
|
||||
@@ -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;
|
||||
@@ -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",
|
||||
}}
|
||||
>
|
||||
✕
|
||||
</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;
|
||||
+58
@@ -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;
|
||||
+1
-1
@@ -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>© Categories</div>
|
||||
<div>❯</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)" }}>❯</div>
|
||||
<div>Settings</div>
|
||||
</Button>
|
||||
<Modal.Title>Categories</Modal.Title>
|
||||
<div></div>
|
||||
<DropdownButton title="☰" 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)" }}>☌</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>➞</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}>+</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>😀</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 };
|
||||
+27
@@ -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>
|
||||
</>
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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}>+</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>😀</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>🗼</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>😀</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>😀</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>🗼</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>😀</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>😀</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>🗼</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>😀</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>😀</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>🗼</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>😀</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;
|
||||
Reference in New Issue
Block a user