repo migration + frontend major update: useTheme replaced CSS; now using ui lib

This commit is contained in:
Danesh 2026-03-28 00:09:30 +01:00
commit 10555b944c
117 changed files with 8034 additions and 0 deletions

19
.gitignore vendored Normal file
View File

@ -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

199
README.md Normal file
View File

@ -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)

712
model/era-platbik.drawio Normal file
View File

@ -0,0 +1,712 @@
<mxfile host="app.diagrams.net" agent="Mozilla/5.0 (X11; Linux x86_64; rv:148.0) Gecko/20100101 Firefox/148.0" version="29.6.6">
<diagram id="R2lEEEUBdFMjLlhIrx00" name="Page-1">
<mxGraphModel dx="1866" dy="1237" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="827" pageHeight="1169" math="0" shadow="0" extFonts="Permanent Marker^https://fonts.googleapis.com/css?family=Permanent+Marker">
<root>
<mxCell id="0" />
<mxCell id="1" parent="0" />
<mxCell id="C-vyLk0tnHw3VtMMgP7b-23" parent="1" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="Purchase" vertex="1">
<mxGeometry height="250" width="327.02" x="49.86" y="50" as="geometry" />
</mxCell>
<mxCell id="C-vyLk0tnHw3VtMMgP7b-24" parent="C-vyLk0tnHw3VtMMgP7b-23" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="327.02" y="30" as="geometry" />
</mxCell>
<mxCell id="C-vyLk0tnHw3VtMMgP7b-25" parent="C-vyLk0tnHw3VtMMgP7b-24" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="PK" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="C-vyLk0tnHw3VtMMgP7b-26" parent="C-vyLk0tnHw3VtMMgP7b-24" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="id int NOT NULL " vertex="1">
<mxGeometry height="30" width="297.02" x="30" as="geometry">
<mxRectangle height="30" width="297.02" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="C-vyLk0tnHw3VtMMgP7b-27" parent="C-vyLk0tnHw3VtMMgP7b-23" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="327.02" y="60" as="geometry" />
</mxCell>
<mxCell id="C-vyLk0tnHw3VtMMgP7b-28" parent="C-vyLk0tnHw3VtMMgP7b-27" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="C-vyLk0tnHw3VtMMgP7b-29" parent="C-vyLk0tnHw3VtMMgP7b-27" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="Name string NOT NULL" vertex="1">
<mxGeometry height="30" width="297.02" x="30" as="geometry">
<mxRectangle height="30" width="297.02" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-1" parent="C-vyLk0tnHw3VtMMgP7b-23" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="327.02" y="90" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-2" parent="xaRHH_SKgdOMai8eRZON-1" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-3" parent="xaRHH_SKgdOMai8eRZON-1" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="PriceInCents int NOT NULL" vertex="1">
<mxGeometry height="30" width="297.02" x="30" as="geometry">
<mxRectangle height="30" width="297.02" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-4" parent="C-vyLk0tnHw3VtMMgP7b-23" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="327.02" y="120" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-5" parent="xaRHH_SKgdOMai8eRZON-4" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-6" parent="xaRHH_SKgdOMai8eRZON-4" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="BuyerPays bool NOT NULL" vertex="1">
<mxGeometry height="30" width="297.02" x="30" as="geometry">
<mxRectangle height="30" width="297.02" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-7" parent="C-vyLk0tnHw3VtMMgP7b-23" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="327.02" y="150" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-8" parent="xaRHH_SKgdOMai8eRZON-7" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-9" parent="xaRHH_SKgdOMai8eRZON-7" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="Timestamp DateTimeOffset NOT NULL" vertex="1">
<mxGeometry height="30" width="297.02" x="30" as="geometry">
<mxRectangle height="30" width="297.02" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-10" parent="C-vyLk0tnHw3VtMMgP7b-23" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="327.02" y="180" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-11" parent="xaRHH_SKgdOMai8eRZON-10" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-12" parent="xaRHH_SKgdOMai8eRZON-10" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="Shop string NOT NULL" vertex="1">
<mxGeometry height="30" width="297.02" x="30" as="geometry">
<mxRectangle height="30" width="297.02" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-27" parent="C-vyLk0tnHw3VtMMgP7b-23" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="327.02" y="210" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-28" parent="xaRHH_SKgdOMai8eRZON-27" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="FK" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-29" parent="xaRHH_SKgdOMai8eRZON-27" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="BuyerId int NOT NULL" vertex="1">
<mxGeometry height="30" width="297.02" x="30" as="geometry">
<mxRectangle height="30" width="297.02" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-56" edge="1" parent="C-vyLk0tnHw3VtMMgP7b-23" source="xaRHH_SKgdOMai8eRZON-27" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=1;exitY=0.5;exitDx=0;exitDy=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" target="xaRHH_SKgdOMai8eRZON-27">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-30" parent="1" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="Item" vertex="1">
<mxGeometry height="188" width="256.93" x="549.95" y="50" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-31" parent="xaRHH_SKgdOMai8eRZON-30" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="256.93" y="30" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-32" parent="xaRHH_SKgdOMai8eRZON-31" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="PK" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-33" parent="xaRHH_SKgdOMai8eRZON-31" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="id int NOT NULL " vertex="1">
<mxGeometry height="30" width="226.93" x="30" as="geometry">
<mxRectangle height="30" width="226.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-34" parent="xaRHH_SKgdOMai8eRZON-30" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="256.93" y="60" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-35" parent="xaRHH_SKgdOMai8eRZON-34" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-36" parent="xaRHH_SKgdOMai8eRZON-34" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="Name string NOT NULL" vertex="1">
<mxGeometry height="30" width="226.93" x="30" as="geometry">
<mxRectangle height="30" width="226.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-37" parent="xaRHH_SKgdOMai8eRZON-30" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="256.93" y="90" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-38" parent="xaRHH_SKgdOMai8eRZON-37" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-39" parent="xaRHH_SKgdOMai8eRZON-37" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="Quantity int NOT NULL" vertex="1">
<mxGeometry height="30" width="226.93" x="30" as="geometry">
<mxRectangle height="30" width="226.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-40" parent="xaRHH_SKgdOMai8eRZON-30" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="256.93" y="120" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-41" parent="xaRHH_SKgdOMai8eRZON-40" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-42" parent="xaRHH_SKgdOMai8eRZON-40" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="PricePerPiece int NOT NULL" vertex="1">
<mxGeometry height="30" width="226.93" x="30" as="geometry">
<mxRectangle height="30" width="226.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-49" parent="xaRHH_SKgdOMai8eRZON-30" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="256.93" y="150" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-50" parent="xaRHH_SKgdOMai8eRZON-49" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="FK" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-51" parent="xaRHH_SKgdOMai8eRZON-49" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="PurchaseId int NOT NULL" vertex="1">
<mxGeometry height="30" width="226.93" x="30" as="geometry">
<mxRectangle height="30" width="226.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-57" parent="1" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="CoPayer" vertex="1">
<mxGeometry height="131" width="250" x="49.86" y="360" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-58" parent="xaRHH_SKgdOMai8eRZON-57" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="250" y="30" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-59" parent="xaRHH_SKgdOMai8eRZON-58" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="PK" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-60" parent="xaRHH_SKgdOMai8eRZON-58" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="id int NOT NULL " vertex="1">
<mxGeometry height="30" width="220" x="30" as="geometry">
<mxRectangle height="30" width="220" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-70" parent="xaRHH_SKgdOMai8eRZON-57" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="250" y="60" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-71" parent="xaRHH_SKgdOMai8eRZON-70" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="FK1" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-72" parent="xaRHH_SKgdOMai8eRZON-70" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="PurchaseId int NOT NULL" vertex="1">
<mxGeometry height="30" width="220" x="30" as="geometry">
<mxRectangle height="30" width="220" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-73" parent="xaRHH_SKgdOMai8eRZON-57" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="250" y="90" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-74" parent="xaRHH_SKgdOMai8eRZON-73" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="FK1" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-75" parent="xaRHH_SKgdOMai8eRZON-73" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="UserId int NOT NULL" vertex="1">
<mxGeometry height="30" width="220" x="30" as="geometry">
<mxRectangle height="30" width="220" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-76" edge="1" parent="1" source="xaRHH_SKgdOMai8eRZON-70" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" target="C-vyLk0tnHw3VtMMgP7b-24">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="40" y="435" />
<mxPoint x="40" y="95" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-77" parent="1" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="User" vertex="1">
<mxGeometry height="278" width="327.02" x="49.86" y="580" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-78" parent="xaRHH_SKgdOMai8eRZON-77" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="327.02" y="30" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-79" parent="xaRHH_SKgdOMai8eRZON-78" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="PK" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-80" parent="xaRHH_SKgdOMai8eRZON-78" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="id int NOT NULL " vertex="1">
<mxGeometry height="30" width="297.02" x="30" as="geometry">
<mxRectangle height="30" width="297.02" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-81" parent="xaRHH_SKgdOMai8eRZON-77" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="327.02" y="60" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-82" parent="xaRHH_SKgdOMai8eRZON-81" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-83" parent="xaRHH_SKgdOMai8eRZON-81" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="Email string NOT NULL" vertex="1">
<mxGeometry height="30" width="297.02" x="30" as="geometry">
<mxRectangle height="30" width="297.02" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-117" parent="xaRHH_SKgdOMai8eRZON-77" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="327.02" y="90" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-118" parent="xaRHH_SKgdOMai8eRZON-117" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-119" parent="xaRHH_SKgdOMai8eRZON-117" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="Name string NOT NULL" vertex="1">
<mxGeometry height="30" width="297.02" x="30" as="geometry">
<mxRectangle height="30" width="297.02" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-120" parent="xaRHH_SKgdOMai8eRZON-77" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="327.02" y="120" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-121" parent="xaRHH_SKgdOMai8eRZON-120" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-122" parent="xaRHH_SKgdOMai8eRZON-120" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="Surname string NOT NULL" vertex="1">
<mxGeometry height="30" width="297.02" x="30" as="geometry">
<mxRectangle height="30" width="297.02" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-123" parent="xaRHH_SKgdOMai8eRZON-77" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="327.02" y="150" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-124" parent="xaRHH_SKgdOMai8eRZON-123" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-125" parent="xaRHH_SKgdOMai8eRZON-123" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="AccountNumber string NOT NULL" vertex="1">
<mxGeometry height="30" width="297.02" x="30" as="geometry">
<mxRectangle height="30" width="297.02" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-151" parent="xaRHH_SKgdOMai8eRZON-77" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="327.02" y="180" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-152" parent="xaRHH_SKgdOMai8eRZON-151" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-153" parent="xaRHH_SKgdOMai8eRZON-151" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="PasswordHash string NOT NULL" vertex="1">
<mxGeometry height="30" width="297.02" x="30" as="geometry">
<mxRectangle height="30" width="297.02" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-84" parent="xaRHH_SKgdOMai8eRZON-77" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="327.02" y="210" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-85" parent="xaRHH_SKgdOMai8eRZON-84" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-86" parent="xaRHH_SKgdOMai8eRZON-84" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="CreatedAt DateTimeOffset NOT NULL" vertex="1">
<mxGeometry height="30" width="297.02" x="30" as="geometry">
<mxRectangle height="30" width="297.02" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-90" parent="xaRHH_SKgdOMai8eRZON-77" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="327.02" y="240" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-91" parent="xaRHH_SKgdOMai8eRZON-90" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="FK" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-92" parent="xaRHH_SKgdOMai8eRZON-90" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="RoleId int NOT NULL" vertex="1">
<mxGeometry height="30" width="297.02" x="30" as="geometry">
<mxRectangle height="30" width="297.02" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-154" edge="1" parent="1" source="xaRHH_SKgdOMai8eRZON-49" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" target="C-vyLk0tnHw3VtMMgP7b-24">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="455" y="215" />
<mxPoint x="455" y="95" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-155" edge="1" parent="1" source="xaRHH_SKgdOMai8eRZON-27" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" target="xaRHH_SKgdOMai8eRZON-78">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="30" y="275" />
<mxPoint x="30" y="625" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-157" parent="1" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="Role" vertex="1">
<mxGeometry height="128" width="277.02" x="49.86" y="980" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-158" parent="xaRHH_SKgdOMai8eRZON-157" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="277.02" y="30" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-159" parent="xaRHH_SKgdOMai8eRZON-158" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="PK" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-160" parent="xaRHH_SKgdOMai8eRZON-158" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="id int NOT NULL " vertex="1">
<mxGeometry height="30" width="247.01999999999998" x="30" as="geometry">
<mxRectangle height="30" width="247.01999999999998" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-161" parent="xaRHH_SKgdOMai8eRZON-157" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="277.02" y="60" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-162" parent="xaRHH_SKgdOMai8eRZON-161" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-163" parent="xaRHH_SKgdOMai8eRZON-161" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="Code string NOT NULL" vertex="1">
<mxGeometry height="30" width="247.01999999999998" x="30" as="geometry">
<mxRectangle height="30" width="247.01999999999998" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-173" parent="xaRHH_SKgdOMai8eRZON-157" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="277.02" y="90" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-174" parent="xaRHH_SKgdOMai8eRZON-173" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-175" parent="xaRHH_SKgdOMai8eRZON-173" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="DisplayName string NOT NULL" vertex="1">
<mxGeometry height="30" width="247.01999999999998" x="30" as="geometry">
<mxRectangle height="30" width="247.01999999999998" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-176" edge="1" parent="1" source="xaRHH_SKgdOMai8eRZON-90" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" target="xaRHH_SKgdOMai8eRZON-158">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-193" parent="1" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="PendingUser" vertex="1">
<mxGeometry height="278" width="336.93" x="469.95" y="830" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-194" parent="xaRHH_SKgdOMai8eRZON-193" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="336.93" y="30" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-195" parent="xaRHH_SKgdOMai8eRZON-194" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="PK" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-196" parent="xaRHH_SKgdOMai8eRZON-194" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="id int NOT NULL " vertex="1">
<mxGeometry height="30" width="306.93" x="30" as="geometry">
<mxRectangle height="30" width="306.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-200" parent="xaRHH_SKgdOMai8eRZON-193" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="336.93" y="60" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-201" parent="xaRHH_SKgdOMai8eRZON-200" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-202" parent="xaRHH_SKgdOMai8eRZON-200" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="Name string NOT NULL" vertex="1">
<mxGeometry height="30" width="306.93" x="30" as="geometry">
<mxRectangle height="30" width="306.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-203" parent="xaRHH_SKgdOMai8eRZON-193" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="336.93" y="90" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-204" parent="xaRHH_SKgdOMai8eRZON-203" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-205" parent="xaRHH_SKgdOMai8eRZON-203" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="Surname string NOT NULL" vertex="1">
<mxGeometry height="30" width="306.93" x="30" as="geometry">
<mxRectangle height="30" width="306.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-206" parent="xaRHH_SKgdOMai8eRZON-193" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="336.93" y="120" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-207" parent="xaRHH_SKgdOMai8eRZON-206" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-208" parent="xaRHH_SKgdOMai8eRZON-206" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="AccountNumber string NOT NULL" vertex="1">
<mxGeometry height="30" width="306.93" x="30" as="geometry">
<mxRectangle height="30" width="306.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-197" parent="xaRHH_SKgdOMai8eRZON-193" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="336.93" y="150" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-198" parent="xaRHH_SKgdOMai8eRZON-197" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-199" parent="xaRHH_SKgdOMai8eRZON-197" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="Email string NOT NULL" vertex="1">
<mxGeometry height="30" width="306.93" x="30" as="geometry">
<mxRectangle height="30" width="306.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-209" parent="xaRHH_SKgdOMai8eRZON-193" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="336.93" y="180" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-210" parent="xaRHH_SKgdOMai8eRZON-209" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-211" parent="xaRHH_SKgdOMai8eRZON-209" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="PasswordHash string NOT NULL" vertex="1">
<mxGeometry height="30" width="306.93" x="30" as="geometry">
<mxRectangle height="30" width="306.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-218" parent="xaRHH_SKgdOMai8eRZON-193" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="336.93" y="210" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-219" parent="xaRHH_SKgdOMai8eRZON-218" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-220" parent="xaRHH_SKgdOMai8eRZON-218" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="IsRead bool NOT NULL" vertex="1">
<mxGeometry height="30" width="306.93" x="30" as="geometry">
<mxRectangle height="30" width="306.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-212" parent="xaRHH_SKgdOMai8eRZON-193" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="336.93" y="240" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-213" parent="xaRHH_SKgdOMai8eRZON-212" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-214" parent="xaRHH_SKgdOMai8eRZON-212" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="RequestedAt DateTimeOffset NOT NULL" vertex="1">
<mxGeometry height="30" width="306.93" x="30" as="geometry">
<mxRectangle height="30" width="306.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-221" parent="1" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="TransactionType" vertex="1">
<mxGeometry height="128" width="276.93" x="529.95" y="280" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-222" parent="xaRHH_SKgdOMai8eRZON-221" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="276.93" y="30" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-223" parent="xaRHH_SKgdOMai8eRZON-222" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="PK" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-224" parent="xaRHH_SKgdOMai8eRZON-222" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="id int NOT NULL " vertex="1">
<mxGeometry height="30" width="246.93" x="30" as="geometry">
<mxRectangle height="30" width="246.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-225" parent="xaRHH_SKgdOMai8eRZON-221" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="276.93" y="60" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-226" parent="xaRHH_SKgdOMai8eRZON-225" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-227" parent="xaRHH_SKgdOMai8eRZON-225" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="Code string NOT NULL" vertex="1">
<mxGeometry height="30" width="246.93" x="30" as="geometry">
<mxRectangle height="30" width="246.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-228" parent="xaRHH_SKgdOMai8eRZON-221" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="276.93" y="90" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-229" parent="xaRHH_SKgdOMai8eRZON-228" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-230" parent="xaRHH_SKgdOMai8eRZON-228" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="DisplayName string NOT NULL" vertex="1">
<mxGeometry height="30" width="246.93" x="30" as="geometry">
<mxRectangle height="30" width="246.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-231" parent="1" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="TransactionLog" vertex="1">
<mxGeometry height="338" width="326.93" x="479.95000000000005" y="465" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-232" parent="xaRHH_SKgdOMai8eRZON-231" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="326.93" y="30" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-233" parent="xaRHH_SKgdOMai8eRZON-232" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="PK" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-234" parent="xaRHH_SKgdOMai8eRZON-232" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="id int NOT NULL " vertex="1">
<mxGeometry height="30" width="296.93" x="30" as="geometry">
<mxRectangle height="30" width="296.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-235" parent="xaRHH_SKgdOMai8eRZON-231" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="326.93" y="60" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-236" parent="xaRHH_SKgdOMai8eRZON-235" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="FK1" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-237" parent="xaRHH_SKgdOMai8eRZON-235" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="TransactionTypeId int NOT NULL" vertex="1">
<mxGeometry height="30" width="296.93" x="30" as="geometry">
<mxRectangle height="30" width="296.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-238" parent="xaRHH_SKgdOMai8eRZON-231" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="326.93" y="90" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-239" parent="xaRHH_SKgdOMai8eRZON-238" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-240" parent="xaRHH_SKgdOMai8eRZON-238" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="Amount int NOT NULL" vertex="1">
<mxGeometry height="30" width="296.93" x="30" as="geometry">
<mxRectangle height="30" width="296.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-261" parent="xaRHH_SKgdOMai8eRZON-231" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="326.93" y="120" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-262" parent="xaRHH_SKgdOMai8eRZON-261" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-263" parent="xaRHH_SKgdOMai8eRZON-261" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="Timestamp DateTimeOffset NOT NULL" vertex="1">
<mxGeometry height="30" width="296.93" x="30" as="geometry">
<mxRectangle height="30" width="296.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-277" parent="xaRHH_SKgdOMai8eRZON-231" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="326.93" y="150" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-278" parent="xaRHH_SKgdOMai8eRZON-277" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-279" parent="xaRHH_SKgdOMai8eRZON-277" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="Note string" vertex="1">
<mxGeometry height="30" width="296.93" x="30" as="geometry">
<mxRectangle height="30" width="296.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-280" parent="xaRHH_SKgdOMai8eRZON-231" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="326.93" y="180" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-281" parent="xaRHH_SKgdOMai8eRZON-280" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="FK2" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-282" parent="xaRHH_SKgdOMai8eRZON-280" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="PurchaseID int" vertex="1">
<mxGeometry height="30" width="296.93" x="30" as="geometry">
<mxRectangle height="30" width="296.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-283" parent="xaRHH_SKgdOMai8eRZON-231" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="326.93" y="210" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-284" parent="xaRHH_SKgdOMai8eRZON-283" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="FK3" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-285" parent="xaRHH_SKgdOMai8eRZON-283" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="FromUserId int NOT NULL" vertex="1">
<mxGeometry height="30" width="296.93" x="30" as="geometry">
<mxRectangle height="30" width="296.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-286" parent="xaRHH_SKgdOMai8eRZON-231" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="326.93" y="240" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-287" parent="xaRHH_SKgdOMai8eRZON-286" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="FK4" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-288" parent="xaRHH_SKgdOMai8eRZON-286" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="ToUserId int NOT NULL" vertex="1">
<mxGeometry height="30" width="296.93" x="30" as="geometry">
<mxRectangle height="30" width="296.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-289" parent="xaRHH_SKgdOMai8eRZON-231" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="326.93" y="270" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-290" parent="xaRHH_SKgdOMai8eRZON-289" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-291" parent="xaRHH_SKgdOMai8eRZON-289" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="GroupId Guid" vertex="1">
<mxGeometry height="30" width="296.93" x="30" as="geometry">
<mxRectangle height="30" width="296.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-292" parent="xaRHH_SKgdOMai8eRZON-231" style="shape=partialRectangle;collapsible=0;dropTarget=0;pointerEvents=0;fillColor=none;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="326.93" y="300" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-293" parent="xaRHH_SKgdOMai8eRZON-292" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="" vertex="1">
<mxGeometry height="30" width="30" as="geometry">
<mxRectangle height="30" width="30" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-294" parent="xaRHH_SKgdOMai8eRZON-292" style="shape=partialRectangle;overflow=hidden;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" value="IsRead bool NOT NULL" vertex="1">
<mxGeometry height="30" width="296.93" x="30" as="geometry">
<mxRectangle height="30" width="296.93" as="alternateBounds" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-299" edge="1" parent="1" source="xaRHH_SKgdOMai8eRZON-280" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" target="C-vyLk0tnHw3VtMMgP7b-24">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="420" y="660" />
<mxPoint x="420" y="95" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-300" edge="1" parent="1" source="xaRHH_SKgdOMai8eRZON-283" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" target="xaRHH_SKgdOMai8eRZON-78">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="400" y="690" />
<mxPoint x="400" y="625" />
</Array>
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-303" edge="1" parent="1" source="xaRHH_SKgdOMai8eRZON-235" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" target="xaRHH_SKgdOMai8eRZON-222">
<mxGeometry relative="1" as="geometry" />
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-305" edge="1" parent="1" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="20" y="464" />
<mxPoint x="20" y="624" />
</Array>
<mxPoint x="49.86000000000013" y="464" as="sourcePoint" />
<mxPoint x="49.86000000000013" y="624" as="targetPoint" />
</mxGeometry>
</mxCell>
<mxCell id="xaRHH_SKgdOMai8eRZON-306" edge="1" parent="1" source="xaRHH_SKgdOMai8eRZON-286" style="edgeStyle=orthogonalEdgeStyle;rounded=0;orthogonalLoop=1;jettySize=auto;html=1;exitX=0;exitY=0.5;exitDx=0;exitDy=0;entryX=1;entryY=0.5;entryDx=0;entryDy=0;fontFamily=Libertinus Serif;fontSource=https%3A%2F%2Ffonts.googleapis.com%2Fcss%3Ffamily%3DLibertinus%2BSerif;fontSize=17;" target="xaRHH_SKgdOMai8eRZON-78">
<mxGeometry relative="1" as="geometry">
<Array as="points">
<mxPoint x="410" y="720" />
<mxPoint x="410" y="625" />
</Array>
</mxGeometry>
</mxCell>
</root>
</mxGraphModel>
</diagram>
</mxfile>

4
model/era-platbik.svg Normal file

File diff suppressed because one or more lines are too long

After

Width:  |  Height:  |  Size: 454 KiB

View File

@ -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<IActionResult> 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;
}
}
}

View File

@ -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<ActionResult<IEnumerable<CommitmentDto>>> GetCommitments()
{
var currentUserId = User.GetUserId();
try
{
var commitments = await _commitmentService.GetCommitmentsForUser(currentUserId);
return Ok(commitments);
}
catch (InvalidOperationException ex)
{
return BadRequest(ex.Message);
}
}
}

View File

@ -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<ConfigDto> GetCurrencyFormat()
{
return Ok(new ConfigDto{ CurrencyFormat = Config.CURRENCY_FORMAT });
}
}

View File

@ -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<IActionResult> 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<JsonElement>(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<JsonElement>(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}");
}
}
}

View File

@ -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<ActionResult<IEnumerable<ItemDto>>> 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);
}
}

View File

@ -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<ActionResult<IEnumerable<PendingUserRequestDto>>> 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<IActionResult> 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();
}
}

View File

@ -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<ActionResult<IEnumerable<GetPurchaseDto>>> 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<ActionResult<IEnumerable<GetPurchaseDto>>> 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<ActionResult<GetPurchaseDto>> 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<IActionResult> 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<ActionResult<GetPurchaseDto>> 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;
}
}

View File

@ -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<ActionResult<IEnumerable<GetTransactionLogDto>>> 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<ActionResult<IEnumerable<CommitmentDto>>> 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<ActionResult<IEnumerable<NotificationDto>>> 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<IActionResult> 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<IActionResult> 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();
}
}

View File

@ -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<ActionResult<IEnumerable<UserDto>>> 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<ActionResult<UserDto>> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<ActionResult<UserDto>> 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));
}
}

View File

@ -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; }
}

View File

@ -0,0 +1,4 @@
public class ConfigDto
{
public string CurrencyFormat { get; set; } = string.Empty;
}

View File

@ -0,0 +1,7 @@
namespace backend.DTOs;
public class ExchangeRateDto
{
public required string Date { get; set; }
public required decimal Rate { get; set; }
}

View File

@ -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<UserDto> CoPayers { get; set; } = new List<UserDto>();
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
}

View File

@ -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; }
}

View File

@ -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; }
}

View File

@ -0,0 +1,7 @@
namespace backend.DTOs;
public class LoginDto
{
public string Email { get; set; } = default!;
public string Password { get; set; } = default!;
}

View File

@ -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;
}

View File

@ -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; }
}

View File

@ -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<int> CoPayerIds { get; set; } = new List<int>(); // User ids
public DateTimeOffset Timestamp { get; set; }
public string ShopName { get; set; } = default!;
public List<ItemDto> Items { get; set; } = new List<ItemDto>();
public bool BuyerPays { get; set; } = true; // Default to true if not specified
}

View File

@ -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
}

View File

@ -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<Item> Items { get; set; } = new List<Item>();
// Many-to-many relationship with User through CoPayer
public virtual ICollection<CoPayer> CoPayers { get; set; } = new List<CoPayer>();
public Purchase PurchaseReference { get; set; } = default!;
public List<User> CoPayerUsers => CoPayers.Select(cp => cp.User).ToList();
}

View File

@ -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; }
}

View File

@ -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}";
}

View File

@ -0,0 +1,121 @@
namespace backend.Data;
using backend.Models;
using Microsoft.EntityFrameworkCore;
public class AppDbContext : DbContext
{
public AppDbContext(DbContextOptions<AppDbContext> options) : base(options) { }
public DbSet<User> Users { get; set; }
public DbSet<PendingUser> PendingUsers { get; set; }
public DbSet<Role> Roles { get; set; }
public DbSet<Purchase> Purchases { get; set; }
public DbSet<Item> Items { get; set; }
public DbSet<CoPayer> CoPayers { get; set; }
public DbSet<TransactionLog> TransactionLogs { get; set; }
public DbSet<TransactionType> 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<Purchase>()
.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<CoPayer>()
.HasKey(cp => new { cp.PurchaseId, cp.UserId });
modelBuilder.Entity<CoPayer>()
.HasOne(cp => cp.Purchase)
.WithMany(p => p.CoPayers)
.HasForeignKey(cp => cp.PurchaseId)
.OnDelete(DeleteBehavior.Cascade); // delete Purchase -> delete CoPayers
modelBuilder.Entity<CoPayer>()
.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<TransactionLog>()
.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<TransactionLog>()
.HasOne(t => t.FromUser)
.WithMany(u => u.TransactionsSent)
.HasForeignKey(t => t.FromUserId)
.OnDelete(DeleteBehavior.Restrict);
modelBuilder.Entity<TransactionLog>()
.HasOne(t => t.ToUser)
.WithMany(u => u.TransactionsReceived)
.HasForeignKey(t => t.ToUserId)
.OnDelete(DeleteBehavior.Restrict);
// ------------------------------------------------------------
// 5) TransactionLog -> Purchase (optional)
// ------------------------------------------------------------
modelBuilder.Entity<TransactionLog>()
.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<Role>()
.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<TransactionType>().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<Role>().HasData(
new Role { Id = 1, Code = Config.RoleCodes.ADMIN, DisplayName = "Administrátor" },
new Role { Id = 2, Code = Config.RoleCodes.USER, DisplayName = "Uživatel" }
);
}
}

View File

@ -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;
}
}

View File

@ -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<User> 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<User> 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
};
}
}

View File

@ -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
};
}
}

View File

@ -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
};
}
}

View File

@ -0,0 +1,43 @@
namespace backend.Middleware;
using System.Net;
using System.Text.Json;
public class ErrorHandlingMiddleware
{
private readonly RequestDelegate _next;
private readonly ILogger<ErrorHandlingMiddleware> _logger;
public ErrorHandlingMiddleware(RequestDelegate next, ILogger<ErrorHandlingMiddleware> 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));
}
}

View File

@ -0,0 +1,424 @@
// <auto-generated />
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
{
/// <inheritdoc />
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<int>("PurchaseId")
.HasColumnType("INTEGER")
.HasColumnOrder(0);
b.Property<int>("UserId")
.HasColumnType("INTEGER")
.HasColumnOrder(1);
b.HasKey("PurchaseId", "UserId");
b.HasIndex("UserId");
b.ToTable("CoPayers");
});
modelBuilder.Entity("backend.Models.Item", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("PricePerPiece")
.HasColumnType("INTEGER");
b.Property<int>("PurchaseId")
.HasColumnType("INTEGER");
b.Property<int>("Quantity")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PurchaseId");
b.ToTable("Items");
});
modelBuilder.Entity("backend.Models.PendingUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AccountNumber")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("IsRead")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("RequestedAt")
.HasColumnType("TEXT");
b.Property<string>("Surname")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PendingUsers");
});
modelBuilder.Entity("backend.Models.Purchase", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BuyerId")
.HasColumnType("INTEGER");
b.Property<bool>("BuyerPays")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("PriceInCents")
.HasColumnType("INTEGER");
b.Property<string>("Shop")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("BuyerId");
b.ToTable("Purchases");
});
modelBuilder.Entity("backend.Models.Role", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Code")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Amount")
.HasColumnType("INTEGER");
b.Property<int>("FromUserId")
.HasColumnType("INTEGER");
b.Property<Guid?>("GroupId")
.HasColumnType("TEXT");
b.Property<bool>("IsRead")
.HasColumnType("INTEGER");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int?>("PurchaseId")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("TEXT");
b.Property<int>("ToUserId")
.HasColumnType("INTEGER");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AccountNumber")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.Property<string>("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
}
}
}

View File

@ -0,0 +1,298 @@
using System;
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
#pragma warning disable CA1814 // Prefer jagged arrays over multidimensional
namespace backend.Migrations
{
/// <inheritdoc />
public partial class InitialCreate : Migration
{
/// <inheritdoc />
protected override void Up(MigrationBuilder migrationBuilder)
{
migrationBuilder.CreateTable(
name: "PendingUsers",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: false),
Surname = table.Column<string>(type: "TEXT", nullable: false),
AccountNumber = table.Column<string>(type: "TEXT", nullable: false),
Email = table.Column<string>(type: "TEXT", nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: false),
IsRead = table.Column<bool>(type: "INTEGER", nullable: false),
RequestedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_PendingUsers", x => x.Id);
});
migrationBuilder.CreateTable(
name: "Roles",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Code = table.Column<string>(type: "TEXT", nullable: false),
DisplayName = table.Column<string>(type: "TEXT", nullable: false)
},
constraints: table =>
{
table.PrimaryKey("PK_Roles", x => x.Id);
});
migrationBuilder.CreateTable(
name: "TransactionTypes",
columns: table => new
{
Id = table.Column<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Code = table.Column<string>(type: "TEXT", maxLength: 64, nullable: false),
DisplayName = table.Column<string>(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<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Email = table.Column<string>(type: "TEXT", maxLength: 200, nullable: false),
Name = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
Surname = table.Column<string>(type: "TEXT", maxLength: 100, nullable: false),
AccountNumber = table.Column<string>(type: "TEXT", maxLength: 50, nullable: false),
PasswordHash = table.Column<string>(type: "TEXT", nullable: false),
CreatedAt = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
RoleId = table.Column<int>(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<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: false),
PriceInCents = table.Column<int>(type: "INTEGER", nullable: false),
BuyerPays = table.Column<bool>(type: "INTEGER", nullable: false),
Timestamp = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
Shop = table.Column<string>(type: "TEXT", nullable: false),
BuyerId = table.Column<int>(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<int>(type: "INTEGER", nullable: false),
UserId = table.Column<int>(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<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
Name = table.Column<string>(type: "TEXT", nullable: false),
Quantity = table.Column<int>(type: "INTEGER", nullable: false),
PricePerPiece = table.Column<int>(type: "INTEGER", nullable: false),
PurchaseId = table.Column<int>(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<int>(type: "INTEGER", nullable: false)
.Annotation("Sqlite:Autoincrement", true),
TransactionTypeId = table.Column<int>(type: "INTEGER", nullable: false),
Amount = table.Column<int>(type: "INTEGER", nullable: false),
Timestamp = table.Column<DateTimeOffset>(type: "TEXT", nullable: false),
Note = table.Column<string>(type: "TEXT", nullable: true),
PurchaseId = table.Column<int>(type: "INTEGER", nullable: true),
FromUserId = table.Column<int>(type: "INTEGER", nullable: false),
ToUserId = table.Column<int>(type: "INTEGER", nullable: false),
GroupId = table.Column<Guid>(type: "TEXT", nullable: true),
IsRead = table.Column<bool>(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");
}
/// <inheritdoc />
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");
}
}
}

View File

@ -0,0 +1,421 @@
// <auto-generated />
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<int>("PurchaseId")
.HasColumnType("INTEGER")
.HasColumnOrder(0);
b.Property<int>("UserId")
.HasColumnType("INTEGER")
.HasColumnOrder(1);
b.HasKey("PurchaseId", "UserId");
b.HasIndex("UserId");
b.ToTable("CoPayers");
});
modelBuilder.Entity("backend.Models.Item", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("PricePerPiece")
.HasColumnType("INTEGER");
b.Property<int>("PurchaseId")
.HasColumnType("INTEGER");
b.Property<int>("Quantity")
.HasColumnType("INTEGER");
b.HasKey("Id");
b.HasIndex("PurchaseId");
b.ToTable("Items");
});
modelBuilder.Entity("backend.Models.PendingUser", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AccountNumber")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasColumnType("TEXT");
b.Property<bool>("IsRead")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("RequestedAt")
.HasColumnType("TEXT");
b.Property<string>("Surname")
.IsRequired()
.HasColumnType("TEXT");
b.HasKey("Id");
b.ToTable("PendingUsers");
});
modelBuilder.Entity("backend.Models.Purchase", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("BuyerId")
.HasColumnType("INTEGER");
b.Property<bool>("BuyerPays")
.HasColumnType("INTEGER");
b.Property<string>("Name")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("PriceInCents")
.HasColumnType("INTEGER");
b.Property<string>("Shop")
.IsRequired()
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("TEXT");
b.HasKey("Id");
b.HasIndex("BuyerId");
b.ToTable("Purchases");
});
modelBuilder.Entity("backend.Models.Role", b =>
{
b.Property<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Code")
.IsRequired()
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<int>("Amount")
.HasColumnType("INTEGER");
b.Property<int>("FromUserId")
.HasColumnType("INTEGER");
b.Property<Guid?>("GroupId")
.HasColumnType("TEXT");
b.Property<bool>("IsRead")
.HasColumnType("INTEGER");
b.Property<string>("Note")
.HasColumnType("TEXT");
b.Property<int?>("PurchaseId")
.HasColumnType("INTEGER");
b.Property<DateTimeOffset>("Timestamp")
.HasColumnType("TEXT");
b.Property<int>("ToUserId")
.HasColumnType("INTEGER");
b.Property<int>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("Code")
.IsRequired()
.HasMaxLength(64)
.HasColumnType("TEXT");
b.Property<string>("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<int>("Id")
.ValueGeneratedOnAdd()
.HasColumnType("INTEGER");
b.Property<string>("AccountNumber")
.IsRequired()
.HasMaxLength(50)
.HasColumnType("TEXT");
b.Property<DateTimeOffset>("CreatedAt")
.HasColumnType("TEXT");
b.Property<string>("Email")
.IsRequired()
.HasMaxLength(200)
.HasColumnType("TEXT");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
.HasColumnType("TEXT");
b.Property<string>("PasswordHash")
.IsRequired()
.HasColumnType("TEXT");
b.Property<int>("RoleId")
.HasColumnType("INTEGER");
b.Property<string>("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
}
}
}

View File

@ -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!;
}

View File

@ -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!;
}

View File

@ -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; }
}

View File

@ -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<Item> Items { get; set; } = new List<Item>();
// Many-to-many relationship with User through CoPayer
public virtual ICollection<CoPayer> CoPayers { get; set; } = new List<CoPayer>();
// Computed property to get list of co-payer users
[NotMapped]
public List<User> CoPayerUsers => CoPayers.Select(cp => cp.User).ToList();
}

View File

@ -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<User> Users { get; set; } = new List<User>();
}

View File

@ -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<><6D>e b<>t null <20> settlement bez konkr<6B>tn<74>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<72>
public bool IsRead { get; set; } = false; // notifikace (ne)p<>e<EFBFBD>tena
}

View File

@ -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 zpìt na logy (volitelné; pro dotazování se hodí)
public ICollection<TransactionLog> TransactionLogs { get; set; } = new List<TransactionLog>();
}

View File

@ -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<75>c<EFBFBD> (Buyer)
[InverseProperty(nameof(Purchase.Buyer))]
public ICollection<Purchase> PurchasesBought { get; set; } = new List<Purchase>();
// CoPayer join z<>znamy, kde je user spolup<75>isp<73>vatel
[InverseProperty(nameof(CoPayer.User))]
public ICollection<CoPayer> CoPayerIn { get; set; } = new List<CoPayer>();
// Transakce, kde je user odes<65>latel / p<><70>jemce
[InverseProperty(nameof(TransactionLog.FromUser))]
public ICollection<TransactionLog> TransactionsSent { get; set; } = new List<TransactionLog>();
[InverseProperty(nameof(TransactionLog.ToUser))]
public ICollection<TransactionLog> TransactionsReceived { get; set; } = new List<TransactionLog>();
}

165
src/backend/Program.cs Normal file
View File

@ -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<IJwtKeyProvider>(new JwtKeyProvider(jwtKey));
Console.WriteLine("Starting application...");
// Add services
builder.Services.AddControllers();
builder.Services.AddEndpointsApiExplorer();
builder.Services.AddHttpClient();
builder.Services.AddScoped<TransactionLogService>();
builder.Services.AddScoped<CommitmentService>();
// 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<AppDbContext>(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<ErrorHandlingMiddleware>();
// 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<AppDbContext>();
db.Database.Migrate(); // Creates DB or updates to latest migration
}
Console.WriteLine("Application configured. Starting...");
app.Run();
}
}

View File

@ -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"
}
}
}
}

View File

@ -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<List<CommitmentDto>> 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;
}
}

View File

@ -0,0 +1,4 @@
public interface IJwtKeyProvider
{
string Key { get; }
}

View File

@ -0,0 +1,9 @@
public class JwtKeyProvider : IJwtKeyProvider
{
public string Key { get; }
public JwtKeyProvider(string key)
{
Key = key;
}
}

View File

@ -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);
}
}

View File

@ -0,0 +1,120 @@
using backend.DTOs;
using System.Collections.Generic;
using System.Text.RegularExpressions;
public class UserValidator
{
public static Dictionary<string, string> ValidateRegisterForm(RegisterUserDto formData)
{
var errors = new Dictionary<string, string>();
// 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;
}
}

View File

@ -0,0 +1,8 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
}
}

View File

@ -0,0 +1,9 @@
{
"Logging": {
"LogLevel": {
"Default": "Information",
"Microsoft.AspNetCore": "Warning"
}
},
"AllowedHosts": "*"
}

View File

@ -0,0 +1,26 @@
<Project Sdk="Microsoft.NET.Sdk.Web">
<PropertyGroup>
<TargetFramework>net10.0</TargetFramework>
<Nullable>enable</Nullable>
<ImplicitUsings>enable</ImplicitUsings>
<UserSecretsId>a6991234-d357-4764-8b91-8415a3567a74</UserSecretsId>
</PropertyGroup>
<ItemGroup>
<PackageReference Include="BCrypt.Net-Next" Version="4.0.3" />
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="8.0.17" />
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Sqlite" Version="9.0.6" />
<PackageReference Include="Microsoft.EntityFrameworkCore.Tools" Version="9.0.6">
<PrivateAssets>all</PrivateAssets>
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
</PackageReference>
<PackageReference Include="Swashbuckle.AspNetCore" Version="6.6.2" />
</ItemGroup>
<ItemGroup>
<Folder Include="Migrations\" />
</ItemGroup>
</Project>

6
src/backend/backend.http Normal file
View File

@ -0,0 +1,6 @@
@backend_HostAddress = http://localhost:5119
GET {{backend_HostAddress}}/weatherforecast/
Accept: application/json
###

25
src/backend/backend.sln Normal file
View File

@ -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

67
src/backend/config.cs Normal file
View File

@ -0,0 +1,67 @@
public static class Config
{
public enum CurrencyCodes
{
CZK,
EUR
}
// Supported currency codes as strings (for validation)
private static readonly HashSet<string> SUPPORTED_CURRENCIES = new() { "CZK", "EUR" };
// Currency Code to Symbol Mapper
private static readonly Dictionary<CurrencyCodes, string> 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<CurrencyCodes>(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";
}
}

View File

@ -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 },
],
},
},
]

BIN
src/frontend/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 20 KiB

14
src/frontend/index.html Normal file
View File

@ -0,0 +1,14 @@
<!DOCTYPE html>
<html lang="cs">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<link rel="icon" href="icon.png" type="image/png" />
<title>Platbík</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/Main.jsx"></script>
</body>
</html>

View File

@ -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"]
}

42
src/frontend/package.json Normal file
View File

@ -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"
}
}

View File

29
src/frontend/src/App.jsx Normal file
View File

@ -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 (
<Routes>
<Route path="/" element={<Navigate to="/vsechny-nakupy" />} />
<Route path="/vsechny-nakupy" element={<Grid title="Seznam všech nákupů"><AllPurchases /></Grid>} />
<Route path="/moje-nakupy" element={<Grid title="Seznam nákupů uživatele"><MyPurchases /></Grid>} />
<Route path="/zavazkove-vztahy" element={<Grid title="Seznam závazkových vztahů uživatele"><MyCommitments /></Grid>} />
<Route path="/o-aplikaci" element={<About />} />
<Route path="/prihlaseni" element={<Auth />} />
<Route path="*" element={<Navigate to="/vsechny-nakupy" />} />
</Routes>
);
}
export default App;

262
src/frontend/src/Grid.jsx Normal file
View File

@ -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 && (
<NotificationsDialog
onClose={handleCloseNotifications}
notifications={myNotifications}
handleNavigateToCommitments={() => {
handleClick("zavazkove-vztahy");
navigate("/zavazkove-vztahy");
}}
/>
)}
{showAuth && !currentUser && (
<Auth
onFail={handleOnFailedRegistrationRequest}
onSuccess={handleOnSuccessRegistrationRequest}
onClose={() => setShowAuth(false)}
/>
)}
{showLogout && currentUser && <Auth onClose={() => setShowLogout(false)} />}
{showSuccessRegistrationRequest && (
<MessageDialog
title="Úspěch"
message="Požadavek na registraci byl odeslán. Nyní se čeká na schválení administrátorem."
onClose={() => setShowSuccessRegistrationRequest(false)}
/>
)}
{showFailedRegistrationRequest && (
<MessageDialog
title="Neočekávaná chyba"
message="Požadavek na registraci se nezdařil. Zkuste to prosím znovu."
onClose={() => setShowFailedRegistrationRequest(false)}
/>
)}
{showRegistrationRequests && currentUser && amIAdmin && (
<RegistrationRequestsDialog
onClose={handleCloseRegistrationRequests}
registrationRequests={allRegistrationRequests}
handleRegisterFromRequest={handleRegisterFromRequest}
/>
)}
{showConfirmRegister && (
<ConfirmationDialog
title="Potvrzení registrace"
content="Opravdu chcete zaregistrovat tohoto uživatele?"
onConfirm={() => {
registerFromRequest(clickedRegisterRequestId);
setShowConfirmRegister(false);
}}
onCancel={() => setShowConfirmRegister(false)}
/>
)}
{toastBarSettings && <ToastBar {...toastBarSettings} />}
</>
);
return (
<GenericGrid
logo="PLATBÍK"
title={title}
navLinks={navLinks}
userDisplayName={currentUser ? `${currentUser.name} ${currentUser.surname}` : null}
onLoginClick={() => 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={<FullPageSpinner variant="determinate" value={80} />}
dialogs={dialogs}
>
{children}
</GenericGrid>
);
}
export default Grid;

27
src/frontend/src/Main.jsx Normal file
View File

@ -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(
<StrictMode>
<ThemeProvider theme={theme}>
<CssBaseline />
<BrowserRouter>
<ConfigProvider>
<AuthProvider>
<App />
</AuthProvider>
</ConfigProvider>
</BrowserRouter>
</ThemeProvider>
</StrictMode>
);

View File

@ -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
)
);
}

View File

@ -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);
}
}

View File

@ -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);
}

View File

@ -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;
};

View File

@ -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
)
);
}

View File

@ -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
);
}

View File

@ -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.");
}
}

View File

@ -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

View File

@ -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;
}

View File

@ -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);
}
};

View File

@ -0,0 +1,3 @@
import { createContext } from "react";
export const AuthContext = createContext(null);

View File

@ -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 (
<AuthContext.Provider
value={{
currentUser,
loading,
login,
logout,
register,
}}
>
{children}
</AuthContext.Provider>
);
}
export default AuthProvider;

View File

@ -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 (
<Box
sx={{
height: "100vh",
width: "100vw",
display: "flex",
flexDirection: "column",
justifyContent: "center",
alignItems: "center",
backgroundColor: theme.palette.background.default,
color: theme.palette.text.primary,
gap: 2,
}}
>
<CircularProgress
size={150}
thickness={6}
style={{ color: theme.palette.primary.main }}
variant={variant}
value={value ? value : null}
sx={{
"& .MuiCircularProgress-circle": {
animationDuration: "2.8s",
},
}}
/>
</Box>
);
}
export default FullPageSpinner;

View File

@ -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 };
}

View File

@ -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 (
<GenericDialog
sxSize="small"
onClose={onClose}
dialogTitle="O aplikaci"
dialogContent={<></>}
dialogActions={<GenericButton triggerOnEnter={true} onClick={onClose} name="OK" />}
/>
);
}
export default About;

View File

@ -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 <NotLogged />;
}
const handleActionButtonClick = async (row) => {
showPurchaseDetails(row, localStorage.getItem("jwtToken"), setAllUsers, setClickedPurchase, setShowDetail);
};
const columns = getPurchaseColumns(handleActionButtonClick, null, currencyFormat);
return (
<>
<GenericTable rows={allPurchases} columns={columns} />
{showDetail && (
<EditPurchaseDialog
editMode="readOnly"
title="Detail nákupu"
allUsers={allUsers}
currentUserId={currentUser.id}
thisPurchase={clickedPurchase}
onClose={() => setShowDetail(false)}
/>
)}
</>
);
}
export default AllPurchases;

View File

@ -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 <RegisterForm onFail={onFail} onSuccess={onSuccess} onClose={onClose} handleRegister={register} />;
}
if (currentUser) {
return <LogoutForm onClose={onClose} handleLogout={handleLogout} />;
}
return <LoginForm onClose={onClose} handleLogin={handleLogin} setShowRegister={setShowRegister} />;
}
export default Auth;

View File

@ -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 <NotLogged />;
}
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 (
<>
<GenericTable rows={commitments} columns={columns} />
{showDetail && (
<DebtorDetailDialog
fromUser={fromUser}
toUser={toUser}
clickedCommitment={clickedCommitment}
handleSettle={handleSettle}
handleCreatePendingSettlement={handleCreatePendingSettlement}
transactionsBetweenUsers={transactionsBetweenUsers}
onClose={() => setShowDetail(false)}
/>
)}
{toastBarSettings && <ToastBar {...toastBarSettings} />}
</>
);
}
export default MyCommitments;

View File

@ -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 <NotLogged />;
}
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 (
<>
<GenericTable rows={myPurchases} columns={columns} toolbarActions={{ handleAddItem: handleClickAdd() }} />
{showAdd && (
<EditPurchaseDialog
title="Přidat nákup"
editMode="add"
onClose={() => setShowAdd(false)}
allUsers={allUsers}
currentUserId={currentUser.id}
thisPurchase={null}
addPurchase={handleAddPurchase}
/>
)}
{showDetail && (
<EditPurchaseDialog
editMode="edit"
title="Upravit nákup"
onClose={() => setShowDetail(false)}
allUsers={allUsers}
currentUserId={currentUser.id}
thisPurchase={clickedPurchase}
addPurchase={handleAddPurchase}
updatePurchase={handleUpdatePurchase}
deletePurchase={handleDeletePurchase}
/>
)}
{toastBarSettings && <ToastBar {...toastBarSettings} />}
</>
);
}
export default MyPurchases;

View File

@ -0,0 +1,9 @@
/**
* Component to display message in body of the grid, when user is not logged in
* @returns
*/
function NotLogged() {
return <p style={{ fontSize: "20px" }}>nelze zobrazit, protože nejste přihlášen.</p>;
}
export default NotLogged;

View File

@ -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 (
<Tooltip title={error ? helperText : ""} arrow>
{value}
</Tooltip>
);
};
export default EditCellWithTooltip;

View File

@ -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. , )
* @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 (
<GenericColumnButton
onClick={() => handleShowDetail(row)}
buttonContent={<PaymentIcon color="action" />}
/>
);
},
},
];
}
export default getCommitmentColumns;

View File

@ -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. , )
* @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 (
<input
type="number"
value={params.value ?? ""}
min={minValue}
step={step}
onChange={(e) => {
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 [
<GridActionsCellItem
icon={<DeleteIcon />}
label="Delete"
onClick={() => {
handleDelete(id);
}}
color="inherit"
/>,
];
},
editable: false,
},
];
return isEditable ? columns : columns.filter((col) => col.field !== "delete");
}
export default getItemColumns;

View File

@ -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. , )
* @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 (
<GenericColumnButton
onClick={() => handleActionButtonClick(param.row)}
buttonContent={<ModeEditIcon color="action" />}
/>
);
} else {
return (
<GenericColumnButton
onClick={() => handleActionButtonClick(param.row)}
buttonContent={<MoreHorizIcon color="action" />}
/>
);
}
},
},
];
}
export default getPurchaseColumns;

View File

@ -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. , )
* @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;

View File

@ -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;
}

View File

@ -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 (
<GenericDialog
onClose={onClose}
dialogTitle={dialogTitle}
dialogContent={
<>
<div style={contentSx}>
<GenericTable rows={transactionsBetweenUsers} columns={getTransactionColumns(currencyFormat)} />
<div style={totalToPaySx}>
<div>
{isCurrentUserCreditor ? "Celkem pohledáváte: " : "Celkem dlužíte: "}{" "}
{absoluteTotalAmountToShow.toLocaleString("cs-CZ", {
minimumFractionDigits: 2,
maximumFractionDigits: 2,
})}{" "}
{currencyFormat}
</div>
</div>
{/* If I am a debtor, show PayDialog */}
{showQr && !isCurrentUserCreditor && (
<PayDialog
handleSetAsPaid={handlePay}
amountInCents={absoluteTotalAmount}
creditor={toUser}
currencyFormat={currencyFormat}
isDefaultCurrency={isDefaultCurrency}
onClose={() => setShowQr(false)}
/>
)}
{showSetAsPaidDialogForCreditor && (
<ConfirmationDialog
onCancel={() => setshowSetAsPaidDialogForCreditor(false)}
onConfirm={handlePay}
title="Zaplaceno"
content="Opravdu Vám dlužník zaplatil tento dluh?"
/>
)}
</div>
</>
}
dialogActions={
<>
<GenericButton triggerOnEnter={absoluteTotalAmount === 0} onClick={onClose} name="Zavřít" />
{
//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 && (
<GenericButton
triggerOnEnter={true}
onClick={() =>
isCurrentUserCreditor ? setshowSetAsPaidDialogForCreditor(true) : setShowQr(true)
}
name={isCurrentUserCreditor ? "Označit jako zaplacené" : "Zaplatit"}
/>
)}
</>
}
/>
);
}
export default DebtorDetailDialog;

View File

@ -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 (
<GenericDialog
onClose={onClose}
dialogTitle={title}
dialogContent={
<>
{/* For styling the grid */}
<Box
sx={{
display: "flex",
flexDirection: { xs: "column", md: "row" },
gap: 2,
width: "100%",
mb: 2,
marginTop: "10px",
}}
>
<TextField
slotProps={{
htmlInput: {
readOnly: editMode === "readOnly",
},
}}
error={Boolean(errors.name)}
helperText={errors.name}
sx={{
...textFieldSx,
flex: { md: 1 },
}}
label="Název nákupu"
value={data.name}
onChange={(e) => updateData("name", e.target.value)}
fullWidth
/>
{/* For styling the grid */}
<Box
sx={{
flex: { md: 1 },
minWidth: { md: "200px" },
}}
>
<GenericSelect
readOnly={editMode === "readOnly"}
error={Boolean(errors.coPayers)}
helperText={errors.coPayers}
name="Spoluplatitelé"
selectOptions={selectionList}
defaultSelection={defaultSelection.map((u) => u.key)}
onChange={handleChooseCoPayer}
/>
</Box>
<TextField
slotProps={{
htmlInput: {
readOnly: editMode === "readOnly",
},
}}
error={Boolean(errors.shop)}
helperText={errors.shop}
sx={{
...textFieldSx,
flex: { md: 1 },
}}
label="Název obchodu"
value={data.shopName}
onChange={(e) => updateData("shopName", e.target.value)}
fullWidth
/>
</Box>
<GenericTable
small={true}
helperText={errors.items}
rows={data.items}
checkForErrors={true}
columns={getItemColumns(
handleDeleteItem,
editMode === "readOnly" ? false : true,
currencyFormat,
theme.palette.error.main
)}
updateActions={editMode === "readOnly" ? null : { handleRowUpdate }}
toolbarActions={editMode === "readOnly" ? null : { handleAddItem }}
/>
</>
}
dialogActions={
<Box
sx={{
display: "flex",
flexDirection: { xs: "column", sm: "row" },
gap: 1,
width: "100%",
justifyContent: "flex-end",
}}
>
<FormControlLabel
control={
<CustomSwitch
checked={data.buyerPays}
onChange={editMode !== "readOnly" ? handleChangeBuyerPays : () => {}}
/>
}
labelPlacement="start"
label={data.buyerPays ? "Platí kupující" : "Neplatí kupující"}
/>
<GenericButton
triggerOnEnter={editMode === "readOnly" ? true : false}
onClick={onClose}
name="Zavřít"
/>
{editMode === "edit" && (
<GenericButton
onClick={handleConfirmDeletePurchase}
name="Smazat nákup"
color={theme.palette.error.main}
/>
)}
{editMode !== "readOnly" && (
<GenericButton
triggerOnEnter={true}
onClick={handleConfrirmSubmitPurchase}
name={editMode === "edit" ? "Uložit nákup" : "Přidat nákup"}
/>
)}
{confirmDialog && (
<ConfirmationDialog
title={confirmDialog.title}
content={confirmDialog.content}
onCancel={handleConfirmDialogClose}
onConfirm={confirmDialog.handler}
/>
)}
</Box>
}
/>
);
}
export default EditPurchaseDialog;

View File

@ -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 (
<GenericDialog
sxSize="medium"
onClose={onClose}
dialogTitle="Notifikace"
dialogContent={
areThereNotifications ? (
<Stack spacing={1}>
{notifications.map((notification) => (
<Alert
icon={
notification.type === "add_purchase" ? (
<AddShoppingCartIcon />
) : notification.type === "remove_purchase" ? (
<RemoveShoppingCartIcon />
) : notification.type === "pending_settlement" ? (
<PaidOutlinedIcon />
) : (
<PaidIcon />
)
}
key={notification.id}
severity="info"
sx={alertSx}
>
<AlertTitle>
{notification.timestamp} | {notification.header}
</AlertTitle>
{notification.text}
</Alert>
))}
</Stack>
) : (
"Žádné notifikace"
)
}
dialogActions={
<>
<GenericButton
onClick={handleNavigateToCommitments}
name="Zobrazit závazkové vztahy"
primary={true}
/>
<GenericButton onClick={onClose} name="Zavřít" />
</>
}
customContentSx={!areThereNotifications ? contentTextSx : null}
/>
);
}
export default NotificationsDialog;

View File

@ -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. , )
* @param {*} isDefaultCurrency -- whether the currency is the default ()
* @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
} .`,
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 (
<GenericDialog
sxSize="medium"
onClose={onClose}
dialogTitle={"Zaplatit"}
customContentSx={customDialogContentSx}
dialogContent={
<div css={contentSx}>
<div css={qrCodeSx}>
<QRCodeSVG value={textInQr} width="100%" height="100%" />
</div>
<div css={textBlocksSx}>
<div css={textBlockSx}>
<h3>Komu:</h3>
{creditor.name} {creditor.surname}
</div>
<div css={textBlockSx}>
<h3>Částka:</h3>
{formattedAmount}
</div>
<div css={textBlockSx}>
<h3>Číslo účtu:</h3>
{creditor.accountNumber}
</div>
</div>
{confirmDialog && (
<ConfirmationDialog
title={confirmDialog.title}
content={confirmDialog.content}
onCancel={() => setConfirmDialog(null)}
onConfirm={confirmDialog.handler}
/>
)}
{toastBarSettings && <ToastBar {...toastBarSettings} duration={10000} />}
</div>
}
dialogActions={
<>
<GenericButton onClick={onClose} name="Zavřít" />
<GenericButton onClick={handleCopyInfoToClipboard} name={copyButtonName} />
<GenericButton triggerOnEnter={true} onClick={handleConfirmPay} name="Zaplaceno" />
</>
}
/>
);
}
export default PayDialog;

View File

@ -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 (
<GenericDialog
sxSize="medium"
onClose={onClose}
dialogTitle="Žádosti o registraci"
dialogContent={
areThereRequests ? (
<Stack spacing={1}>
{registrationRequests.map((notification) => (
<Alert
icon={
<Link
onClick={() => handleRegisterFromRequest(notification.id)}
sx={{ cursor: "pointer" }}
>
<AddIcon sx={{ color: theme.palette.primary.main }} />
</Link>
}
key={notification.id}
severity="info"
sx={alertSx}
>
<AlertTitle>
{notification.requestedAt} | {notification.name} {notification.surname}
</AlertTitle>
{notification.email} | {notification.accountNumber}
</Alert>
))}
</Stack>
) : (
"Žádné žádosti o registraci"
)
}
customContentSx={!areThereRequests ? contentTextSx : null}
dialogActions={
<>
<GenericButton onClick={onClose} name="Zavřít" />
</>
}
/>
);
}
export default RegistrationRequestsDialog;

View File

@ -0,0 +1,3 @@
import { createContext } from "react";
export const ConfigContext = createContext(null);

View File

@ -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 (
<ConfigContext.Provider
value={{
loading,
currencyFormat,
isDefaultCurrency: currencyFormat === "Kč",
}}
>
{children}
</ConfigContext.Provider>
);
}
export default ConfigProvider;

View File

@ -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
);
}
}

Some files were not shown because too many files have changed in this diff Show More