mirror of
https://github.com/Danneschs/platbik.git
synced 2026-05-06 09:08:57 +02:00
repo migration + frontend major update: useTheme replaced CSS; now using ui lib
This commit is contained in:
commit
10555b944c
19
.gitignore
vendored
Normal file
19
.gitignore
vendored
Normal 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
199
README.md
Normal 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
|
||||

|
||||
712
model/era-platbik.drawio
Normal file
712
model/era-platbik.drawio
Normal 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
4
model/era-platbik.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 454 KiB |
106
src/backend/Controllers/AuthController.cs
Normal file
106
src/backend/Controllers/AuthController.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
36
src/backend/Controllers/CommitmentController.cs
Normal file
36
src/backend/Controllers/CommitmentController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
19
src/backend/Controllers/ConfigController.cs
Normal file
19
src/backend/Controllers/ConfigController.cs
Normal 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 });
|
||||
}
|
||||
|
||||
}
|
||||
108
src/backend/Controllers/ExchangeRateController.cs
Normal file
108
src/backend/Controllers/ExchangeRateController.cs
Normal 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}");
|
||||
}
|
||||
}
|
||||
}
|
||||
45
src/backend/Controllers/ItemController.cs
Normal file
45
src/backend/Controllers/ItemController.cs
Normal 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);
|
||||
}
|
||||
}
|
||||
76
src/backend/Controllers/PendingUserController.cs
Normal file
76
src/backend/Controllers/PendingUserController.cs
Normal 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();
|
||||
}
|
||||
}
|
||||
355
src/backend/Controllers/PurchaseController.cs
Normal file
355
src/backend/Controllers/PurchaseController.cs
Normal 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;
|
||||
}
|
||||
|
||||
|
||||
}
|
||||
206
src/backend/Controllers/TransactionLogController.cs
Normal file
206
src/backend/Controllers/TransactionLogController.cs
Normal 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();
|
||||
}
|
||||
|
||||
}
|
||||
179
src/backend/Controllers/UserController.cs
Normal file
179
src/backend/Controllers/UserController.cs
Normal 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));
|
||||
}
|
||||
|
||||
}
|
||||
10
src/backend/DTOs/CommitmentDto.cs
Normal file
10
src/backend/DTOs/CommitmentDto.cs
Normal 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; }
|
||||
}
|
||||
4
src/backend/DTOs/ConfigDto.cs
Normal file
4
src/backend/DTOs/ConfigDto.cs
Normal file
@ -0,0 +1,4 @@
|
||||
public class ConfigDto
|
||||
{
|
||||
public string CurrencyFormat { get; set; } = string.Empty;
|
||||
}
|
||||
7
src/backend/DTOs/ExchangeRate.cs
Normal file
7
src/backend/DTOs/ExchangeRate.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace backend.DTOs;
|
||||
|
||||
public class ExchangeRateDto
|
||||
{
|
||||
public required string Date { get; set; }
|
||||
public required decimal Rate { get; set; }
|
||||
}
|
||||
14
src/backend/DTOs/GetPurchaseDto.cs
Normal file
14
src/backend/DTOs/GetPurchaseDto.cs
Normal 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
|
||||
}
|
||||
14
src/backend/DTOs/GetTransactionLogDto.cs
Normal file
14
src/backend/DTOs/GetTransactionLogDto.cs
Normal 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; }
|
||||
}
|
||||
9
src/backend/DTOs/ItemDto.cs
Normal file
9
src/backend/DTOs/ItemDto.cs
Normal 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; }
|
||||
}
|
||||
7
src/backend/DTOs/LoginDto.cs
Normal file
7
src/backend/DTOs/LoginDto.cs
Normal file
@ -0,0 +1,7 @@
|
||||
namespace backend.DTOs;
|
||||
|
||||
public class LoginDto
|
||||
{
|
||||
public string Email { get; set; } = default!;
|
||||
public string Password { get; set; } = default!;
|
||||
}
|
||||
11
src/backend/DTOs/NotificationDto.cs
Normal file
11
src/backend/DTOs/NotificationDto.cs
Normal 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;
|
||||
}
|
||||
12
src/backend/DTOs/PendingUserRequestDto.cs
Normal file
12
src/backend/DTOs/PendingUserRequestDto.cs
Normal 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; }
|
||||
}
|
||||
13
src/backend/DTOs/PostPurchaseDto.cs
Normal file
13
src/backend/DTOs/PostPurchaseDto.cs
Normal 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
|
||||
}
|
||||
8
src/backend/DTOs/PostTransactionLogDto.cs
Normal file
8
src/backend/DTOs/PostTransactionLogDto.cs
Normal 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
|
||||
}
|
||||
31
src/backend/DTOs/RawPurchaseDto.cs
Normal file
31
src/backend/DTOs/RawPurchaseDto.cs
Normal 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();
|
||||
}
|
||||
10
src/backend/DTOs/RegisterUserDto.cs
Normal file
10
src/backend/DTOs/RegisterUserDto.cs
Normal 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; }
|
||||
}
|
||||
12
src/backend/DTOs/UserDto.cs
Normal file
12
src/backend/DTOs/UserDto.cs
Normal 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}";
|
||||
}
|
||||
121
src/backend/Data/AppDbContext.cs
Normal file
121
src/backend/Data/AppDbContext.cs
Normal 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" }
|
||||
);
|
||||
}
|
||||
}
|
||||
15
src/backend/Extensions/ClaimsPrincipalExtention.cs
Normal file
15
src/backend/Extensions/ClaimsPrincipalExtention.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
138
src/backend/Mappers/PurchaseMapper.cs
Normal file
138
src/backend/Mappers/PurchaseMapper.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
22
src/backend/Mappers/TransactionLogMapper.cs
Normal file
22
src/backend/Mappers/TransactionLogMapper.cs
Normal 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
|
||||
};
|
||||
}
|
||||
|
||||
}
|
||||
20
src/backend/Mappers/UserMapper.cs
Normal file
20
src/backend/Mappers/UserMapper.cs
Normal 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
|
||||
};
|
||||
}
|
||||
}
|
||||
43
src/backend/Middleware/ErrorHandlingMiddleware.cs
Normal file
43
src/backend/Middleware/ErrorHandlingMiddleware.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
424
src/backend/Migrations/20260327223748_InitialCreate.Designer.cs
generated
Normal file
424
src/backend/Migrations/20260327223748_InitialCreate.Designer.cs
generated
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
298
src/backend/Migrations/20260327223748_InitialCreate.cs
Normal file
298
src/backend/Migrations/20260327223748_InitialCreate.cs
Normal 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");
|
||||
}
|
||||
}
|
||||
}
|
||||
421
src/backend/Migrations/AppDbContextModelSnapshot.cs
Normal file
421
src/backend/Migrations/AppDbContextModelSnapshot.cs
Normal 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
|
||||
}
|
||||
}
|
||||
}
|
||||
17
src/backend/Models/CoPayer.cs
Normal file
17
src/backend/Models/CoPayer.cs
Normal 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!;
|
||||
}
|
||||
|
||||
|
||||
15
src/backend/Models/Item.cs
Normal file
15
src/backend/Models/Item.cs
Normal 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!;
|
||||
}
|
||||
|
||||
13
src/backend/Models/PendingUser.cs
Normal file
13
src/backend/Models/PendingUser.cs
Normal 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; }
|
||||
}
|
||||
33
src/backend/Models/Purchase.cs
Normal file
33
src/backend/Models/Purchase.cs
Normal 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();
|
||||
}
|
||||
11
src/backend/Models/Role.cs
Normal file
11
src/backend/Models/Role.cs
Normal 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>();
|
||||
}
|
||||
31
src/backend/Models/TransactionLog.cs
Normal file
31
src/backend/Models/TransactionLog.cs
Normal 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
|
||||
}
|
||||
|
||||
17
src/backend/Models/TransactionType.cs
Normal file
17
src/backend/Models/TransactionType.cs
Normal 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>();
|
||||
}
|
||||
48
src/backend/Models/User.cs
Normal file
48
src/backend/Models/User.cs
Normal 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
165
src/backend/Program.cs
Normal 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();
|
||||
|
||||
}
|
||||
}
|
||||
43
src/backend/Properties/launchSettings.json
Normal file
43
src/backend/Properties/launchSettings.json
Normal 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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
69
src/backend/Services/CommitmentService.cs
Normal file
69
src/backend/Services/CommitmentService.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
4
src/backend/Services/IJwtKeyProvider.cs
Normal file
4
src/backend/Services/IJwtKeyProvider.cs
Normal file
@ -0,0 +1,4 @@
|
||||
public interface IJwtKeyProvider
|
||||
{
|
||||
string Key { get; }
|
||||
}
|
||||
9
src/backend/Services/JwtKeyProvider.cs
Normal file
9
src/backend/Services/JwtKeyProvider.cs
Normal file
@ -0,0 +1,9 @@
|
||||
public class JwtKeyProvider : IJwtKeyProvider
|
||||
{
|
||||
public string Key { get; }
|
||||
|
||||
public JwtKeyProvider(string key)
|
||||
{
|
||||
Key = key;
|
||||
}
|
||||
}
|
||||
162
src/backend/Services/TransactionLogService.cs
Normal file
162
src/backend/Services/TransactionLogService.cs
Normal 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);
|
||||
}
|
||||
|
||||
}
|
||||
120
src/backend/Validators/UserValidator.cs
Normal file
120
src/backend/Validators/UserValidator.cs
Normal 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;
|
||||
}
|
||||
}
|
||||
8
src/backend/appsettings.Development.json
Normal file
8
src/backend/appsettings.Development.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
}
|
||||
}
|
||||
9
src/backend/appsettings.json
Normal file
9
src/backend/appsettings.json
Normal file
@ -0,0 +1,9 @@
|
||||
{
|
||||
"Logging": {
|
||||
"LogLevel": {
|
||||
"Default": "Information",
|
||||
"Microsoft.AspNetCore": "Warning"
|
||||
}
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
26
src/backend/backend.csproj
Normal file
26
src/backend/backend.csproj
Normal 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
6
src/backend/backend.http
Normal file
@ -0,0 +1,6 @@
|
||||
@backend_HostAddress = http://localhost:5119
|
||||
|
||||
GET {{backend_HostAddress}}/weatherforecast/
|
||||
Accept: application/json
|
||||
|
||||
###
|
||||
25
src/backend/backend.sln
Normal file
25
src/backend/backend.sln
Normal 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
67
src/backend/config.cs
Normal 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";
|
||||
}
|
||||
}
|
||||
33
src/frontend/eslint.config.js
Normal file
33
src/frontend/eslint.config.js
Normal 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
BIN
src/frontend/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 20 KiB |
14
src/frontend/index.html
Normal file
14
src/frontend/index.html
Normal 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>
|
||||
27
src/frontend/jsconfig.json
Normal file
27
src/frontend/jsconfig.json
Normal 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
42
src/frontend/package.json
Normal 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"
|
||||
}
|
||||
}
|
||||
0
src/frontend/public/favicon.ico
Normal file
0
src/frontend/public/favicon.ico
Normal file
29
src/frontend/src/App.jsx
Normal file
29
src/frontend/src/App.jsx
Normal 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
262
src/frontend/src/Grid.jsx
Normal 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
27
src/frontend/src/Main.jsx
Normal 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>
|
||||
);
|
||||
29
src/frontend/src/api/commitments.js
Normal file
29
src/frontend/src/api/commitments.js
Normal 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
|
||||
)
|
||||
);
|
||||
}
|
||||
21
src/frontend/src/api/configs.js
Normal file
21
src/frontend/src/api/configs.js
Normal 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);
|
||||
}
|
||||
}
|
||||
22
src/frontend/src/api/exchangeRates.js
Normal file
22
src/frontend/src/api/exchangeRates.js
Normal 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);
|
||||
}
|
||||
18
src/frontend/src/api/items.js
Normal file
18
src/frontend/src/api/items.js
Normal 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;
|
||||
};
|
||||
44
src/frontend/src/api/pendingUsers.js
Normal file
44
src/frontend/src/api/pendingUsers.js
Normal 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
|
||||
)
|
||||
);
|
||||
}
|
||||
109
src/frontend/src/api/purchases.js
Normal file
109
src/frontend/src/api/purchases.js
Normal 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
|
||||
);
|
||||
}
|
||||
119
src/frontend/src/api/transactionLogs.js
Normal file
119
src/frontend/src/api/transactionLogs.js
Normal 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.");
|
||||
}
|
||||
}
|
||||
4
src/frontend/src/api/url.js
Normal file
4
src/frontend/src/api/url.js
Normal 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
|
||||
100
src/frontend/src/api/users.js
Normal file
100
src/frontend/src/api/users.js
Normal 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;
|
||||
}
|
||||
33
src/frontend/src/api/wrappers/showPurchaseDetails.js
Normal file
33
src/frontend/src/api/wrappers/showPurchaseDetails.js
Normal 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);
|
||||
}
|
||||
};
|
||||
3
src/frontend/src/auth/AuthContext.js
Normal file
3
src/frontend/src/auth/AuthContext.js
Normal file
@ -0,0 +1,3 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export const AuthContext = createContext(null);
|
||||
111
src/frontend/src/auth/AuthProvider.jsx
Normal file
111
src/frontend/src/auth/AuthProvider.jsx
Normal 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;
|
||||
41
src/frontend/src/auth/FullPageSpinner.jsx
Normal file
41
src/frontend/src/auth/FullPageSpinner.jsx
Normal 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;
|
||||
39
src/frontend/src/auth/useCurrentUser.jsx
Normal file
39
src/frontend/src/auth/useCurrentUser.jsx
Normal 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 };
|
||||
}
|
||||
22
src/frontend/src/bookmarks/About.jsx
Normal file
22
src/frontend/src/bookmarks/About.jsx
Normal 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;
|
||||
77
src/frontend/src/bookmarks/AllPurchases.jsx
Normal file
77
src/frontend/src/bookmarks/AllPurchases.jsx
Normal 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;
|
||||
42
src/frontend/src/bookmarks/Auth.jsx
Normal file
42
src/frontend/src/bookmarks/Auth.jsx
Normal 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;
|
||||
133
src/frontend/src/bookmarks/MyCommitments.jsx
Normal file
133
src/frontend/src/bookmarks/MyCommitments.jsx
Normal 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;
|
||||
171
src/frontend/src/bookmarks/MyPurchases.jsx
Normal file
171
src/frontend/src/bookmarks/MyPurchases.jsx
Normal 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;
|
||||
9
src/frontend/src/bookmarks/NotLogged.jsx
Normal file
9
src/frontend/src/bookmarks/NotLogged.jsx
Normal 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;
|
||||
20
src/frontend/src/components/columns/EditCellWithTooltip.jsx
Normal file
20
src/frontend/src/components/columns/EditCellWithTooltip.jsx
Normal 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;
|
||||
83
src/frontend/src/components/columns/getCommitmentColumns.jsx
Normal file
83
src/frontend/src/components/columns/getCommitmentColumns.jsx
Normal 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. Kč, €)
|
||||
* @returns
|
||||
*/
|
||||
function getCommitmentColumns(handleShowDetail, currencyFormat) {
|
||||
const widths = getDebtorColumnWidths();
|
||||
const flexes = {};
|
||||
|
||||
return [
|
||||
{
|
||||
field: "state",
|
||||
headerName: "Typ vztahu",
|
||||
headerClassName: "column-header",
|
||||
...(widths.state ? { width: widths.state } : {}),
|
||||
...(flexes.state ? { flex: flexes.state } : {}),
|
||||
valueFormatter: (param) => {
|
||||
const value = param;
|
||||
switch (value) {
|
||||
case "debt":
|
||||
return "Dluh";
|
||||
case "claim":
|
||||
return "Pohledávka";
|
||||
case "even":
|
||||
return "Vyrovnáno";
|
||||
default:
|
||||
return "Neznámý stav";
|
||||
}
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "toName",
|
||||
headerName: "Vůči komu",
|
||||
headerClassName: "column-header",
|
||||
...(widths.name ? { width: widths.name } : {}),
|
||||
...(flexes.name ? { flex: flexes.name } : {}),
|
||||
},
|
||||
{
|
||||
field: "amount",
|
||||
headerName: "Částka",
|
||||
align: "right",
|
||||
headerClassName: "column-header",
|
||||
...(widths.total ? { width: widths.total } : {}),
|
||||
...(flexes.total ? { flex: flexes.total } : {}),
|
||||
valueFormatter: (param) => {
|
||||
const value = param < 0 ? -param : param; // Ensure positive value for formatting
|
||||
|
||||
if (value === 0) {
|
||||
return "Vyrovnáno";
|
||||
}
|
||||
|
||||
return `${(value / 100).toLocaleString("cs-CZ", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})} ${currencyFormat}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "pay",
|
||||
headerName: "Detail",
|
||||
headerClassName: "column-header",
|
||||
...(widths.pay ? { width: widths.pay } : {}),
|
||||
...(flexes.pay ? { flex: flexes.pay } : {}),
|
||||
renderCell: (params) => {
|
||||
const row = params.row;
|
||||
return (
|
||||
<GenericColumnButton
|
||||
onClick={() => handleShowDetail(row)}
|
||||
buttonContent={<PaymentIcon color="action" />}
|
||||
/>
|
||||
);
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default getCommitmentColumns;
|
||||
138
src/frontend/src/components/columns/getItemColumns.jsx
Normal file
138
src/frontend/src/components/columns/getItemColumns.jsx
Normal 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. Kč, €)
|
||||
* @returns columns for item table
|
||||
*/
|
||||
function getItemColumns(handleDelete, isEditable, currencyFormat, errorColor) {
|
||||
let widths = getItemColumnWidths(); // Flexes are used instead of widths
|
||||
const flexes = {};
|
||||
if (widths) {
|
||||
widths.delete;
|
||||
|
||||
widths = Object.fromEntries(Object.entries(widths).map(([key, value]) => [key, value * 0.75]));
|
||||
}
|
||||
|
||||
/**
|
||||
* Renders a number input cell for editing
|
||||
* @param {Object} params - The parameters for the cell
|
||||
* @returns {JSX.Element} - The rendered number input cell
|
||||
*/
|
||||
const numberEditCell = (params) => {
|
||||
const minValue = 1;
|
||||
const step = 1;
|
||||
return (
|
||||
<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;
|
||||
133
src/frontend/src/components/columns/getPurchaseColumns.jsx
Normal file
133
src/frontend/src/components/columns/getPurchaseColumns.jsx
Normal 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. Kč, €)
|
||||
* @returns {Array} columns
|
||||
*/
|
||||
function getPurchaseColumns(handleActionButtonClick, actionColName, currencyFormat) {
|
||||
const widths = getPurchaseColumnWidths(); // Flexes are used instead of widths
|
||||
const flexes = {};
|
||||
|
||||
return [
|
||||
{
|
||||
field: "name",
|
||||
headerName: "Název",
|
||||
...(flexes.name ? { flex: flexes.name } : {}),
|
||||
...(widths.name ? { width: widths.name } : {}),
|
||||
headerClassName: "column-header",
|
||||
valueFormatter: (param) => {
|
||||
return param ? param : "Není uveden";
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "buyerName",
|
||||
headerName: "Kupující",
|
||||
headerClassName: "column-header",
|
||||
...(flexes.buyer ? { flex: flexes.buyer } : {}),
|
||||
...(widths.buyer ? { width: widths.buyer } : {}),
|
||||
valueFormatter: (param) => {
|
||||
const buyer = param;
|
||||
if (!buyer) return "Není uveden";
|
||||
return buyer;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "coPayers",
|
||||
headerName: "Spoluplatitelé",
|
||||
headerClassName: "column-header",
|
||||
...(flexes.buyer ? { flex: flexes.buyer } : {}),
|
||||
...(widths.buyer ? { width: widths.buyer } : {}),
|
||||
valueFormatter: (param) => {
|
||||
const coPayers = param;
|
||||
if (!coPayers || coPayers.length === 0) return "Žádní spoluplatitelé";
|
||||
return coPayers
|
||||
.map((coPayer) => {
|
||||
const user = coPayer;
|
||||
if (!user || !user.name || !user.surname) return "Není uveden";
|
||||
return `${user.name} ${user.surname}`;
|
||||
})
|
||||
.join(", ");
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "timestamp",
|
||||
headerName: "Datum",
|
||||
headerClassName: "column-header",
|
||||
...(flexes.date ? { flex: flexes.date } : {}),
|
||||
...(widths.date ? { width: widths.date } : {}),
|
||||
valueFormatter: (param) => {
|
||||
const timestamp = param;
|
||||
if (!timestamp) return "";
|
||||
const date = new Date(timestamp);
|
||||
if (isNaN(date.getTime())) return "Neplatné datum";
|
||||
return date.toLocaleDateString("cs-CZ", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "shopName",
|
||||
headerName: "Obchod",
|
||||
...(flexes.shop ? { flex: flexes.shop } : {}),
|
||||
...(widths.shop ? { width: widths.shop } : {}),
|
||||
headerClassName: "column-header",
|
||||
valueFormatter: (param) => {
|
||||
return param ? param : "---";
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "priceInCents",
|
||||
headerName: "Cena nákupu",
|
||||
align: "right",
|
||||
headerClassName: "column-header",
|
||||
...(flexes.price ? { flex: flexes.price } : {}),
|
||||
...(widths.price ? { width: widths.price } : {}),
|
||||
valueFormatter: (param) => {
|
||||
if (!param) return "---";
|
||||
const priceInCents = Number(param);
|
||||
const price = priceInCents / 100; // Convert cents to currency
|
||||
return price
|
||||
? `${price.toLocaleString("cs-CZ", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
})} ${currencyFormat}`
|
||||
: "---";
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "action",
|
||||
headerName: actionColName ? actionColName : "Detail",
|
||||
headerClassName: "column-header",
|
||||
...(flexes.isPaid ? { flex: flexes.isPaid } : {}),
|
||||
...(widths.isPaid ? { width: widths.isPaid } : {}),
|
||||
renderCell: (param) => {
|
||||
if (actionColName === "Upravit") {
|
||||
return (
|
||||
<GenericColumnButton
|
||||
onClick={() => handleActionButtonClick(param.row)}
|
||||
buttonContent={<ModeEditIcon color="action" />}
|
||||
/>
|
||||
);
|
||||
} else {
|
||||
return (
|
||||
<GenericColumnButton
|
||||
onClick={() => handleActionButtonClick(param.row)}
|
||||
buttonContent={<MoreHorizIcon color="action" />}
|
||||
/>
|
||||
);
|
||||
}
|
||||
},
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default getPurchaseColumns;
|
||||
@ -0,0 +1,82 @@
|
||||
import { getTransactionColumnWidths } from "@columns/widths/ColumnWidths.js";
|
||||
|
||||
/**
|
||||
* Function to get columns for transaction table
|
||||
* @param {*} currencyFormat -- currency format from config (e.g. Kč, €)
|
||||
* @returns
|
||||
*/
|
||||
function getTransactionColumns(currencyFormat) {
|
||||
const widths = getTransactionColumnWidths();
|
||||
const flexes = {};
|
||||
|
||||
return [
|
||||
{
|
||||
field: "date",
|
||||
headerName: "Datum",
|
||||
headerClassName: "column-header",
|
||||
...(widths.date ? { width: widths.date } : {}),
|
||||
...(flexes.date ? { flex: flexes.date } : {}),
|
||||
valueFormatter: (param) => {
|
||||
const timestamp = param;
|
||||
if (!timestamp) return "";
|
||||
const date = new Date(timestamp);
|
||||
if (isNaN(date.getTime())) return "Neplatné datum";
|
||||
return date.toLocaleDateString("cs-CZ", {
|
||||
year: "numeric",
|
||||
month: "2-digit",
|
||||
day: "2-digit",
|
||||
hour: "2-digit",
|
||||
minute: "2-digit",
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "type",
|
||||
headerName: "Typ transakce",
|
||||
headerClassName: "column-header",
|
||||
...(widths.type ? { width: widths.type } : {}),
|
||||
...(flexes.type ? { flex: flexes.type } : {}),
|
||||
valueFormatter: (param) => {
|
||||
return param;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "fromName",
|
||||
headerName: "Od koho",
|
||||
headerClassName: "column-header",
|
||||
...(widths.from ? { width: widths.from } : {}),
|
||||
...(flexes.from ? { flex: flexes.from } : {}),
|
||||
},
|
||||
{
|
||||
field: "toName",
|
||||
headerName: "Komu",
|
||||
headerClassName: "column-header",
|
||||
...(widths.to ? { width: widths.to } : {}),
|
||||
...(flexes.to ? { flex: flexes.to } : {}),
|
||||
},
|
||||
{
|
||||
field: "amount",
|
||||
headerName: "Částka",
|
||||
headerClassName: "column-header",
|
||||
...(widths.price ? { width: widths.price } : {}),
|
||||
...(flexes.price ? { flex: flexes.price } : {}),
|
||||
valueFormatter: (param) => {
|
||||
const amount = param;
|
||||
const formattedAmount = amount.toLocaleString("cs-CZ", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
return `${formattedAmount} ${currencyFormat}`;
|
||||
},
|
||||
},
|
||||
{
|
||||
field: "description",
|
||||
headerName: "Popis",
|
||||
headerClassName: "column-header",
|
||||
...(widths.description ? { width: widths.description } : {}),
|
||||
...(flexes.description ? { flex: flexes.description } : {}),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
||||
export default getTransactionColumns;
|
||||
111
src/frontend/src/components/columns/widths/ColumnWidths.js
Normal file
111
src/frontend/src/components/columns/widths/ColumnWidths.js
Normal 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;
|
||||
}
|
||||
139
src/frontend/src/components/dialogs/DebtorDetailDialog.jsx
Normal file
139
src/frontend/src/components/dialogs/DebtorDetailDialog.jsx
Normal 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;
|
||||
389
src/frontend/src/components/dialogs/EditPurchaseDialog.jsx
Normal file
389
src/frontend/src/components/dialogs/EditPurchaseDialog.jsx
Normal 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;
|
||||
99
src/frontend/src/components/dialogs/NotificationsDialog.jsx
Normal file
99
src/frontend/src/components/dialogs/NotificationsDialog.jsx
Normal 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;
|
||||
234
src/frontend/src/components/dialogs/PayDialog.jsx
Normal file
234
src/frontend/src/components/dialogs/PayDialog.jsx
Normal 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. Kč, €)
|
||||
* @param {*} isDefaultCurrency -- whether the currency is the default (Kč)
|
||||
* @returns
|
||||
*/
|
||||
function PayDialog({ creditor, handleSetAsPaid, onClose, amountInCents, currencyFormat, isDefaultCurrency }) {
|
||||
const [copyButtonName, setCopyButtonName] = useState("Zkopírovat");
|
||||
const [confirmDialog, setConfirmDialog] = useState(null);
|
||||
const [currentRate, setCurrentRate] = useState(1);
|
||||
const [toastBarSettings, setToastBarSettings] = useState(null);
|
||||
|
||||
const token = localStorage.getItem("jwtToken");
|
||||
|
||||
useEffect(() => {
|
||||
const fetchCurrentRate = async () => {
|
||||
const defaultRate = 1;
|
||||
try {
|
||||
const exchangeRate = await getCurrentRate(token);
|
||||
|
||||
setCurrentRate(exchangeRate.rateDecimal);
|
||||
setToastBarSettings({
|
||||
message: `Poslední dostupný kurz z ${exchangeRate.date.toLocaleDateString()} načten, 1 ${currencyFormat} = ${
|
||||
exchangeRate.rateDecimal
|
||||
} Kč.`,
|
||||
type: "success",
|
||||
});
|
||||
} catch (error) {
|
||||
setCurrentRate(defaultRate); // Fallback to 1 if fetch fails
|
||||
setToastBarSettings({
|
||||
message:
|
||||
error.message ||
|
||||
`Neznámá chyba při načítání aktuálního kurzu, 1 ${currencyFormat} = ${defaultRate} Kč.`,
|
||||
type: "error",
|
||||
});
|
||||
}
|
||||
};
|
||||
if (!isDefaultCurrency) {
|
||||
fetchCurrentRate();
|
||||
}
|
||||
}, [currencyFormat, isDefaultCurrency, token]);
|
||||
|
||||
const amount = (amountInCents / 100) * currentRate;
|
||||
const formattedAmount = Number(amount).toLocaleString("cs-CZ", {
|
||||
minimumFractionDigits: 2,
|
||||
maximumFractionDigits: 2,
|
||||
});
|
||||
|
||||
/**
|
||||
* Gets IBAN from account number
|
||||
* @description Converts account number to IBAN format
|
||||
* @param {*} fullAccount account number in format "number/bankCode"
|
||||
* @returns
|
||||
*/
|
||||
const getIban = (fullAccount) => {
|
||||
const prefix = "CZ";
|
||||
// fullAccount = "143929001/5500"; // For testing
|
||||
|
||||
// Get the bank code and account number
|
||||
const [accountPart, bankCode] = fullAccount.split("/");
|
||||
|
||||
if (!bankCode || !accountPart) {
|
||||
throw new Error("Invalid account format -- expected 'number/bankCode' or 'prefix-number/bankCode'");
|
||||
}
|
||||
|
||||
// Get the prefix and main part of the account number
|
||||
const [prefixPart, numberPart] = accountPart.includes("-") ? accountPart.split("-") : ["", accountPart];
|
||||
|
||||
const pre = prefixPart.padStart(6, "0"); // Prefix (6 ciphers)
|
||||
const main = numberPart.padStart(10, "0"); // Account number (10 ciphers)
|
||||
|
||||
const accountBase = `${bankCode}${pre}${main}`; // BBBBPPPPPPCCCCCCCCCC
|
||||
const ibanRaw = `${accountBase}${prefix}00`; // For chesksum
|
||||
|
||||
// Chars to numbers (C=12, Z=35)
|
||||
const ibanNumeric = ibanRaw.replace(/[A-Z]/g, (ch) => (ch.charCodeAt(0) - 55).toString());
|
||||
|
||||
// Compute control numbers (mod 97)
|
||||
const mod97 = BigInt(ibanNumeric) % 97n;
|
||||
const checksum = String(98n - mod97).padStart(2, "0");
|
||||
|
||||
// Result -- IBAN
|
||||
return `${prefix}${checksum}${bankCode}${pre}${main}`;
|
||||
};
|
||||
|
||||
const iban = getIban(creditor.accountNumber);
|
||||
const textInQr = `SPD*1.0*ACC:${iban}*AM:${formattedAmount}*CC:CZK`;
|
||||
|
||||
/**
|
||||
* Handles copying text to clipboard
|
||||
* @description Copies text to clipboard and changes button name
|
||||
*/
|
||||
const handleCopyInfoToClipboard = () => {
|
||||
navigator.clipboard
|
||||
.writeText(textInQr)
|
||||
.then(() => {
|
||||
setCopyButtonName("Zkopírováno");
|
||||
})
|
||||
.catch((err) => {
|
||||
console.error("Error copying text: ", err);
|
||||
});
|
||||
};
|
||||
|
||||
const handleConfirmPay = () => {
|
||||
setConfirmDialog({
|
||||
title: "Zaplatit nákup",
|
||||
content: "Oprvdu chcete označit tento nákup jako zaplacený?",
|
||||
handler: handleSetAsPaid,
|
||||
});
|
||||
};
|
||||
|
||||
return (
|
||||
<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} Kč
|
||||
</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;
|
||||
@ -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;
|
||||
3
src/frontend/src/config/ConfigContext.js
Normal file
3
src/frontend/src/config/ConfigContext.js
Normal file
@ -0,0 +1,3 @@
|
||||
import { createContext } from "react";
|
||||
|
||||
export const ConfigContext = createContext(null);
|
||||
32
src/frontend/src/config/ConfigProvider.jsx
Normal file
32
src/frontend/src/config/ConfigProvider.jsx
Normal 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;
|
||||
30
src/frontend/src/mappers/purchaseMapper.js
Normal file
30
src/frontend/src/mappers/purchaseMapper.js
Normal 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
Loading…
x
Reference in New Issue
Block a user