From 10555b944cfc5064bd682c2885f05def4bf9b4ec Mon Sep 17 00:00:00 2001 From: Danesh Date: Sat, 28 Mar 2026 00:09:30 +0100 Subject: [PATCH] repo migration + frontend major update: useTheme replaced CSS; now using ui lib --- .gitignore | 19 + README.md | 199 +++++ model/era-platbik.drawio | 712 ++++++++++++++++++ model/era-platbik.svg | 4 + src/backend/Controllers/AuthController.cs | 106 +++ .../Controllers/CommitmentController.cs | 36 + src/backend/Controllers/ConfigController.cs | 19 + .../Controllers/ExchangeRateController.cs | 108 +++ src/backend/Controllers/ItemController.cs | 45 ++ .../Controllers/PendingUserController.cs | 76 ++ src/backend/Controllers/PurchaseController.cs | 355 +++++++++ .../Controllers/TransactionLogController.cs | 206 +++++ src/backend/Controllers/UserController.cs | 179 +++++ src/backend/DTOs/CommitmentDto.cs | 10 + src/backend/DTOs/ConfigDto.cs | 4 + src/backend/DTOs/ExchangeRate.cs | 7 + src/backend/DTOs/GetPurchaseDto.cs | 14 + src/backend/DTOs/GetTransactionLogDto.cs | 14 + src/backend/DTOs/ItemDto.cs | 9 + src/backend/DTOs/LoginDto.cs | 7 + src/backend/DTOs/NotificationDto.cs | 11 + src/backend/DTOs/PendingUserRequestDto.cs | 12 + src/backend/DTOs/PostPurchaseDto.cs | 13 + src/backend/DTOs/PostTransactionLogDto.cs | 8 + src/backend/DTOs/RawPurchaseDto.cs | 31 + src/backend/DTOs/RegisterUserDto.cs | 10 + src/backend/DTOs/UserDto.cs | 12 + src/backend/Data/AppDbContext.cs | 121 +++ .../Extensions/ClaimsPrincipalExtention.cs | 15 + src/backend/Mappers/PurchaseMapper.cs | 138 ++++ src/backend/Mappers/TransactionLogMapper.cs | 22 + src/backend/Mappers/UserMapper.cs | 20 + .../Middleware/ErrorHandlingMiddleware.cs | 43 ++ .../20260327223748_InitialCreate.Designer.cs | 424 +++++++++++ .../20260327223748_InitialCreate.cs | 298 ++++++++ .../Migrations/AppDbContextModelSnapshot.cs | 421 +++++++++++ src/backend/Models/CoPayer.cs | 17 + src/backend/Models/Item.cs | 15 + src/backend/Models/PendingUser.cs | 13 + src/backend/Models/Purchase.cs | 33 + src/backend/Models/Role.cs | 11 + src/backend/Models/TransactionLog.cs | 31 + src/backend/Models/TransactionType.cs | 17 + src/backend/Models/User.cs | 48 ++ src/backend/Program.cs | 165 ++++ src/backend/Properties/launchSettings.json | 43 ++ src/backend/Services/CommitmentService.cs | 69 ++ src/backend/Services/IJwtKeyProvider.cs | 4 + src/backend/Services/JwtKeyProvider.cs | 9 + src/backend/Services/TransactionLogService.cs | 162 ++++ src/backend/Validators/UserValidator.cs | 120 +++ src/backend/appsettings.Development.json | 8 + src/backend/appsettings.json | 9 + src/backend/backend.csproj | 26 + src/backend/backend.http | 6 + src/backend/backend.sln | 25 + src/backend/config.cs | 67 ++ src/frontend/eslint.config.js | 33 + src/frontend/icon.png | Bin 0 -> 20973 bytes src/frontend/index.html | 14 + src/frontend/jsconfig.json | 27 + src/frontend/package.json | 42 ++ src/frontend/public/favicon.ico | 0 src/frontend/src/App.jsx | 29 + src/frontend/src/Grid.jsx | 262 +++++++ src/frontend/src/Main.jsx | 27 + src/frontend/src/api/commitments.js | 29 + src/frontend/src/api/configs.js | 21 + src/frontend/src/api/exchangeRates.js | 22 + src/frontend/src/api/items.js | 18 + src/frontend/src/api/pendingUsers.js | 44 ++ src/frontend/src/api/purchases.js | 109 +++ src/frontend/src/api/transactionLogs.js | 119 +++ src/frontend/src/api/url.js | 4 + src/frontend/src/api/users.js | 100 +++ .../src/api/wrappers/showPurchaseDetails.js | 33 + src/frontend/src/auth/AuthContext.js | 3 + src/frontend/src/auth/AuthProvider.jsx | 111 +++ src/frontend/src/auth/FullPageSpinner.jsx | 41 + src/frontend/src/auth/useCurrentUser.jsx | 39 + src/frontend/src/bookmarks/About.jsx | 22 + src/frontend/src/bookmarks/AllPurchases.jsx | 77 ++ src/frontend/src/bookmarks/Auth.jsx | 42 ++ src/frontend/src/bookmarks/MyCommitments.jsx | 133 ++++ src/frontend/src/bookmarks/MyPurchases.jsx | 171 +++++ src/frontend/src/bookmarks/NotLogged.jsx | 9 + .../columns/EditCellWithTooltip.jsx | 20 + .../columns/getCommitmentColumns.jsx | 83 ++ .../src/components/columns/getItemColumns.jsx | 138 ++++ .../components/columns/getPurchaseColumns.jsx | 133 ++++ .../columns/getTransactionColumns.jsx | 82 ++ .../components/columns/widths/ColumnWidths.js | 111 +++ .../components/dialogs/DebtorDetailDialog.jsx | 139 ++++ .../components/dialogs/EditPurchaseDialog.jsx | 389 ++++++++++ .../dialogs/NotificationsDialog.jsx | 99 +++ .../src/components/dialogs/PayDialog.jsx | 234 ++++++ .../dialogs/RegistrationRequestsDialog.jsx | 92 +++ src/frontend/src/config/ConfigContext.js | 3 + src/frontend/src/config/ConfigProvider.jsx | 32 + src/frontend/src/mappers/purchaseMapper.js | 30 + src/frontend/src/objects/CommitmentDto.js | 10 + src/frontend/src/objects/EditPurchase.js | 12 + src/frontend/src/objects/ExchangeRateDto.js | 8 + src/frontend/src/objects/GetPurchaseDto.js | 12 + .../src/objects/GetTransactionLogDto.js | 11 + src/frontend/src/objects/Item.js | 8 + src/frontend/src/objects/ItemDto.js | 8 + src/frontend/src/objects/NotificationDto.js | 19 + .../src/objects/PendingRegisterRequestDto.js | 18 + src/frontend/src/objects/PostPurchaseDto.js | 12 + .../src/objects/PostTransactionLogDto.js | 7 + src/frontend/src/objects/RegisterUserDto.js | 9 + src/frontend/src/objects/Settlement.js | 13 + src/frontend/src/objects/Transaction.js | 31 + src/frontend/src/styles/index.css | 5 + src/frontend/src/theme.js | 10 + src/frontend/vite.config.js | 49 ++ 117 files changed, 8034 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 model/era-platbik.drawio create mode 100644 model/era-platbik.svg create mode 100644 src/backend/Controllers/AuthController.cs create mode 100644 src/backend/Controllers/CommitmentController.cs create mode 100644 src/backend/Controllers/ConfigController.cs create mode 100644 src/backend/Controllers/ExchangeRateController.cs create mode 100644 src/backend/Controllers/ItemController.cs create mode 100644 src/backend/Controllers/PendingUserController.cs create mode 100644 src/backend/Controllers/PurchaseController.cs create mode 100644 src/backend/Controllers/TransactionLogController.cs create mode 100644 src/backend/Controllers/UserController.cs create mode 100644 src/backend/DTOs/CommitmentDto.cs create mode 100644 src/backend/DTOs/ConfigDto.cs create mode 100644 src/backend/DTOs/ExchangeRate.cs create mode 100644 src/backend/DTOs/GetPurchaseDto.cs create mode 100644 src/backend/DTOs/GetTransactionLogDto.cs create mode 100644 src/backend/DTOs/ItemDto.cs create mode 100644 src/backend/DTOs/LoginDto.cs create mode 100644 src/backend/DTOs/NotificationDto.cs create mode 100644 src/backend/DTOs/PendingUserRequestDto.cs create mode 100644 src/backend/DTOs/PostPurchaseDto.cs create mode 100644 src/backend/DTOs/PostTransactionLogDto.cs create mode 100644 src/backend/DTOs/RawPurchaseDto.cs create mode 100644 src/backend/DTOs/RegisterUserDto.cs create mode 100644 src/backend/DTOs/UserDto.cs create mode 100644 src/backend/Data/AppDbContext.cs create mode 100644 src/backend/Extensions/ClaimsPrincipalExtention.cs create mode 100644 src/backend/Mappers/PurchaseMapper.cs create mode 100644 src/backend/Mappers/TransactionLogMapper.cs create mode 100644 src/backend/Mappers/UserMapper.cs create mode 100644 src/backend/Middleware/ErrorHandlingMiddleware.cs create mode 100644 src/backend/Migrations/20260327223748_InitialCreate.Designer.cs create mode 100644 src/backend/Migrations/20260327223748_InitialCreate.cs create mode 100644 src/backend/Migrations/AppDbContextModelSnapshot.cs create mode 100644 src/backend/Models/CoPayer.cs create mode 100644 src/backend/Models/Item.cs create mode 100644 src/backend/Models/PendingUser.cs create mode 100644 src/backend/Models/Purchase.cs create mode 100644 src/backend/Models/Role.cs create mode 100644 src/backend/Models/TransactionLog.cs create mode 100644 src/backend/Models/TransactionType.cs create mode 100644 src/backend/Models/User.cs create mode 100644 src/backend/Program.cs create mode 100644 src/backend/Properties/launchSettings.json create mode 100644 src/backend/Services/CommitmentService.cs create mode 100644 src/backend/Services/IJwtKeyProvider.cs create mode 100644 src/backend/Services/JwtKeyProvider.cs create mode 100644 src/backend/Services/TransactionLogService.cs create mode 100644 src/backend/Validators/UserValidator.cs create mode 100644 src/backend/appsettings.Development.json create mode 100644 src/backend/appsettings.json create mode 100644 src/backend/backend.csproj create mode 100644 src/backend/backend.http create mode 100644 src/backend/backend.sln create mode 100644 src/backend/config.cs create mode 100644 src/frontend/eslint.config.js create mode 100644 src/frontend/icon.png create mode 100644 src/frontend/index.html create mode 100644 src/frontend/jsconfig.json create mode 100644 src/frontend/package.json create mode 100644 src/frontend/public/favicon.ico create mode 100644 src/frontend/src/App.jsx create mode 100644 src/frontend/src/Grid.jsx create mode 100644 src/frontend/src/Main.jsx create mode 100644 src/frontend/src/api/commitments.js create mode 100644 src/frontend/src/api/configs.js create mode 100644 src/frontend/src/api/exchangeRates.js create mode 100644 src/frontend/src/api/items.js create mode 100644 src/frontend/src/api/pendingUsers.js create mode 100644 src/frontend/src/api/purchases.js create mode 100644 src/frontend/src/api/transactionLogs.js create mode 100644 src/frontend/src/api/url.js create mode 100644 src/frontend/src/api/users.js create mode 100644 src/frontend/src/api/wrappers/showPurchaseDetails.js create mode 100644 src/frontend/src/auth/AuthContext.js create mode 100644 src/frontend/src/auth/AuthProvider.jsx create mode 100644 src/frontend/src/auth/FullPageSpinner.jsx create mode 100644 src/frontend/src/auth/useCurrentUser.jsx create mode 100644 src/frontend/src/bookmarks/About.jsx create mode 100644 src/frontend/src/bookmarks/AllPurchases.jsx create mode 100644 src/frontend/src/bookmarks/Auth.jsx create mode 100644 src/frontend/src/bookmarks/MyCommitments.jsx create mode 100644 src/frontend/src/bookmarks/MyPurchases.jsx create mode 100644 src/frontend/src/bookmarks/NotLogged.jsx create mode 100644 src/frontend/src/components/columns/EditCellWithTooltip.jsx create mode 100644 src/frontend/src/components/columns/getCommitmentColumns.jsx create mode 100644 src/frontend/src/components/columns/getItemColumns.jsx create mode 100644 src/frontend/src/components/columns/getPurchaseColumns.jsx create mode 100644 src/frontend/src/components/columns/getTransactionColumns.jsx create mode 100644 src/frontend/src/components/columns/widths/ColumnWidths.js create mode 100644 src/frontend/src/components/dialogs/DebtorDetailDialog.jsx create mode 100644 src/frontend/src/components/dialogs/EditPurchaseDialog.jsx create mode 100644 src/frontend/src/components/dialogs/NotificationsDialog.jsx create mode 100644 src/frontend/src/components/dialogs/PayDialog.jsx create mode 100644 src/frontend/src/components/dialogs/RegistrationRequestsDialog.jsx create mode 100644 src/frontend/src/config/ConfigContext.js create mode 100644 src/frontend/src/config/ConfigProvider.jsx create mode 100644 src/frontend/src/mappers/purchaseMapper.js create mode 100644 src/frontend/src/objects/CommitmentDto.js create mode 100644 src/frontend/src/objects/EditPurchase.js create mode 100644 src/frontend/src/objects/ExchangeRateDto.js create mode 100644 src/frontend/src/objects/GetPurchaseDto.js create mode 100644 src/frontend/src/objects/GetTransactionLogDto.js create mode 100644 src/frontend/src/objects/Item.js create mode 100644 src/frontend/src/objects/ItemDto.js create mode 100644 src/frontend/src/objects/NotificationDto.js create mode 100644 src/frontend/src/objects/PendingRegisterRequestDto.js create mode 100644 src/frontend/src/objects/PostPurchaseDto.js create mode 100644 src/frontend/src/objects/PostTransactionLogDto.js create mode 100644 src/frontend/src/objects/RegisterUserDto.js create mode 100644 src/frontend/src/objects/Settlement.js create mode 100644 src/frontend/src/objects/Transaction.js create mode 100644 src/frontend/src/styles/index.css create mode 100644 src/frontend/src/theme.js create mode 100644 src/frontend/vite.config.js diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..3be6ead --- /dev/null +++ b/.gitignore @@ -0,0 +1,19 @@ +dist +node_modules +*.bak +package-lock.json +.idea +bin +obj +.vs +deploy +publish +publish +app.db-shm +app.db-wal +*.user +usersecrets.json + +.npmrc +icons/ +*.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..766bbcc --- /dev/null +++ b/README.md @@ -0,0 +1,199 @@ +# Platbík + +**Autor:** Danneschs + +Webová aplikace pro správu společných nákupů a vyrovnávání dluhů mezi uživateli. + +## O aplikaci + +Platbík je webová aplikace pro evidenci a správu společných nákupů ve skupině lidí (např. spolubydlící, přátelé, rodina). Aplikace umožňuje zaznamenávat nákupy, automaticky počítat dluhy mezi uživateli a spravovat jejich vyrovnání. + +### Vznik +Návrh frontendu této aplikace vznikl jako semestrální práce na **Fakultě aplikovaných věd Západočeské univerzity v Plzni** z předmětu Úvod do uživatelských rozhraní (KIV/UUR). Backend byl později dokončen jako semestrální práce z předmětu Základy počítačových sítí (KIV/ZPS) a na aplikaci je dále pracováno v rámci osobního použití. + +### Hlavní funkce + +- **Evidence nákupů** — zaznamenávání nákupů s detaily (položky, cena, obchod), +- **Správa spoluplatitelů** — určení, kdo se na nákupu podílel, +- **Automatický výpočet dluhů** — systém automaticky počítá, kdo komu dluží, +- **Závazkové vztahy** — přehled dluhů mezi uživateli, +- **Historie transakcí** — kompletní historie všech nákupů a vyrovnání, +- **Notifikace** — upozornění na nové transakce, +- **Podpora více měn** — nastavitelná měna přes konfiguraci (CZK, EUR). + +--- + +## Technologie + +### Backend (C#/.NET) +| Technologie | Verze | +|---|---| +| ASP.NET Core | 10.0 | +| Entity Framework Core | 9.0.6 | +| JWT autentizace | 8.0.17 | +| SQLite | 9.0.6 | + +### Frontend +| Technologie | Verze | +|---|---| +| React | 19 | +| Material-UI (MUI) | 7 | +| MUI X Data Grid | 7 | +| React Router | 7 | +| Zustand | 5 | +| Vite | 6 | +| **@danneschs/libnik-ui** | **0.1.0** | + +Frontend využívá sdílenou komponentovou knihovnu **[@danneschs/libnik-ui](https://github.com/Danneschs/libnik-ui)**, která obsahuje obecné UI komponenty (dialogy, tabulky, formuláře, rozložení stránky) opakovaně použitelné napříč projekty. + +--- + +## Struktura projektu + +``` +platbik/ +├── src/ +│ ├── backend/ # Backend C#/.NET +│ │ ├── Controllers/ # API endpointy +│ │ ├── Models/ # Datové modely +│ │ ├── Services/ # Business logika +│ │ ├── DTOs/ # Data Transfer Objects +│ │ ├── Middleware/ # Middleware komponenty +│ │ └── Data/ # Databázový kontext a SQLite soubor +│ └── frontend/ # React aplikace (Vite) +│ └── src/ +│ ├── Main.jsx # Vstupní bod, ThemeProvider + BrowserRouter +│ ├── App.jsx # Definice routes +│ ├── Grid.jsx # Obal stránky (autentizace, navigace, dialogy) +│ ├── theme.js # MUI theme projektu (barvy, paleta) +│ ├── styles/ # Globální CSS (výška html/body/#root) +│ ├── api/ # Komunikace s backendem (fetch funkce) +│ │ └── wrappers/ # Pomocné obalové funkce +│ ├── auth/ # Autentizační kontext a komponenty +│ │ ├── AuthContext.js +│ │ ├── AuthProvider.jsx +│ │ └── FullPageSpinner.jsx +│ ├── config/ # Konfigurace měny (kontext) +│ ├── bookmarks/ # Stránky aplikace +│ │ ├── AllPurchases.jsx +│ │ ├── MyPurchases.jsx +│ │ ├── MyCommitments.jsx +│ │ ├── Auth.jsx +│ │ ├── About.jsx +│ │ └── NotLogged.jsx +│ ├── components/ +│ │ ├── dialogs/ # Aplikační dialogy +│ │ │ ├── EditPurchaseDialog.jsx +│ │ │ ├── DebtorDetailDialog.jsx +│ │ │ ├── NotificationsDialog.jsx +│ │ │ ├── PayDialog.jsx +│ │ │ └── RegistrationRequestsDialog.jsx +│ │ └── columns/ # Definice sloupců pro DataGrid +│ │ ├── getPurchaseColumns.jsx +│ │ ├── getCommitmentColumns.jsx +│ │ ├── getItemColumns.jsx +│ │ ├── getTransactionColumns.jsx +│ │ ├── EditCellWithTooltip.jsx +│ │ └── widths/ColumnWidths.js +│ ├── mappers/ # Transformace dat z API +│ └── objects/ # DTO a doménové objekty +└── model/ # Databázový ER diagram +``` + +--- + +## Instalace a spuštění + +### Požadavky + +- .NET SDK 10.0 (pro backend) +- Node.js a npm (pro frontend) + +### Backend (C#) + +```bash +cd src/backend + +# Nastavení JWT klíče jako environment variable +export JWT__Key="váš-tajný-klíč" + +# Volitelně: nastavení měny (základní je CZK) +export CURRENCY_CODE="CZK" + +# Aktualizace databáze +dotnet ef database update + +# Spuštění +dotnet run +``` + +Backend běží standardně na `http://localhost:5000` nebo `https://localhost:5001`. + +### Frontend + +```bash +cd src/frontend + +# Instalace závislostí +npm install + +# Vývojový server +npm run dev + +# Nebo build pro produkci +npm run build +``` + +Frontend běží standardně na `http://localhost:5173`. + +--- + +## Konfigurace + +### Environment proměnné (pro backend) + +| Proměnná | Povinná | Popis | +|---|---|---| +| `JWT__Key` | ano | Tajný klíč pro podepisování JWT tokenů | +| `CURRENCY_CODE` | ne | Kód měny (`"CZK"`, `"EUR"`), výchozí `"CZK"` | + +### Barvy a vzhled + +Barvy aplikace jsou definovány v `src/frontend/src/theme.js` jako MUI theme. Změnou hodnot v tomto souboru se upraví vzhled celé aplikace — komponenty z `@danneschs/libnik-ui` čtou barvy z `ThemeProvider` tohoto projektu, takže reagují na nastavené téma stejně jako ostatní MUI komponenty. + +--- + +## API Endpointy + +### Autentizace +- `POST /api/auth/register` — registrace nového uživatele +- `POST /api/auth/login` — přihlášení uživatele + +### Uživatelé +- `GET /api/users` — seznam všech uživatelů +- `GET /api/users/{id}` — detail uživatele +- `PUT /api/users/{id}` — aktualizace uživatele +- `DELETE /api/users/{id}` — smazání uživatele + +### Nákupy +- `GET /api/purchases` — seznam všech nákupů +- `GET /api/purchases/{id}` — detail nákupu +- `POST /api/purchases` — vytvoření nového nákupu +- `PUT /api/purchases/{id}` — aktualizace nákupu +- `DELETE /api/purchases/{id}` — smazání nákupu + +### Závazkové vztahy +- `GET /api/commitments` — přehled dluhů mezi uživateli + +### Transakce +- `GET /api/transaction-logs` — historie transakcí +- `POST /api/transaction-logs` — vytvoření nové transakce + +### Konfigurace +- `GET /api/config` — globální konfigurace aplikace +- `GET /api/exchange-rates` — aktuální směnné kurzy + +--- + +## Databázový model +![ERA diagram](model/era-platbik.svg) diff --git a/model/era-platbik.drawio b/model/era-platbik.drawio new file mode 100644 index 0000000..0fa2a1e --- /dev/null +++ b/model/era-platbik.drawio @@ -0,0 +1,712 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/model/era-platbik.svg b/model/era-platbik.svg new file mode 100644 index 0000000..a612e85 --- /dev/null +++ b/model/era-platbik.svg @@ -0,0 +1,4 @@ + + + +PurchasePKid int NOT NULL Name string NOT NULLPriceInCents int NOT NULLBuyerPays bool NOT NULLTimestamp DateTimeOffset NOT NULLShop string NOT NULLFKBuyerId int NOT NULLItemPKid int NOT NULL Name string NOT NULLQuantity int NOT NULLPricePerPiece int NOT NULLFKPurchaseId int NOT NULLCoPayerPKid int NOT NULL FK1PurchaseId int NOT NULLFK1UserId int NOT NULLUserPKid int NOT NULL Email string NOT NULLName string NOT NULLSurname string NOT NULLAccountNumber string NOT NULLPasswordHash string NOT NULLCreatedAt DateTimeOffset NOT NULLFKRoleId int NOT NULLRolePKid int NOT NULL Code string NOT NULLDisplayName string NOT NULLPendingUserPKid int NOT NULL Name string NOT NULLSurname string NOT NULLAccountNumber string NOT NULLEmail string NOT NULLPasswordHash string NOT NULLIsRead bool NOT NULLRequestedAt DateTimeOffset NOT NULLTransactionTypePKid int NOT NULL Code string NOT NULLDisplayName string NOT NULLTransactionLogPKid int NOT NULL FK1TransactionTypeId int NOT NULLAmount int NOT NULLTimestamp DateTimeOffset NOT NULLNote stringFK2PurchaseID intFK3FromUserId int NOT NULLFK4ToUserId int NOT NULLGroupId GuidIsRead bool NOT NULL \ No newline at end of file diff --git a/src/backend/Controllers/AuthController.cs b/src/backend/Controllers/AuthController.cs new file mode 100644 index 0000000..363a445 --- /dev/null +++ b/src/backend/Controllers/AuthController.cs @@ -0,0 +1,106 @@ +namespace backend.Controllers; + +using backend.DTOs; +using backend.Data; + +using Microsoft.AspNetCore.Mvc; +using Microsoft.IdentityModel.Tokens; +using Microsoft.EntityFrameworkCore; +using System.IdentityModel.Tokens.Jwt; +using System.Security.Claims; +using System.Text; + + +[ApiController] +[Route("api/[controller]")] +public class AuthController : ControllerBase +{ + private readonly IJwtKeyProvider _jwtKeyProvider; + private readonly int _tokenExpireTime = 10; // hours + + private readonly AppDbContext _db; + + public AuthController(AppDbContext db, IJwtKeyProvider jwtKeyProvider) + { + _db = db; + _jwtKeyProvider = jwtKeyProvider; + } + + [HttpPost("login")] + public async Task Login([FromBody] LoginDto model) + { + //Console.WriteLine($"Login called with Email: {model?.Email}"); + + if (model == null) + return BadRequest("Model is required"); + + if (!IsValidEmail(model.Email) || string.IsNullOrEmpty(model.Password)) + return BadRequest("Email and password are required"); + + var user = await _db.Users.FirstOrDefaultAsync(u => u.Email == model.Email); + + if (user == null) + { + //Console.WriteLine("User not found"); + return Unauthorized("Invalid credentials"); + } + + // Verify password + if (!BCrypt.Net.BCrypt.Verify(model.Password, user.PasswordHash)) + { + //Console.WriteLine("Invalid password"); + return Unauthorized("Invalid credentials"); + } + + try + { + var tokenHandler = new JwtSecurityTokenHandler(); + var key = Encoding.UTF8.GetBytes(_jwtKeyProvider.Key); + + var tokenDescriptor = new SecurityTokenDescriptor + { + Subject = new ClaimsIdentity(new[] + { + new Claim(ClaimTypes.NameIdentifier, user.Id.ToString()), + new Claim(ClaimTypes.Name, user.Name), + new Claim("email", user.Email) + }), + Expires = DateTime.UtcNow.AddHours(_tokenExpireTime), + SigningCredentials = new SigningCredentials( + new SymmetricSecurityKey(key), + SecurityAlgorithms.HmacSha256Signature) + }; + + var token = tokenHandler.CreateToken(tokenDescriptor); + var tokenString = tokenHandler.WriteToken(token); + + //Console.WriteLine($"Token created: {tokenString.Substring(0, Math.Min(50, tokenString.Length))}..."); + + return Ok(new + { + Token = tokenString, + ExpiresAt = DateTime.UtcNow.AddHours(_tokenExpireTime), + Name = user.Name, + Surname = user.Surname + }); + } + catch (Exception ex) + { + Console.WriteLine($"Error creating token: {ex.Message}"); + return StatusCode(500, $"Error creating token: {ex.Message}"); + } + } + + private bool IsValidEmail(string email) + { + try + { + var addr = new System.Net.Mail.MailAddress(email); + return addr.Address == email; + } + catch + { + return false; + } + } +} diff --git a/src/backend/Controllers/CommitmentController.cs b/src/backend/Controllers/CommitmentController.cs new file mode 100644 index 0000000..657df5a --- /dev/null +++ b/src/backend/Controllers/CommitmentController.cs @@ -0,0 +1,36 @@ +namespace backend.Controllers; + +using backend.DTOs; +using backend.Extensions; +using backend.Services; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; + +[Route("api/[controller]")] +public class CommitmentController : ControllerBase +{ + private readonly CommitmentService _commitmentService; + + public CommitmentController(CommitmentService commitmentService) + { + _commitmentService = commitmentService; + } + + [HttpGet("all-commitments")] + [Authorize] + public async Task>> GetCommitments() + { + var currentUserId = User.GetUserId(); + + try + { + var commitments = await _commitmentService.GetCommitmentsForUser(currentUserId); + return Ok(commitments); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } +} diff --git a/src/backend/Controllers/ConfigController.cs b/src/backend/Controllers/ConfigController.cs new file mode 100644 index 0000000..3232244 --- /dev/null +++ b/src/backend/Controllers/ConfigController.cs @@ -0,0 +1,19 @@ +namespace backend.Controllers; + +using Microsoft.AspNetCore.Mvc; + +[ApiController] +[Route("api/[controller]")] +public class ConfigController : ControllerBase +{ + + public ConfigController(){} + + + [HttpGet("currency-format")] + public ActionResult GetCurrencyFormat() + { + return Ok(new ConfigDto{ CurrencyFormat = Config.CURRENCY_FORMAT }); + } + +} diff --git a/src/backend/Controllers/ExchangeRateController.cs b/src/backend/Controllers/ExchangeRateController.cs new file mode 100644 index 0000000..0742266 --- /dev/null +++ b/src/backend/Controllers/ExchangeRateController.cs @@ -0,0 +1,108 @@ +namespace backend.Controllers; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using System.Text.Json; + +using backend.Extensions; +using backend.DTOs; +using Microsoft.Extensions.Caching.Memory; + +[ApiController] +[Route("api/[controller]")] +public class ExchangeRateController : ControllerBase +{ + private readonly HttpClient _httpClient; + private readonly IMemoryCache _cache; + + public ExchangeRateController(IMemoryCache cache, IHttpClientFactory httpClientFactory) + { + _cache = cache; + _httpClient = httpClientFactory.CreateClient(); + } + + private const int CacheExpirationHours = 24; + + [HttpGet("current-rate")] + [Authorize] + public async Task GetMonthlyRates() + { + var currency = Config.CURRENCY_CODE; + if (currency == Config.CurrencyCodes.CZK) + return BadRequest("CZK currency does not require exchange rate."); + + // Cache key – unique per currency + var cacheKey = $"cnb-rate-{currency}"; + + // Try to get from cache first + if (_cache.TryGetValue(cacheKey, out ExchangeRateDto? cached)) + { + return Ok(cached); + } + + try + { + // Not in cache -- fetch from API + var now = DateTime.UtcNow; + var yearMonth = now.ToString("yyyy-MM"); + + var response = await _httpClient.GetAsync( + $"https://api.cnb.cz/cnbapi/exrates/daily-currency-month?currency={currency}&yearMonth={yearMonth}" + ); + + if (!response.IsSuccessStatusCode) + return StatusCode((int)response.StatusCode, "Failed to fetch exchange rates from CNB."); + + var json = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); + + if (!json.TryGetProperty("rates", out var rates)) + return NotFound("Exchange rate data not found in API response."); + + var ratesArray = rates.EnumerateArray().ToList(); + if (ratesArray.Count == 0) + { + // Handle empty month (e.g., early January) -- try previous month + var previousMonth = now.AddMonths(-1).ToString("yyyy-MM"); + response = await _httpClient.GetAsync( + $"https://api.cnb.cz/cnbapi/exrates/daily-currency-month?currency={currency}&yearMonth={previousMonth}" + ); + + if (!response.IsSuccessStatusCode) + return NotFound("No exchange rates available for current or previous month."); + + json = JsonSerializer.Deserialize(await response.Content.ReadAsStringAsync()); + if (!json.TryGetProperty("rates", out rates)) + return NotFound("Exchange rate data not found."); + + ratesArray = rates.EnumerateArray().ToList(); + if (ratesArray.Count == 0) + return NotFound("No exchange rates available."); + } + + var last = ratesArray.Last(); + var rate = last.GetProperty("rate").GetDecimal(); + var date = last.GetProperty("validFor").GetString(); + + var result = new ExchangeRateDto + { + Rate = rate, + Date = date ?? string.Empty + }; + + // Cache for 24 hours + _cache.Set(cacheKey, result, + new MemoryCacheEntryOptions() + .SetAbsoluteExpiration(TimeSpan.FromHours(CacheExpirationHours))); + + return Ok(result); + } + catch (JsonException ex) + { + return StatusCode(500, $"Failed to parse exchange rate data: {ex.Message}"); + } + catch (Exception ex) + { + return StatusCode(500, $"An error occurred while fetching exchange rates: {ex.Message}"); + } + } +} diff --git a/src/backend/Controllers/ItemController.cs b/src/backend/Controllers/ItemController.cs new file mode 100644 index 0000000..2b71925 --- /dev/null +++ b/src/backend/Controllers/ItemController.cs @@ -0,0 +1,45 @@ +namespace backend.Controllers; + +using backend.Data; +using backend.DTOs; +using backend.Extensions; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +[Route("api/[controller]")] +public class ItemController : ControllerBase +{ + private readonly AppDbContext _db; + public ItemController(AppDbContext db) + { + _db = db; + } + + [HttpGet("items-by-purchase/{purchaseId}")] + [Authorize] + public async Task>> GetItemsByPurchase(int purchaseId) + { + var authUserId = User.GetUserId(); + + var items = await _db.Items + .AsNoTracking() + .Where(i => i.PurchaseId == purchaseId) // filter by purchase + .Select(i => new ItemDto + { + Id = i.Id, + Name = i.Name, + Quantity = i.Quantity, + PriceInCents = i.PricePerPiece + }) + .ToListAsync(); + + if (items == null || items.Count == 0) + { + return Ok(new { }); + } + + return Ok(items); + } +} diff --git a/src/backend/Controllers/PendingUserController.cs b/src/backend/Controllers/PendingUserController.cs new file mode 100644 index 0000000..eb61275 --- /dev/null +++ b/src/backend/Controllers/PendingUserController.cs @@ -0,0 +1,76 @@ +namespace backend.Controllers; + +using backend.Data; +using backend.DTOs; +using backend.Extensions; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +[ApiController] +[Route("api/[controller]")] +public class PendingUserController : ControllerBase +{ + private readonly AppDbContext _db; + + public PendingUserController(AppDbContext db) + { + _db = db; + } + + [HttpGet("all")] + [Authorize] + public async Task>> GetAll() + { + var userId = User.GetUserId(); + + var user = await _db.Users + .Include(u => u.Role) + .FirstOrDefaultAsync(u => u.Id == userId); + + if (user == null) + return NotFound(); + + if (user.Role.Code != Config.RoleCodes.ADMIN) + return Forbid(); + + var pendingUsers = await _db.PendingUsers + .Select(u => new PendingUserRequestDto + { + Id = u.Id, + Name = u.Name, + Surname = u.Surname, + AccountNumber = u.AccountNumber, + Email = u.Email, + RequestedAt = u.RequestedAt, + IsRead = u.IsRead + }) + //.OrderByDescending(u => u.RequestedAt) + .ToListAsync(); + + return Ok(pendingUsers); + } + + [HttpPost("mark-as-read/{id}")] + [Authorize] + public async Task MarkAsRead(int id) + { + var currentUserId = User.GetUserId(); + + var currentUser = await _db.Users.Include(u => u.Role).FirstOrDefaultAsync(u => u.Id == currentUserId); + if (currentUser == null) + return Unauthorized("User not found."); + + if (currentUser.Role == null || currentUser.Role.Code != Config.RoleCodes.ADMIN) + return Forbid(); + + var pendingUser = await _db.PendingUsers.FirstOrDefaultAsync(tl => tl.Id == id); + if (pendingUser == null) + return NotFound("Pending user not found."); + + pendingUser.IsRead = true; + await _db.SaveChangesAsync(); + return Ok(); + } +} diff --git a/src/backend/Controllers/PurchaseController.cs b/src/backend/Controllers/PurchaseController.cs new file mode 100644 index 0000000..e3784f4 --- /dev/null +++ b/src/backend/Controllers/PurchaseController.cs @@ -0,0 +1,355 @@ +namespace backend.Controllers; + +using backend.Data; +using backend.DTOs; +using backend.Extensions; +using backend.Mappers; +using backend.Models; +using backend.Services; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using System; + +[ApiController] +[Route("api/[controller]")] +public class PurchaseController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly TransactionLogService _transactionLogService; + + public PurchaseController(AppDbContext db, TransactionLogService transactionLogService) + { + _db = db; + _transactionLogService = transactionLogService; + } + + [HttpGet("all-purchases")] + [Authorize] + public async Task>> GetAll() + { + var userId = User.GetUserId(); + + var result = await _db.Purchases + .AsNoTracking() // better for read-only queries + //.OrderByDescending(p => p.Timestamp) + .Select(p => new GetPurchaseDto + { + Id = p.Id, + Name = p.Name, + BuyerName = p.Buyer.Name + " " + p.Buyer.Surname, + CoPayers = p.CoPayers.Select(cp => new UserDto + { + Id = cp.User.Id, + Name = cp.User.Name, + Surname = cp.User.Surname, + AccountNumber = cp.User.AccountNumber, + Email = cp.User.Email, + RoleCode = cp.User.Role.Code + }).ToList(), + Timestamp = p.Timestamp, + ShopName = p.Shop, + PriceInCents = p.PriceInCents, + BuyerPays = p.BuyerPays + }) + .ToListAsync(); + + return Ok(result); + } + + [HttpGet("my-purchases")] + [Authorize] + public async Task>> GetMy() + { + var userId = User.GetUserId(); + + var result = await _db.Purchases + .AsNoTracking() // better for read-only queries + //.OrderByDescending(p => p.Timestamp) + .Where(p => p.Buyer.Id == userId) + .Select(p => new GetPurchaseDto + { + Id = p.Id, + Name = p.Name, + BuyerName = p.Buyer.Name + " " + p.Buyer.Surname, + CoPayers = p.CoPayers.Select(cp => new UserDto + { + Id = cp.User.Id, + Name = cp.User.Name, + Surname = cp.User.Surname, + AccountNumber = cp.User.AccountNumber, + Email = cp.User.Email, + RoleCode = cp.User.Role.Code + }).ToList(), + Timestamp = p.Timestamp, + ShopName = p.Shop, + PriceInCents = p.PriceInCents, + BuyerPays = p.BuyerPays + }) + .ToListAsync(); + + return Ok(result); + } + + [HttpPost("add-purchase")] + [Authorize] + public async Task> Add([FromBody] PostPurchaseDto dto, CancellationToken ct) + { + if (!IsValidPurchase(dto)) return BadRequest("Invalid purchase data."); + + var authUserId = User.GetUserId(); + + if (dto.BuyerId != authUserId) + return Forbid(); + + var buyer = await _db.Users.FirstOrDefaultAsync(u => u.Id == dto.BuyerId, ct); + if (buyer == null) + return BadRequest($"Buyer with given ID does not exist."); + + // Load co-payers + var coPayers = await _db.Users + .Where(u => dto.CoPayerIds.Contains(u.Id) && u.Id != dto.BuyerId) + .ToListAsync(ct); + + // Mapping using mapper + var purchase = PurchaseMapper.ToEntity(dto, buyer, coPayers); + + using var transactionScope = await _db.Database.BeginTransactionAsync(ct); + try + { + // Adds purchase to the database + _db.Purchases.Add(purchase); + + // Creates needed transactions to this purchase + await _transactionLogService.AddPurchaseTxAsync(purchase, ct); + + await _db.SaveChangesAsync(ct); + await transactionScope.CommitAsync(ct); + } + catch (Exception ex) + { + await transactionScope.RollbackAsync(ct); + return BadRequest(ex.Message); + } + + // Reload purchase with all related data for DTO mapping + var savedPurchase = await _db.Purchases + .Include(p => p.Buyer) + .Include(p => p.CoPayers) + .ThenInclude(cp => cp.User) + .ThenInclude(u => u.Role) + .FirstOrDefaultAsync(p => p.Id == purchase.Id, ct); + + if (savedPurchase == null) + return NotFound("Purchase not found after save."); + + // Return newly saved purchase as DTO (outside transaction) + return Ok(PurchaseMapper.ToGetDto(savedPurchase)); + } + + [HttpDelete("{id}")] + [Authorize] + public async Task Delete(int id, CancellationToken ct) + { + var authUserId = User.GetUserId(); + + var purchase = await _db.Purchases + .Include(p => p.Buyer) + .Include(p => p.CoPayers) + .ThenInclude(cp => cp.User) + .FirstOrDefaultAsync(p => p.Id == id, ct); + + if (purchase == null) + return NotFound(); + + if (purchase.Buyer.Id != authUserId) + return Forbid(); + + // Creates transaction log for removing purchase + try + { + await _transactionLogService.RemovePurchaseTxAsync(id, ct); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + + _db.Purchases.Remove(purchase); + await _db.SaveChangesAsync(ct); + + return NoContent(); + } + + [HttpPut("update")] + [Authorize] + public async Task> Update([FromBody] PostPurchaseDto dto, CancellationToken ct) + { + var authUserId = User.GetUserId(); + + if (dto.BuyerId != authUserId) + return Forbid(); + + if (dto == null || dto.Id == 0 || !IsValidPurchase(dto)) + return BadRequest("Invalid purchase data."); + + // Select purchase + var purchase = await _db.Purchases + .Include(p => p.Buyer) + .Include(p => p.Items) + .Include(p => p.CoPayers) + .ThenInclude(cp => cp.User) + .FirstOrDefaultAsync(p => p.Id == dto.Id); + + if (purchase == null) + return NotFound("Purchase not found."); + + // Create a deep copy for comparison + var oldPurchase = PurchaseMapper.ToRawDto(purchase); + + if (oldPurchase == null) + return NotFound("Cannot copy purchase."); + + + /* + * Basic fields update + */ + purchase.Name = dto.Name; + purchase.Timestamp = dto.Timestamp; + purchase.Shop = dto.ShopName; + purchase.PriceInCents = PurchaseMapper.CalculatePriceFromItems(dto); + purchase.BuyerPays = dto.BuyerPays; + + /* + * Items update + */ + var existingItems = purchase.Items.ToList(); + + // List IDs from DTO + var newItemIds = dto.Items.Select(i => i.Id).ToHashSet(); + + // Delete removed items + foreach (var item in existingItems) + { + if (!newItemIds.Contains(item.Id)) + { + _db.Items.Remove(item); + } + } + + // Update and add new items + foreach (var dtoItem in dto.Items) + { + var existing = existingItems.FirstOrDefault(i => i.Id == dtoItem.Id); + + if (existing != null) + { + // Update existing + existing.Name = dtoItem.Name; + existing.PricePerPiece = dtoItem.PriceInCents; + existing.Quantity = dtoItem.Quantity; + } + else + { + // Add new + purchase.Items.Add(new Item + { + Name = dtoItem.Name, + PricePerPiece = dtoItem.PriceInCents, + Quantity = dtoItem.Quantity, + PurchaseId = purchase.Id + }); + } + } + + /* + * CoPayers update + */ + + // Load coPayers + var newCoPayers = await _db.Users + .Where(u => dto.CoPayerIds.Contains(u.Id) && u.Id != dto.BuyerId) + .ToListAsync(ct); + + var existingCoPayers = purchase.CoPayerUsers; + + // Add new coPayers + foreach (var ncp in newCoPayers) + { + if (!existingCoPayers.Any(ocp => ocp.Id == ncp.Id)) + { + purchase.CoPayers.Add(new CoPayer + { + UserId = ncp.Id, + User = ncp + }); + } + } + + // Remove old coPayers + foreach (var ecp in existingCoPayers) + { + if (!newCoPayers.Any(ncp => ncp.Id == ecp.Id)) + { + var toRemove = purchase.CoPayers.FirstOrDefault(cp => cp.UserId == ecp.Id); + if (toRemove != null) + { + purchase.CoPayers.Remove(toRemove); + //_db.CoPayers.Remove(toRemove); // cascade + } + } + } + + /* + * Transactions update + */ + // Creates transaction log for updating purchase + using var transactionScope = await _db.Database.BeginTransactionAsync(ct); + try + { + // Group ID + Guid guid = Guid.NewGuid(); + + // Updates transaction logs - remove old and add new coPayers + await _transactionLogService.UpdatePurchaseCoPayersInTxAsync(oldPurchase, purchase, ct, guid); + + await _db.SaveChangesAsync(ct); + await transactionScope.CommitAsync(ct); + } + catch (Exception ex) + { + await transactionScope.RollbackAsync(ct); + return BadRequest(ex.Message); + } + + // Reload purchase with all related data for DTO mapping + var savedPurchase = await _db.Purchases + .Include(p => p.Buyer) + .Include(p => p.CoPayers) + .ThenInclude(cp => cp.User) + .ThenInclude(u => u.Role) + .FirstOrDefaultAsync(p => p.Id == purchase.Id, ct); + + if (savedPurchase == null) + return NotFound("Purchase not found after update."); + + // Return newly saved purchase as DTO + return Ok(PurchaseMapper.ToGetDto(savedPurchase)); + } + + private bool IsValidPurchase(PostPurchaseDto dto) + { + if (string.IsNullOrWhiteSpace(dto.Name) || PurchaseMapper.CalculatePriceFromItems(dto) <= 0 || dto.BuyerId <= 0) + return false; + if (dto.CoPayerIds == null || !dto.CoPayerIds.Any()) + return false; + if (dto.Items == null || !dto.Items.Any()) + return false; + + + return true; + } + + +} diff --git a/src/backend/Controllers/TransactionLogController.cs b/src/backend/Controllers/TransactionLogController.cs new file mode 100644 index 0000000..461ac16 --- /dev/null +++ b/src/backend/Controllers/TransactionLogController.cs @@ -0,0 +1,206 @@ +namespace backend.Controllers; + +using backend.Data; +using backend.Extensions; +using backend.DTOs; +using backend.Mappers; +using backend.Models; +using backend.Services; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +[ApiController] +[Route("api/[controller]")] +public class TransactionLogController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly CommitmentService _commitmentService; + + public TransactionLogController(AppDbContext db, CommitmentService commitmentService) + { + _db = db; + _commitmentService = commitmentService; + } + + + [HttpGet("logs-between-users")] + [Authorize] + public async Task>> GetLogsBetweenUsers([FromQuery] int userId1, [FromQuery] int userId2) + { + var currentUserId = User.GetUserId(); + + if (userId1 != currentUserId && userId2 != currentUserId) + return Forbid(); + + var ttPendingSettlement = await _db.TransactionTypes.FirstOrDefaultAsync(tt => tt.Code == Config.TransactionTypeCodes.PENDING_SETTLEMENT); + + if (ttPendingSettlement == null) + return BadRequest("Transaction type not found."); + + var logs = await _db.TransactionLogs + .Include(tl => tl.TransactionType) + .Include(tl => tl.FromUser) + .Include(tl => tl.ToUser) + .Where(tl => + ((tl.FromUserId == userId1 && tl.ToUserId == userId2) || + (tl.FromUserId == userId2 && tl.ToUserId == userId1)) && + (tl.TransactionTypeId != ttPendingSettlement.Id)) + //.OrderByDescending(tl => tl.Timestamp) + .Select(tx => TransactionLogMapper.ToGetDto(tx)) + .ToListAsync(); + + return Ok(logs); + } + + [HttpPost("settle")] + [Authorize] + public async Task>> AddSettlement([FromBody] PostTransactionLogDto dto) + { + var authUserId = User.GetUserId(); + + // Find users by name (or you may want to use IDs instead) + var FromUser = await _db.Users.FirstOrDefaultAsync(u => u.Id == dto.FromUserId); + var ToUser = await _db.Users.FirstOrDefaultAsync(u => u.Id == dto.ToUserId); + + if (FromUser == null || ToUser == null) + return BadRequest("User(s) not found."); + + if (ToUser.Id != authUserId) + return Forbid(); + + // Validate input + if (dto == null || dto.AmountInCents <= 0 || dto.FromUserId == 0 || dto.ToUserId == 0) + return BadRequest("Invalid data."); + + // Find a default TransactionType (or set a fixed one) + var addSettlementType = await _db.TransactionTypes.FirstOrDefaultAsync(tt => tt.Code == Config.TransactionTypeCodes.ADD_SETTLEMENT); + var pendingSettlementType = await _db.TransactionTypes.FirstOrDefaultAsync(tt => tt.Code == Config.TransactionTypeCodes.PENDING_SETTLEMENT); + + if (addSettlementType == null || pendingSettlementType == null) + return BadRequest("Transaction type not found."); + + var pendingSettlementLog = await _db.TransactionLogs + .Where(tx => tx.FromUserId == dto.FromUserId && tx.ToUserId == dto.ToUserId && tx.TransactionTypeId == pendingSettlementType.Id) + .OrderByDescending(tx => tx.Id) + .FirstOrDefaultAsync(); + + if (pendingSettlementLog == null) + return Unauthorized("Debtor did not set the debt as paid."); + + var settlementLog = await _db.TransactionLogs + .FirstOrDefaultAsync(tx => tx.FromUserId == dto.FromUserId + && tx.ToUserId == dto.ToUserId + && tx.TransactionTypeId == addSettlementType.Id + && tx.GroupId == pendingSettlementLog.GroupId); + + if (settlementLog != null) + return Unauthorized("This debt has already been settled."); + + + var log = new TransactionLog + { + FromUserId = dto.FromUserId, + ToUserId = dto.ToUserId, + Amount = dto.AmountInCents, + Timestamp = DateTimeOffset.UtcNow, + TransactionTypeId = addSettlementType.Id, + GroupId = pendingSettlementLog.GroupId, + Note = $"Kupující {FromUser.Name} {FromUser.Surname} zaplatil {ToUser.Name} {ToUser.Surname} hodnotu {dto.AmountInCents / 100.00} {Config.CURRENCY_FORMAT}" + }; + + _db.TransactionLogs.Add(log); + await _db.SaveChangesAsync(); + + try + { + var newCommitments = await _commitmentService.GetCommitmentsForUser(authUserId); + return Ok(newCommitments); + } + catch (InvalidOperationException ex) + { + return BadRequest(ex.Message); + } + } + + [HttpGet("my-notifications")] + [Authorize] + public async Task>> GetMyNotifications() + { + var currentUserId = User.GetUserId(); + + var notifications = await _db.TransactionLogs + .Include(tl => tl.FromUser) + .Include(tl => tl.ToUser) + .Include(tl => tl.TransactionType) + .Where(tl => tl.ToUserId == currentUserId) // && tl.TransactionType.Code == "add_settlement" + .Select(g => new NotificationDto + { + Id = g.Id, + Type = g.TransactionType.Code, + Header = g.TransactionType.DisplayName, + Text = $"Od {g.FromUser.Name} {g.FromUser.Surname} za {g.Amount / 100.00} {Config.CURRENCY_FORMAT}", + Timestamp = g.Timestamp, + IsRead = g.IsRead, + }) + //.OrderByDescending(s => s.Timestamp) + .ToListAsync(); + + return Ok(notifications); + } + + [HttpPost("mark-as-read/{id}")] + [Authorize] + public async Task MarkAsRead(int id) + { + var currentUserId = User.GetUserId(); + + var log = await _db.TransactionLogs.FirstOrDefaultAsync(tl => tl.Id == id && tl.ToUserId == currentUserId); + if (log == null) + { + return NotFound("Transaction log not found."); + } + log.IsRead = true; + await _db.SaveChangesAsync(); + return Ok(); + } + + [HttpPost("pending-settle")] + [Authorize] + public async Task CreatePendingSettlement([FromBody] PostTransactionLogDto dto) + { + var currentUserId = User.GetUserId(); + + // Find users by name (or you may want to use IDs instead) + var FromUser = await _db.Users.FirstOrDefaultAsync(u => u.Id == dto.FromUserId); + var ToUser = await _db.Users.FirstOrDefaultAsync(u => u.Id == dto.ToUserId); + + + var transactionType = await _db.TransactionTypes.FirstOrDefaultAsync(tt => tt.Code == Config.TransactionTypeCodes.PENDING_SETTLEMENT); + + if (FromUser == null || ToUser == null || transactionType == null) + return BadRequest("User(s) or transaction type not found."); + + if (FromUser.Id != currentUserId) + return Forbid(); + + var guid = Guid.NewGuid(); + + var log = new TransactionLog + { + FromUserId = dto.FromUserId, + ToUserId = dto.ToUserId, + Amount = dto.AmountInCents, + Timestamp = DateTimeOffset.UtcNow, + TransactionTypeId = transactionType.Id, + GroupId = guid, + Note = $"Kupující {FromUser.Name} {FromUser.Surname} označil uživateli {ToUser.Name} {ToUser.Surname} hodnotu {dto.AmountInCents / 100.00} {Config.CURRENCY_FORMAT} jako zaplacenou" + }; + + _db.TransactionLogs.Add(log); + await _db.SaveChangesAsync(); + return Ok(); + } + +} diff --git a/src/backend/Controllers/UserController.cs b/src/backend/Controllers/UserController.cs new file mode 100644 index 0000000..487db3f --- /dev/null +++ b/src/backend/Controllers/UserController.cs @@ -0,0 +1,179 @@ +namespace backend.Controllers; + +using backend.Data; +using backend.Models; +using backend.DTOs; +using backend.Mappers; + +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; +using backend.Extensions; + +[ApiController] +[Route("api/[controller]")] +public class UserController : ControllerBase +{ + private readonly AppDbContext _db; + + public UserController(AppDbContext db) + { + _db = db; + } + + [HttpGet("all")] + [Authorize] + public async Task>> GetAll() + { + var userIdClaim = User.GetUserId(); + + return await _db.Users + .Include(u => u.Role) + .Select(u => new UserDto + { + Id = u.Id, + Name = u.Name, + Surname = u.Surname, + AccountNumber = u.AccountNumber, + Email = u.Email, + RoleCode = u.Role.Code + }) + .ToListAsync(); + } + + [HttpGet("me")] + [Authorize] + public async Task> GetCurrent() + { + var userId = User.GetUserId(); + + var user = await _db.Users + .Include(u => u.Role) + .FirstOrDefaultAsync(u => u.Id == userId); + if (user == null) + return NotFound(); + + return new UserDto + { + Id = user.Id, + Name = user.Name, + Surname = user.Surname, + AccountNumber = user.AccountNumber, + Email = user.Email, + RoleCode = user.Role.Code + }; + } + + [HttpPost("register-{id}")] + public async Task Register(int id) + { + // Creates user from pending user with given id + + var pendingUser = await _db.PendingUsers.FindAsync(id); + var userRole = await _db.Roles.FirstOrDefaultAsync(r => r.Code == Config.RoleCodes.USER); + + + if (pendingUser == null) + { + return NotFound("Pending user not found."); + } + var existingUser = await _db.Users.FirstOrDefaultAsync(u => u.Email == pendingUser.Email); + + if (existingUser != null) + { + return Conflict("User with this email already exists."); + } + + if (userRole == null) + { + return NotFound("User role not found."); + } + + return await CreateUser(pendingUser, userRole); + } + + private async Task CreateUser(PendingUser pendingUser, Role role) + { + var newUser = new User + { + Name = pendingUser.Name, + Surname = pendingUser.Surname, + AccountNumber = pendingUser.AccountNumber, + Email = pendingUser.Email, + PasswordHash = pendingUser.PasswordHash, + Role = role + }; + + _db.Users.Add(newUser); + _db.PendingUsers.Remove(pendingUser); + await _db.SaveChangesAsync(); + + return Ok(new { message = "Registration request submitted successfully", id = newUser.Id }); + } + + [HttpPost("request-registration")] + public async Task RequestRegistration([FromBody] RegisterUserDto userDto) + { + // Creates a pending user registration request + var errors = UserValidator.ValidateRegisterForm(userDto); + if (errors.Count > 0) + return BadRequest(errors); + + var existingUser = await _db.Users.FirstOrDefaultAsync(u => u.Email == userDto.Email); + if (existingUser != null) + return Conflict("User with this email already exists."); + + + var existingPendingUser = await _db.PendingUsers.FirstOrDefaultAsync(u => u.Email == userDto.Email); + if (existingPendingUser != null) + return Conflict("A registration request with this email already exists."); + + + var newUser = new PendingUser + { + Name = userDto.Name, + Surname = userDto.Surname, + AccountNumber = userDto.AccountNumber, + Email = userDto.Email, + PasswordHash = BCrypt.Net.BCrypt.HashPassword(userDto.Password), + RequestedAt = DateTimeOffset.UtcNow + }; + + _db.PendingUsers.Add(newUser); + await _db.SaveChangesAsync(); + + + // For the first user registration request, create admin user directly + var userCount = await _db.Users.CountAsync(); + var pendingUserCount = await _db.PendingUsers.CountAsync(); + + if (userCount == 0 && pendingUserCount == 1) + { + var adminRole = await _db.Roles.FirstOrDefaultAsync(r => r.Code == Config.RoleCodes.ADMIN); + if (adminRole == null) + return NotFound("Admin role not found."); + + return await CreateUser(newUser, adminRole); + } + + return Ok(new { message = "Registration request submitted successfully", id = newUser.Id }); + } + + [HttpGet("{id}")] + [Authorize] + public async Task> GetById(int id) + { + var userId = User.GetUserId(); + + var user = await _db.Users.FindAsync(id); + if (user == null) + return NotFound(); + + var role = await _db.Roles.FindAsync(user.RoleId); + if (role == null) + return NotFound(); + + return Ok(UserMapper.ToDto(user, role.Code)); + } + +} diff --git a/src/backend/DTOs/CommitmentDto.cs b/src/backend/DTOs/CommitmentDto.cs new file mode 100644 index 0000000..0a3d8da --- /dev/null +++ b/src/backend/DTOs/CommitmentDto.cs @@ -0,0 +1,10 @@ +namespace backend.DTOs; + +public class CommitmentDto +{ + public required int FromUserId { get; set; } + public required int ToUserId { get; set; } + public required string ToName { get; set; } = default!; + public required string State { get; set; } + public required int TotalInCents { get; set; } +} diff --git a/src/backend/DTOs/ConfigDto.cs b/src/backend/DTOs/ConfigDto.cs new file mode 100644 index 0000000..6d41338 --- /dev/null +++ b/src/backend/DTOs/ConfigDto.cs @@ -0,0 +1,4 @@ +public class ConfigDto +{ + public string CurrencyFormat { get; set; } = string.Empty; +} \ No newline at end of file diff --git a/src/backend/DTOs/ExchangeRate.cs b/src/backend/DTOs/ExchangeRate.cs new file mode 100644 index 0000000..7d42e0c --- /dev/null +++ b/src/backend/DTOs/ExchangeRate.cs @@ -0,0 +1,7 @@ +namespace backend.DTOs; + +public class ExchangeRateDto +{ + public required string Date { get; set; } + public required decimal Rate { get; set; } +} \ No newline at end of file diff --git a/src/backend/DTOs/GetPurchaseDto.cs b/src/backend/DTOs/GetPurchaseDto.cs new file mode 100644 index 0000000..fada369 --- /dev/null +++ b/src/backend/DTOs/GetPurchaseDto.cs @@ -0,0 +1,14 @@ +namespace backend.DTOs; +using backend.Models; + +public class GetPurchaseDto +{ + public int Id { get; set; } + public string Name { get; set; } = default!; + public string BuyerName { get; set; } = default!; + public List CoPayers { get; set; } = new List(); + public DateTimeOffset Timestamp { get; set; } + public string ShopName { get; set; } = default!; + public int PriceInCents { get; set; } // in cents + public bool BuyerPays { get; set; } = true; // Default to true if not specified +} diff --git a/src/backend/DTOs/GetTransactionLogDto.cs b/src/backend/DTOs/GetTransactionLogDto.cs new file mode 100644 index 0000000..1b3b279 --- /dev/null +++ b/src/backend/DTOs/GetTransactionLogDto.cs @@ -0,0 +1,14 @@ +using backend.Models; + +namespace backend.DTOs; + +public class GetTransactionLogDto +{ + public int Id { get; set; } + public DateTimeOffset Date { get; set; } + public string Type { get; set; } = default!; + public string FromName { get; set; } = default!; + public string ToName { get; set; } = default!; + public int Amount { get; set; } // in cents + public string? Description { get; set; } +} diff --git a/src/backend/DTOs/ItemDto.cs b/src/backend/DTOs/ItemDto.cs new file mode 100644 index 0000000..7f72398 --- /dev/null +++ b/src/backend/DTOs/ItemDto.cs @@ -0,0 +1,9 @@ +namespace backend.DTOs; + +public class ItemDto +{ + public int Id { get; set; } + public string Name { get; set; } = default!; + public int Quantity { get; set; } + public int PriceInCents { get; set; } +} diff --git a/src/backend/DTOs/LoginDto.cs b/src/backend/DTOs/LoginDto.cs new file mode 100644 index 0000000..92eaf41 --- /dev/null +++ b/src/backend/DTOs/LoginDto.cs @@ -0,0 +1,7 @@ +namespace backend.DTOs; + +public class LoginDto +{ + public string Email { get; set; } = default!; + public string Password { get; set; } = default!; +} diff --git a/src/backend/DTOs/NotificationDto.cs b/src/backend/DTOs/NotificationDto.cs new file mode 100644 index 0000000..8b8a047 --- /dev/null +++ b/src/backend/DTOs/NotificationDto.cs @@ -0,0 +1,11 @@ +namespace backend.DTOs; + +public class NotificationDto +{ + public int Id { get; set; } + public string Type { get; set; } = default!; + public string Header { get; set; } = default!; + public string Text { get; set; } = default!; + public DateTimeOffset Timestamp { get; set; } + public bool IsRead { get; set; } = false; +} diff --git a/src/backend/DTOs/PendingUserRequestDto.cs b/src/backend/DTOs/PendingUserRequestDto.cs new file mode 100644 index 0000000..bf77bba --- /dev/null +++ b/src/backend/DTOs/PendingUserRequestDto.cs @@ -0,0 +1,12 @@ +namespace backend.DTOs; + +public class PendingUserRequestDto +{ + public required int Id { get; set; } + public required string Name { get; set; } + public required string Surname { get; set; } + public required string AccountNumber { get; set; } + public required string Email { get; set; } + public required bool IsRead { get; set; } + public required DateTimeOffset RequestedAt { get; set; } +} \ No newline at end of file diff --git a/src/backend/DTOs/PostPurchaseDto.cs b/src/backend/DTOs/PostPurchaseDto.cs new file mode 100644 index 0000000..ac8f720 --- /dev/null +++ b/src/backend/DTOs/PostPurchaseDto.cs @@ -0,0 +1,13 @@ +namespace backend.DTOs; + +public class PostPurchaseDto +{ + public int Id { get; set; } + public string Name { get; set; } = default!; + public int BuyerId { get; set; } = default!; + public List CoPayerIds { get; set; } = new List(); // User ids + public DateTimeOffset Timestamp { get; set; } + public string ShopName { get; set; } = default!; + public List Items { get; set; } = new List(); + public bool BuyerPays { get; set; } = true; // Default to true if not specified +} diff --git a/src/backend/DTOs/PostTransactionLogDto.cs b/src/backend/DTOs/PostTransactionLogDto.cs new file mode 100644 index 0000000..3da4c5f --- /dev/null +++ b/src/backend/DTOs/PostTransactionLogDto.cs @@ -0,0 +1,8 @@ +namespace backend.DTOs; + +public class PostTransactionLogDto +{ + public int FromUserId { get; set; } + public int ToUserId { get; set; } + public int AmountInCents { get; set; } // in cents +} diff --git a/src/backend/DTOs/RawPurchaseDto.cs b/src/backend/DTOs/RawPurchaseDto.cs new file mode 100644 index 0000000..a8b9b31 --- /dev/null +++ b/src/backend/DTOs/RawPurchaseDto.cs @@ -0,0 +1,31 @@ +using backend.Models; +using System.ComponentModel.DataAnnotations.Schema; + +namespace backend.DTOs; + +public class RawPurchaseDto +{ + public int Id { get; set; } + + public string Name { get; set; } = default!; + + public int PriceInCents { get; set; } // in cents + + public bool BuyerPays { get; set; } + + public DateTimeOffset Timestamp { get; set; } + public string Shop { get; set; } = default!; + + + // Foreign key to User + public int BuyerId { get; set; } + + // Navigation properties + public virtual User Buyer { get; set; } = default!; + public virtual ICollection Items { get; set; } = new List(); + + // Many-to-many relationship with User through CoPayer + public virtual ICollection CoPayers { get; set; } = new List(); + public Purchase PurchaseReference { get; set; } = default!; + public List CoPayerUsers => CoPayers.Select(cp => cp.User).ToList(); +} diff --git a/src/backend/DTOs/RegisterUserDto.cs b/src/backend/DTOs/RegisterUserDto.cs new file mode 100644 index 0000000..e42a789 --- /dev/null +++ b/src/backend/DTOs/RegisterUserDto.cs @@ -0,0 +1,10 @@ +namespace backend.DTOs; + +public class RegisterUserDto +{ + public required string Name { get; set; } + public required string Surname { get; set; } + public required string AccountNumber { get; set; } + public required string Email { get; set; } + public required string Password { get; set; } +} diff --git a/src/backend/DTOs/UserDto.cs b/src/backend/DTOs/UserDto.cs new file mode 100644 index 0000000..329b7c2 --- /dev/null +++ b/src/backend/DTOs/UserDto.cs @@ -0,0 +1,12 @@ +namespace backend.DTOs; + +public class UserDto +{ + public int Id { get; set; } + public string Name { get; set; } = default!; + public string Surname { get; set; } = default!; + public string AccountNumber { get; set; } = default!; + public string Email { get; set; } = default!; + public string RoleCode { get; set; } = default!; + public string FullName => $"{Name} {Surname}"; +} diff --git a/src/backend/Data/AppDbContext.cs b/src/backend/Data/AppDbContext.cs new file mode 100644 index 0000000..dc4d674 --- /dev/null +++ b/src/backend/Data/AppDbContext.cs @@ -0,0 +1,121 @@ +namespace backend.Data; + +using backend.Models; + +using Microsoft.EntityFrameworkCore; + +public class AppDbContext : DbContext +{ + public AppDbContext(DbContextOptions options) : base(options) { } + + public DbSet Users { get; set; } + public DbSet PendingUsers { get; set; } + public DbSet Roles { get; set; } + public DbSet Purchases { get; set; } + public DbSet Items { get; set; } + public DbSet CoPayers { get; set; } + public DbSet TransactionLogs { get; set; } + public DbSet TransactionTypes { get; set; } + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + base.OnModelCreating(modelBuilder); + + // ------------------------------------------------------------ + // 1) Purchase -> Buyer (User) [1:N] + // Convention would handle this (BuyerId + nav Buyer + nav PurchasesBought). + // We explicitly set DeleteBehavior.Restrict so that when deleting a User, + // EF doesn't attempt cascade through multiple tables (SQLite has limitations). + // ------------------------------------------------------------ + modelBuilder.Entity() + .HasOne(p => p.Buyer) + .WithMany(u => u.PurchasesBought) + .HasForeignKey(p => p.BuyerId) + .OnDelete(DeleteBehavior.Restrict); + + // ------------------------------------------------------------ + // 2) CoPayer join: Purchase *..* User + // Convention: PurchaseId + UserId + navs would suffice; but we'll add index + // and prevent duplicates (User cannot be twice in the same purchase). + // ------------------------------------------------------------ + // Create composite primary key from PurchaseId and UserId. + modelBuilder.Entity() + .HasKey(cp => new { cp.PurchaseId, cp.UserId }); + + modelBuilder.Entity() + .HasOne(cp => cp.Purchase) + .WithMany(p => p.CoPayers) + .HasForeignKey(cp => cp.PurchaseId) + .OnDelete(DeleteBehavior.Cascade); // delete Purchase -> delete CoPayers + + modelBuilder.Entity() + .HasOne(cp => cp.User) + .WithMany(u => u.CoPayerIn) + .HasForeignKey(cp => cp.UserId) + .OnDelete(DeleteBehavior.Restrict); // don't delete user automatically + + // ------------------------------------------------------------ + // 3) TransactionLog -> TransactionType [M:1 Lookup] + // ------------------------------------------------------------ + modelBuilder.Entity() + .HasOne(t => t.TransactionType) + .WithMany(tt => tt.TransactionLogs) + .HasForeignKey(t => t.TransactionTypeId) + .OnDelete(DeleteBehavior.Restrict); + + // ------------------------------------------------------------ + // 4) TransactionLog -> FromUser, ToUser (twice to the same entity) + // Convention confused? Better to be explicit. + // ------------------------------------------------------------ + modelBuilder.Entity() + .HasOne(t => t.FromUser) + .WithMany(u => u.TransactionsSent) + .HasForeignKey(t => t.FromUserId) + .OnDelete(DeleteBehavior.Restrict); + + modelBuilder.Entity() + .HasOne(t => t.ToUser) + .WithMany(u => u.TransactionsReceived) + .HasForeignKey(t => t.ToUserId) + .OnDelete(DeleteBehavior.Restrict); + + // ------------------------------------------------------------ + // 5) TransactionLog -> Purchase (optional) + // ------------------------------------------------------------ + modelBuilder.Entity() + .HasOne(t => t.Purchase) + .WithMany() // don't want back collection; or .WithMany(p => p.TransactionLogs) if adding nav + .HasForeignKey(t => t.PurchaseId) + .OnDelete(DeleteBehavior.SetNull); + + // ------------------------------------------------------------ + // 6) Item -> Purchase [1:N] + // Convention would handle this; for demonstration we leave it to EF. + // ------------------------------------------------------------ + + // ------------------------------------------------------------ + // 7) Role -> User [1:N] + // ------------------------------------------------------------ + modelBuilder.Entity() + .HasMany(r => r.Users) + .WithOne(u => u.Role) + .HasForeignKey(u => u.RoleId) + .OnDelete(DeleteBehavior.Restrict); + + // ------------------------------------------------------------ + // SEED: TransactionType lookup (static data) + // IMPORTANT: No runtime calculations; only constants. + // ------------------------------------------------------------ + modelBuilder.Entity().HasData( + new TransactionType { Id = 1, Code = Config.TransactionTypeCodes.ADD_PURCHASE, DisplayName = "Přidání nákupu" }, + new TransactionType { Id = 2, Code = Config.TransactionTypeCodes.ADD_SETTLEMENT, DisplayName = "Přidání platby" }, + new TransactionType { Id = 3, Code = Config.TransactionTypeCodes.REMOVE_PURCHASE, DisplayName = "Zrušení nákupu" }, + new TransactionType { Id = 4, Code = Config.TransactionTypeCodes.PENDING_SETTLEMENT, DisplayName = "Čekající platba" } + ); + + modelBuilder.Entity().HasData( + new Role { Id = 1, Code = Config.RoleCodes.ADMIN, DisplayName = "Administrátor" }, + new Role { Id = 2, Code = Config.RoleCodes.USER, DisplayName = "Uživatel" } + ); + } +} diff --git a/src/backend/Extensions/ClaimsPrincipalExtention.cs b/src/backend/Extensions/ClaimsPrincipalExtention.cs new file mode 100644 index 0000000..22e14d5 --- /dev/null +++ b/src/backend/Extensions/ClaimsPrincipalExtention.cs @@ -0,0 +1,15 @@ +namespace backend.Extensions; + +using System.Security.Claims; + +public static class ClaimsPrincipalExtension +{ + public static int GetUserId(this ClaimsPrincipal user) + { + var id = user.FindFirstValue(ClaimTypes.NameIdentifier); + if (!int.TryParse(id, out var userId)) + throw new UnauthorizedAccessException("Invalid user ID in token."); + + return userId; + } +} \ No newline at end of file diff --git a/src/backend/Mappers/PurchaseMapper.cs b/src/backend/Mappers/PurchaseMapper.cs new file mode 100644 index 0000000..973d33d --- /dev/null +++ b/src/backend/Mappers/PurchaseMapper.cs @@ -0,0 +1,138 @@ +namespace backend.Mappers; + +using backend.DTOs; +using backend.Models; + +public static class PurchaseMapper +{ + public static Purchase ToEntity(PostPurchaseDto dto, User buyer, List coPayers) + { + var purchase = new Purchase + { + Name = dto.Name.Trim(), + BuyerId = dto.BuyerId, + Buyer = buyer, + Timestamp = dto.Timestamp.ToUniversalTime(), + Shop = dto.ShopName.Trim(), + PriceInCents = CalculatePriceFromItems(dto), + BuyerPays = dto.BuyerPays, // Default to true if not specified + }; + + // Items + if (dto.Items?.Count > 0) + { + foreach (var itemDto in dto.Items) + { + purchase.Items.Add(new Item + { + Name = itemDto.Name.Trim(), + Quantity = itemDto.Quantity, + PricePerPiece = itemDto.PriceInCents + }); + } + } + + // CoPayers + AddCoPayers(purchase, coPayers); + + return purchase; + } + + private static void AddCoPayers(Purchase purchase, List coPayers) + { + foreach (var coUser in coPayers) + { + if (!purchase.CoPayers.Any(cp => cp.UserId == coUser.Id)) + { + purchase.CoPayers.Add(new CoPayer + { + UserId = coUser.Id, + User = coUser + }); + } + } + } + + public static PostPurchaseDto ToPostDto(Purchase p) + { + return new PostPurchaseDto + { + Id = p.Id, + Name = p.Name, + BuyerId = p.BuyerId, + CoPayerIds = p.CoPayers.Select(cp => cp.UserId).ToList(), + Timestamp = p.Timestamp, + ShopName = p.Shop, + Items = p.Items.Select(i => new ItemDto + { + Name = i.Name, + Quantity = i.Quantity, + PriceInCents = i.PricePerPiece + }).ToList(), + BuyerPays = p.BuyerPays + }; + } + + public static int CalculatePriceFromItems(PostPurchaseDto dto) + { + int validPrice = 0; + foreach (var item in dto.Items) + { + validPrice += item.PriceInCents * item.Quantity; + } + return validPrice; + } + + public static RawPurchaseDto ToRawDto(Purchase p) + { + return new RawPurchaseDto + { + Id = p.Id, + Name = p.Name, + PriceInCents = p.PriceInCents, + BuyerPays = p.BuyerPays, + Timestamp = p.Timestamp, + Shop = p.Shop, + BuyerId = p.BuyerId, + Buyer = p.Buyer, // Reference is fine since we're not modifying + // Copy collections if needed for comparison + Items = p.Items.Select(i => new Item + { + Id = i.Id, + Name = i.Name, + Quantity = i.Quantity, + PricePerPiece = i.PricePerPiece, + PurchaseId = i.PurchaseId + }).ToList(), + CoPayers = p.CoPayers.Select(cp => new CoPayer + { + UserId = cp.UserId, + User = cp.User // Reference is fine since we're not modifying + }).ToList(), + PurchaseReference = p + }; + } + + public static GetPurchaseDto ToGetDto(Purchase p) + { + return new GetPurchaseDto + { + Id = p.Id, + Name = p.Name, + BuyerName = p.Buyer.Name + " " + p.Buyer.Surname, + CoPayers = p.CoPayers.Select(cp => new UserDto + { + Id = cp.User.Id, + Name = cp.User.Name, + Surname = cp.User.Surname, + AccountNumber = cp.User.AccountNumber, + Email = cp.User.Email, + RoleCode = cp.User.Role.Code + }).ToList(), + Timestamp = p.Timestamp, + ShopName = p.Shop, + PriceInCents = p.PriceInCents, + BuyerPays = p.BuyerPays + }; + } +} diff --git a/src/backend/Mappers/TransactionLogMapper.cs b/src/backend/Mappers/TransactionLogMapper.cs new file mode 100644 index 0000000..84b0bc4 --- /dev/null +++ b/src/backend/Mappers/TransactionLogMapper.cs @@ -0,0 +1,22 @@ +using backend.DTOs; +using backend.Models; + +namespace backend.Mappers; + +public static class TransactionLogMapper +{ + public static GetTransactionLogDto ToGetDto(TransactionLog log) + { + return new GetTransactionLogDto + { + Id = log.Id, + Date = log.Timestamp, + Type = log.TransactionType.DisplayName, + FromName = $"{log.FromUser.Name} {log.FromUser.Surname}", + ToName = $"{log.ToUser.Name} {log.ToUser.Surname}", + Amount = log.Amount, + Description = log.Note + }; + } + +} diff --git a/src/backend/Mappers/UserMapper.cs b/src/backend/Mappers/UserMapper.cs new file mode 100644 index 0000000..9a071bc --- /dev/null +++ b/src/backend/Mappers/UserMapper.cs @@ -0,0 +1,20 @@ +namespace backend.Mappers; + +using backend.Models; +using backend.DTOs; + +public static class UserMapper +{ + public static UserDto ToDto(User user, string rCode) + { + return new UserDto + { + Id = user.Id, + Name = user.Name, + Surname = user.Surname, + AccountNumber = user.AccountNumber, + Email = user.Email, + RoleCode = rCode + }; + } +} diff --git a/src/backend/Middleware/ErrorHandlingMiddleware.cs b/src/backend/Middleware/ErrorHandlingMiddleware.cs new file mode 100644 index 0000000..d3d4da8 --- /dev/null +++ b/src/backend/Middleware/ErrorHandlingMiddleware.cs @@ -0,0 +1,43 @@ +namespace backend.Middleware; + +using System.Net; +using System.Text.Json; + +public class ErrorHandlingMiddleware +{ + private readonly RequestDelegate _next; + private readonly ILogger _logger; + + public ErrorHandlingMiddleware(RequestDelegate next, ILogger logger) + { + _next = next; + _logger = logger; + } + + public async Task InvokeAsync(HttpContext context) + { + try + { + await _next(context); + } + catch (UnauthorizedAccessException ex) + { + _logger.LogWarning(ex, "Unauthorized access"); + await WriteErrorResponse(context, HttpStatusCode.Unauthorized, ex.Message); + } + catch (Exception ex) + { + _logger.LogError(ex, "Unhandled exception"); + await WriteErrorResponse(context, HttpStatusCode.InternalServerError, "An unexpected error occurred."); + } + } + + private static async Task WriteErrorResponse(HttpContext context, HttpStatusCode status, string message) + { + context.Response.ContentType = "application/json"; + context.Response.StatusCode = (int)status; + + var response = new { error = message }; + await context.Response.WriteAsync(JsonSerializer.Serialize(response)); + } +} \ No newline at end of file diff --git a/src/backend/Migrations/20260327223748_InitialCreate.Designer.cs b/src/backend/Migrations/20260327223748_InitialCreate.Designer.cs new file mode 100644 index 0000000..ff60a00 --- /dev/null +++ b/src/backend/Migrations/20260327223748_InitialCreate.Designer.cs @@ -0,0 +1,424 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using backend.Data; + +#nullable disable + +namespace backend.Migrations +{ + [DbContext(typeof(AppDbContext))] + [Migration("20260327223748_InitialCreate")] + partial class InitialCreate + { + /// + protected override void BuildTargetModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + modelBuilder.Entity("backend.Models.CoPayer", b => + { + b.Property("PurchaseId") + .HasColumnType("INTEGER") + .HasColumnOrder(0); + + b.Property("UserId") + .HasColumnType("INTEGER") + .HasColumnOrder(1); + + b.HasKey("PurchaseId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("CoPayers"); + }); + + modelBuilder.Entity("backend.Models.Item", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PricePerPiece") + .HasColumnType("INTEGER"); + + b.Property("PurchaseId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseId"); + + b.ToTable("Items"); + }); + + modelBuilder.Entity("backend.Models.PendingUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountNumber") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RequestedAt") + .HasColumnType("TEXT"); + + b.Property("Surname") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PendingUsers"); + }); + + modelBuilder.Entity("backend.Models.Purchase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BuyerId") + .HasColumnType("INTEGER"); + + b.Property("BuyerPays") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PriceInCents") + .HasColumnType("INTEGER"); + + b.Property("Shop") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BuyerId"); + + b.ToTable("Purchases"); + }); + + modelBuilder.Entity("backend.Models.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Roles"); + + b.HasData( + new + { + Id = 1, + Code = "admin", + DisplayName = "Administrátor" + }, + new + { + Id = 2, + Code = "user", + DisplayName = "Uživatel" + }); + }); + + modelBuilder.Entity("backend.Models.TransactionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("FromUserId") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("IsRead") + .HasColumnType("INTEGER"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("PurchaseId") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("ToUserId") + .HasColumnType("INTEGER"); + + b.Property("TransactionTypeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("FromUserId"); + + b.HasIndex("PurchaseId"); + + b.HasIndex("ToUserId"); + + b.HasIndex("TransactionTypeId"); + + b.ToTable("TransactionLogs"); + }); + + modelBuilder.Entity("backend.Models.TransactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TransactionTypes"); + + b.HasData( + new + { + Id = 1, + Code = "add_purchase", + DisplayName = "Přidání nákupu" + }, + new + { + Id = 2, + Code = "add_settlement", + DisplayName = "Přidání platby" + }, + new + { + Id = 3, + Code = "remove_purchase", + DisplayName = "Zrušení nákupu" + }, + new + { + Id = 4, + Code = "pending_settlement", + DisplayName = "Čekající platba" + }); + }); + + modelBuilder.Entity("backend.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.Property("Surname") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("RoleId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("backend.Models.CoPayer", b => + { + b.HasOne("backend.Models.Purchase", "Purchase") + .WithMany("CoPayers") + .HasForeignKey("PurchaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("backend.Models.User", "User") + .WithMany("CoPayerIn") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Purchase"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("backend.Models.Item", b => + { + b.HasOne("backend.Models.Purchase", "Purchase") + .WithMany("Items") + .HasForeignKey("PurchaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Purchase"); + }); + + modelBuilder.Entity("backend.Models.Purchase", b => + { + b.HasOne("backend.Models.User", "Buyer") + .WithMany("PurchasesBought") + .HasForeignKey("BuyerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Buyer"); + }); + + modelBuilder.Entity("backend.Models.TransactionLog", b => + { + b.HasOne("backend.Models.User", "FromUser") + .WithMany("TransactionsSent") + .HasForeignKey("FromUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("backend.Models.Purchase", "Purchase") + .WithMany() + .HasForeignKey("PurchaseId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("backend.Models.User", "ToUser") + .WithMany("TransactionsReceived") + .HasForeignKey("ToUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("backend.Models.TransactionType", "TransactionType") + .WithMany("TransactionLogs") + .HasForeignKey("TransactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("FromUser"); + + b.Navigation("Purchase"); + + b.Navigation("ToUser"); + + b.Navigation("TransactionType"); + }); + + modelBuilder.Entity("backend.Models.User", b => + { + b.HasOne("backend.Models.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("backend.Models.Purchase", b => + { + b.Navigation("CoPayers"); + + b.Navigation("Items"); + }); + + modelBuilder.Entity("backend.Models.Role", b => + { + b.Navigation("Users"); + }); + + modelBuilder.Entity("backend.Models.TransactionType", b => + { + b.Navigation("TransactionLogs"); + }); + + modelBuilder.Entity("backend.Models.User", b => + { + b.Navigation("CoPayerIn"); + + b.Navigation("PurchasesBought"); + + b.Navigation("TransactionsReceived"); + + b.Navigation("TransactionsSent"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/backend/Migrations/20260327223748_InitialCreate.cs b/src/backend/Migrations/20260327223748_InitialCreate.cs new file mode 100644 index 0000000..bdc3e7d --- /dev/null +++ b/src/backend/Migrations/20260327223748_InitialCreate.cs @@ -0,0 +1,298 @@ +using System; +using Microsoft.EntityFrameworkCore.Migrations; + +#nullable disable + +#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional + +namespace backend.Migrations +{ + /// + public partial class InitialCreate : Migration + { + /// + protected override void Up(MigrationBuilder migrationBuilder) + { + migrationBuilder.CreateTable( + name: "PendingUsers", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: false), + Surname = table.Column(type: "TEXT", nullable: false), + AccountNumber = table.Column(type: "TEXT", nullable: false), + Email = table.Column(type: "TEXT", nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: false), + IsRead = table.Column(type: "INTEGER", nullable: false), + RequestedAt = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_PendingUsers", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Roles", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Code = table.Column(type: "TEXT", nullable: false), + DisplayName = table.Column(type: "TEXT", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Roles", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "TransactionTypes", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Code = table.Column(type: "TEXT", maxLength: 64, nullable: false), + DisplayName = table.Column(type: "TEXT", maxLength: 128, nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TransactionTypes", x => x.Id); + }); + + migrationBuilder.CreateTable( + name: "Users", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Email = table.Column(type: "TEXT", maxLength: 200, nullable: false), + Name = table.Column(type: "TEXT", maxLength: 100, nullable: false), + Surname = table.Column(type: "TEXT", maxLength: 100, nullable: false), + AccountNumber = table.Column(type: "TEXT", maxLength: 50, nullable: false), + PasswordHash = table.Column(type: "TEXT", nullable: false), + CreatedAt = table.Column(type: "TEXT", nullable: false), + RoleId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Users", x => x.Id); + table.ForeignKey( + name: "FK_Users_Roles_RoleId", + column: x => x.RoleId, + principalTable: "Roles", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Purchases", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: false), + PriceInCents = table.Column(type: "INTEGER", nullable: false), + BuyerPays = table.Column(type: "INTEGER", nullable: false), + Timestamp = table.Column(type: "TEXT", nullable: false), + Shop = table.Column(type: "TEXT", nullable: false), + BuyerId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Purchases", x => x.Id); + table.ForeignKey( + name: "FK_Purchases_Users_BuyerId", + column: x => x.BuyerId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "CoPayers", + columns: table => new + { + PurchaseId = table.Column(type: "INTEGER", nullable: false), + UserId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_CoPayers", x => new { x.PurchaseId, x.UserId }); + table.ForeignKey( + name: "FK_CoPayers_Purchases_PurchaseId", + column: x => x.PurchaseId, + principalTable: "Purchases", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + table.ForeignKey( + name: "FK_CoPayers_Users_UserId", + column: x => x.UserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.CreateTable( + name: "Items", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + Name = table.Column(type: "TEXT", nullable: false), + Quantity = table.Column(type: "INTEGER", nullable: false), + PricePerPiece = table.Column(type: "INTEGER", nullable: false), + PurchaseId = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_Items", x => x.Id); + table.ForeignKey( + name: "FK_Items_Purchases_PurchaseId", + column: x => x.PurchaseId, + principalTable: "Purchases", + principalColumn: "Id", + onDelete: ReferentialAction.Cascade); + }); + + migrationBuilder.CreateTable( + name: "TransactionLogs", + columns: table => new + { + Id = table.Column(type: "INTEGER", nullable: false) + .Annotation("Sqlite:Autoincrement", true), + TransactionTypeId = table.Column(type: "INTEGER", nullable: false), + Amount = table.Column(type: "INTEGER", nullable: false), + Timestamp = table.Column(type: "TEXT", nullable: false), + Note = table.Column(type: "TEXT", nullable: true), + PurchaseId = table.Column(type: "INTEGER", nullable: true), + FromUserId = table.Column(type: "INTEGER", nullable: false), + ToUserId = table.Column(type: "INTEGER", nullable: false), + GroupId = table.Column(type: "TEXT", nullable: true), + IsRead = table.Column(type: "INTEGER", nullable: false) + }, + constraints: table => + { + table.PrimaryKey("PK_TransactionLogs", x => x.Id); + table.ForeignKey( + name: "FK_TransactionLogs_Purchases_PurchaseId", + column: x => x.PurchaseId, + principalTable: "Purchases", + principalColumn: "Id", + onDelete: ReferentialAction.SetNull); + table.ForeignKey( + name: "FK_TransactionLogs_TransactionTypes_TransactionTypeId", + column: x => x.TransactionTypeId, + principalTable: "TransactionTypes", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_TransactionLogs_Users_FromUserId", + column: x => x.FromUserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + table.ForeignKey( + name: "FK_TransactionLogs_Users_ToUserId", + column: x => x.ToUserId, + principalTable: "Users", + principalColumn: "Id", + onDelete: ReferentialAction.Restrict); + }); + + migrationBuilder.InsertData( + table: "Roles", + columns: new[] { "Id", "Code", "DisplayName" }, + values: new object[,] + { + { 1, "admin", "Administrátor" }, + { 2, "user", "Uživatel" } + }); + + migrationBuilder.InsertData( + table: "TransactionTypes", + columns: new[] { "Id", "Code", "DisplayName" }, + values: new object[,] + { + { 1, "add_purchase", "Přidání nákupu" }, + { 2, "add_settlement", "Přidání platby" }, + { 3, "remove_purchase", "Zrušení nákupu" }, + { 4, "pending_settlement", "Čekající platba" } + }); + + migrationBuilder.CreateIndex( + name: "IX_CoPayers_UserId", + table: "CoPayers", + column: "UserId"); + + migrationBuilder.CreateIndex( + name: "IX_Items_PurchaseId", + table: "Items", + column: "PurchaseId"); + + migrationBuilder.CreateIndex( + name: "IX_Purchases_BuyerId", + table: "Purchases", + column: "BuyerId"); + + migrationBuilder.CreateIndex( + name: "IX_TransactionLogs_FromUserId", + table: "TransactionLogs", + column: "FromUserId"); + + migrationBuilder.CreateIndex( + name: "IX_TransactionLogs_PurchaseId", + table: "TransactionLogs", + column: "PurchaseId"); + + migrationBuilder.CreateIndex( + name: "IX_TransactionLogs_ToUserId", + table: "TransactionLogs", + column: "ToUserId"); + + migrationBuilder.CreateIndex( + name: "IX_TransactionLogs_TransactionTypeId", + table: "TransactionLogs", + column: "TransactionTypeId"); + + migrationBuilder.CreateIndex( + name: "IX_Users_Email", + table: "Users", + column: "Email", + unique: true); + + migrationBuilder.CreateIndex( + name: "IX_Users_RoleId", + table: "Users", + column: "RoleId"); + } + + /// + protected override void Down(MigrationBuilder migrationBuilder) + { + migrationBuilder.DropTable( + name: "CoPayers"); + + migrationBuilder.DropTable( + name: "Items"); + + migrationBuilder.DropTable( + name: "PendingUsers"); + + migrationBuilder.DropTable( + name: "TransactionLogs"); + + migrationBuilder.DropTable( + name: "Purchases"); + + migrationBuilder.DropTable( + name: "TransactionTypes"); + + migrationBuilder.DropTable( + name: "Users"); + + migrationBuilder.DropTable( + name: "Roles"); + } + } +} diff --git a/src/backend/Migrations/AppDbContextModelSnapshot.cs b/src/backend/Migrations/AppDbContextModelSnapshot.cs new file mode 100644 index 0000000..18e51e1 --- /dev/null +++ b/src/backend/Migrations/AppDbContextModelSnapshot.cs @@ -0,0 +1,421 @@ +// +using System; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Storage.ValueConversion; +using backend.Data; + +#nullable disable + +namespace backend.Migrations +{ + [DbContext(typeof(AppDbContext))] + partial class AppDbContextModelSnapshot : ModelSnapshot + { + protected override void BuildModel(ModelBuilder modelBuilder) + { +#pragma warning disable 612, 618 + modelBuilder.HasAnnotation("ProductVersion", "9.0.6"); + + modelBuilder.Entity("backend.Models.CoPayer", b => + { + b.Property("PurchaseId") + .HasColumnType("INTEGER") + .HasColumnOrder(0); + + b.Property("UserId") + .HasColumnType("INTEGER") + .HasColumnOrder(1); + + b.HasKey("PurchaseId", "UserId"); + + b.HasIndex("UserId"); + + b.ToTable("CoPayers"); + }); + + modelBuilder.Entity("backend.Models.Item", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PricePerPiece") + .HasColumnType("INTEGER"); + + b.Property("PurchaseId") + .HasColumnType("INTEGER"); + + b.Property("Quantity") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("PurchaseId"); + + b.ToTable("Items"); + }); + + modelBuilder.Entity("backend.Models.PendingUser", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountNumber") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("IsRead") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RequestedAt") + .HasColumnType("TEXT"); + + b.Property("Surname") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("PendingUsers"); + }); + + modelBuilder.Entity("backend.Models.Purchase", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("BuyerId") + .HasColumnType("INTEGER"); + + b.Property("BuyerPays") + .HasColumnType("INTEGER"); + + b.Property("Name") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("PriceInCents") + .HasColumnType("INTEGER"); + + b.Property("Shop") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("BuyerId"); + + b.ToTable("Purchases"); + }); + + modelBuilder.Entity("backend.Models.Role", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("Roles"); + + b.HasData( + new + { + Id = 1, + Code = "admin", + DisplayName = "Administrátor" + }, + new + { + Id = 2, + Code = "user", + DisplayName = "Uživatel" + }); + }); + + modelBuilder.Entity("backend.Models.TransactionLog", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Amount") + .HasColumnType("INTEGER"); + + b.Property("FromUserId") + .HasColumnType("INTEGER"); + + b.Property("GroupId") + .HasColumnType("TEXT"); + + b.Property("IsRead") + .HasColumnType("INTEGER"); + + b.Property("Note") + .HasColumnType("TEXT"); + + b.Property("PurchaseId") + .HasColumnType("INTEGER"); + + b.Property("Timestamp") + .HasColumnType("TEXT"); + + b.Property("ToUserId") + .HasColumnType("INTEGER"); + + b.Property("TransactionTypeId") + .HasColumnType("INTEGER"); + + b.HasKey("Id"); + + b.HasIndex("FromUserId"); + + b.HasIndex("PurchaseId"); + + b.HasIndex("ToUserId"); + + b.HasIndex("TransactionTypeId"); + + b.ToTable("TransactionLogs"); + }); + + modelBuilder.Entity("backend.Models.TransactionType", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("Code") + .IsRequired() + .HasMaxLength(64) + .HasColumnType("TEXT"); + + b.Property("DisplayName") + .IsRequired() + .HasMaxLength(128) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.ToTable("TransactionTypes"); + + b.HasData( + new + { + Id = 1, + Code = "add_purchase", + DisplayName = "Přidání nákupu" + }, + new + { + Id = 2, + Code = "add_settlement", + DisplayName = "Přidání platby" + }, + new + { + Id = 3, + Code = "remove_purchase", + DisplayName = "Zrušení nákupu" + }, + new + { + Id = 4, + Code = "pending_settlement", + DisplayName = "Čekající platba" + }); + }); + + modelBuilder.Entity("backend.Models.User", b => + { + b.Property("Id") + .ValueGeneratedOnAdd() + .HasColumnType("INTEGER"); + + b.Property("AccountNumber") + .IsRequired() + .HasMaxLength(50) + .HasColumnType("TEXT"); + + b.Property("CreatedAt") + .HasColumnType("TEXT"); + + b.Property("Email") + .IsRequired() + .HasMaxLength(200) + .HasColumnType("TEXT"); + + b.Property("Name") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.Property("PasswordHash") + .IsRequired() + .HasColumnType("TEXT"); + + b.Property("RoleId") + .HasColumnType("INTEGER"); + + b.Property("Surname") + .IsRequired() + .HasMaxLength(100) + .HasColumnType("TEXT"); + + b.HasKey("Id"); + + b.HasIndex("Email") + .IsUnique(); + + b.HasIndex("RoleId"); + + b.ToTable("Users"); + }); + + modelBuilder.Entity("backend.Models.CoPayer", b => + { + b.HasOne("backend.Models.Purchase", "Purchase") + .WithMany("CoPayers") + .HasForeignKey("PurchaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.HasOne("backend.Models.User", "User") + .WithMany("CoPayerIn") + .HasForeignKey("UserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Purchase"); + + b.Navigation("User"); + }); + + modelBuilder.Entity("backend.Models.Item", b => + { + b.HasOne("backend.Models.Purchase", "Purchase") + .WithMany("Items") + .HasForeignKey("PurchaseId") + .OnDelete(DeleteBehavior.Cascade) + .IsRequired(); + + b.Navigation("Purchase"); + }); + + modelBuilder.Entity("backend.Models.Purchase", b => + { + b.HasOne("backend.Models.User", "Buyer") + .WithMany("PurchasesBought") + .HasForeignKey("BuyerId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Buyer"); + }); + + modelBuilder.Entity("backend.Models.TransactionLog", b => + { + b.HasOne("backend.Models.User", "FromUser") + .WithMany("TransactionsSent") + .HasForeignKey("FromUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("backend.Models.Purchase", "Purchase") + .WithMany() + .HasForeignKey("PurchaseId") + .OnDelete(DeleteBehavior.SetNull); + + b.HasOne("backend.Models.User", "ToUser") + .WithMany("TransactionsReceived") + .HasForeignKey("ToUserId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.HasOne("backend.Models.TransactionType", "TransactionType") + .WithMany("TransactionLogs") + .HasForeignKey("TransactionTypeId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("FromUser"); + + b.Navigation("Purchase"); + + b.Navigation("ToUser"); + + b.Navigation("TransactionType"); + }); + + modelBuilder.Entity("backend.Models.User", b => + { + b.HasOne("backend.Models.Role", "Role") + .WithMany("Users") + .HasForeignKey("RoleId") + .OnDelete(DeleteBehavior.Restrict) + .IsRequired(); + + b.Navigation("Role"); + }); + + modelBuilder.Entity("backend.Models.Purchase", b => + { + b.Navigation("CoPayers"); + + b.Navigation("Items"); + }); + + modelBuilder.Entity("backend.Models.Role", b => + { + b.Navigation("Users"); + }); + + modelBuilder.Entity("backend.Models.TransactionType", b => + { + b.Navigation("TransactionLogs"); + }); + + modelBuilder.Entity("backend.Models.User", b => + { + b.Navigation("CoPayerIn"); + + b.Navigation("PurchasesBought"); + + b.Navigation("TransactionsReceived"); + + b.Navigation("TransactionsSent"); + }); +#pragma warning restore 612, 618 + } + } +} diff --git a/src/backend/Models/CoPayer.cs b/src/backend/Models/CoPayer.cs new file mode 100644 index 0000000..ea6c690 --- /dev/null +++ b/src/backend/Models/CoPayer.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace backend.Models; + +public class CoPayer +{ + [Key, Column(Order = 0)] + public int PurchaseId { get; set; } + public Purchase Purchase { get; set; } = default!; + + [Key, Column(Order = 1)] + public int UserId { get; set; } + public User User { get; set; } = default!; +} + + diff --git a/src/backend/Models/Item.cs b/src/backend/Models/Item.cs new file mode 100644 index 0000000..e769230 --- /dev/null +++ b/src/backend/Models/Item.cs @@ -0,0 +1,15 @@ +using System.ComponentModel.DataAnnotations; + +namespace backend.Models; + +public class Item +{ + public int Id { get; set; } + public string Name { get; set; } = default!; + public int Quantity { get; set; } + public int PricePerPiece { get; set; } + + public int PurchaseId { get; set; } + public Purchase Purchase { get; set; } = default!; +} + diff --git a/src/backend/Models/PendingUser.cs b/src/backend/Models/PendingUser.cs new file mode 100644 index 0000000..73328cc --- /dev/null +++ b/src/backend/Models/PendingUser.cs @@ -0,0 +1,13 @@ +namespace backend.Models; + +public class PendingUser +{ + public int Id { get; set; } + public string Name { get; set; } = default!; + public string Surname { get; set; } = default!; + public string AccountNumber { get; set; } = default!; + public string Email { get; set; } = default!; + public string PasswordHash { get; set; } = default!; + public bool IsRead { get; set; } = false; + public DateTimeOffset RequestedAt { get; set; } +} \ No newline at end of file diff --git a/src/backend/Models/Purchase.cs b/src/backend/Models/Purchase.cs new file mode 100644 index 0000000..34c46d0 --- /dev/null +++ b/src/backend/Models/Purchase.cs @@ -0,0 +1,33 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; + +namespace backend.Models; + +public class Purchase +{ + public int Id { get; set; } + + public string Name { get; set; } = default!; + + public int PriceInCents { get; set; } // in cents + + public bool BuyerPays { get; set; } + + public DateTimeOffset Timestamp { get; set; } + public string Shop { get; set; } = default!; + + + // Foreign key to User + public int BuyerId { get; set; } + + // Navigation properties + public virtual User Buyer { get; set; } = default!; + public virtual ICollection Items { get; set; } = new List(); + + // Many-to-many relationship with User through CoPayer + public virtual ICollection CoPayers { get; set; } = new List(); + + // Computed property to get list of co-payer users + [NotMapped] + public List CoPayerUsers => CoPayers.Select(cp => cp.User).ToList(); +} \ No newline at end of file diff --git a/src/backend/Models/Role.cs b/src/backend/Models/Role.cs new file mode 100644 index 0000000..538478c --- /dev/null +++ b/src/backend/Models/Role.cs @@ -0,0 +1,11 @@ +namespace backend.Models; + +public class Role +{ + public int Id { get; set; } + public string Code { get; set; } = default!; + public string DisplayName { get; set; } = default!; + + // Navigation to User entities + public virtual ICollection Users { get; set; } = new List(); +} \ No newline at end of file diff --git a/src/backend/Models/TransactionLog.cs b/src/backend/Models/TransactionLog.cs new file mode 100644 index 0000000..d95d0d8 --- /dev/null +++ b/src/backend/Models/TransactionLog.cs @@ -0,0 +1,31 @@ +using System.ComponentModel.DataAnnotations; + +namespace backend.Models; + +public class TransactionLog +{ + public int Id { get; set; } + + // Lookup na TransactionType + public int TransactionTypeId { get; set; } + public TransactionType TransactionType { get; set; } = default!; + + public int Amount { get; set; } // v centech + public DateTimeOffset Timestamp { get; set; } + public string? Note { get; set; } + + // FK na Purchase (m��e b�t null � settlement bez konkr�tn�ho n�kupu) + public int? PurchaseId { get; set; } + public Purchase? Purchase { get; set; } + + // Od koho komu + public int FromUserId { get; set; } + public User FromUser { get; set; } = default!; + + public int ToUserId { get; set; } + public User ToUser { get; set; } = default!; + + public Guid? GroupId { get; set; } // pokud m� skupiny; jinak pry� + public bool IsRead { get; set; } = false; // notifikace (ne)p�e�tena +} + diff --git a/src/backend/Models/TransactionType.cs b/src/backend/Models/TransactionType.cs new file mode 100644 index 0000000..66d2266 --- /dev/null +++ b/src/backend/Models/TransactionType.cs @@ -0,0 +1,17 @@ +using System.ComponentModel.DataAnnotations; + +namespace backend.Models; + +public class TransactionType +{ + public int Id { get; set; } + + [MaxLength(64)] + public string Code { get; set; } = default!; // nap. "add_purchase" + + [MaxLength(128)] + public string DisplayName { get; set; } = default!; // nap. "Add Purchase" + + // Navigace zpt na logy (voliteln; pro dotazovn se hod) + public ICollection TransactionLogs { get; set; } = new List(); +} diff --git a/src/backend/Models/User.cs b/src/backend/Models/User.cs new file mode 100644 index 0000000..b57f6e5 --- /dev/null +++ b/src/backend/Models/User.cs @@ -0,0 +1,48 @@ +using System.ComponentModel.DataAnnotations; +using System.ComponentModel.DataAnnotations.Schema; +using Microsoft.EntityFrameworkCore; + +namespace backend.Models; + +[Index(nameof(Email), IsUnique = true)] +public class User +{ + public int Id { get; set; } + + [MaxLength(200)] + public string Email { get; set; } = default!; + + [MaxLength(100)] + public string Name { get; set; } = default!; + + [MaxLength(100)] + public string Surname { get; set; } = default!; + + [MaxLength(50)] + public string AccountNumber { get; set; } = default!; + + public string PasswordHash { get; set; } = default!; + + public DateTimeOffset CreatedAt { get; set; } = DateTime.UtcNow; + + // FK na role + public int RoleId { get; set; } + public virtual Role Role { get; set; } = default!; + + // --- Navigace --- + // N�kupy, kde je tento user kupuj�c� (Buyer) + [InverseProperty(nameof(Purchase.Buyer))] + public ICollection PurchasesBought { get; set; } = new List(); + + // CoPayer join z�znamy, kde je user spolup�isp�vatel + [InverseProperty(nameof(CoPayer.User))] + public ICollection CoPayerIn { get; set; } = new List(); + + // Transakce, kde je user odes�latel / p��jemce + [InverseProperty(nameof(TransactionLog.FromUser))] + public ICollection TransactionsSent { get; set; } = new List(); + + [InverseProperty(nameof(TransactionLog.ToUser))] + public ICollection TransactionsReceived { get; set; } = new List(); +} + diff --git a/src/backend/Program.cs b/src/backend/Program.cs new file mode 100644 index 0000000..41ea993 --- /dev/null +++ b/src/backend/Program.cs @@ -0,0 +1,165 @@ +namespace backend; + +using backend.Services; +using backend.Middleware; +using backend.Data; + +using Microsoft.AspNetCore.Authentication.JwtBearer; +using Microsoft.EntityFrameworkCore; +using Microsoft.IdentityModel.Tokens; +using Microsoft.OpenApi.Models; +using System.Text; + + +public class Program +{ + public static void Main(string[] args) + { + // Initialize currency from environment variable (set at build/deployment time) + var currencyArg = Environment.GetEnvironmentVariable("CURRENCY_CODE"); + Config.Initialize(currencyArg); + + var builder = WebApplication.CreateBuilder(args); + var jwtKey = builder.Configuration["Jwt:Key"] + ?? throw new InvalidOperationException("JWT key is not configured. Please set environment variable JWT__Key."); + var key = Encoding.UTF8.GetBytes(jwtKey); + builder.Services.AddSingleton(new JwtKeyProvider(jwtKey)); + + Console.WriteLine("Starting application..."); + + // Add services + builder.Services.AddControllers(); + builder.Services.AddEndpointsApiExplorer(); + builder.Services.AddHttpClient(); + builder.Services.AddScoped(); + builder.Services.AddScoped(); + + // JWT Authentication + builder.Services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme) + .AddJwtBearer(options => + { + options.TokenValidationParameters = new TokenValidationParameters + { + ValidateIssuerSigningKey = true, + IssuerSigningKey = new SymmetricSecurityKey(key), + ValidateIssuer = false, + ValidateAudience = false, + ValidateLifetime = true, + ClockSkew = TimeSpan.Zero + }; + + options.Events = new JwtBearerEvents + { + OnMessageReceived = context => + { + //Console.WriteLine($"OnMessageReceived: Token = {context.Token}"); + //Console.WriteLine("Token recieved"); + return Task.CompletedTask; + }, + OnTokenValidated = context => + { + //Console.WriteLine("OnTokenValidated: Success"); + return Task.CompletedTask; + }, + OnAuthenticationFailed = context => + { + Console.WriteLine($"OnAuthenticationFailed: {context.Exception.Message}"); + return Task.CompletedTask; + } + }; + }); + + builder.Services.AddAuthorization(); + + // Database + builder.Services.AddDbContext(options => + options.UseSqlite("Data Source=Data/app.db")); + + // CORS: in dev allow Vite (localhost:5173), in production allow specific domain + var allowedOrigins = builder.Environment.IsDevelopment() + ? new[] { Config.DEV_DOMAIN } // Vite dev server + : new[] { Config.PRODUCT_DOMAIN }; // production domain + + // CORS + builder.Services.AddCors(options => + { + options.AddPolicy("AllowFrontend", policy => + { + policy + .WithOrigins(allowedOrigins) + .AllowAnyHeader() + .AllowAnyMethod(); + }); + }); + + // In-memory caching + builder.Services.AddMemoryCache(); + + // Swagger + builder.Services.AddSwaggerGen(c => + { + c.SwaggerDoc("v1", new OpenApiInfo { Title = "API", Version = "v1" }); + + c.AddSecurityDefinition("Bearer", new OpenApiSecurityScheme + { + Description = "JWT Authorization header using the Bearer scheme. Enter 'Bearer' [space] and then your token in the text input below.", + Name = "Authorization", + In = ParameterLocation.Header, + Type = SecuritySchemeType.ApiKey, + Scheme = "Bearer" + }); + + c.AddSecurityRequirement(new OpenApiSecurityRequirement + { + { + new OpenApiSecurityScheme + { + Reference = new OpenApiReference + { + Type = ReferenceType.SecurityScheme, + Id = "Bearer" + } + }, + new string[] {} + } + }); + }); + + /* Built app */ + var app = builder.Build(); + app.UseMiddleware(); + + + // Configure pipeline + if (app.Environment.IsDevelopment()) + { + app.UseSwagger(); + app.UseSwaggerUI(); + app.UseCors("AllowFrontend"); + } + else + { + // Disable CORS in production -- frontend is served from same origin + // app.UseHttpsRedirection(); + // app.MapFallbackToFile("index.html"); + } + + // Authentication must come before Authorization + app.UseAuthentication(); + app.UseAuthorization(); + + app.MapControllers(); + + + // Database migrations + using (var scope = app.Services.CreateScope()) + { + var db = scope.ServiceProvider.GetRequiredService(); + db.Database.Migrate(); // Creates DB or updates to latest migration + } + + Console.WriteLine("Application configured. Starting..."); + app.Run(); + + } +} \ No newline at end of file diff --git a/src/backend/Properties/launchSettings.json b/src/backend/Properties/launchSettings.json new file mode 100644 index 0000000..0e8765c --- /dev/null +++ b/src/backend/Properties/launchSettings.json @@ -0,0 +1,43 @@ +{ + "$schema": "http://json.schemastore.org/launchsettings.json", + "iisSettings": { + "windowsAuthentication": false, + "anonymousAuthentication": true, + "iisExpress": { + "applicationUrl": "http://localhost:9802", + "sslPort": 44381 + } + }, + "profiles": { + "http": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "launchUrl": "swagger", + "applicationUrl": "http://localhost:5119", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "CURRENCY_CODE": "EUR" + } + }, + "https": { + "commandName": "Project", + "dotnetRunMessages": true, + "launchBrowser": false, + "launchUrl": "swagger", + "applicationUrl": "https://localhost:7297;http://localhost:5119", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development", + "CURRENCY_CODE": "EUR" + } + }, + "IIS Express": { + "commandName": "IISExpress", + "launchBrowser": true, + "launchUrl": "swagger", + "environmentVariables": { + "ASPNETCORE_ENVIRONMENT": "Development" + } + } + } +} diff --git a/src/backend/Services/CommitmentService.cs b/src/backend/Services/CommitmentService.cs new file mode 100644 index 0000000..9ddb341 --- /dev/null +++ b/src/backend/Services/CommitmentService.cs @@ -0,0 +1,69 @@ +namespace backend.Services; + +using backend.Data; +using backend.DTOs; +using Microsoft.EntityFrameworkCore; + +public class CommitmentService +{ + private readonly AppDbContext _db; + + public CommitmentService(AppDbContext db) + { + _db = db; + } + + public async Task> GetCommitmentsForUser(int currentUserId) + { + var ttPendingSettlement = await _db.TransactionTypes.FirstOrDefaultAsync(tt => tt.Code == Config.TransactionTypeCodes.PENDING_SETTLEMENT); + var ttAddPurchase = await _db.TransactionTypes.FirstOrDefaultAsync(tt => tt.Code == Config.TransactionTypeCodes.ADD_PURCHASE); + var ttAddSettlement = await _db.TransactionTypes.FirstOrDefaultAsync(tt => tt.Code == Config.TransactionTypeCodes.ADD_SETTLEMENT); + var ttRemovePurchase = await _db.TransactionTypes.FirstOrDefaultAsync(tt => tt.Code == Config.TransactionTypeCodes.REMOVE_PURCHASE); + + if (ttPendingSettlement == null || ttAddPurchase == null || ttAddSettlement == null || ttRemovePurchase == null) + throw new InvalidOperationException("One or more required transaction types not found."); + + // Calculate net commitments between users + var netCommitments = (await _db.TransactionLogs + // Step 1: select transactions where current user is a participant + .Where(t => t.TransactionType != null + && (t.FromUserId == currentUserId || t.ToUserId == currentUserId) + && t.FromUserId != t.ToUserId + && t.TransactionTypeId != ttPendingSettlement.Id) + // Step 2: convert each transaction to directional value from current user's perspective + .Select(t => new + { + OtherUserId = t.FromUserId == currentUserId ? t.ToUserId : t.FromUserId, + OtherUserName = t.FromUserId == currentUserId ? t.ToUser!.Name : t.FromUser!.Name, + OtherUserSurname = t.FromUserId == currentUserId ? t.ToUser!.Surname : t.FromUser!.Surname, + + Amount = + t.TransactionTypeId == ttAddPurchase.Id + ? (t.FromUserId == currentUserId ? -t.Amount : +t.Amount) + : t.TransactionTypeId == ttRemovePurchase.Id + ? (t.FromUserId == currentUserId ? +t.Amount : -t.Amount) + : t.TransactionTypeId == ttAddSettlement.Id + ? (t.FromUserId == currentUserId ? -t.Amount : +t.Amount) + : 0 + }) + .ToListAsync()) + // Step 3: group transactions by the other user + .GroupBy(x => new { x.OtherUserId, x.OtherUserName, x.OtherUserSurname }) + // Step 4: sum amounts and create DTO + .Select(g => + { + var sum = g.Sum(x => x.Amount); + return new CommitmentDto + { + FromUserId = sum > 0 ? currentUserId : g.Key.OtherUserId, + ToUserId = sum > 0 ? g.Key.OtherUserId : currentUserId, + TotalInCents = Math.Abs(sum), + ToName = g.Key.OtherUserName + " " + g.Key.OtherUserSurname, + State = sum > 0 ? "debt" : sum < 0 ? "claim" : "even" + }; + }) + .ToList(); + + return netCommitments; + } +} diff --git a/src/backend/Services/IJwtKeyProvider.cs b/src/backend/Services/IJwtKeyProvider.cs new file mode 100644 index 0000000..b962d3b --- /dev/null +++ b/src/backend/Services/IJwtKeyProvider.cs @@ -0,0 +1,4 @@ +public interface IJwtKeyProvider +{ + string Key { get; } +} \ No newline at end of file diff --git a/src/backend/Services/JwtKeyProvider.cs b/src/backend/Services/JwtKeyProvider.cs new file mode 100644 index 0000000..bfa0f13 --- /dev/null +++ b/src/backend/Services/JwtKeyProvider.cs @@ -0,0 +1,9 @@ +public class JwtKeyProvider : IJwtKeyProvider +{ + public string Key { get; } + + public JwtKeyProvider(string key) + { + Key = key; + } +} \ No newline at end of file diff --git a/src/backend/Services/TransactionLogService.cs b/src/backend/Services/TransactionLogService.cs new file mode 100644 index 0000000..10eda48 --- /dev/null +++ b/src/backend/Services/TransactionLogService.cs @@ -0,0 +1,162 @@ +namespace backend.Services; + +using backend.Data; +using backend.Models; +using backend.DTOs; +using backend.Mappers; + +using Microsoft.EntityFrameworkCore; + + +public class TransactionLogService +{ + private readonly AppDbContext _db; + + public TransactionLogService(AppDbContext db) + { + _db = db; + } + + public async Task AddPurchaseTxAsync(Purchase purchase, CancellationToken ct, Guid? guid = null) + { + bool isUpdatePurchase = guid != null ? true : false; + + var transactionType = await _db.TransactionTypes + .FirstOrDefaultAsync(t => t.Code == Config.TransactionTypeCodes.ADD_PURCHASE, ct); + + if (transactionType == null) + throw new InvalidOperationException($"TransactionType '{Config.TransactionTypeCodes.ADD_PURCHASE}' not found."); + + int pricePerPersonInCents; + + if (purchase.BuyerPays) + { + pricePerPersonInCents = purchase.PriceInCents / (purchase.CoPayerUsers.Count + 1); // +1 for the buyer + } + else + { + pricePerPersonInCents = purchase.PriceInCents / purchase.CoPayerUsers.Count; + } + + // Creates new transaction logs from buyer to every co-payer + foreach (var cp in purchase.CoPayerUsers) { + + var tx = new TransactionLog + { + TransactionType = transactionType, + Amount = pricePerPersonInCents, // in cents + Timestamp = DateTimeOffset.UtcNow, + Note = + isUpdatePurchase + ? + $"Kupující {purchase.Buyer.Name} {purchase.Buyer.Surname} nakoupil pro {cp.Name} {cp.Surname} za {pricePerPersonInCents/100.00} {Config.CURRENCY_FORMAT} (úprava nákupu)" + : + $"Kupující {purchase.Buyer.Name} {purchase.Buyer.Surname} nakoupil pro {cp.Name} {cp.Surname} za {pricePerPersonInCents/100.00} {Config.CURRENCY_FORMAT}", + Purchase = purchase, + FromUser = purchase.Buyer, + ToUser = cp, + GroupId = guid + }; + + _db.TransactionLogs.Add(tx); + } + } + + /** + * If we have ID + */ + public async Task RemovePurchaseTxAsync(int purchaseId, CancellationToken ct, Guid? guid = null) + { + + var purchase = await _db.Purchases + .Include(p => p.Buyer) + .Include(p => p.Items) + .Include(p => p.CoPayers) + .ThenInclude(cp => cp.User) + .FirstOrDefaultAsync(p => p.Id == purchaseId, ct); + + if (purchase == null) + throw new InvalidOperationException("Purchase not found."); + + + await RemovePurchaseTxAsync(purchase, ct, guid); + } + + + /** + * If we have whole Purchase object + */ + public async Task RemovePurchaseTxAsync(Purchase purchase, CancellationToken ct, Guid? guid = null) + { + await RemovePurchaseTxAsync(PurchaseMapper.ToRawDto(purchase), ct, guid); + } + + public async Task RemovePurchaseTxAsync(RawPurchaseDto rawPurchaseDto, CancellationToken ct, Guid? guid = null) + { + var transactionType = await _db.TransactionTypes + .FirstOrDefaultAsync(t => t.Code == Config.TransactionTypeCodes.REMOVE_PURCHASE, ct); + + if (transactionType == null) + throw new InvalidOperationException($"TransactionType '{Config.TransactionTypeCodes.REMOVE_PURCHASE}' not found."); + + int pricePerPersonInCents; + + if (rawPurchaseDto.BuyerPays) + { + pricePerPersonInCents = rawPurchaseDto.PriceInCents / (rawPurchaseDto.CoPayers.Count + 1); // +1 for the buyer + } + else + { + pricePerPersonInCents = rawPurchaseDto.PriceInCents / (rawPurchaseDto.CoPayers.Count); + } + + + // Creates the same transaction as adding purchase, but with "remove_purchase" type + foreach (var cp in rawPurchaseDto.CoPayerUsers) + { + bool isUpdatePurchase = guid != null ? true : false; + var tx = new TransactionLog + { + TransactionType = transactionType, + Amount = pricePerPersonInCents, // in cents + Timestamp = DateTimeOffset.UtcNow, + Note = + isUpdatePurchase + ? + $"Kupující {rawPurchaseDto.Buyer.Name} {rawPurchaseDto.Buyer.Surname} zrušil nákup pro {cp.Name} {cp.Surname} za {pricePerPersonInCents / 100.00} {Config.CURRENCY_FORMAT} (úprava nákupu)" + : + $"Kupující {rawPurchaseDto.Buyer.Name} {rawPurchaseDto.Buyer.Surname} zrušil nákup pro {cp.Name} {cp.Surname} za {pricePerPersonInCents / 100.00} {Config.CURRENCY_FORMAT}", + Purchase = rawPurchaseDto.PurchaseReference, + FromUser = rawPurchaseDto.Buyer, + ToUser = cp, + GroupId = guid + }; + + _db.TransactionLogs.Add(tx); + } + } + + public async Task UpdatePurchaseCoPayersInTxAsync(RawPurchaseDto oldPurchase, Purchase newPurchase, CancellationToken ct, Guid? guid = null) + { + var oldCoPayerUsers = oldPurchase.CoPayerUsers; + var newCoPayerUsers = newPurchase.CoPayerUsers; + + if (oldCoPayerUsers == null || newCoPayerUsers == null) { + return; + } + + // If oldCoPayerUsers and newCoPayerUsers contain the same user Ids, return + if (oldCoPayerUsers.Count == newCoPayerUsers.Count && + !oldCoPayerUsers.ExceptBy(newCoPayerUsers.Select(u => u.Id), u => u.Id).Any() && + !newCoPayerUsers.ExceptBy(oldCoPayerUsers.Select(u => u.Id), u => u.Id).Any() && + oldPurchase.BuyerPays == newPurchase.BuyerPays && + oldPurchase.PriceInCents == newPurchase.PriceInCents) + { + return; + } + + await RemovePurchaseTxAsync(oldPurchase, ct, guid); + await AddPurchaseTxAsync(newPurchase, ct, guid); + } + +} diff --git a/src/backend/Validators/UserValidator.cs b/src/backend/Validators/UserValidator.cs new file mode 100644 index 0000000..7931d03 --- /dev/null +++ b/src/backend/Validators/UserValidator.cs @@ -0,0 +1,120 @@ +using backend.DTOs; +using System.Collections.Generic; +using System.Text.RegularExpressions; + +public class UserValidator +{ + public static Dictionary ValidateRegisterForm(RegisterUserDto formData) + { + var errors = new Dictionary(); + + // Validate name + if (string.IsNullOrWhiteSpace(formData.Name)) + { + errors["name"] = "Zadejte jméno"; + } + else if (formData.Name.Length > 40) + { + errors["name"] = "Jméno je příliš dlouhé"; + } + + // Validate surname + if (string.IsNullOrWhiteSpace(formData.Surname)) + { + errors["surname"] = "Zadejte příjmení"; + } + else if (formData.Surname.Length > 40) + { + errors["surname"] = "Příjmení je příliš dlouhé"; + } + + // Validate account number + string account = formData.AccountNumber; + if (!Regex.IsMatch(account, @"^[0-9/-]+$")) + { + errors["accountNumber"] = "Špatný formát čísla účtu"; + } + else if (!account.Contains("/")) + { + errors["accountNumber"] = "Neplatné číslo účtu"; + } + else + { + var parts = account.Split('/'); + string numberPart = parts[0]; + string bankCode = parts[1]; + + if (!Regex.IsMatch(bankCode, @"^\d{4}$")) + { + errors["accountNumber"] = "Neplatný kód banky"; + } + else + { + string? prefix = null; + string baseNumber; + + if (numberPart.Contains("-")) + { + var accountParts = numberPart.Split('-'); + prefix = accountParts[0]; + baseNumber = accountParts[1]; + } + else + { + baseNumber = numberPart; + } + + // Total length check (prefix + base can be up to 16 digits) + int totalLength = (prefix != null ? prefix.Length : 0) + baseNumber.Length; + + if (totalLength > 16) + { + errors["accountNumber"] = "Číslo účtu je příliš dlouhé (max 16 číslic)"; + } + else if (prefix != null && (prefix.Length < 1 || prefix.Length > 6 || !Regex.IsMatch(prefix, @"^\d+$"))) + { + errors["accountNumber"] = "Předčíslí má chybný formát (max 6 číslic)"; + } + else if (baseNumber.Length < 2 || baseNumber.Length > 10 || !Regex.IsMatch(baseNumber, @"^\d+$")) + { + errors["accountNumber"] = "Základní část má chybný formát (2-10 číslic)"; + } + else + { + // Check modulo 11 for the complete account number (prefix + base) + if (!Modulo11Valid(prefix ?? "") || !Modulo11Valid(baseNumber)) + { + errors["accountNumber"] = "Číslo účtu není validní (modulo 11)"; + } + } + } + } + + // Validate email + if (!Regex.IsMatch(formData.Email ?? "", @"^[^@]+@[^@]+\.[^@]+$")) + { + errors["email"] = "Neplatný email"; + } + + // Validate password + if (formData.Password == null || formData.Password.Length < 6) + { + errors["password"] = "Heslo musí mít alespoň 6 znaků"; + } + + return errors; + } + + private static bool Modulo11Valid(string number) + { + int[] weights = { 6, 3, 7, 9, 10, 5, 8, 4, 2, 1 }; + var padded = number.PadLeft(10, '0'); + + int sum = 0; + for (int i = 0; i < 10; i++) + { + sum += (padded[i] - '0') * weights[i]; + } + return sum % 11 == 0; + } +} diff --git a/src/backend/appsettings.Development.json b/src/backend/appsettings.Development.json new file mode 100644 index 0000000..0c208ae --- /dev/null +++ b/src/backend/appsettings.Development.json @@ -0,0 +1,8 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + } +} diff --git a/src/backend/appsettings.json b/src/backend/appsettings.json new file mode 100644 index 0000000..10f68b8 --- /dev/null +++ b/src/backend/appsettings.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/src/backend/backend.csproj b/src/backend/backend.csproj new file mode 100644 index 0000000..1130e4f --- /dev/null +++ b/src/backend/backend.csproj @@ -0,0 +1,26 @@ + + + + net10.0 + enable + enable + a6991234-d357-4764-8b91-8415a3567a74 + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + + + + + diff --git a/src/backend/backend.http b/src/backend/backend.http new file mode 100644 index 0000000..1cff570 --- /dev/null +++ b/src/backend/backend.http @@ -0,0 +1,6 @@ +@backend_HostAddress = http://localhost:5119 + +GET {{backend_HostAddress}}/weatherforecast/ +Accept: application/json + +### diff --git a/src/backend/backend.sln b/src/backend/backend.sln new file mode 100644 index 0000000..ba13578 --- /dev/null +++ b/src/backend/backend.sln @@ -0,0 +1,25 @@ + +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.14.36221.1 d17.14 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "backend", "backend.csproj", "{5C1621A8-B31C-4051-A378-D67E146EFBD9}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {5C1621A8-B31C-4051-A378-D67E146EFBD9}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5C1621A8-B31C-4051-A378-D67E146EFBD9}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5C1621A8-B31C-4051-A378-D67E146EFBD9}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5C1621A8-B31C-4051-A378-D67E146EFBD9}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(ExtensibilityGlobals) = postSolution + SolutionGuid = {017CEB01-6888-4986-BC44-473992DEF6DC} + EndGlobalSection +EndGlobal diff --git a/src/backend/config.cs b/src/backend/config.cs new file mode 100644 index 0000000..1668c09 --- /dev/null +++ b/src/backend/config.cs @@ -0,0 +1,67 @@ +public static class Config +{ + public enum CurrencyCodes + { + CZK, + EUR + } + + // Supported currency codes as strings (for validation) + private static readonly HashSet SUPPORTED_CURRENCIES = new() { "CZK", "EUR" }; + + // Currency Code to Symbol Mapper + private static readonly Dictionary CURRENCY_SYMBOLS = new() + { + { CurrencyCodes.CZK, "Kč" }, + { CurrencyCodes.EUR, "€" }, + }; + + // Parse currency from environment variable or use default + private static CurrencyCodes ParseCurrencyCode(string? currencyArg) + { + if (string.IsNullOrWhiteSpace(currencyArg)) + return CurrencyCodes.CZK; // Default + + currencyArg = currencyArg.Trim().ToUpper(); + + if (!SUPPORTED_CURRENCIES.Contains(currencyArg)) + return CurrencyCodes.CZK; // Fallback to default if unsupported + + return Enum.Parse(currencyArg); + } + + // This will be set from Program.cs + private static CurrencyCodes? _currencyCode; + public static CurrencyCodes CURRENCY_CODE + { + get => _currencyCode ?? CurrencyCodes.CZK; + set => _currencyCode = value; + } + + public static string CURRENCY_FORMAT => CURRENCY_SYMBOLS[CURRENCY_CODE]; + + public const string PRODUCT_DOMAIN = ""; // If CORS is set in production, set this to the production frontend domain + public const string DEV_DOMAIN = "http://localhost:5173"; + + // Initialize from environment or command line args + public static void Initialize(string? currencyArg) + { + CURRENCY_CODE = ParseCurrencyCode(currencyArg); + } + + // Transaction Type Codes + public static class TransactionTypeCodes + { + public const string ADD_PURCHASE = "add_purchase"; + public const string ADD_SETTLEMENT = "add_settlement"; + public const string REMOVE_PURCHASE = "remove_purchase"; + public const string PENDING_SETTLEMENT = "pending_settlement"; + } + + // Role Codes + public static class RoleCodes + { + public const string ADMIN = "admin"; + public const string USER = "user"; + } +} \ No newline at end of file diff --git a/src/frontend/eslint.config.js b/src/frontend/eslint.config.js new file mode 100644 index 0000000..ec2b712 --- /dev/null +++ b/src/frontend/eslint.config.js @@ -0,0 +1,33 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' + +export default [ + { ignores: ['dist'] }, + { + files: ['**/*.{js,jsx}'], + languageOptions: { + ecmaVersion: 2020, + globals: globals.browser, + parserOptions: { + ecmaVersion: 'latest', + ecmaFeatures: { jsx: true }, + sourceType: 'module', + }, + }, + plugins: { + 'react-hooks': reactHooks, + 'react-refresh': reactRefresh, + }, + rules: { + ...js.configs.recommended.rules, + ...reactHooks.configs.recommended.rules, + 'no-unused-vars': ['error', { varsIgnorePattern: '^[A-Z_]' }], + 'react-refresh/only-export-components': [ + 'warn', + { allowConstantExport: true }, + ], + }, + }, +] diff --git a/src/frontend/icon.png b/src/frontend/icon.png new file mode 100644 index 0000000000000000000000000000000000000000..15431ec7aa3cb77f89db862ac16800dbbb89ef78 GIT binary patch literal 20973 zcmeHv`9DT8!+pIY_=nB&q|tu1wyz3-;6FU>M=p3m5U()&#|Ax55QCr-504()ciJc6 zcNbSe;+fmalt=qF?0B(`8@cbnKf4Gir=JwfoKbjRwC7OsI!j)WjaxVsb{+qACurS? z6VD%VvU9R;pOO$lvh6q7&pi}yb;8hFXd(MZ|FMU0Is*iT??OU=oJ!nm;gx4&-4nNX z$9D$5TNvZ-{WVI@6eVAD({?VUP_gu>s8sTg$x7V0k+~)aEf*(DUhwHxB-8~Gvm7eb zM)@7)Bn|LVWKUrOOx;Vq_}%C8J2|Rk4_EfnKVCU6Br)bgOkcc+hM=vlM3l*ulgIR_ zvDVH5U&Z98o&vIhY$r*V?HiW{ijV4BkS6i3TH1Zj>1SL`SPz-2Y2j7q`qJdVR|9mh zon!~AL#aYYukGuNa6WCj@r0*%6!BrI2^)0!=;&@s?@CW2ec9eZ!^1~CyN}-7%y!aN zMcXMWzg0uwUKE3rjnU6=*t!m?r(Y$hsVeY1Y%i2s|MgDEqw^=&UtYd^^SYE$c!lRB z{rS)xO6$2G$L+bIWj#XV7@x38vA7st!RmcMn_Y)eq%h$-KA+ID_+`ZjnJ0X(JsR0q zw^ijx|CR0olAK3wU%uIBjl`n$5x9Z+_kE*oY|xPm!j^G<`hp@p{&HkZZArM+CS>T{ z?>g64Ki19Vr-ecqCZfvF%FM8hT4dfKk5TP z>YOq^_HkBi5R!&XG59eA&aX>$pFG`W!sIy68 z+fI4nb|)fUcf6k(I-A?#n;>LXR^kliC!}7<4Tdg+i6QhVs+TLX56~1L=(O?ZwF@@) zHcecj$?On9x&dL#95EVQ*KZS*ih)Ia`qb#Am`Um^mhVAaA?fkKppt!&IAZz3{4*U$ zh6n@=msj{i<2FqUbRIC4LGO3=xh|q4O3Jo@7jJxJLDf+Wt73K@@Ni~A_vrNRKC{R5 zoqu(+Lujjb5prN9ccCUDCP6|Iec5N~UaZX*&(rWk^~bza9htDI1u)UE3QYXqRkE-f z|JD=0ELkQhdZ0mN|KS1YA59W_z{JL-NoirSQt!|sSU*uN*7`QMO&+#18LM<#_U)BM zJwAmBc(VbpDdc93xmqV`cekb*^-^B_U{D29Y_n3!muwe!*KrZ)djqm6`*-$9|7aUF zKwtKkl2D2zWt;ZzhM@YVKs$R5SG_F^zxld|Kju{yLwg7Q(f+N=1DsIuJ9X0JsF+My z73yVn<1fK}6@$4i)rejlby!5_*>YP#8(l56)fh3+XT8~ksCHdGg*)*KSPqapWyWU- zb-3zr=KMo#`|2V$os4tfII;5wvzpe`P4{FaQFuzntNpyfUUvKB(s?b8C zmzxnt6Kcv#PZg~&AI$%Jv3J%#GRCl9`p46MIJQ@kQmMbbAq@aqsOP;p|MYS126*z9 zk^-7-P=m#o7IQJgU*T|7kEfuo{2`chtKKW;^_}}*1#D55#|u_)7iuwLwB+}Ki2%Wj zifmID66Ut2sBM4=Aph>PG$7y;5z!a@uRq1`0(9TL=-;@_-iaoYCBG9)Ys9;r!L&E+ zWs$fw9mSt9b2IZoVR++am?NyYjL~1J7|eME#faN#meKT03*JQx6ayy{Npt zYImWLyf`ZoHI>Z;F)WNYrJ{lrKiDXgI>_0=$}ANhiWA>)qUQK=F@q zpIgzJCLGWCf7r_d*vuVF`$EDL_l^L#RKF5I+sVDCFe_pm(Y4|H%TG(nuB zucB^gTcR|eAdlBXdU5c3n+;IGdD~jsuPI1DuYS>?M(Dy^V+N+T?VVZ~a0B%+PV^5J z4hCB?vN`%5`^@fhzydy#53ppMIQ>;m5&hI)`pwK>xHo|v3K~vB&C&5*-WwU4qB}PUWMf79M+|A?-6!|E9`0L^fTY9VgM|5UsVRLXcwYzzRj%h1lviB?YDJX>4gv!v@Jkoebq1d z`J-ob0bYQ|b)dFgx?%{FX`Xq*_ABp?fwwhsLwBJY4f_Cw_~ZjT%b#r6h{FQS9f%Vn zUzMNSRrPKCT_Zyv$`6^1DM)!K!1x@gy7lLGK`>*x{4^NAxiUcI?Kl{)84U0X+cCDT zgmQl&0L=Sgecz!$Oy+p?yy!V~bm>N=*tbaDn@NtZYQ#n#&m^oz!9ALID}wmnlK)ndCs=qvG1?Lc zuJP4T`t{c4hW97gKAM$uHh)e5w9hs5!w+CKn<%W=@Pnt~NIVuy$qdp=3TrwW^jlbe z5oGP>vGbLX_|n~S&%D$fsij0a?)N>I6kVmbL44#=N#rZJtt>r zsiZ%&aDcr&w_94auTX2b|08?fpig0Pw&=bsQ?w7pK7B+(GAhMUk$#hmy7TQ>wjBrc-=iy9-TMVvR*-?Et9JjV{8huPm_c{Yge zUMz=hfY9k6=(a~~HB(+?P!#iu@gskrl6pG1_ zNj79rIY^$GEQUPL&6IT8!h%MT(YFcLc~y=bHVc%&ixQuSE9GH-Ps>50Gej|D zY6)Gddk;E{RNZZ*&V64F9i)X(Is8>vIW3LZ{B=72pT;R@i5#Pqp7d0fp5X{n2{62E{Z7Cp?Hz`zVu9K*_Xej zdwe{Du8S%OdQI=y6$i1Mt;jqyNhF#CzZBVtE15~aynv#d=EabLWlIrSTvc@XMD^Yz z&UM1b%#B(`l;ciXcK-J5o|L+;W?{vtk^DF_jEuUH>(S*yLBo17>#MS?m%qew^tJoU z9*H%(bBAYRvRWg}O4aFG1Ld8VtoJdAhB?j8kk_!83R(cODC#BdgK zcFqYGt>5OC*pP!M04J%!67t^Fa z7;nF$Qz*xlnR#1fzmwptg(T<($9^APcFwAF5m|=WRzz6o)RBT~y+qq#e>Vp2=^ut} zN0^lmo&6>}XxAan>fZ66&MJqkvD?>wCE9*lhy$zTv>4jlU8;l%m93+{Y4U8?fesRC z0tdavspU3Fh%K(s`#z?<(qU{5%Cp?e(Kp!6<;E9tg8tDt@2L4MHulG^mE}5=Fj479 z6;;*9xTTXc9mwq$UJO~>WZ($WOmB437V;aNbNlXba+~ikI~FLj;8l(S6UGG9MC+`46M=Tz7K}I$>=)Qmrr#Q%s2DfrVzc%Ue#x(f_@i(LYHueCme9-d^A@kFi}~xvg+>mL}v@wPlD8@ zf{qSEq2EQt;_Rh44+$aBgg0G%<3*x0UeQRx?h>u%zw$J9 z`YEl@qmx(r@Enav;wtFo-y&4IS|;!En85kQc6NRY-3&@}V)MAPaLJvv!+Z}|rKnx+ z%HkA#q&y~8x(>B2tu(l(kVhs57?lG3XQM;;SHZ=viA}ek(BoWP_j9`lBcWv*fEoAc zkYZw+-O?s&3uQTmRI;CG+PW%nda&DoHTx?eFvtnUz!d2ne~Uq&8*Mi_H{k+r3@??- z=-0}CYE7qarzjO%*fAH(?U|yq_~PUP9h}_gj(&yb2rRvVpMoQIZV@2`vgJ_Bav||8 zhOHVaJUJ$Xry59>-dG+hkMKv&VUfg?8Z~)s){7IypV3zw=pO_EQub6%QllIGW;l~xn`d>cH?*uG8JE28lc2)=IwPU9VD=xj|* z=w!JTg6UEa@I$yT75eoIiAL@Vf-)22E4!qznsY_5c zx2+>uW<5gK874nXoINUC1HE8lm;?5`S<)dFB_P-7?fa}FN13rX*;#QTG6so;bV%L%dO7d7)Mbs|jte+s_vyF6Flb(yLTR{|_@aBn=a5edzr=GsHtD-s97W@{rn%-GI8 zWG!PJhIt*yYg0IX=l%OvyBV7=?N}N9tH5)j)xEJQqU4a$>1QiLpT1B+NtqUmP1JvE z#VMN~T4B+5iQdaW38c-t(8ND&V6hVPiFNP$SSvMz>`ty9Oc;9dDuCVub&zqZ$KcHCY61=r#eps z-TE_J%eB8^JiUXw!82Y2KzI8J%}vUeC!V?Yb%}<4`Ha&vS6x{{w+^AVJwflPJJ5h6 zCpQ=yH0*exQ^>%uw{slWCXmI`eX!_ulLNA%o#zE_Mv%Po0>2Y1^6T@Bvg+mVrS!lo zOg^^6_f#Y#nvi^N}NntG_HNe{85Fo^P#5b>~zo1ii_~f#6Mqb#_ELuiL zDJr(&Xpc037-X$F)%Rq%v>Ly-|bqTyjhr1_w-1oT2MaA zKpqlx#g(KG1sNCBmoDek(&oM)G9_0Dc*SMG}2sb zWfJ?#x#qEsz-y79Cg}Y|+qc7Mqz`{6@eM(`kC>k*{#xP#H`;4G{O_}kvpbxGFqbcF4PUnq8DqS7=D3UoVS#$J9=wK8z^(WZWI z_8 zyQF4jPzSQ4QJ&-%NCrW7uarS=XQx`S49jq?Y(i$<<)51oT+VI!Oi79V{oBQVHZ|q2 z^=8+kse?4}dUqJyK$IKLR_@GrD;XWgkfD5v%H!=S_9COgYj)P)g#;vSU39$g+el{6 zm<8DjoZMS&BbFq?vdTv4-;+PR^)!>oMi_c{F^OujFgx4+D6;;+EyVjn%r?{TaIg8P zYE*e< zm(K~Er;=%_g1q@87n3Do+C!PN9pmp##B8wY`*2-Ftjfk})FgfkvE(pv zpiAo|TaEKYv6AcI_gj z>-lT?-+YwlY4+mL!cU4}s38jDO%pedW$A0;x>xOuLOFCsmRo?Mwnv0uKYpK_j~{TZ z0H_bZt~*wCm1cXPZrD20Lv-k-mv*WRawe5g6Wo_s7SzRxiok+)eOJ0=uubYCJ-X1L z{`2~>DRXT~ceN)usE8F$fjcn&ht^dTI_bUW(dzvZ<6-uKLj#-%1HzV&CAxuUX}itO zt)j@dDI0@xACk7=FBb4|pzXeyJXtBgGY}=70EpVMDvF2G_j;7D^QBp#F3r-9F&nnp ze=8BUO?r_U(*H%VFcsa!3KuYGT@o2n{O4$1maQc{pE^$sB1zFj+dEb19{?y_S3!CD zb35UjV4%)Dz%+yMyNySCQXeUdH+Y+<#i2RWI;f8e$}oU}dy)UR8m{*8_ui5!k|{pd*(&wN>I z!f$81dxF^@KS;t^Ze1F$ zxeLVjV~rfwB>p@BQ5x1B@Rh8$6SXN?%J&4o+_lw>rhh&}Me~eV1ZH%qfg1SOzT!n{ zv90xFIpFH!>N3NhVK3&o@X7lCnTGN(vt1{pH~i4Ox`G5W@{N^B0nMg~_H^_MQ^sp& zzM+D*jK>Zf4tpeBvAV&RK1yy5)JVSTy@Aq|j9^^|p35yj`lsj_a{dl$~ zYZjIz(CS$1KC{=R1{M6vy`@i%7)x970>b}yn>Q8H{)MEQ z`w|GY@!OT)KK2W+dS#VW_4@(9HY|T$JO5<7GxJ>eZK7#4AaN}%I5G9RTej1?YUqU< zxw|m6UZCU4n8_rSA%|Qupa%(rYEdGaq)RN|V!TbSPC(E!U?&h3$mrqpjXmV>1SNk| z3R~1L;7Ly2ie0E`q6m^BVJlooQX`-j4>C)Qlaz;lCgm7xjreFaTa!pJ!Y%Ch&nlj6 zcv4tbOUE__pAhZ&f<5y`7wlU8UF5sehqi7?afiP?7-5~Drs4ndH3H;rPu3!;WAjLd zQs<4IIGB*`fKmF3D<2RU$bU7$zxb0uOm5=p!|yBqOvDP|<1};_e7o?g-b;3F7UT;D zdO#W=zFkUfqSw9S<#!-Z`l?k(eBU(IsproiSaY2$YCsTnTB=}ufKJxZx5GJI?eSq5 zGX~)NAXKtNDF+AL4%s3Q^VFxvo0$+Mhju)OD)z$lgx76A6?ucSMAJv!+7SU{Db2PU zNb1C*OOG2!nrHKX)`_5|$HT?12eA1sjVZcUip@fa-K7p7W43i|xbvRK-vfXLs%R@I zY<-%6ietWeyFk)cRB*^vyP%qTGVBWRt30N(|I_oyeQ`++;BNou?(?~+m)WX!uFWm_ z5{P2D8S)2UfPV_rlv3&KbRWy00$~2Lnv1A^>>(^W+YWg30A#x`D+9{-0|V)|f%kA+ zgXZr(mt~ay%+|4LRIuj4)CXsxTV>Z%%z99@gAXn+CIWmX7l~TklN6ZKU(~R0HR)mi ztX8d|pto!UIHgpIBKT~|#_s=To9o57TKwlm1NOrsmeQ%jfiZ2l|EytA z=CZOP66*|I*v#HN6!n&hhiLj0IkX>1;g_8)C83 z0M^M`Ncm21$@MC)MR+9Ldl)dk)Du2pU7!TS`E*q!DU$W6HexsGVGH9=)n6EJZ_4%t zHux768CgH%Is*seU`|dU7M+=kOyK;3{-Q#LKI8r(GM_T4pCdYDd$3*R$~D&Bx6R`;Cg;thbKVDJ27vJ_Tq=KzomiB1BXM$ z45vu{aEG4IB9pMO;}+*Vw^TW}fnZ>#EKm$58txnUdb)eSP`>hXEHY&^KgJPVVC3e0ubcUEkj5 zs`r!2_U9Yt0sLoG%c;KOlvuL`U|fGu(dkv6ZkuS}+y_P^Xx%!Z?N;;iKYWb;EL&91 zY|jRHzXCA}&%_jre~ky>cMU`v%Uf}Yi5PYlW*fC7+$5KY7Ex0#~9b<3Bf({ogpkcw2U+z#a$Eo@9QlvV|kec2pT`=xe^W${=@$ zMltF@05|1Q@NZ?(TzxJ3xYb!KC_uXIi-zTN&TIi?$?U8BLkX+6Akn5%yx_pBKimN# zo@6QGCxU1r7YRqqf42dceXQ)>lnWCAN{i)iUG6_@I=$`A2z>yA0m8b0--sEpRa^<6 z;?U|@#qspKpud}TV7}4-99DORAe|0}GT`heO-nxf%jz=xyLHG#20+6W->-^8Nn z6~LdaV6fo0m|3kueM&9f}ZU1Zn zm`^FdDDfV!@dL>CH}#O6Yv{HS?0ART1(xXK;A}Tq4d&l7o3p(O24WgkPChaeLQ4Qj z!RZ*VWjLihsmKRMuJwN>dxxGd7Cb_703P{ub6z=wsk z4DX1OS#i-=jN1MlJi)_EhN47{;*&$&JOffNP<;g@O&EB6iz8+7pn72hRH3)pa9@ES3p(x48uaVpFT}r5XvdOfLYQzYbMhT-P`4#q@jtM?tAl-QhsuTVVuz zUR}$+@mB9agezT%aJJ!+rQ-dXiZy?auy!dK)}_1_3^2So){J6oZol?BGPc~vZSfkA z%fH9LVN8erHmTA!Yj`WHA@-p5^!E(7aIhMX(~L5ve{ZGf50YNEm7Ls4>cLzPoA^Nl z^!|G=O`&!NxNBdnhO;`I(K*~$h&V3`YpVw|bGP+VL!w~;gMH0o8mCGBrs8R9bx^;_ z$wUFc@KTpl;2uo_vA68CXDJ!LCXH87HGd#Xb38n{_^mT$$Rdj>~T%l~5*~ zXN3XUQ%HUNwEsP|r>tvkk^4M$70J`RF;U)pWv#QIT5+CD*2Z^_KmFY#%<0coGF1Id_}mNxerGRcuGzhx+5)`alU3(pe#Pi| zo;Q=G2{a!3#J%FKCy|g94IeevV$GZG!pij&w|T7E)bMvT#^$s2k)fXDG%c)Bj>1fe za3m%k`0AHH3~U}Ab=WA+S)m5&)ipmBz^=U#~Cg~kvo?% z#_OHQFsxVIjkyM*b(}ycu_^- z_+r*2RVT5?0G9f1VeAkNlqH+y7tDNGo=Xnk_j766x0;nRFFXR8C|Y$*=39FF$>GFU z%^pJrpYEqqYSsdX(&-wo&|lTMaB#BJ1~s%$P*r3tzS4#PS$eG<%5SnFi9@VX zsB~8`D5`R`IaGbM{ZS$g@cVClu#Lu%$Gu!k`u@U!Aq|f1`wo{**fV=q%5nEsT}1jV zONVUtYmqnu6b4>uE(^1lhiHujmYycR3bP&Uezj4=v7Y6gKV^X1PK>2iw?RQ-?@yy0 zgWwzXier7{OOhA~2f41y)JPMkYwNZufvUt(O9c4^aWx1%wod_0E3kWhWF^7d&y^97 z)#eNssD!`yq5srgvzs(XzwnUlBvvvc!rM@n&(iBHC~|TJ#=Dy_X~E}!BE9}(lt$=X z`$oIo)pbmp*s?x9?U$P!d~3*W=THK?;cC5?J)|?*XP}#6VG&qiF}gRu9VgjTp!JP- zDv3Y5iw>%G$*!v;_Wb}K%Eqaqg1kv$IToN&`*FE+;D>udH4ff&^(-V44p)ibnqaGM zmv=v?ZA)r-Ue=y^ss>D>XchSNO)Lnt;cfUqRu}`y@;^f`N`i1L21wX>RdQC>;B%_z zDAsjEAe+|GA{#{Gx_Dh;PV~=X8dKPsTy@VE&m!w* zS0j&Zun|haV)Q#MI4n`*qR9LM?xMB8X!Hj4gPseYMJLjX4(R#yeX(V-Dj>wws?^{v z>FMPD(sQIxqEQ0KE7jMEW>0eIZ$?702qVevRh4=^jVYgV677XwMm5cE51VeDJ@Maw>);>qQ%xrUR za_W4HB8h8M!a&(QUQAk= zeyPp0fH9%Wd=(C0u=ee2{4iW)cfJXK-n(cR_Lw4U6rN&a1ssq1TB2$|U*2h$L&>Aq z#s8MLwROC%o?yY~e3W(<&L23!6D2@Lg&t$G<7#SsgxY@V?QwW>A%Sim()-Fi0UqQR zcS@H*{@TPklKlH^Q40QahY@Jq^SjVmf5ffL4ae|)Yah6w)78PJlCA|*cY#m!+UDC}YB-a1ukKBd6e#+Y zqwG~$oOmD|-fm-%6Ugvg1G-3yr%2!vFlgg|+Z@uu#`N8<6!gzxJjf6$jocVC@mDD{ zdF#sLk6n!6f^md5UKF;?^>5Iu*}GaIJYR?5I{mIb1C3&!Ng{D&v})}|bwmH&yrj^9 zfs*Ioo43@;^9di4eA?a>A{7)PEXtQV867Oxf<_9^ObDvC=1<}84hz2u9{_~NA6eOk zghLeSE>Pk&!OvC*-$nEVT_Tf}aF@vWqp+XhMq|qmhT9V62AjaJtt%8(jgZOFd~$~V zAQ?wsw?tapqzbENQhICijGwO4l7_-`=-^+aS<*UbuYv6}4`VeP!u^Lij|{3IZUj+icz7wm;9ABOxa!FT>w$P>fjI#U8cNl`?q# zxMD5b$VAsHu1{X!AzICjk^t{WT+ysOD}{z`Mv_tq^>cnWaHawJw%-mHQ$fd2dtz;% z8ZB=;<9ndsak^ddhZVZzX-%_g#Fnw*ZJqPO1)xV{Kg*Fp@>k(LJ06CW^h*1J`9_xd zF3mP}{~G?Sq7`O>FWR9XfUSX+lvWhG2sa)1&)@C|PrYhdgIc$X)$qU`qY1Kn?ZDqL zcuqScAE_@qqyv+zPg`?@t^4ByC-P z#tV6(Gw)Tv*XhWW@yk*1Iw}j-r=}@0N6q@c^OarAi(mZAbnkBc%sQ0S(yj_*vOW=T zI@(Xhi-h$Ucp4Q9evL0rc)boZta+_~M&bnMF>t3w2<91dIZc2R2fSI4zr;~R{V>A9 zd2NT9DBI4=qwt#*j7scN)OW?ses`xm`&`a32)eoeBF)K86qTu0MnoOE6GTwi#&qK@+>W&QE>!EENqQLH?=p~MEvk8kbnJ{4hzJhsqsL5;VUn*U!U^X+obi zuuLz=68=I0e=l~Qtj2|L*D1S+>`1sMk{{x5l^_&MZEH5taZ-{>QpOn5E*))&!gJC{3CI!@8)c$ zqgmLDvI8zb8Qa{~tOoa6O=aGDi*r3TQFj{JdKmU4x98L_-xqc~*u(*@hE+1#9uXVp zb8v^J8OKTw1@~zpXq8j;`kXoLq@U}JcOvY4CQkmXd}XPo*`}<0ZsdY(f4MJc{8ne3 zo;WAs;Kpw4V;0(apdsLvGrB}1__C9;WkvGc7b;$q%;ui>5ALqCLMSH~Ch#PF-g;{+ zhTSB$BJd67Z*|%+Pvc ze>1jgDywYZ#D<_b7CIiknrTk$uwIVpRD*)HtUNjt1fg?TkObcyAafTODsW8%I=%Y+ zhaGBKOjn0xNI0_Q;uS!gAq~`QO7< z%cg8W#+Z*HNINQ*ML+9K$I293BYWQq=I2|5vFP+aIY&DYT;Py4Y}I4k&j9rYFtwN< zf;{&{&>#`R^Vk^fxQ5WL7SlzLqLqTtE0-`<2;KYU?|UUHTNq}p12?pSZT{Yt1h#$8 z{EYBax_uIE%iaj3<&eR%Pt4ChS$94_ixMZaXEgcm`!Oq|bi5EjI#&w5Ub&gFvW2r( z#$t<@Oat{*V84p_cDQ>Sh)Bb(+<|;yavJj0w$7_%T{mJ;`BC~F+sCdEHV>B)|Lv^H zL#&m*6@gno%g$pw$ep%y**N;2T4_+5?+_N{O z>752mRhOu$$rIgwS?#maqmg4Gjgd!Xi(aNIyx&yo$RqCl+Ul%JST1+rFRn?S?5K5tJs56azgBzEs+7 zU~O4?UJR+#S5VS!Tz~S<&Oc^2t1M?P+XgX@pMTHE_5++~Al;$+oJR_O(ot+5Wk_-v zZ#flrpxa?|$Px6}JS^bR`f{YQmUixT;}uFxb#>S#%x8P=hV~o}(SJq-#iM-|f5_Fr z2qT=i0?$h!wfYMjd*$~1dFGsomT~=sz5vkB8cdneis;k38V^MUt*jJGmj~ znX%1L|Af*;!Kb`2&K}pLJB+~{;hZ8(wvSiR5elM6rRp04t!Kdn-`7orJTYX&>EK`N zFBg7G#tlR|%Y)nP!wW|JeVkRFUtxW!pxr_;zpO@Ladz;VJuYVcHCu)LdDMckPB)cww?|9KtwAU!^9>$j{7Zo4rxA$=Ua;Me^hf`ojqH zG`5LesU(E&4#4xp#3xXj#_uSjrMG9hBLT}bl5@2K9KfRZbqsWd7r{R1lg~M;>Q2z! zgX%1!(9p9zjj;}FQyHtOWE>;w!B1w?wLfOI<~`JVw-X)x^@E=0gfRkKG9OF` zS9KOf&Y$xb*ur&$vIi)=CSi-s;;yrj$ai;uwtt^*YQkMU!*;S@17KLq?#wyE`#aey z&TItez+zM#-t`X~eHez7>#6h3t@i_9;%t!u{exYu;Ey9goA1R0)_!=GP6At9v@`2X zc@Q|FC5{yBV|KijhA-7e@d_gm&b_xKcw@G0`lv>lJ#MfQT*PjAdk@Ed9Cf~dZQ_y6 z(hkqlk8l75r|GBreR*QIp8I_|%mHBE8AF|TjR3-URxo-GSGW`5F2&|?GI}CxiW-l- z4kUhAx-Q`XuN#0w$3uapK_!ap6NPWxJ;a}Q@l^%?)L4j2h+#WxH9lIqhkgb@~`>7P`A z7?0@`%XRdV0H2O!9!)8hi#{HfX3EXb?tYv5XSxM@v-d$zGU3}is!qDaBx`-8H1g24 zzIG|M_zF+3h5Q7qg4BP*@as$VmnEAsEZ6xiZN~#CiG3vymYQ%M2vbg%wy~YuzTepY zF*n@;$T}HieDg80z+1>n^8>5{3y%`#hh!cIQW*JGLDG2S_wgtfaM84ylA907K!OVC z3zh9j$AhGvH42-m!y+k?V%Rqyn=1qe8S{JD5;)X^KWZ>k0gKo2p`B6G0QkX;r5;IP zM4-ly+*=_)FAQ=g6!?{dn)iZo27BMqD>OLzTBW@0v9Fr}sFj0hbqT8hvW;)IHp}7VCc;PX$TEQXAq#o4R2$sS;JoIu^9xJ$mSV zGbg;Y3Q1IT#Ayrw_tYit{)7sWma~#-{TrYT!`*~~Rrhke0owxd-%s?~fk`4xR4Gb(W_g>|&9MBh?rEQ+} zqwo(pa0u9UKVuf8&uk0-v1%Dv1b~zrOMub}5|^{m$Ao4sK;Uk~NuT4RC@|*>ws@3& z;^sCM`;C>}P2I4?41TLHl3NN7aDbis6>1?cFv%k%gufiBEpMBJ-|&_ z3%v}C<{P@yUwaQ%hdp*^V3~sF_B|E7m9U!$|2TxIvHyZ!FI6^I^g>iR(+IPz?;K{4 zTPJT~574^6yx$^|={S1mtn(h2+Jr=^aNHx75c&PIvt{UXvs}?@<&CgjF2qusYl_;z znrMz0d+R66)>D`VuaBL9srFdc=cYQq(&t%|`#RLscm;SVGBY;R{{I~00OSITL-p{B~m2=rtV>bF8iY}{5IFA92 z;(Vo+T9%oQ|Ld7;RP|{XRHk2Vc=o3{!x%9skef8Qhs$cVw(q=p8 zUR8950WE`2^|IFf4~GG9g)sNZr}7*$cN)w92E*d`V{V63tuUZt{N{St<`M( zQjxQ2+r1d7=Ynm2ZJexp76*jZ+FG@6tsnSGl9N)yPWHZyLfe#T=$~tVP{FTOrFJIH zz6~LD0X@mGd5G~mh|=kPpz{Gj5{W*CcLV@#zK{xSt0;IwHu}Rg^~OwcNZ>2%lj>E^u6`V3n)Bp`P;&c z{`Hja3T&!7c^;&AT0Zx6?w1>O<$ylLly;7UlWcbCFQv|?HXI3xys-b}f)@68zo4p8 z7QCiOvEn5DAW1P-H0~C={T(4$rNQZ*_e)_lDll=+yl@T%KSs)GoX(A2xf%+k1GYJI zu!?H2v*|DvYo24&I@A!RKKj;}xC^rAJtmO}kEj#)jBOA^ol2}Z9yfX~y4Xoct(xYjqe|_CJYp|p>>h_PGTd@@;vmo(l-TDkWr_bm=C}eo4lYwBWz{JNwe9y;qP~*97BsQ?Wbar-PVK$eO^!a&&iUGU*;T@(} zebTi!M0IAoVRh!{tiBMGtYc;`UGw{ba}wy)+RKLl!au} z@bH%IJ=MKnz5kau@73`E4PhGzNGw)p<2+_jaH6B^(zm9FiL2vA{R7|E*~HG0Yq}3s i9@uj0^_L| + + + + + + Platbík + + + +
+ + + diff --git a/src/frontend/jsconfig.json b/src/frontend/jsconfig.json new file mode 100644 index 0000000..abe5f9f --- /dev/null +++ b/src/frontend/jsconfig.json @@ -0,0 +1,27 @@ +{ + "compilerOptions": { + "baseUrl": ".", + "paths": { + "@bookmarks/*": ["src/bookmarks/*"], + "@auth/*": ["src/auth/*"], + "@objects/*": ["src/objects/*"], + "@styles/*": ["src/styles/*"], + + "@api/*": ["src/api/*"], + "@components/*": ["src/components/*"], + "@forms/*": ["src/components/forms/*"], + "@tables/*": ["src/components/tables/*"], + "@dialogs/*": ["src/components/dialogs/*"], + "@columns/*": ["src/components/columns/*"], + "@atoms/*": ["src/components/atoms/*"], + "@main/*": ["src/*"], + "@backend/*": ["backend/*"], + "@models/*": ["backend/models/*"], + "@routes/*": ["backend/routes/*"], + "@mappers/*": ["src/mappers/*"], + "@config/*": ["src/config/*"] + }, + "moduleResolution": "node" + }, + "include": ["src", "backend"] +} diff --git a/src/frontend/package.json b/src/frontend/package.json new file mode 100644 index 0000000..290d5db --- /dev/null +++ b/src/frontend/package.json @@ -0,0 +1,42 @@ +{ + "name": "my-app", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "dev:server": "node backend/server.js", + "dev:all": "concurrently \"npm run dev\" \"npm run dev:server\"", + "build": "vite build", + "lint": "eslint .", + "preview": "vite preview" + }, + "dependencies": { + "@danneschs/libnik-ui": "file:../../../libnik-ui/danneschs-libnik-ui-0.1.0.tgz", + "@emotion/react": "^11.14.0", + "@emotion/styled": "^11.14.0", + "@fontsource/roboto": "^5.2.5", + "@mui/icons-material": "^7.0.1", + "@mui/material": "^7.0.1", + "@mui/x-data-grid": "^7.28.3", + "@mui/x-data-grid-generator": "^8.1.0", + "bcryptjs": "^3.0.2", + "qrcode.react": "^4.2.0", + "react": "^19.0.0", + "react-dom": "^19.0.0", + "react-router-dom": "^7.4.0", + "zustand": "^5.0.3" + }, + "devDependencies": { + "@eslint/js": "^9.21.0", + "@types/react": "^19.0.10", + "@types/react-dom": "^19.0.4", + "@vitejs/plugin-react": "^4.3.4", + "eslint": "^9.21.0", + "eslint-plugin-react-hooks": "^5.1.0", + "eslint-plugin-react-refresh": "^0.4.19", + "globals": "^15.15.0", + "vite": "^6.2.0", + "vite-plugin-singlefile": "^2.2.0" + } +} diff --git a/src/frontend/public/favicon.ico b/src/frontend/public/favicon.ico new file mode 100644 index 0000000..e69de29 diff --git a/src/frontend/src/App.jsx b/src/frontend/src/App.jsx new file mode 100644 index 0000000..3459c36 --- /dev/null +++ b/src/frontend/src/App.jsx @@ -0,0 +1,29 @@ +import { Routes, Route, Navigate } from 'react-router-dom'; + +import Grid from '@main/Grid.jsx'; +import AllPurchases from "@bookmarks/AllPurchases.jsx"; +import MyPurchases from "@bookmarks/MyPurchases.jsx"; +import MyCommitments from "@bookmarks/MyCommitments.jsx"; +import About from '@bookmarks/About.jsx'; +import Auth from '@bookmarks/Auth.jsx'; + +/** + * Provides all routes for the application. + * @description This component serves as the main routing component for the application, defining all the routes and their corresponding components. + * @returns App component with all routes. + */ +function App() { + return ( + + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + ); +} + +export default App; \ No newline at end of file diff --git a/src/frontend/src/Grid.jsx b/src/frontend/src/Grid.jsx new file mode 100644 index 0000000..b2f6bec --- /dev/null +++ b/src/frontend/src/Grid.jsx @@ -0,0 +1,262 @@ +import { useState, useContext, useEffect } from "react"; +import { useNavigate, useLocation } from "react-router-dom"; + +import { AuthContext } from "@auth/AuthContext"; + +import Auth from "@bookmarks/Auth.jsx"; +import { GenericGrid } from "@danneschs/libnik-ui"; +import FullPageSpinner from "@auth/FullPageSpinner"; +import NotificationsDialog from "@dialogs/NotificationsDialog.jsx"; +import RegistrationRequestsDialog from "@dialogs/RegistrationRequestsDialog.jsx"; +import { getAllNotificationsService } from "@api/transactionLogs"; +import { getAllRegistrationRequestsService } from "@api/pendingUsers"; +import { registerFromRequestService } from "@api/users"; +import { getCurrencyFormatService } from "@api/configs"; +import { ToastBar, ConfirmationDialog, MessageDialog } from "@danneschs/libnik-ui"; +import AutoFixHighIcon from "@mui/icons-material/AutoFixHigh"; + +/** Nav-link definitions for this app. */ +const APP_NAV_LINKS = [ +{ label: "Všechny nákupy", to: "/vsechny-nakupy", key: "vsechny-nakupy" }, +{ label: "Moje nákupy", to: "/moje-nakupy", key: "moje-nakupy" }, +{ label: "Závazkové vztahy", to: "/zavazkove-vztahy", key: "zavazkove-vztahy" }, +]; + +/** + * Grid component + * @description App-specific grid wrapper. Owns all state and side-effects; + * delegates rendering to GenericGrid. + * @param {string} title Page title displayed in the header. + * @param {ReactNode} children Main page content. + */ +function Grid({ title, children }) { +const [showAuth, setShowAuth] = useState(false); +const [showLogout, setShowLogout] = useState(false); +const [showNotifications, setShowNotifications] = useState(false); +const [myNotifications, setMyNotifications] = useState([]); +const location = useLocation(); +const [activeLink, setActiveLink] = useState(""); +const [showSuccessRegistrationRequest, setShowSuccessRegistrationRequest] = useState(false); +const [showFailedRegistrationRequest, setShowFailedRegistrationRequest] = useState(false); +const [showRegistrationRequests, setShowRegistrationRequests] = useState(false); +const [allRegistrationRequests, setAllRegistrationRequests] = useState([]); +const [showConfirmRegister, setShowConfirmRegister] = useState(false); +const [clickedRegisterRequestId, setClickedRegisterRequestId] = useState(0); +const [toastBarSettings, setToastBarSettings] = useState(null); + +const { currentUser, loading } = useContext(AuthContext); + +const navigate = useNavigate(); + +const amIAdmin = currentUser?.roleCode === "admin"; + +// Update activeLink when route changes +useEffect(() => { +if (loading) return; +getCurrencyFormatService(); + +setActiveLink(location.pathname.substring(1)); + +const fetchNotifications = async () => { +const notifications = await getAllNotificationsService(localStorage.getItem("jwtToken")); +setMyNotifications(notifications || []); +}; + +const fetchAllRegistrationRequests = async () => { +const requests = await getAllRegistrationRequestsService(localStorage.getItem("jwtToken")); +setAllRegistrationRequests(requests || []); +}; + +if (!currentUser) { +setShowAuth(true); +} else { +fetchNotifications(); +if (currentUser.roleCode === "admin") { +fetchAllRegistrationRequests(); +} +} +}, [location.pathname, currentUser, loading]); + +const handleOnSuccessRegistrationRequest = () => { +setShowAuth(false); +setShowSuccessRegistrationRequest(true); +}; + +const handleOnFailedRegistrationRequest = () => { +setShowAuth(false); +setShowFailedRegistrationRequest(true); +}; + +const handleClick = (link) => { +setActiveLink(link); +handleCloseNotifications(false); +}; + +const handleRegisterFromRequest = async (id) => { +setShowConfirmRegister(true); +setClickedRegisterRequestId(id); +}; + +const setSuccessToastBar = (message) => { +setToastBarSettings({ +message: message, +type: "success", +onClose: () => setToastBarSettings(null), +}); +}; + +const setErrorToastBar = (message) => { +setToastBarSettings({ +message: message, +type: "error", +onClose: () => setToastBarSettings(null), +}); +}; + +const registerFromRequest = async (id) => { +const token = localStorage.getItem("jwtToken"); +try { +const result = await registerFromRequestService(token, id); +if (result) { +setSuccessToastBar("Uživatel úspěšně zaregistrován."); +setAllRegistrationRequests((prevRequests) => prevRequests.filter((req) => req.id !== id)); +} +} catch (error) { +setErrorToastBar("Registrace uživatele se nezdařila."); +console.error("Failed to register from request:", error); +} +}; + +const handleSetShowLoginWithActiveLink = (link) => { +setShowAuth(true); +setActiveLink(link); +}; + +const handleShowNotifications = async () => { +setShowNotifications(true); +}; + +const handleCloseNotifications = async (shouldMarkAsRead = true) => { +const token = localStorage.getItem("jwtToken"); +if (shouldMarkAsRead) { +try { +await Promise.all(myNotifications.map((element) => element.markAsRead(token))); +} catch (error) { +//setErrorToastBar("Nepodařilo se označit notifikace jako přečtené."); +console.error("Failed to mark notifications as read:", error); +} +} +setShowNotifications(false); +}; + +const getUnreadNotificationsCount = () => { +return myNotifications.filter((notification) => !notification.isRead).length; +}; + +const getUnreadRegisterRequestsCount = () => { +if (amIAdmin) { +return allRegistrationRequests.filter((request) => !request.isRead).length; +} +return 0; +}; + +const handleCloseRegistrationRequests = async () => { +const token = localStorage.getItem("jwtToken"); +try { +await Promise.all(allRegistrationRequests.map((element) => element.markAsRead(token))); +} catch (error) { +//setErrorToastBar("Nepodařilo se označit žádosti o registraci jako přečtené."); +console.error("Failed to mark registration requests as read:", error); +} +setShowRegistrationRequests(false); +}; + +// Build nav links: onClick is auth-aware — logged-in users navigate normally, +// guests are shown the login dialog first. +const navLinks = APP_NAV_LINKS.map((link) => ({ +...link, +isActive: activeLink === link.key, +onClick: currentUser +? () => handleClick(link.key) +: () => handleSetShowLoginWithActiveLink(link.key), +})); + +const dialogs = ( +<> +{showNotifications && currentUser && ( + { +handleClick("zavazkove-vztahy"); +navigate("/zavazkove-vztahy"); +}} +/> +)} +{showAuth && !currentUser && ( + setShowAuth(false)} +/> +)} +{showLogout && currentUser && setShowLogout(false)} />} +{showSuccessRegistrationRequest && ( + setShowSuccessRegistrationRequest(false)} +/> +)} +{showFailedRegistrationRequest && ( + setShowFailedRegistrationRequest(false)} +/> +)} +{showRegistrationRequests && currentUser && amIAdmin && ( + +)} +{showConfirmRegister && ( + { +registerFromRequest(clickedRegisterRequestId); +setShowConfirmRegister(false); +}} +onCancel={() => setShowConfirmRegister(false)} +/> +)} +{toastBarSettings && } + +); + +return ( + setShowAuth(true)} +onLogoutClick={() => setShowLogout(true)} +notificationCount={getUnreadNotificationsCount()} +onNotificationsClick={handleShowNotifications} +adminBadgeCount={amIAdmin ? getUnreadRegisterRequestsCount() : 0} +onAdminClick={amIAdmin ? () => setShowRegistrationRequests(true) : undefined} +AdminIcon={amIAdmin ? AutoFixHighIcon : null} +loading={loading} +loadingFallback={} +dialogs={dialogs} +> +{children} + +); +} + +export default Grid; diff --git a/src/frontend/src/Main.jsx b/src/frontend/src/Main.jsx new file mode 100644 index 0000000..b7ecff6 --- /dev/null +++ b/src/frontend/src/Main.jsx @@ -0,0 +1,27 @@ +import { StrictMode } from "react"; +import { createRoot } from "react-dom/client"; +import { BrowserRouter } from "react-router-dom"; +import { ThemeProvider } from "@mui/material/styles"; +import CssBaseline from "@mui/material/CssBaseline"; + +import App from "@main/App.jsx"; +import "@styles/index.css"; +import { theme } from "./theme"; + +import AuthProvider from "@auth/AuthProvider.jsx"; +import ConfigProvider from "@config/ConfigProvider.jsx"; + +createRoot(document.getElementById("root")).render( + + + + + + + + + + + + +); diff --git a/src/frontend/src/api/commitments.js b/src/frontend/src/api/commitments.js new file mode 100644 index 0000000..3f08b06 --- /dev/null +++ b/src/frontend/src/api/commitments.js @@ -0,0 +1,29 @@ +import { URL } from "@api/url.js"; +import CommitmentDto from "@objects/CommitmentDto"; +import { v4 as uuidv4 } from "uuid"; + +export async function getAllCommitmentsService(token) { + const response = await fetch(`${URL}/commitment/all-commitments`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + if (!response.ok) { + throw new Error( + "Unable to fetch commitments, bad response from server" + ); + } + const commitments = await response.json(); + return commitments.map( + (c) => + new CommitmentDto( + uuidv4(), + c.fromUserId, + c.toUserId, + c.state, + c.toName, + c.totalInCents + ) + ); +} diff --git a/src/frontend/src/api/configs.js b/src/frontend/src/api/configs.js new file mode 100644 index 0000000..46a59e3 --- /dev/null +++ b/src/frontend/src/api/configs.js @@ -0,0 +1,21 @@ +import { URL } from "@api/url.js"; + +export async function getCurrencyFormatService() { + const response = await fetch(`${URL}/config/currency-format`, { + method: "GET", + headers: { + "Content-Type": "application/json", + }, + }); + try { + const data = await response.json(); + // Handle the currency data + if (data.currencyFormat == null && data.currencyFormat !== "Kč" && data.currencyFormat !== "€") { + return "Kč"; // Default currency format + } + + return data.currencyFormat; + } catch (error) { + console.error("Error fetching currency data (setting default):", error); + } +} diff --git a/src/frontend/src/api/exchangeRates.js b/src/frontend/src/api/exchangeRates.js new file mode 100644 index 0000000..12129d0 --- /dev/null +++ b/src/frontend/src/api/exchangeRates.js @@ -0,0 +1,22 @@ +import ExchangeRateDto from "@objects/ExchangeRateDto.js"; + +export async function getCurrentRate(token) { + const currentRate = await fetch("/api/exchangeRate/current-rate", { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + + if (!currentRate.ok) { + if (currentRate.status === 404) { + // Not found + throw new Error("Nenalezena žádná data pro tento měsíc."); + } + throw new Error("Neznámá chyba při načítání aktuálního kurzu do CZK. Bude použit kurz 1."); + } + + const data = await currentRate.json(); + return new ExchangeRateDto(data.date, data.rate); +} diff --git a/src/frontend/src/api/items.js b/src/frontend/src/api/items.js new file mode 100644 index 0000000..5122c6e --- /dev/null +++ b/src/frontend/src/api/items.js @@ -0,0 +1,18 @@ +import { URL } from "@api/url"; + +export const getItemsByPurchaseIdService = async (purchaseId, token) => { + const response = await fetch( + `${URL}/item/items-by-purchase/${purchaseId}`, + { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + } + ); + if (!response.ok) { + throw new Error("Unable to fetch items, bad response from server"); + } + const items = await response.json(); + return items; +}; diff --git a/src/frontend/src/api/pendingUsers.js b/src/frontend/src/api/pendingUsers.js new file mode 100644 index 0000000..1f16cf2 --- /dev/null +++ b/src/frontend/src/api/pendingUsers.js @@ -0,0 +1,44 @@ +import { URL } from "@api/url.js"; +import PendingRegisterRequestDto from "@objects/PendingRegisterRequestDto"; + +export async function markRegistrationRequestAsRead(token, id) { + const response = await fetch(`${URL}/pendinguser/mark-as-read/${id}`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + throw new Error("Unable to fetch pending users, bad response from server"); + } +} + +export async function getAllRegistrationRequestsService(token) { + const response = await fetch(`${URL}/pendinguser/all`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + "Content-Type": "application/json", + }, + }); + if (!response.ok) { + throw new Error("Unable to fetch registration requests, bad response from server"); + } + + const data = await response.json(); + return data + .sort((a, b) => new Date(b.requestedAt) - new Date(a.requestedAt)) + .map( + (item) => + new PendingRegisterRequestDto( + item.id, + item.name, + item.surname, + item.accountNumber, + item.email, + new Date(item.requestedAt).toLocaleString("cs-CZ"), + item.isRead + ) + ); +} diff --git a/src/frontend/src/api/purchases.js b/src/frontend/src/api/purchases.js new file mode 100644 index 0000000..8d43254 --- /dev/null +++ b/src/frontend/src/api/purchases.js @@ -0,0 +1,109 @@ +import { URL } from "@api/url.js"; +import { GetPurchaseDto } from "@objects/GetPurchaseDto.js"; + +export async function getAllPurchasesService(token) { + const response = await fetch(`${URL}/purchase/all-purchases`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + if (!response.ok) { + throw new Error("Unable to fetch purchases, bad response from server"); + } + const purchases = await response.json(); + return purchases; +} + +export async function getMyPurchasesService(token) { + const response = await fetch(`${URL}/purchase/my-purchases`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + if (!response.ok) { + throw new Error("Unable to fetch purchases, bad response from server"); + } + const purchases = await response.json(); + return purchases.map( + (p) => + new GetPurchaseDto( + p.id, + p.name, + p.buyerName, + p.coPayers, + p.timestamp, + p.shopName, + p.priceInCents, + p.buyerPays + ) + ); +} + +export async function removePurchaseService(purchaseId, token) { + const response = await fetch(`${URL}/purchase/${purchaseId}`, { + method: "DELETE", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + if (!response.ok) { + throw new Error("Unable to remove purchase, bad response from server"); + } + return response.ok ? null : await response.json(); +} + +export async function addPurchaseService(purchase, token) { + const response = await fetch(`${URL}/purchase/add-purchase`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(purchase), + }); + if (!response.ok) { + throw new Error("Unable to add purchase, bad response from server"); + } + + const newPurchase = await response.json(); + + return new GetPurchaseDto( + newPurchase.id, + newPurchase.name, + newPurchase.buyerName, + newPurchase.coPayers, + newPurchase.timestamp, + newPurchase.shopName, + newPurchase.priceInCents, + newPurchase.buyerPays + ); +} + +export async function updatePurchaseService(purchase, token) { + const response = await fetch(`${URL}/purchase/update`, { + method: "PUT", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(purchase), + }); + if (!response.ok) { + throw new Error("Unable to update purchase, bad response from server"); + } + + const newPurchase = await response.json(); + + return new GetPurchaseDto( + newPurchase.id, + newPurchase.name, + newPurchase.buyerName, + newPurchase.coPayers, + newPurchase.timestamp, + newPurchase.shopName, + newPurchase.priceInCents, + newPurchase.buyerPays + ); +} diff --git a/src/frontend/src/api/transactionLogs.js b/src/frontend/src/api/transactionLogs.js new file mode 100644 index 0000000..decfee3 --- /dev/null +++ b/src/frontend/src/api/transactionLogs.js @@ -0,0 +1,119 @@ +import { URL } from "@api/url.js"; +import GetTransactionLogDto from "@objects/GetTransactionLogDto.js"; +import PostTransactionLogDto from "@objects/PostTransactionLogDto.js"; +import NotificationDto from "@objects/NotificationDto.js"; +import CommitmentDto from "@objects/CommitmentDto"; +import { v4 as uuidv4 } from "uuid"; + +export async function getTransactionLogsBetweenUsersService(token, userId1, userId2) { + const response = await fetch(`${URL}/transactionlog/logs-between-users?userId1=${userId1}&userId2=${userId2}`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error("Failed to fetch transaction logs"); + } + + const data = await response.json(); + + return data.map( + (item) => + new GetTransactionLogDto( + item.id, + item.date, + item.type, + item.fromName, + item.toName, + item.amount, + item.description + ) + ); +} + +export async function createPendingSettlementService(token, fromUserId, toUserId, amountInCents) { + const body = new PostTransactionLogDto(fromUserId, toUserId, amountInCents); + const response = await fetch(`${URL}/transactionlog/pending-settle`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error("Failed to create pending settlement"); + } + + return response.ok; +} + +export async function settleService(token, fromUserId, toUserId, amountInCents) { + const body = new PostTransactionLogDto(fromUserId, toUserId, amountInCents); + + const response = await fetch(`${URL}/transactionlog/settle`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + if (!response.ok) { + throw new Error("Failed to settle transaction"); + } + const newCommitments = await response.json(); + + return newCommitments.map( + (c) => new CommitmentDto(uuidv4(), c.fromUserId, c.toUserId, c.state, c.toName, c.totalInCents) + ); +} + +export async function getAllNotificationsService(token) { + const response = await fetch(`${URL}/transactionlog/my-notifications`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error("Failed to fetch notifications"); + } + + const data = await response.json(); + + return data + .sort((a, b) => new Date(b.timestamp) - new Date(a.timestamp)) + .map( + (item) => + new NotificationDto( + item.id, + item.type, + item.header, + item.text, + new Date(item.timestamp).toLocaleString("cs-CZ"), + item.isRead, + item.fromUserId, + item.toUserId, + item.amount + ) + ); +} + +export async function markNotificationAsReadService(token, transactionId) { + const response = await fetch(`${URL}/transactionlog/mark-as-read/${transactionId}`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + if (!response.ok) { + throw new Error("Unable to mark notification as read on server."); + } +} diff --git a/src/frontend/src/api/url.js b/src/frontend/src/api/url.js new file mode 100644 index 0000000..6141737 --- /dev/null +++ b/src/frontend/src/api/url.js @@ -0,0 +1,4 @@ +export const URL = + import.meta.env.MODE === "development" + ? "/api" // Vite proxy to přepošle na backend + : "/api"; // produkce: backend servíruje API a frontend ze stejného hostu diff --git a/src/frontend/src/api/users.js b/src/frontend/src/api/users.js new file mode 100644 index 0000000..7959830 --- /dev/null +++ b/src/frontend/src/api/users.js @@ -0,0 +1,100 @@ +import { URL } from "@api/url.js"; + +export async function loginService(email, password) { + // Try to login the user + const response = await fetch(`${URL}/auth/login`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ email, password }), + }); + + if (!response.ok) { + return null; // Invalid credentials or user not found + } + + const data = await response.json(); + const token = data.token; + // Get user information after successful login + return getCurrentUserService(token); +} + +export async function getCurrentUserService(token) { + const userResponse = await fetch(`${URL}/user/me`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + // Not logged in + if (userResponse.status === 401) return null; + + if (!userResponse.ok) { + throw new Error("Unable to fetch user, bad response from server"); + } + + const user = await userResponse.json(); + + return { token, user }; +} + +export async function registerFromRequestService(token, id) { + const response = await fetch(`${URL}/user/register-${id}`, { + method: "POST", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + if (!response.ok) { + throw new Error("Unable to register user from request"); + } + + return await response.json(); +} + +export async function requestRegistrationService(newUserDto) { + const response = await fetch(`${URL}/user/request-registration`, { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify(newUserDto), + }); + + if (!response.ok) { + const errorData = await response.json(); + return { success: false, errors: errorData }; // Registration failed + } + + return { success: true }; // Registration successful +} + +export async function getAllUsersService(token) { + const response = await fetch(`${URL}/user/all`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + if (!response.ok) { + throw new Error("Unable to fetch users, bad response from server"); + } + const users = await response.json(); + return users; +} + +export async function getUserByIdService(token, userId) { + const response = await fetch(`${URL}/user/${userId}`, { + method: "GET", + headers: { + Authorization: `Bearer ${token}`, + }, + }); + if (!response.ok) { + throw new Error("Unable to fetch user, bad response from server"); + } + const user = await response.json(); + return user; +} diff --git a/src/frontend/src/api/wrappers/showPurchaseDetails.js b/src/frontend/src/api/wrappers/showPurchaseDetails.js new file mode 100644 index 0000000..3dad847 --- /dev/null +++ b/src/frontend/src/api/wrappers/showPurchaseDetails.js @@ -0,0 +1,33 @@ +import { getItemsByPurchaseIdService } from "@api/items"; +import { getAllUsersService } from "@api/users"; +import { EditPurchase } from "@objects/EditPurchase.js"; + +export const showPurchaseDetails = async ( + row, + token, + setAllUsers, + setClickedPurchase, + setShowDetail +) => { + // Logic to display purchase details + try { + const items = await getItemsByPurchaseIdService(row.id, token); + const allUsers = await getAllUsersService(token); + setAllUsers(allUsers || []); + + const thisPurchase = new EditPurchase( + row.id, + row.name, + row.coPayers, + new Date(row.timestamp), + row.shopName, + row.priceInCents, + items, // SIDE EFFECT: items here are type ItemDto, so they have id as int! + row.buyerPays + ); + setClickedPurchase(thisPurchase); + setShowDetail(true); + } catch (error) { + console.error("Error fetching purchase details:", error); + } +}; diff --git a/src/frontend/src/auth/AuthContext.js b/src/frontend/src/auth/AuthContext.js new file mode 100644 index 0000000..51ebe83 --- /dev/null +++ b/src/frontend/src/auth/AuthContext.js @@ -0,0 +1,3 @@ +import { createContext } from "react"; + +export const AuthContext = createContext(null); \ No newline at end of file diff --git a/src/frontend/src/auth/AuthProvider.jsx b/src/frontend/src/auth/AuthProvider.jsx new file mode 100644 index 0000000..1bbc39e --- /dev/null +++ b/src/frontend/src/auth/AuthProvider.jsx @@ -0,0 +1,111 @@ +import { useState, useEffect } from "react"; +import { AuthContext } from "@auth/AuthContext"; + +import { loginService, getCurrentUserService, requestRegistrationService } from "@api/users"; +import RegisterUserDto from "@objects/RegisterUserDto"; + +/** + * AuthProvider component to provide authentication context for children components + * @param {*} children + * @returns currentUser, loading, login, logout, register + */ +function AuthProvider({ children }) { + const [currentUser, setCurrentUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const tokenFromStorage = localStorage.getItem("jwtToken"); + // No token in storage + if (!tokenFromStorage) { + setLoading(false); + return; + } + + const fetchUser = async () => { + try { + const { user: loggedUser } = await getCurrentUserService(tokenFromStorage); + + // Invalid token or user not found + if (!loggedUser) { + setLogoutState(); + setLoading(false); + return; + } + setCurrentUser(loggedUser); + } catch (err) { + console.error("Failed to fetch user while loading the web:", err); + setLogoutState(); + } finally { + setLoading(false); + } + }; + + fetchUser(); + }, []); + + const login = async (email, password) => { + try { + const credentials = await loginService(email, password); + const { token, user } = credentials || {}; + + // Invalid credentials or user not found + if (!credentials || !token || !user) { + //console.error("Invalid login response:", { token, user }); + setLogoutState(); + return false; + } + setLoggedInState(token, user); + return true; + } catch (err) { + console.error("Login failed:", err); + setLogoutState(); + return false; + } + }; + + const setLoggedInState = (token, user) => { + localStorage.setItem("jwtToken", token); + setCurrentUser(user); + }; + + const setLogoutState = () => { + localStorage.removeItem("jwtToken"); + setCurrentUser(null); + }; + + const logout = () => { + setLogoutState(); + }; + + const register = async (name, surname, accountNumber, email, password) => { + const newUserDto = new RegisterUserDto(name, surname, accountNumber, email, password); + let registerServiceResponse = null; + try { + registerServiceResponse = await requestRegistrationService(newUserDto); + if (registerServiceResponse.success) { + // Optionally, you can auto-login after registration + //const loginSuccess = await login(email, password); + return { success: true }; // Request for registration was successful + } + } catch (err) { + console.error("Registration failed:", err); + } + return registerServiceResponse; + }; + + return ( + + {children} + + ); +} + +export default AuthProvider; diff --git a/src/frontend/src/auth/FullPageSpinner.jsx b/src/frontend/src/auth/FullPageSpinner.jsx new file mode 100644 index 0000000..338b273 --- /dev/null +++ b/src/frontend/src/auth/FullPageSpinner.jsx @@ -0,0 +1,41 @@ +import { Box, CircularProgress, useTheme } from "@mui/material"; + +/** + * Spinner that covers the full page for loading states + * @param {*} variant -- "determinate" | "indeterminate" + * @param {*} value -- number between 0-100 for determinate variant + * @returns + */ +function FullPageSpinner({ variant = "indeterminate", value }) { + const theme = useTheme(); + return ( + + + + ); +} + +export default FullPageSpinner; diff --git a/src/frontend/src/auth/useCurrentUser.jsx b/src/frontend/src/auth/useCurrentUser.jsx new file mode 100644 index 0000000..87d40e8 --- /dev/null +++ b/src/frontend/src/auth/useCurrentUser.jsx @@ -0,0 +1,39 @@ +import { useState, useEffect } from "react"; + +/** + * Custom hook to fetch and return the current user + * @returns {Object} - { user: Object, loading: boolean } + */ +export function useCurrentUser() { + const [user, setUser] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchUser = async () => { + const token = localStorage.getItem("token"); + if (!token) { + setLoading(false); + return; + } + + try { + const res = await fetch("/api/me", { + headers: { Authorization: `Bearer ${token}` }, + }); + + if (res.ok) { + const data = await res.json(); + setUser(data); + } + } catch (err) { + console.error("Chyba při načítání uživatele:", err); + } finally { + setLoading(false); + } + }; + + fetchUser(); + }, []); + + return { user, loading }; +} diff --git a/src/frontend/src/bookmarks/About.jsx b/src/frontend/src/bookmarks/About.jsx new file mode 100644 index 0000000..7c501af --- /dev/null +++ b/src/frontend/src/bookmarks/About.jsx @@ -0,0 +1,22 @@ +import { GenericDialog, GenericButton } from "@danneschs/libnik-ui"; + +/** + * About dialog component + * @param {*} props (onClose) + * @returns + */ +function About(props) { + const { onClose } = props; + + return ( + } + dialogActions={} + /> + ); +} + +export default About; diff --git a/src/frontend/src/bookmarks/AllPurchases.jsx b/src/frontend/src/bookmarks/AllPurchases.jsx new file mode 100644 index 0000000..1fdbb81 --- /dev/null +++ b/src/frontend/src/bookmarks/AllPurchases.jsx @@ -0,0 +1,77 @@ +import { useState, useContext } from "react"; + +import { GenericTable } from "@danneschs/libnik-ui"; +import getPurchaseColumns from "@columns/getPurchaseColumns.jsx"; +import EditPurchaseDialog from "@dialogs/EditPurchaseDialog.jsx"; +import { getAllPurchasesService } from "@api/purchases"; +import { useEffect } from "react"; +import { showPurchaseDetails } from "@api/wrappers/showPurchaseDetails"; +import { AuthContext } from "@auth/AuthContext"; +import NotLogged from "@bookmarks/NotLogged.jsx"; +import { ConfigContext } from "@config/ConfigContext"; + +/** + * Component for displaying all purchases + * Main bookmark + * @returns + */ +function AllPurchases() { + const [showDetail, setShowDetail] = useState(false); + const [allPurchases, setAllPurchases] = useState([]); + const [clickedPurchase, setClickedPurchase] = useState({}); + + const { currentUser } = useContext(AuthContext); + + const [allUsers, setAllUsers] = useState([]); + const { currencyFormat } = useContext(ConfigContext); + // Fetch all purchases from the server when the component mounts + const token = localStorage.getItem("jwtToken"); + useEffect(() => { + // Fetch all purchases from the server when the component mounts + const getAllPurchases = async () => { + try { + const fetchedPurchases = await getAllPurchasesService(token); + setAllPurchases(fetchedPurchases); + } catch (error) { + console.error("Error fetching all purchases:", error); + } + }; + + const fetchData = async () => { + try { + await getAllPurchases(); + } catch (error) { + console.error("Error fetching all users:", error); + } + }; + if (!currentUser) return; + fetchData(); + }, [token, currentUser]); + + if (!currentUser) { + return ; + } + + const handleActionButtonClick = async (row) => { + showPurchaseDetails(row, localStorage.getItem("jwtToken"), setAllUsers, setClickedPurchase, setShowDetail); + }; + + const columns = getPurchaseColumns(handleActionButtonClick, null, currencyFormat); + + return ( + <> + + {showDetail && ( + setShowDetail(false)} + /> + )} + + ); +} +export default AllPurchases; diff --git a/src/frontend/src/bookmarks/Auth.jsx b/src/frontend/src/bookmarks/Auth.jsx new file mode 100644 index 0000000..0beb2f4 --- /dev/null +++ b/src/frontend/src/bookmarks/Auth.jsx @@ -0,0 +1,42 @@ +import { useContext, useState } from "react"; + +import { AuthContext } from "@auth/AuthContext.js"; +import { LoginForm, RegisterForm, LogoutForm } from "@danneschs/libnik-ui"; + +/** + * Auth component for handling user authentication + * @param {*} onFail - Callback function for failed authentication + * @param {*} onSuccess - Callback function for successful authentication + * @param {*} onClose - Callback function for closing the authentication dialog + * @returns + */ +function Auth({ onFail, onSuccess, onClose }) { + const [showRegister, setShowRegister] = useState(false); + const { currentUser, login, logout, register } = useContext(AuthContext); + + const handleLogin = async (email, password) => { + const success = await login(email, password); + if (success) { + onClose(); + return true; + } + return false; + }; + + const handleLogout = () => { + logout(); + onClose(); + }; + + if (showRegister) { + return ; + } + + if (currentUser) { + return ; + } + + return ; +} + +export default Auth; diff --git a/src/frontend/src/bookmarks/MyCommitments.jsx b/src/frontend/src/bookmarks/MyCommitments.jsx new file mode 100644 index 0000000..515db0e --- /dev/null +++ b/src/frontend/src/bookmarks/MyCommitments.jsx @@ -0,0 +1,133 @@ +import { useContext, useEffect, useState } from "react"; +import NotLogged from "@bookmarks/NotLogged.jsx"; +import { AuthContext } from "@auth/AuthContext.js"; +import { GenericTable } from "@danneschs/libnik-ui"; +import DebtorDetailDialog from "@dialogs/DebtorDetailDialog.jsx"; +import { getAllCommitmentsService } from "@api/commitments.js"; +import { + getTransactionLogsBetweenUsersService, + createPendingSettlementService, + settleService, +} from "@api/transactionLogs"; +import getCommitmentColumns from "@columns/getCommitmentColumns.jsx"; +import { getUserByIdService } from "@api/users"; +import { ToastBar } from "@danneschs/libnik-ui"; +import { ConfigContext } from "@config/ConfigContext.js"; + +/** + * Component for displaying user's commitments + * Bookmark component + * @returns + */ +function MyCommitments() { + const [showDetail, setShowDetail] = useState(false); + const [clickedCommitment, setClickedCommitment] = useState({}); + const [commitments, setCommitments] = useState([]); + const [transactionsBetweenUsers, setTransactionsBetweenUsers] = useState([]); + const [fromUser, setFromUser] = useState({}); + const [toUser, setToUser] = useState({}); + const [toastBarSettings, setToastBarSettings] = useState(null); + + const token = localStorage.getItem("jwtToken"); + + const { currencyFormat } = useContext(ConfigContext); + const { currentUser } = useContext(AuthContext); + useEffect(() => { + const getAllCommitments = async () => { + try { + const commitments = await getAllCommitmentsService(token); + setCommitments(commitments); + } catch (error) { + console.error("Error fetching commitments:", error); + } + }; + + if (!currentUser) return; + getAllCommitments(); + }, [currentUser, token]); + + if (!currentUser) { + return ; + } + + const setSuccessToastBar = (message) => { + setToastBarSettings({ + message: message, + type: "success", + onClose: () => setToastBarSettings(null), + }); + }; + + const setErrorToastBar = (message) => { + setToastBarSettings({ + message: message, + type: "error", + onClose: () => setToastBarSettings(null), + }); + }; + + /** + * Shows the detail dialog + * @param {*} row -- The row that was clicked + */ + const handleActionButtonClick = async (row) => { + const txs = await getTransactionLogsBetweenUsersService(token, row.fromUserId, row.toUserId); + const fromUser = await getUserByIdService(token, row.fromUserId); + const toUser = await getUserByIdService(token, row.toUserId); + + setFromUser(fromUser); + setToUser(toUser); + setTransactionsBetweenUsers(txs.sort((a, b) => new Date(b.date) - new Date(a.date))); + setClickedCommitment(row); + setShowDetail(true); + }; + + const handleSettle = async (fromUserId, toUserId, amount) => { + try { + const newCommitments = await settleService(token, fromUserId, toUserId, amount); + + if (newCommitments && newCommitments.length > 0) { + // Update commitments state to reflect the settled commitment + setCommitments(newCommitments); + } else { + setErrorToastBar("Chyba ve vrácení transakce."); + } + setSuccessToastBar("Dluh byl úspěšně označen jako splacený."); + } catch { + // TODO?: různé výjimky + setErrorToastBar("Dlužník neoznačil dluh jako zaplacený."); + } + }; + + const handleCreatePendingSettlement = async (fromUserId, toUserId, amount) => { + try { + await createPendingSettlementService(token, fromUserId, toUserId, amount); + setSuccessToastBar("Dluh byl označen jako zaplacený."); + } catch { + // TODO?: různé výjimky + setErrorToastBar("Neočekávaná chyba."); + } + }; + + const columns = getCommitmentColumns(handleActionButtonClick, currencyFormat); + + return ( + <> + + {showDetail && ( + setShowDetail(false)} + /> + )} + {toastBarSettings && } + + ); +} + +export default MyCommitments; diff --git a/src/frontend/src/bookmarks/MyPurchases.jsx b/src/frontend/src/bookmarks/MyPurchases.jsx new file mode 100644 index 0000000..1399ed7 --- /dev/null +++ b/src/frontend/src/bookmarks/MyPurchases.jsx @@ -0,0 +1,171 @@ +import { useState, useEffect, useContext } from "react"; +import { AuthContext } from "@auth/AuthContext"; + +import { GenericTable } from "@danneschs/libnik-ui"; +import EditPurchaseDialog from "@dialogs/EditPurchaseDialog.jsx"; +import getPurchaseColumns from "@columns/getPurchaseColumns.jsx"; +import NotLogged from "@bookmarks/NotLogged.jsx"; +import { getMyPurchasesService } from "@api/purchases"; +import { showPurchaseDetails } from "@api/wrappers/showPurchaseDetails"; +import { addPurchaseService } from "@api/purchases"; +import { removePurchaseService } from "@api/purchases"; +import { getAllUsersService } from "@api/users"; +import { updatePurchaseService } from "@api/purchases"; +import PurchaseMapper from "@mappers/purchaseMapper.js"; +import { ConfigContext } from "@config/ConfigContext"; +import { ToastBar } from "@danneschs/libnik-ui"; + +/** + * Component for displaying user's purchases + * Bookmark component + * @returns + */ +function MyPurchases() { + const [showAdd, setShowAdd] = useState(false); + const [showDetail, setShowDetail] = useState(false); + + const { currentUser } = useContext(AuthContext); + const [allUsers, setAllUsers] = useState([]); + const [clickedPurchase, setClickedPurchase] = useState({}); + const [myPurchases, setMyPurchases] = useState([]); + const [toastBarSettings, setToastBarSettings] = useState(null); + + const { currencyFormat } = useContext(ConfigContext); + + const token = localStorage.getItem("jwtToken"); + useEffect(() => { + // Fetch my purchases from the server when the component mounts + const getMyPurchases = async () => { + try { + const fetchedPurchases = await getMyPurchasesService(token); + setMyPurchases(fetchedPurchases); + } catch (error) { + console.error("Error fetching my purchases:", error); + } + }; + + const fetchData = async () => { + try { + await getMyPurchases(); + } catch (error) { + console.error("Error fetching all users:", error); + } + }; + if (!currentUser) return; + fetchData(); + }, [token, currentUser]); + + if (!currentUser) { + return ; + } + + const setSuccessToastBar = (message) => { + setToastBarSettings({ + message: message, + type: "success", + onClose: () => setToastBarSettings(null), + }); + }; + + const setErrorToastBar = (message) => { + setToastBarSettings({ + message: message, + type: "error", + onClose: () => setToastBarSettings(null), + }); + }; + + const handleActionButtonClick = async (row) => { + showPurchaseDetails(row, token, setAllUsers, setClickedPurchase, setShowDetail); + }; + + const columns = getPurchaseColumns(handleActionButtonClick, "Upravit", currencyFormat); + + const handleAddPurchase = async (newPurchase) => { + try { + const postPurchase = PurchaseMapper.fromEditToPost(newPurchase, currentUser.id, 0); + const getPurchase = await addPurchaseService(postPurchase, token); + if (getPurchase.id) { + setMyPurchases((prevPurchases) => [...prevPurchases, { ...getPurchase, id: getPurchase.id }]); + setSuccessToastBar("Nákup byl úspěšně přidán."); + } else { + console.error("Error adding purchase:", getPurchase); + setErrorToastBar("Došlo k neočekáváné chybě při přidávání nákupu."); + } + } catch (error) { + setErrorToastBar("Došlo k neočekáváné chybě při přidávání nákupu."); + console.error("Error adding purchase:", error); + } + }; + + const handleUpdatePurchase = async (updatedPurchase) => { + try { + const postPurchase = PurchaseMapper.fromEditToPost(updatedPurchase, currentUser.id, updatedPurchase.id); + + const getPurchase = await updatePurchaseService(postPurchase, token); + + if (getPurchase.id) { + setMyPurchases((prevPurchases) => + prevPurchases.map((purchase) => + purchase.id === getPurchase.id ? { ...purchase, ...getPurchase } : purchase + ) + ); + setSuccessToastBar("Nákup byl úspěšně upraven."); + } else { + console.error("Error updating purchase:", getPurchase); + setErrorToastBar("Došlo k neočekáváné chybě při úpravě nákupu."); + } + } catch (error) { + setErrorToastBar("Došlo k neočekáváné chybě při úpravě nákupu."); + console.error("Error updating purchase:", error); + } + }; + + const handleDeletePurchase = async (purchaseIdToRemove) => { + try { + await removePurchaseService(purchaseIdToRemove, token); + setMyPurchases((prevPurchases) => prevPurchases.filter((purchase) => purchase.id !== purchaseIdToRemove)); + } catch (error) { + console.error(`Error removing purchase with id ${purchaseIdToRemove}:`, error); + } + }; + + const handleClickAdd = () => async () => { + setShowAdd(true); + const allUsers = await getAllUsersService(token); + setAllUsers(allUsers || []); + }; + + return ( + <> + + {showAdd && ( + setShowAdd(false)} + allUsers={allUsers} + currentUserId={currentUser.id} + thisPurchase={null} + addPurchase={handleAddPurchase} + /> + )} + {showDetail && ( + setShowDetail(false)} + allUsers={allUsers} + currentUserId={currentUser.id} + thisPurchase={clickedPurchase} + addPurchase={handleAddPurchase} + updatePurchase={handleUpdatePurchase} + deletePurchase={handleDeletePurchase} + /> + )} + {toastBarSettings && } + + ); +} + +export default MyPurchases; diff --git a/src/frontend/src/bookmarks/NotLogged.jsx b/src/frontend/src/bookmarks/NotLogged.jsx new file mode 100644 index 0000000..1d8b48d --- /dev/null +++ b/src/frontend/src/bookmarks/NotLogged.jsx @@ -0,0 +1,9 @@ +/** + * Component to display message in body of the grid, when user is not logged in + * @returns + */ +function NotLogged() { + return

nelze zobrazit, protože nejste přihlášen.

; +} + +export default NotLogged; diff --git a/src/frontend/src/components/columns/EditCellWithTooltip.jsx b/src/frontend/src/components/columns/EditCellWithTooltip.jsx new file mode 100644 index 0000000..33cc07b --- /dev/null +++ b/src/frontend/src/components/columns/EditCellWithTooltip.jsx @@ -0,0 +1,20 @@ +import { Tooltip } from "@mui/material"; + +/** + * EditCellWithTooltip component + * @description This component is used to display a cell with a tooltip for error messages. + * It wraps the cell value in a tooltip that shows the error message when there is an error. + * @param {*} error - Boolean indicating if there is an error + * @param {*} helperText - The error message to be displayed in the tooltip + * @param {*} value - The value to be displayed in the cell + * @returns + */ +const EditCellWithTooltip = ({ error, helperText, value }) => { + return ( + + {value} + + ); +}; + +export default EditCellWithTooltip; diff --git a/src/frontend/src/components/columns/getCommitmentColumns.jsx b/src/frontend/src/components/columns/getCommitmentColumns.jsx new file mode 100644 index 0000000..76b1a0d --- /dev/null +++ b/src/frontend/src/components/columns/getCommitmentColumns.jsx @@ -0,0 +1,83 @@ +import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; +import PaymentIcon from "@mui/icons-material/Payment"; +import { getDebtorColumnWidths } from "@columns/widths/ColumnWidths.js"; +import { GenericColumnButton } from "@danneschs/libnik-ui"; + +/** + * Function to get columns for commitment table, contains button in the last column + * @param {*} handleShowDetail -- function to show detail of the purchase (onClick event) + * @param {*} currencyFormat -- currency format from config (e.g. Kč, €) + * @returns + */ +function getCommitmentColumns(handleShowDetail, currencyFormat) { + const widths = getDebtorColumnWidths(); + const flexes = {}; + + return [ + { + field: "state", + headerName: "Typ vztahu", + headerClassName: "column-header", + ...(widths.state ? { width: widths.state } : {}), + ...(flexes.state ? { flex: flexes.state } : {}), + valueFormatter: (param) => { + const value = param; + switch (value) { + case "debt": + return "Dluh"; + case "claim": + return "Pohledávka"; + case "even": + return "Vyrovnáno"; + default: + return "Neznámý stav"; + } + }, + }, + { + field: "toName", + headerName: "Vůči komu", + headerClassName: "column-header", + ...(widths.name ? { width: widths.name } : {}), + ...(flexes.name ? { flex: flexes.name } : {}), + }, + { + field: "amount", + headerName: "Částka", + align: "right", + headerClassName: "column-header", + ...(widths.total ? { width: widths.total } : {}), + ...(flexes.total ? { flex: flexes.total } : {}), + valueFormatter: (param) => { + const value = param < 0 ? -param : param; // Ensure positive value for formatting + + if (value === 0) { + return "Vyrovnáno"; + } + + return `${(value / 100).toLocaleString("cs-CZ", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} ${currencyFormat}`; + }, + }, + { + field: "pay", + headerName: "Detail", + headerClassName: "column-header", + ...(widths.pay ? { width: widths.pay } : {}), + ...(flexes.pay ? { flex: flexes.pay } : {}), + renderCell: (params) => { + const row = params.row; + return ( + handleShowDetail(row)} + buttonContent={} + /> + ); + }, + }, + ]; +} + +export default getCommitmentColumns; diff --git a/src/frontend/src/components/columns/getItemColumns.jsx b/src/frontend/src/components/columns/getItemColumns.jsx new file mode 100644 index 0000000..b8b6069 --- /dev/null +++ b/src/frontend/src/components/columns/getItemColumns.jsx @@ -0,0 +1,138 @@ +import { GridActionsCellItem } from "@mui/x-data-grid"; +import DeleteIcon from "@mui/icons-material/DeleteOutlined"; + +import { getItemColumnWidths } from "@columns/widths/ColumnWidths.js"; +import { inputSx } from "@danneschs/libnik-ui"; + +/** + * Gets columns for item table + * @param {*} handleDelete -- reference to the function that handles delete action + * @param {*} editable -- if the table is editable (optional) + * @param {*} currencyFormat -- currency format from config (e.g. Kč, €) + * @returns columns for item table + */ +function getItemColumns(handleDelete, isEditable, currencyFormat, errorColor) { + let widths = getItemColumnWidths(); // Flexes are used instead of widths + const flexes = {}; + if (widths) { + widths.delete; + + widths = Object.fromEntries(Object.entries(widths).map(([key, value]) => [key, value * 0.75])); + } + + /** + * Renders a number input cell for editing + * @param {Object} params - The parameters for the cell + * @returns {JSX.Element} - The rendered number input cell + */ + const numberEditCell = (params) => { + const minValue = 1; + const step = 1; + return ( + { + const newValue = e.target.value; + params.api.setEditCellValue({ id: params.id, field: params.field, value: newValue }, e); + }} + style={!params.error ? inputSx : { ...inputSx, color: errorColor }} + autoFocus + /> + ); + }; + + const validatePrice = (value) => { + const val = Number(value); + return isNaN(val) || val < 1 || !/^\d+(\.\d{1,2})?$/.test(value) || val > 999_999; + }; + + const validateAmount = (value) => { + const val = Number(value); + return isNaN(val) || val < 1 || !/^\d+$/.test(value) || val > 999; + }; + + const columns = [ + { + field: "name", + headerName: "Položka", + ...(widths.name ? { width: widths.name } : {}), + ...(flexes.name ? { flex: flexes.name } : {}), + headerClassName: "column-header", + editable: isEditable, + }, + { + field: "quantity", + type: "number", + inputProps: { min: 1 }, + headerName: "Množství", + ...(widths.amount ? { width: widths.amount } : {}), + ...(flexes.amount ? { flex: flexes.amount } : {}), + headerClassName: "column-header", + valueFormatter: (param) => { + return param + ? `${param.toLocaleString("cs-CZ", { minimumFractionDigits: 0, maximumFractionDigits: 0 })} ks` + : param; + }, + preProcessEditCellProps: (params) => { + const isError = validateAmount(params.props.value); + return { ...params.props, error: isError }; + }, + renderEditCell: (params) => { + return numberEditCell(params); + }, + editable: isEditable, + }, + { + field: "priceInCrowns", + type: "number", + headerName: "Cena za kus", + ...(widths.priceInCents ? { width: widths.priceInCents } : {}), + ...(flexes.priceInCents ? { flex: flexes.priceInCents } : {}), + headerClassName: "column-header", + editable: isEditable, + valueFormatter: (param) => + param + ? `${param.toLocaleString("cs-CZ", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} ${currencyFormat}` + : param, + preProcessEditCellProps: (params) => { + const isError = validatePrice(params.props.value); + return { ...params.props, error: isError }; + }, + renderEditCell: (params) => { + return numberEditCell(params); + }, + }, + { + field: "delete", + type: "actions", + headerName: "", + headerClassName: "column-header", + ...(widths.delete ? { width: widths.delete } : {}), + ...(flexes.delete ? { flex: flexes.delete } : {}), + cellClassName: "actions", + getActions: ({ id }) => { + return [ + } + label="Delete" + onClick={() => { + handleDelete(id); + }} + color="inherit" + />, + ]; + }, + editable: false, + }, + ]; + + return isEditable ? columns : columns.filter((col) => col.field !== "delete"); +} + +export default getItemColumns; diff --git a/src/frontend/src/components/columns/getPurchaseColumns.jsx b/src/frontend/src/components/columns/getPurchaseColumns.jsx new file mode 100644 index 0000000..5a53ccc --- /dev/null +++ b/src/frontend/src/components/columns/getPurchaseColumns.jsx @@ -0,0 +1,133 @@ +import { getPurchaseColumnWidths } from "@columns/widths/ColumnWidths.js"; +import { GenericColumnButton } from "@danneschs/libnik-ui"; +import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; +import ModeEditIcon from "@mui/icons-material/ModeEdit"; +import { ConfigContext } from "@config/ConfigContext"; + +/** + * Gets columns for purchase table + * @param {*} handleActionButtonClick function for showing purchase detail for onClick event on the button in the last row + * @param {*} actionColName name of the last column (action column) + * @param {*} currencyFormat currency format from config (e.g. Kč, €) + * @returns {Array} columns + */ +function getPurchaseColumns(handleActionButtonClick, actionColName, currencyFormat) { + const widths = getPurchaseColumnWidths(); // Flexes are used instead of widths + const flexes = {}; + + return [ + { + field: "name", + headerName: "Název", + ...(flexes.name ? { flex: flexes.name } : {}), + ...(widths.name ? { width: widths.name } : {}), + headerClassName: "column-header", + valueFormatter: (param) => { + return param ? param : "Není uveden"; + }, + }, + { + field: "buyerName", + headerName: "Kupující", + headerClassName: "column-header", + ...(flexes.buyer ? { flex: flexes.buyer } : {}), + ...(widths.buyer ? { width: widths.buyer } : {}), + valueFormatter: (param) => { + const buyer = param; + if (!buyer) return "Není uveden"; + return buyer; + }, + }, + { + field: "coPayers", + headerName: "Spoluplatitelé", + headerClassName: "column-header", + ...(flexes.buyer ? { flex: flexes.buyer } : {}), + ...(widths.buyer ? { width: widths.buyer } : {}), + valueFormatter: (param) => { + const coPayers = param; + if (!coPayers || coPayers.length === 0) return "Žádní spoluplatitelé"; + return coPayers + .map((coPayer) => { + const user = coPayer; + if (!user || !user.name || !user.surname) return "Není uveden"; + return `${user.name} ${user.surname}`; + }) + .join(", "); + }, + }, + { + field: "timestamp", + headerName: "Datum", + headerClassName: "column-header", + ...(flexes.date ? { flex: flexes.date } : {}), + ...(widths.date ? { width: widths.date } : {}), + valueFormatter: (param) => { + const timestamp = param; + if (!timestamp) return ""; + const date = new Date(timestamp); + if (isNaN(date.getTime())) return "Neplatné datum"; + return date.toLocaleDateString("cs-CZ", { + year: "numeric", + month: "2-digit", + day: "2-digit", + }); + }, + }, + { + field: "shopName", + headerName: "Obchod", + ...(flexes.shop ? { flex: flexes.shop } : {}), + ...(widths.shop ? { width: widths.shop } : {}), + headerClassName: "column-header", + valueFormatter: (param) => { + return param ? param : "---"; + }, + }, + { + field: "priceInCents", + headerName: "Cena nákupu", + align: "right", + headerClassName: "column-header", + ...(flexes.price ? { flex: flexes.price } : {}), + ...(widths.price ? { width: widths.price } : {}), + valueFormatter: (param) => { + if (!param) return "---"; + const priceInCents = Number(param); + const price = priceInCents / 100; // Convert cents to currency + return price + ? `${price.toLocaleString("cs-CZ", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })} ${currencyFormat}` + : "---"; + }, + }, + { + field: "action", + headerName: actionColName ? actionColName : "Detail", + headerClassName: "column-header", + ...(flexes.isPaid ? { flex: flexes.isPaid } : {}), + ...(widths.isPaid ? { width: widths.isPaid } : {}), + renderCell: (param) => { + if (actionColName === "Upravit") { + return ( + handleActionButtonClick(param.row)} + buttonContent={} + /> + ); + } else { + return ( + handleActionButtonClick(param.row)} + buttonContent={} + /> + ); + } + }, + }, + ]; +} + +export default getPurchaseColumns; diff --git a/src/frontend/src/components/columns/getTransactionColumns.jsx b/src/frontend/src/components/columns/getTransactionColumns.jsx new file mode 100644 index 0000000..812320a --- /dev/null +++ b/src/frontend/src/components/columns/getTransactionColumns.jsx @@ -0,0 +1,82 @@ +import { getTransactionColumnWidths } from "@columns/widths/ColumnWidths.js"; + +/** + * Function to get columns for transaction table + * @param {*} currencyFormat -- currency format from config (e.g. Kč, €) + * @returns + */ +function getTransactionColumns(currencyFormat) { + const widths = getTransactionColumnWidths(); + const flexes = {}; + + return [ + { + field: "date", + headerName: "Datum", + headerClassName: "column-header", + ...(widths.date ? { width: widths.date } : {}), + ...(flexes.date ? { flex: flexes.date } : {}), + valueFormatter: (param) => { + const timestamp = param; + if (!timestamp) return ""; + const date = new Date(timestamp); + if (isNaN(date.getTime())) return "Neplatné datum"; + return date.toLocaleDateString("cs-CZ", { + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + minute: "2-digit", + }); + }, + }, + { + field: "type", + headerName: "Typ transakce", + headerClassName: "column-header", + ...(widths.type ? { width: widths.type } : {}), + ...(flexes.type ? { flex: flexes.type } : {}), + valueFormatter: (param) => { + return param; + }, + }, + { + field: "fromName", + headerName: "Od koho", + headerClassName: "column-header", + ...(widths.from ? { width: widths.from } : {}), + ...(flexes.from ? { flex: flexes.from } : {}), + }, + { + field: "toName", + headerName: "Komu", + headerClassName: "column-header", + ...(widths.to ? { width: widths.to } : {}), + ...(flexes.to ? { flex: flexes.to } : {}), + }, + { + field: "amount", + headerName: "Částka", + headerClassName: "column-header", + ...(widths.price ? { width: widths.price } : {}), + ...(flexes.price ? { flex: flexes.price } : {}), + valueFormatter: (param) => { + const amount = param; + const formattedAmount = amount.toLocaleString("cs-CZ", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + return `${formattedAmount} ${currencyFormat}`; + }, + }, + { + field: "description", + headerName: "Popis", + headerClassName: "column-header", + ...(widths.description ? { width: widths.description } : {}), + ...(flexes.description ? { flex: flexes.description } : {}), + }, + ]; +} + +export default getTransactionColumns; diff --git a/src/frontend/src/components/columns/widths/ColumnWidths.js b/src/frontend/src/components/columns/widths/ColumnWidths.js new file mode 100644 index 0000000..e218393 --- /dev/null +++ b/src/frontend/src/components/columns/widths/ColumnWidths.js @@ -0,0 +1,111 @@ +/** + * Used for AllPurchases and MyPurchases tables + */ +export function getPurchaseColumnWidths() { + const widths = { + name: 350, + buyer: 350, + date: 200, + shop: 200, + price: 200, + isPaid: 100, + }; + return { + ...widths, + total: + widths.name + + widths.buyer + + widths.date + + widths.shop + + widths.price + + widths.isPaid, + }; +} + +/** + * Used for AllPurchases and MyPurchases tables + */ +export function getPurchaseColumnFlexes() { + const flexes = { + name: 0.35, + buyer: 0.15, + date: 0.1, + shop: 0.15, + price: 0.15, + isPaid: 0.1, + }; + + return flexes; +} + +/** + * Used for the Item table in the AddPurchase dialog + */ +export function getItemColumnWidths() { + const widths = { + delete: 100, + name: 550, + amount: 400, + priceInCents: 400, + total: 0, + }; + widths.total = widths.name + widths.amount + widths.priceInCents; + return { + ...widths, + total: widths.total, + }; +} + +/** + * Used for the Item table in the AddPurchase dialog + */ +export function getItemColumnFlexes() { + const flexes = { + delete: 0.05, + name: 0.65, + amount: 0.15, + priceInCents: 0.15, + }; + + return flexes; +} + +export function getDebtorDetailColumnFlexes() { + const flexes = getPurchaseColumnFlexes(); + return flexes; +} + +/** + * Used for the ToPay bookmark table + */ +export function getDebtorColumnFlexes() { + const flexes = { + name: 0.7, + debt: 0.2, + detail: 0.1, + }; + + return flexes; +} + +export function getDebtorColumnWidths() { + const widths = { + name: 350, + state: 150, + total: 300, + pay: 100, + }; + return widths; +} + +export function getTransactionColumnWidths() { + const widths = { + date: 200, + type: 150, + from: 250, + to: 250, + price: 100, + description: 500, + }; + return widths; +} diff --git a/src/frontend/src/components/dialogs/DebtorDetailDialog.jsx b/src/frontend/src/components/dialogs/DebtorDetailDialog.jsx new file mode 100644 index 0000000..3f04a17 --- /dev/null +++ b/src/frontend/src/components/dialogs/DebtorDetailDialog.jsx @@ -0,0 +1,139 @@ +import { useState, useContext } from "react"; +import PayDialog from "@dialogs/PayDialog.jsx"; +import { GenericButton, ConfirmationDialog, GenericDialog, GenericTable } from '@danneschs/libnik-ui'; +import getTransactionColumns from "@columns/getTransactionColumns.jsx"; +import "@fontsource/roboto"; +import { ConfigContext } from "@config/ConfigContext"; + +const contentSx = { + display: "flex", + flexDirection: "column", + alignItems: "center", + height: "100%", +}; + +const totalToPaySx = { + padding: "8px", + borderTop: "1px solid #ccc", + width: "100%", + textAlign: "center", +}; + +/** + * Dialog for displaying all purchases between two users -- debtor and creditor + * @param {*} onClose -- function to close the dialog + * @param {*} fromUser -- user who owes money (debtor) + * @param {*} toUser -- user who is owed money (creditor) + * @param {*} handleSettle -- function to settle the commitment + * @param {*} handleCreatePendingSettlement -- function to create pending settlement + * @param {*} transactionsBetweenUsers -- list of transactions between the two users + * @param {*} clickedCommitment -- commitment object containing details about the debt + * @returns + */ +function DebtorDetailDialog({ + onClose, + fromUser, + toUser, + handleSettle, + handleCreatePendingSettlement, + transactionsBetweenUsers, + clickedCommitment, +}) { + const [showQr, setShowQr] = useState(false); + const [showSetAsPaidDialogForCreditor, setshowSetAsPaidDialogForCreditor] = useState(false); + const { currencyFormat, isDefaultCurrency } = useContext(ConfigContext); + const dialogTitle = "Podrobné transakce"; + + const absoluteTotalAmount = clickedCommitment.amount < 0 ? -clickedCommitment.amount : clickedCommitment.amount; + const absoluteTotalAmountToShow = absoluteTotalAmount / 100; + + // If creditorId is not provided, this dialog shows transactions between current user and his debtor + // If debtorId is not provided, this dialog shows transactions between current user and his creditor + const isCurrentUserCreditor = clickedCommitment?.state === "claim"; + + /** + * Settle the purchase between two users + * Settles, or creates pending settlement + */ + const handlePay = async () => { + if (!isCurrentUserCreditor) { + // I owe money to the creditor, so I pay him (this dialog shows) + // Disabled, because just creditor can mark the purchase as paid + await handleCreatePendingSettlement(fromUser.id, toUser.id, absoluteTotalAmount); + } else { + // I am the creditor, so I mark the purchase as paid + try { + await handleSettle(fromUser.id, toUser.id, absoluteTotalAmount); + } catch (error) { + console.error("Error settling the purchase:", error); + } + } + setShowQr(false); // Close the QR dialog + onClose(); // Close the detail dialog + }; + return ( + +
+ + +
+
+ {isCurrentUserCreditor ? "Celkem pohledáváte: " : "Celkem dlužíte: "}{" "} + {absoluteTotalAmountToShow.toLocaleString("cs-CZ", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + })}{" "} + {currencyFormat} +
+
+ + {/* If I am a debtor, show PayDialog */} + {showQr && !isCurrentUserCreditor && ( + setShowQr(false)} + /> + )} + {showSetAsPaidDialogForCreditor && ( + setshowSetAsPaidDialogForCreditor(false)} + onConfirm={handlePay} + title="Zaplaceno" + content="Opravdu Vám dlužník zaplatil tento dluh?" + /> + )} +
+ + } + dialogActions={ + <> + + + { + //If I am creditor, I can set the debt as paid + // If the relationship is even, just show the detail of the transactions + } + {absoluteTotalAmount !== 0 && ( + + isCurrentUserCreditor ? setshowSetAsPaidDialogForCreditor(true) : setShowQr(true) + } + name={isCurrentUserCreditor ? "Označit jako zaplacené" : "Zaplatit"} + /> + )} + + } + /> + ); +} + +export default DebtorDetailDialog; diff --git a/src/frontend/src/components/dialogs/EditPurchaseDialog.jsx b/src/frontend/src/components/dialogs/EditPurchaseDialog.jsx new file mode 100644 index 0000000..266ae2a --- /dev/null +++ b/src/frontend/src/components/dialogs/EditPurchaseDialog.jsx @@ -0,0 +1,389 @@ +import { useState, useContext } from "react"; + +import getItemColumns from "@columns/getItemColumns.jsx"; +import { GenericButton, ConfirmationDialog, GenericSelect, GenericDialog, GenericTable } from '@danneschs/libnik-ui'; + +import { TextField, Box, FormControlLabel, Switch, useTheme } from "@mui/material"; +import { styled } from "@mui/material/styles"; + +import { EditPurchase } from "@objects/EditPurchase.js"; +import { Item } from "@objects/Item.js"; +import { ItemDto } from "@objects/ItemDto.js"; +import { textFieldSx } from "@danneschs/libnik-ui"; +import { ConfigContext } from "@config/ConfigContext"; +import { v4 as uuidv4 } from "uuid"; + + +/** + * Data class for managing selectable user in the co-payers select + */ +class SelectedUser { + constructor(id, name) { + this.key = id; + this.value = name; + } + + toString() { + return this.value; + } +} + +/** + * Data class for managing purchase data in the dialog + */ +class Data { + constructor(thisPurchase) { + this.purchaseId = thisPurchase?.id || ""; + this.name = thisPurchase?.name || ""; + this.coPayers = thisPurchase?.coPayers || []; + this.shopName = thisPurchase?.shopName || ""; + this.items = + thisPurchase?.items?.length > 0 + ? thisPurchase?.items.map( + (itemDto) => new Item(itemDto.id, itemDto.name, itemDto.quantity, itemDto.priceInCents / 100) + ) + : []; + this.buyerPays = thisPurchase ? thisPurchase.buyerPays : true; + } +} + +/** + * Dialog for adding/editing/showing purchase details + * @param {*} onClose -- function to close the dialog + * @param {*} title -- title of the dialog + * @param {*} editMode -- mode of the dialog (add/edit/readOnly) + * @param {*} thisPurchase -- purchase to edit/show details + * @param {*} allUsers -- list of all users for selecting co-payers + * @param {*} currentUserId -- id of the current user + * @param {*} addPurchase -- function to add a new purchase + * @param {*} updatePurchase -- function to update an existing purchase + * @param {*} deletePurchase -- function to delete a purchase + * @returns + */ +function EditPurchaseDialog({ + onClose, + title, + editMode, + thisPurchase, + allUsers, + currentUserId, + addPurchase, + updatePurchase, + deletePurchase, +}) { + const theme = useTheme(); + const [data, setData] = useState(new Data(thisPurchase)); + const [errors, setErrors] = useState({ + name: "", + coPayers: "", + shopName: "", + items: "", + }); + + const { currencyFormat } = useContext(ConfigContext); + + const [confirmDialog, setConfirmDialog] = useState(null); + const updateData = (field, value) => { + setData((prev) => ({ ...prev, [field]: value })); + // clear related error + setErrors((prev) => ({ ...prev, [field]: "" })); + }; + /** + * Validate the form data + * @returns true if there are errors in the form, false otherwise + */ + const validateForm = () => { + const newErrors = {}; + + if (!data.name.trim()) newErrors.name = "Zadejte jméno nákupu"; + if (data.coPayers.length <= 0) newErrors.coPayerIds = "Vyberte spoluplatitele"; + if (!data.shopName.trim()) newErrors.shop = "Zadejte název obchodu"; + if (data.items.length <= 0) newErrors.items = "Přidejte alespoň jeden předmět"; + if (data.items.some((item) => item.name.trim() === "" || item.quantity === 0 || item.priceInCents === 0)) { + newErrors.items = "Vyplňte všechny údaje v položkách"; + } + + setErrors(newErrors); + return Object.keys(newErrors).length > 0; + }; + + /** + * Confirms the purchase and adds/updates it + */ + const handleSubmitPurchase = () => { + const priceInCrowns = data.items.reduce((acc, item) => acc + item.priceInCrowns * item.quantity, 0); + const newPurchase = new EditPurchase( + data.purchaseId || 0, + data.name, + data.coPayers, + new Date().toISOString(), + data.shopName, + priceInCrowns, + data.items.map((item) => new ItemDto(0, item.name, item.quantity, Math.round(item.priceInCrowns * 100))), + data.buyerPays + ); + + if (editMode === "edit") { + updatePurchase(newPurchase); + } else if (editMode === "add") { + addPurchase(newPurchase); + } + + onClose(true); + }; + + /** + * Close the confirm dialog if user cancels + */ + const handleConfirmDialogClose = () => { + setConfirmDialog(null); + }; + + /** + * Show confirm dialog for adding/updating the purchase + */ + const handleConfrirmSubmitPurchase = () => { + if (validateForm()) return; + setConfirmDialog({ + title: editMode === "edit" ? "Upravit nákup" : "Přidat nákup", + content: editMode === "edit" ? "Oprvdu chcete upravit tento nákup?" : "Oprvdu chcete přidat tento nákup?", + handler: handleSubmitPurchase, + }); + }; + + /** + * Delete the purchase + */ + const handleDeletePurchase = () => { + deletePurchase(thisPurchase.id); + onClose(true); + }; + + /** + * Show confirm dialog for deleting the purchase + */ + const handleConfirmDeletePurchase = () => { + setConfirmDialog({ + title: "Smazat nákup", + content: "Oprvdu chcete smazat tento nákup?", + handler: handleDeletePurchase, + }); + }; + + /** + * Chooses co-payers from the list of users + * @param {*} selectedIds -- array of selected user ids to show in the select + */ + const handleChooseCoPayer = (selectedIds) => { + const payers = selectedIds.map((id) => { + const user = allUsers.find((u) => u.id === id); + return user; + }); + updateData("coPayers", payers); + }; + + /** + * Adds a new item to the purchase + * @param {*} newItem -- new item to add + */ + const handleAddItem = () => { + const newItem = new Item(uuidv4(), "", "", ""); + updateData("items", [...data.items, newItem]); + }; + + /** + * Deletes an item from the purchase + * @param {*} id -- id of the item to delete + */ + const handleDeleteItem = (id) => { + updateData( + "items", + data.items.filter((item) => item.id !== id) + ); + }; + + /** + * Updates an item in the item table + * @param {*} newRow -- new row data + * @returns updated row data + */ + const handleRowUpdate = (newRow) => { + const updatedItem = new Item(newRow.id, newRow.name, Number(newRow.quantity), Number(newRow.priceInCrowns)); + const updatedItems = data.items.map((item) => (item.id === newRow.id ? updatedItem : item)); + updateData("items", updatedItems); + return { ...newRow, isNew: false }; + }; + + // Creates a list of selectable users for the co-payers select + const selectionList = allUsers + .filter((user) => user.id !== currentUserId) + .map((user) => new SelectedUser(user.id, `${user.name} ${user.surname}`)); + + // Sets the default selection for the co-payers select, if editMode is not 'add' + const defaultSelection = + editMode !== "add" && thisPurchase && thisPurchase.coPayers + ? thisPurchase.coPayers + .map((cp) => { + const user = allUsers.find((u) => u.id === cp.id); + return user ? new SelectedUser(user.id, `${user.name} ${user.surname}`) : null; + }) + .filter(Boolean) + : []; + + /** + * Switch for setting, if the purchase price should be split just between co-payers or buyer and co-payers + * @param {*} event -- event from the switch + */ + const handleChangeBuyerPays = (event) => { + "handleChangeBuyerPays", event.target.checked; + setData({ ...data, buyerPays: event.target.checked }); + }; + + const CustomSwitch = styled(Switch)(({ theme }) => ({ + "& .MuiSwitch-switchBase.Mui-checked": { + color: theme.palette.primary.main, + "& + .MuiSwitch-track": { + backgroundColor: theme.palette.primary.main, + }, + }, + })); + + return ( + + {/* For styling the grid */} + + updateData("name", e.target.value)} + fullWidth + /> + {/* For styling the grid */} + + u.key)} + onChange={handleChooseCoPayer} + /> + + updateData("shopName", e.target.value)} + fullWidth + /> + + + + } + dialogActions={ + + {}} + /> + } + labelPlacement="start" + label={data.buyerPays ? "Platí kupující" : "Neplatí kupující"} + /> + + {editMode === "edit" && ( + + )} + {editMode !== "readOnly" && ( + + )} + {confirmDialog && ( + + )} + + } + /> + ); +} + +export default EditPurchaseDialog; diff --git a/src/frontend/src/components/dialogs/NotificationsDialog.jsx b/src/frontend/src/components/dialogs/NotificationsDialog.jsx new file mode 100644 index 0000000..0250761 --- /dev/null +++ b/src/frontend/src/components/dialogs/NotificationsDialog.jsx @@ -0,0 +1,99 @@ +import { useTheme } from "@mui/material"; +import { alpha } from "@mui/material/styles"; +import { GenericButton, GenericDialog } from "@danneschs/libnik-ui"; +import AlertTitle from "@mui/material/AlertTitle"; +import Alert from "@mui/material/Alert"; +import Stack from "@mui/material/Stack"; + +import AddShoppingCartIcon from "@mui/icons-material/AddShoppingCart"; +import RemoveShoppingCartIcon from "@mui/icons-material/RemoveShoppingCart"; +import PaidIcon from "@mui/icons-material/Paid"; +import PaidOutlinedIcon from "@mui/icons-material/PaidOutlined"; + +const contentTextSx = { + height: "100%", + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + fontSize: "1.5rem", + textAlign: "center", +}; + +/** + * Notifications dialog component + * @param {*} onClose -- function to close the dialog + * @param {*} notifications -- list of notifications to display + * @param {*} handleNavigateToCommitments -- function to navigate to commitments page + * @returns + */ +function NotificationsDialog({ onClose, notifications, handleNavigateToCommitments }) { + const theme = useTheme(); + const alertSx = { + backgroundColor: alpha(theme.palette.primary.main, 0.06), + color: theme.palette.text.primary, + "& .MuiAlert-icon": { + color: theme.palette.primary.main, + }, + "&:hover": { + backgroundColor: theme.palette.primary.main, + color: theme.palette.background.default, + "& .MuiAlert-icon": { + color: theme.palette.background.default, + }, + }, + }; + + const areThereNotifications = notifications.length > 0; + return ( + + {notifications.map((notification) => ( + + ) : notification.type === "remove_purchase" ? ( + + ) : notification.type === "pending_settlement" ? ( + + ) : ( + + ) + } + key={notification.id} + severity="info" + sx={alertSx} + > + + {notification.timestamp} | {notification.header} + + {notification.text} + + ))} + + ) : ( + "Žádné notifikace" + ) + } + dialogActions={ + <> + + + + } + customContentSx={!areThereNotifications ? contentTextSx : null} + /> + ); +} + +export default NotificationsDialog; diff --git a/src/frontend/src/components/dialogs/PayDialog.jsx b/src/frontend/src/components/dialogs/PayDialog.jsx new file mode 100644 index 0000000..b1b775e --- /dev/null +++ b/src/frontend/src/components/dialogs/PayDialog.jsx @@ -0,0 +1,234 @@ +import { useState, useEffect } from "react"; +import { QRCodeSVG } from "qrcode.react"; +import { GenericButton, ConfirmationDialog, GenericDialog } from '@danneschs/libnik-ui'; +import { getCurrentRate } from "@api/exchangeRates"; +import { ToastBar } from "@danneschs/libnik-ui"; + +/** @jsxImportSource @emotion/react */ +import { css } from "@emotion/react"; + +const customDialogContentSx = { + display: "flex", + flexDirection: "row", + alignItems: "center", + justifyContent: "center", + + "@media (max-width: 600px)": { + display: "block", + }, +}; + +const contentSx = css({ + display: "flex", + flexDirection: "row", + justifyContent: "center", + alignItems: "center", + gap: "50px", + + "@media (max-width: 600px)": { + flexDirection: "column", + alignItems: "center", + }, +}); + +const qrCodeSx = css({ + width: "250px", + height: "250px", +}); + +const textBlocksSx = { + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + + maxWidth: "250px", + maxHeight: "250px", + + width: "220px", + height: "250px", + + "@media (max-width: 600px)": { + fontSize: "1.5em", + + overflowY: "none", + maxWidth: "100%", + maxHeight: "100%", + + width: "auto", + height: "auto", + margin: "0", + }, +}; + +const textBlockSx = { + textAlign: "center", + wordBreak: "break-word", + overflowWrap: "anywhere", +}; + +/** + * Dialog for displaying payment information + * @param {*} creditor -- creditor information (name, surname, account number) + * @param {*} handleSetAsPaid -- function to mark the payment as paid + * @param {*} onClose -- function to close the dialog + * @param {*} amountInCents -- amount to pay in cents + * @param {*} currencyFormat -- currency format from config (e.g. Kč, €) + * @param {*} isDefaultCurrency -- whether the currency is the default (Kč) + * @returns + */ +function PayDialog({ creditor, handleSetAsPaid, onClose, amountInCents, currencyFormat, isDefaultCurrency }) { + const [copyButtonName, setCopyButtonName] = useState("Zkopírovat"); + const [confirmDialog, setConfirmDialog] = useState(null); + const [currentRate, setCurrentRate] = useState(1); + const [toastBarSettings, setToastBarSettings] = useState(null); + + const token = localStorage.getItem("jwtToken"); + + useEffect(() => { + const fetchCurrentRate = async () => { + const defaultRate = 1; + try { + const exchangeRate = await getCurrentRate(token); + + setCurrentRate(exchangeRate.rateDecimal); + setToastBarSettings({ + message: `Poslední dostupný kurz z ${exchangeRate.date.toLocaleDateString()} načten, 1 ${currencyFormat} = ${ + exchangeRate.rateDecimal + } Kč.`, + type: "success", + }); + } catch (error) { + setCurrentRate(defaultRate); // Fallback to 1 if fetch fails + setToastBarSettings({ + message: + error.message || + `Neznámá chyba při načítání aktuálního kurzu, 1 ${currencyFormat} = ${defaultRate} Kč.`, + type: "error", + }); + } + }; + if (!isDefaultCurrency) { + fetchCurrentRate(); + } + }, [currencyFormat, isDefaultCurrency, token]); + + const amount = (amountInCents / 100) * currentRate; + const formattedAmount = Number(amount).toLocaleString("cs-CZ", { + minimumFractionDigits: 2, + maximumFractionDigits: 2, + }); + + /** + * Gets IBAN from account number + * @description Converts account number to IBAN format + * @param {*} fullAccount account number in format "number/bankCode" + * @returns + */ + const getIban = (fullAccount) => { + const prefix = "CZ"; + // fullAccount = "143929001/5500"; // For testing + + // Get the bank code and account number + const [accountPart, bankCode] = fullAccount.split("/"); + + if (!bankCode || !accountPart) { + throw new Error("Invalid account format -- expected 'number/bankCode' or 'prefix-number/bankCode'"); + } + + // Get the prefix and main part of the account number + const [prefixPart, numberPart] = accountPart.includes("-") ? accountPart.split("-") : ["", accountPart]; + + const pre = prefixPart.padStart(6, "0"); // Prefix (6 ciphers) + const main = numberPart.padStart(10, "0"); // Account number (10 ciphers) + + const accountBase = `${bankCode}${pre}${main}`; // BBBBPPPPPPCCCCCCCCCC + const ibanRaw = `${accountBase}${prefix}00`; // For chesksum + + // Chars to numbers (C=12, Z=35) + const ibanNumeric = ibanRaw.replace(/[A-Z]/g, (ch) => (ch.charCodeAt(0) - 55).toString()); + + // Compute control numbers (mod 97) + const mod97 = BigInt(ibanNumeric) % 97n; + const checksum = String(98n - mod97).padStart(2, "0"); + + // Result -- IBAN + return `${prefix}${checksum}${bankCode}${pre}${main}`; + }; + + const iban = getIban(creditor.accountNumber); + const textInQr = `SPD*1.0*ACC:${iban}*AM:${formattedAmount}*CC:CZK`; + + /** + * Handles copying text to clipboard + * @description Copies text to clipboard and changes button name + */ + const handleCopyInfoToClipboard = () => { + navigator.clipboard + .writeText(textInQr) + .then(() => { + setCopyButtonName("Zkopírováno"); + }) + .catch((err) => { + console.error("Error copying text: ", err); + }); + }; + + const handleConfirmPay = () => { + setConfirmDialog({ + title: "Zaplatit nákup", + content: "Oprvdu chcete označit tento nákup jako zaplacený?", + handler: handleSetAsPaid, + }); + }; + + return ( + +
+ +
+ +
+
+

Komu:

+ {creditor.name} {creditor.surname} +
+
+

Částka:

+ {formattedAmount} Kč +
+
+

Číslo účtu:

+ {creditor.accountNumber} +
+
+ + {confirmDialog && ( + setConfirmDialog(null)} + onConfirm={confirmDialog.handler} + /> + )} + {toastBarSettings && } + + } + dialogActions={ + <> + + + + + } + /> + ); +} + +export default PayDialog; diff --git a/src/frontend/src/components/dialogs/RegistrationRequestsDialog.jsx b/src/frontend/src/components/dialogs/RegistrationRequestsDialog.jsx new file mode 100644 index 0000000..37f0eb7 --- /dev/null +++ b/src/frontend/src/components/dialogs/RegistrationRequestsDialog.jsx @@ -0,0 +1,92 @@ +import { useTheme } from "@mui/material"; +import { alpha } from "@mui/material/styles"; +import { GenericDialog, GenericButton } from "@danneschs/libnik-ui"; +import AlertTitle from "@mui/material/AlertTitle"; +import Alert from "@mui/material/Alert"; +import Stack from "@mui/material/Stack"; +import Link from "@mui/material/Link"; + +import AddIcon from "@mui/icons-material/Add"; + +const contentTextSx = { + height: "100%", + display: "flex", + flexDirection: "column", + justifyContent: "center", + alignItems: "center", + fontSize: "1.5rem", + textAlign: "center", +}; + +/** + * Registration requests dialog component + * @param {*} onClose -- function to close the dialog + * @param {*} registrationRequests -- list of registration requests to display + * @param {*} handleRegisterFromRequest -- function to handle registration from request + * @returns + */ +function RegistrationRequestsDialog({ onClose, registrationRequests, handleRegisterFromRequest }) { + const theme = useTheme(); + const alertSx = { + backgroundColor: alpha(theme.palette.primary.main, 0.06), + color: theme.palette.text.primary, + "& .MuiAlert-icon": { + color: theme.palette.primary.main, + }, + "&:hover": { + backgroundColor: theme.palette.primary.main, + color: theme.palette.background.default, + "& .MuiAlert-icon": { + color: theme.palette.background.default, + }, + "& svg": { + color: theme.palette.background.default, + }, + }, + }; + + const areThereRequests = registrationRequests.length > 0; + + return ( + + {registrationRequests.map((notification) => ( + handleRegisterFromRequest(notification.id)} + sx={{ cursor: "pointer" }} + > + + + } + key={notification.id} + severity="info" + sx={alertSx} + > + + {notification.requestedAt} | {notification.name} {notification.surname} + + {notification.email} | {notification.accountNumber} + + ))} + + ) : ( + "Žádné žádosti o registraci" + ) + } + customContentSx={!areThereRequests ? contentTextSx : null} + dialogActions={ + <> + + + } + /> + ); +} +export default RegistrationRequestsDialog; diff --git a/src/frontend/src/config/ConfigContext.js b/src/frontend/src/config/ConfigContext.js new file mode 100644 index 0000000..f611976 --- /dev/null +++ b/src/frontend/src/config/ConfigContext.js @@ -0,0 +1,3 @@ +import { createContext } from "react"; + +export const ConfigContext = createContext(null); diff --git a/src/frontend/src/config/ConfigProvider.jsx b/src/frontend/src/config/ConfigProvider.jsx new file mode 100644 index 0000000..4cdfb21 --- /dev/null +++ b/src/frontend/src/config/ConfigProvider.jsx @@ -0,0 +1,32 @@ +import { useState, useEffect } from "react"; +import { ConfigContext } from "@config/ConfigContext"; + +import { getCurrencyFormatService } from "@api/configs"; + +function ConfigProvider({ children }) { + const [currencyFormat, setCurrencyFormat] = useState(""); + const [loading, setLoading] = useState(true); + + useEffect(() => { + const fetchCurrencyFormat = async () => { + const currencyFormat = await getCurrencyFormatService(); + setCurrencyFormat(currencyFormat); + setLoading(false); + }; + fetchCurrencyFormat(); + }, []); + + return ( + + {children} + + ); +} + +export default ConfigProvider; diff --git a/src/frontend/src/mappers/purchaseMapper.js b/src/frontend/src/mappers/purchaseMapper.js new file mode 100644 index 0000000..94215a3 --- /dev/null +++ b/src/frontend/src/mappers/purchaseMapper.js @@ -0,0 +1,30 @@ +import { PostPurchaseDto } from "@objects/PostPurchaseDto.js"; +import { GetPurchaseDto } from "@objects/GetPurchaseDto.js"; + +export default class PurchaseMapper { + static fromEditToPost(purchase, currentUserId, id = 0) { + return new PostPurchaseDto( + id, // ID will be assigned by the server + purchase.name, + currentUserId, + purchase.coPayers.map((user) => user.id), + purchase.timestamp, + purchase.shopName, + purchase.items, // here are items of type ItemDto, so id is type int and has value 0; prices are in cents + purchase.buyerPays + ); + } + + static fromEditToGet(purchase, currentUserId) { + return new GetPurchaseDto( + purchase.id, + purchase.name, + currentUserId, + purchase.coPayers, + purchase.timestamp, + purchase.shopName, + purchase.priceInCents, + purchase.buyerPays + ); + } +} diff --git a/src/frontend/src/objects/CommitmentDto.js b/src/frontend/src/objects/CommitmentDto.js new file mode 100644 index 0000000..29daec3 --- /dev/null +++ b/src/frontend/src/objects/CommitmentDto.js @@ -0,0 +1,10 @@ +export default class CommitmentDto { + constructor(id, fromUserId, toUserId, state, toName, totalInCents) { + this.id = id; + this.fromUserId = fromUserId; + this.toUserId = toUserId; + this.state = state; + this.toName = toName; + this.amount = totalInCents; + } +} diff --git a/src/frontend/src/objects/EditPurchase.js b/src/frontend/src/objects/EditPurchase.js new file mode 100644 index 0000000..ada54c0 --- /dev/null +++ b/src/frontend/src/objects/EditPurchase.js @@ -0,0 +1,12 @@ +export class EditPurchase { + constructor(id, name, coPayers, timestamp, shopName, priceInCrowns, items, buyerPays) { + this.id = id; + this.name = name; + this.coPayers = coPayers; + this.timestamp = timestamp; + this.shopName = shopName; + this.priceInCents = Math.floor(priceInCrowns * 100); // Convert crowns to cents; + this.items = items; + this.buyerPays = buyerPays; + } +} diff --git a/src/frontend/src/objects/ExchangeRateDto.js b/src/frontend/src/objects/ExchangeRateDto.js new file mode 100644 index 0000000..6300344 --- /dev/null +++ b/src/frontend/src/objects/ExchangeRateDto.js @@ -0,0 +1,8 @@ +export default class ExchangeRateDto { + constructor(dateString, rateDecimal) { + this.dateString = dateString; + this.rateDecimal = rateDecimal; + this.date = new Date(dateString); + this.rate = parseFloat(rateDecimal).toFixed(2); + } +} diff --git a/src/frontend/src/objects/GetPurchaseDto.js b/src/frontend/src/objects/GetPurchaseDto.js new file mode 100644 index 0000000..8b10c15 --- /dev/null +++ b/src/frontend/src/objects/GetPurchaseDto.js @@ -0,0 +1,12 @@ +export class GetPurchaseDto { + constructor(id, name, buyerName, coPayers, timestamp, shopName, priceInCents, buyerPays) { + this.id = id; + this.name = name; + this.buyerName = buyerName; + this.coPayers = coPayers || []; + this.timestamp = timestamp; + this.shopName = shopName; + this.priceInCents = priceInCents; + this.buyerPays = buyerPays; + } +} diff --git a/src/frontend/src/objects/GetTransactionLogDto.js b/src/frontend/src/objects/GetTransactionLogDto.js new file mode 100644 index 0000000..98723d9 --- /dev/null +++ b/src/frontend/src/objects/GetTransactionLogDto.js @@ -0,0 +1,11 @@ +export default class GetTransactionLogDto { + constructor(id, date, type, fromName, toName, amountInCents, description) { + this.id = id; + this.date = date; + this.type = type; + this.fromName = fromName; + this.toName = toName; + this.amount = amountInCents / 100; + this.description = description; + } +} diff --git a/src/frontend/src/objects/Item.js b/src/frontend/src/objects/Item.js new file mode 100644 index 0000000..d8a1057 --- /dev/null +++ b/src/frontend/src/objects/Item.js @@ -0,0 +1,8 @@ +export class Item { + constructor(id, name, quantity, priceInCrowns) { + this.id = id; // For frontend, string -- uuidv4 (sometimes int xd) + this.name = name; + this.quantity = quantity; + this.priceInCrowns = priceInCrowns; + } +} diff --git a/src/frontend/src/objects/ItemDto.js b/src/frontend/src/objects/ItemDto.js new file mode 100644 index 0000000..f6af451 --- /dev/null +++ b/src/frontend/src/objects/ItemDto.js @@ -0,0 +1,8 @@ +export class ItemDto { + constructor(id, name, quantity, priceInCents) { + this.id = id; // For backend, int + this.name = name; + this.quantity = quantity; + this.priceInCents = priceInCents; + } +} diff --git a/src/frontend/src/objects/NotificationDto.js b/src/frontend/src/objects/NotificationDto.js new file mode 100644 index 0000000..bf0c641 --- /dev/null +++ b/src/frontend/src/objects/NotificationDto.js @@ -0,0 +1,19 @@ +import { markNotificationAsReadService } from "@api/transactionLogs"; + +class NotificationDto { + constructor(id, type, header, text, timestamp, isRead) { + this.id = id; + this.type = type; // "pending_settlement", "add_purchase", "remove_purchase" + this.header = header; + this.text = text; + this.timestamp = timestamp; + this.isRead = isRead; + } + + async markAsRead(token) { + this.isRead = true; + return await markNotificationAsReadService(token, this.id); + } +} + +export default NotificationDto; diff --git a/src/frontend/src/objects/PendingRegisterRequestDto.js b/src/frontend/src/objects/PendingRegisterRequestDto.js new file mode 100644 index 0000000..fc305dc --- /dev/null +++ b/src/frontend/src/objects/PendingRegisterRequestDto.js @@ -0,0 +1,18 @@ +import { markRegistrationRequestAsRead } from "@api/pendingUsers"; + +export default class PendingRegisterRequestDto { + constructor(id, name, surname, accountNumber, email, requestedAt, isRead) { + this.id = id; + this.name = name; + this.surname = surname; + this.accountNumber = accountNumber; + this.email = email; + this.requestedAt = requestedAt; + this.isRead = isRead; + } + + async markAsRead(token) { + this.isRead = true; + return await markRegistrationRequestAsRead(token, this.id); + } +} diff --git a/src/frontend/src/objects/PostPurchaseDto.js b/src/frontend/src/objects/PostPurchaseDto.js new file mode 100644 index 0000000..2024107 --- /dev/null +++ b/src/frontend/src/objects/PostPurchaseDto.js @@ -0,0 +1,12 @@ +export class PostPurchaseDto { + constructor(id, name, buyerId, coPayerIds, timestamp, shopName, items, buyerPays) { + this.id = id; + this.name = name; + this.buyerId = buyerId; + this.timestamp = timestamp; + this.shopName = shopName; + this.items = items; + this.coPayerIds = coPayerIds; + this.buyerPays = buyerPays; + } +} diff --git a/src/frontend/src/objects/PostTransactionLogDto.js b/src/frontend/src/objects/PostTransactionLogDto.js new file mode 100644 index 0000000..3607a74 --- /dev/null +++ b/src/frontend/src/objects/PostTransactionLogDto.js @@ -0,0 +1,7 @@ +export default class PostTransactionLogDto { + constructor(fromUserId, toUserId, amountInCents) { + this.fromUserId = fromUserId; + this.toUserId = toUserId; + this.amountInCents = amountInCents; + } +} diff --git a/src/frontend/src/objects/RegisterUserDto.js b/src/frontend/src/objects/RegisterUserDto.js new file mode 100644 index 0000000..adc1e08 --- /dev/null +++ b/src/frontend/src/objects/RegisterUserDto.js @@ -0,0 +1,9 @@ +export default class RegisterUserDto { + constructor(name, surname, accountNumber, email, password) { + this.name = name; + this.surname = surname; + this.accountNumber = accountNumber; + this.email = email; + this.password = password; + } +} diff --git a/src/frontend/src/objects/Settlement.js b/src/frontend/src/objects/Settlement.js new file mode 100644 index 0000000..a48ae73 --- /dev/null +++ b/src/frontend/src/objects/Settlement.js @@ -0,0 +1,13 @@ +export class Settlement { + constructor(id, fromUserId, toUserId, amount, date) { + this.id = id; + this.fromUserId = fromUserId; + this.toUserId = toUserId; + this.amount = amount; + this.date = date; + } + + toString() { + return `Settlement: from ${this.fromUserId} to ${this.toUserId}, amount: ${this.amount}, date: ${this.date}`; + } +} diff --git a/src/frontend/src/objects/Transaction.js b/src/frontend/src/objects/Transaction.js new file mode 100644 index 0000000..e1aa2b2 --- /dev/null +++ b/src/frontend/src/objects/Transaction.js @@ -0,0 +1,31 @@ +/** + * Transaction class representing a financial transaction between users. + * "User A owes User B X amount of money" -- purchase meaning + * "User A pays User B X amount of money" -- settlement meaning + */ +export class Transaction { + constructor(id, groupId, type, fromUserId, toUserId, amount, relatedPurchaseId, relatedSettlementId, timestamp, note) { + this.id = id; + this.groupId = groupId; + this.type = type; // add_purchase, add_settlement, remove_purchase (meaning: reason, why user A owes user B) + this.fromUserId = fromUserId; + this.toUserId = toUserId; + this.amount = amount; + this.relatedPurchaseId = relatedPurchaseId; + this.relatedSettlementId = relatedSettlementId; + this.timestamp = timestamp; + this.note = note; + } + + toString() { + return `Transaction ID: ${this.id}, Date: ${this.date}, Items: ${this.items.length}, Payers: ${this.payers.length}, Settlement: ${this.settlement}`; + } +} + +/* +| Typ transakce | fromUserId | toUserId | amount | Význam | +| ----------------- | ------------- | ------------- | ------ | ------------------------------------------------------------------------------------------ | +| `add_purchase` | spoluplatitel | kupující | částka | Někdo dluží kupujícímu za nákup | +| `remove_purchase` | spoluplatital | kupující | částka | Smazání dluhu, že někdo dluží kupujícímu za nákup (kopíruje add_purchase, ale má jiný typ) | +| `add_settlement` | plátce | příjemce | částka | Čistá platba mezi uživateli | +*/ \ No newline at end of file diff --git a/src/frontend/src/styles/index.css b/src/frontend/src/styles/index.css new file mode 100644 index 0000000..b598de2 --- /dev/null +++ b/src/frontend/src/styles/index.css @@ -0,0 +1,5 @@ +html, +body, +#root { + height: 100%; +} diff --git a/src/frontend/src/theme.js b/src/frontend/src/theme.js new file mode 100644 index 0000000..40255ef --- /dev/null +++ b/src/frontend/src/theme.js @@ -0,0 +1,10 @@ +import { createTheme } from "@mui/material/styles"; + +export const theme = createTheme({ + palette: { + primary: { main: "#1FA66E" }, + background: { default: "#F7F7FF" }, + text: { primary: "#07090F" }, + error: { main: "#d32f2f" }, + }, +}); diff --git a/src/frontend/vite.config.js b/src/frontend/vite.config.js new file mode 100644 index 0000000..8129439 --- /dev/null +++ b/src/frontend/vite.config.js @@ -0,0 +1,49 @@ +import { defineConfig } from "vite"; +import react from "@vitejs/plugin-react"; +import path from "path"; +import { fileURLToPath } from "url"; +import { viteSingleFile } from "vite-plugin-singlefile"; + +const __filename = fileURLToPath(import.meta.url); +const __dirname = path.dirname(__filename); + +// https://vite.dev/config/ +export default defineConfig({ + plugins: [react(), viteSingleFile()], + build: { + target: "esnext", + cssCodeSplit: false, + assetsInlineLimit: 100000000, // vše inline + }, + resolve: { + alias: { + "@bookmarks": path.resolve(__dirname, "src/bookmarks"), + "@auth": path.resolve(__dirname, "src/auth"), + "@objects": path.resolve(__dirname, "src/objects"), + "@styles": path.resolve(__dirname, "src/styles"), + + "@api": path.resolve(__dirname, "src/api"), + "@components": path.resolve(__dirname, "src/components"), + "@forms": path.resolve(__dirname, "src/components/forms"), + "@tables": path.resolve(__dirname, "src/components/tables"), + "@dialogs": path.resolve(__dirname, "src/components/dialogs"), + "@columns": path.resolve(__dirname, "src/components/columns"), + "@atoms": path.resolve(__dirname, "src/components/atoms"), + "@main": path.resolve(__dirname, "src"), + "@backend": path.resolve(__dirname, "backend"), + "@models": path.resolve(__dirname, "backend/models"), + "@routes": path.resolve(__dirname, "backend/routes"), + "@mappers": path.resolve(__dirname, "src/mappers"), + "@config": path.resolve(__dirname, "src/config"), + }, + }, + server: { + proxy: { + "/api": { + target: "http://localhost:5119", // port backendu z launchSettings + changeOrigin: true, + secure: false, // backend není HTTPS + }, + }, + }, +});