Compare commits
93 Commits
cba564a811
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 9f51f508ce | |||
| 62824549fc | |||
| 7d7f5750e3 | |||
| 789ba35c60 | |||
| 1226c42f19 | |||
| 5e083aeaf6 | |||
| 69bb19e4eb | |||
| db791a1183 | |||
| ad77c2fe2b | |||
| 87a0241f11 | |||
| 5956f27646 | |||
| 69901a295c | |||
| 527759eb7b | |||
| d4fff6741c | |||
| a10070b9c7 | |||
| d10d1a6fdb | |||
| 97a275478d | |||
| 731eab3898 | |||
| f018e62163 | |||
| 10e02b5e9b | |||
| c365af1d42 | |||
| 7ddc346e88 | |||
| 925293d626 | |||
| 9b024967e6 | |||
| 288d470c1b | |||
| 84a72a8557 | |||
| d4223ed38f | |||
| 9f2c5db825 | |||
| 18cbc2225f | |||
| 267c4165dd | |||
| ef1c1d8ba1 | |||
| 8d4ae30224 | |||
| 02e7ed7030 | |||
| 9595bedd8e | |||
| af661632cc | |||
| 5062887010 | |||
| b41d5c5d33 | |||
| 4b377ce9f4 | |||
| 5e084ab0a8 | |||
| 559804765b | |||
| 5da1e2fd75 | |||
| ab32be98a6 | |||
| 8681247e76 | |||
| f6dbf489ad | |||
| eaa06029bb | |||
| 9e16d6004a | |||
| 0df7449a99 | |||
| 7f61e011ed | |||
| 9c372b31a6 | |||
| fd7a8024a9 | |||
| 4a8e3d02e0 | |||
| f7af144275 | |||
| cb3c8c0d18 | |||
| a997a3b825 | |||
| c58f6fe364 | |||
| 69bc76cab4 | |||
| 4b1f9e78df | |||
| 4c00f868c7 | |||
| 8b9ccdc694 | |||
| b8d1fddd91 | |||
| 9246729edf | |||
| e13b5f2cdc | |||
| 63c7624a00 | |||
| f58613d661 | |||
| d71e523074 | |||
| 1c8e02b3fa | |||
| feadab4dff | |||
| 41c342bb0f | |||
| 2e3000c3fc | |||
| 92e4da4b93 | |||
| 5978a96dd7 | |||
| b9375d66b6 | |||
| b07b0c1f0f | |||
| fd9b9c7c2e | |||
| b6f9b5fb26 | |||
| 87d81f98e9 | |||
| c5555b3003 | |||
| d8f82bb2d1 | |||
| 390241aa53 | |||
| b323f7a29f | |||
| 8ca16936a8 | |||
| f0998c818a | |||
| 0cf9f3cd0f | |||
| b382446828 | |||
| 16318c70f7 | |||
| f173d46c2e | |||
| 73fbe30b3d | |||
| 229bfe0b79 | |||
| 321ffc3b7c | |||
| 0fa5b080d8 | |||
| 85052df8a5 | |||
| bcbf76fda6 | |||
| b989c43ec3 |
10
.drone.yml
10
.drone.yml
@@ -42,9 +42,11 @@ steps:
|
||||
- name: docker build and push
|
||||
image: docker:24.0.7
|
||||
commands:
|
||||
- docker build . -t $docker_registry$docker_repo:$DRONE_BRANCH
|
||||
- dockerImageWithTag="$docker_registry$docker_repo:$DRONE_BRANCH"
|
||||
- docker build . -t $dockerImageWithTag
|
||||
- echo $docker_password | docker login --username $docker_username --password-stdin $docker_registry
|
||||
- docker push $docker_registry$docker_repo:$DRONE_BRANCH
|
||||
- docker push $dockerImageWithTag
|
||||
- echo "Built and pushed $dockerImageWithTag"
|
||||
environment:
|
||||
docker_username:
|
||||
from_secret: docker_username
|
||||
@@ -60,6 +62,10 @@ steps:
|
||||
when:
|
||||
branch:
|
||||
- main
|
||||
- production
|
||||
event:
|
||||
exclude:
|
||||
- pull_request
|
||||
depends_on:
|
||||
- compile (.NET)
|
||||
- test
|
||||
|
||||
@@ -1,2 +1,2 @@
|
||||
dotnet ef migrations add $args[0] --project .\src\WebApi\WebApi.csproj --output-dir Persistence/Migrations
|
||||
dotnet ef migrations script --idempotent --project .\src\WebApi\WebApi.csproj --output migrations/migration.sql
|
||||
dotnet ef migrations add $args[0] --project .\src\Vegasco.Server.Api\Vegasco.Server.Api.csproj --output-dir Persistence/Migrations
|
||||
dotnet ef migrations script --idempotent --project .\src\Vegasco.Server.Api\Vegasco.Server.Api.csproj --output ./src/Vegasco.Server.Api/migrations/migration.sql
|
||||
|
||||
34
README.md
34
README.md
@@ -1,17 +1,21 @@
|
||||
# Vegasco Server
|
||||
|
||||
Backend for the vegasco (**VE**hicle **GAS** **CO**nsumption) application.
|
||||
Vegasco (**VE**hicle **GAS** **CO**nsumption) application.
|
||||
|
||||
Includes the backend (`src/Vegasco.Server.Api`) and the frontend (`src/Vegasco-Web`). Uses [Aspire](https://learn.microsoft.com/en-us/dotnet/aspire/get-started/aspire-overview).
|
||||
|
||||
## Getting Started
|
||||
|
||||
### Configuration
|
||||
|
||||
| Configuration | Description | Default | Required |
|
||||
|--------------------------|---------------------------------------------------------------------------------------------------------------|------------------------------------------------------------|----------|
|
||||
| JWT:MetadataUrl | The oidc meta data url | - | true |
|
||||
| JWT:ValidAudience | The valid audience of the JWT token. | - | true |
|
||||
| JWT:NameClaimType | The claim type of the user's name claim. For keycloak, using `preferred_username` is often the better choice. | http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name | false |
|
||||
| JWT:AllowHttpMetadataUrl | Whether to allow the meta data url to have http as protocol. Always true when `ASPNETCORE_ENVIRONMENT=true` | false | false |
|
||||
| Configuration | Description | Default | Required |
|
||||
|------------------------------------|---------------------------------------------------------------------------------------------------------------|------------------------------------------------------------|----------|
|
||||
| JWT:MetadataUrl | The oidc meta data url | - | true |
|
||||
| JWT:ValidAudience | The valid audience of the JWT token. | - | true |
|
||||
| JWT:NameClaimType | The claim type of the user's name claim. For keycloak, using `preferred_username` is often the better choice. | http://schemas.xmlsoap.org/ws/2005/05/identity/claims/name | false |
|
||||
| JWT:AllowHttpMetadataUrl | Whether to allow the meta data url to have http as protocol. Always true when `ASPNETCORE_ENVIRONMENT=true` | false | false |
|
||||
| ConnectionStrings:seq | The seq http endpoint to send the logs and traces to. If not set, logs and traces will not be sent to seq. | - | false |
|
||||
| ConnectionStrings:vegasco-database | The connection string to the postgres database. | - | true |
|
||||
|
||||
The application uses the prefix `Vegasco_` for environment variable names. The prefix is removed when the application reads the environment variables and duplicate entries are overwritten by the environment variables.
|
||||
|
||||
@@ -64,4 +68,18 @@ creates a Postgres database as a docker container, and starts the Api with the c
|
||||
|
||||
Ensure you have an identity provider set up, for example Keycloak, and configured the relevant options described above.
|
||||
|
||||
Then, to run the application, ensure you have Docker running, then run the `Vegasco.Server.AppHost` launch profile.
|
||||
Then, to run the application, ensure you have Docker running, then run either the `http` or `https` launch profile of the `Vegasco.Server.AppHost` project.
|
||||
|
||||
## Deployment
|
||||
|
||||
Build server by running in project root:
|
||||
|
||||
```shell
|
||||
docker build . -t docker.nuyken.dev/vegasco/api:main
|
||||
```
|
||||
|
||||
Builder web client by running in `src/Vegasco-Web`:
|
||||
|
||||
```shell
|
||||
docker build -t docker.nuyken.dev/vegasco/web:main --build-arg CONFIGURATION=production .
|
||||
```
|
||||
|
||||
10
src/Vegasco-Web/.dockerignore
Normal file
10
src/Vegasco-Web/.dockerignore
Normal file
@@ -0,0 +1,10 @@
|
||||
node_modules
|
||||
npm-debug.log
|
||||
Dockerfile*
|
||||
docker-compose*
|
||||
.dockerignore
|
||||
.git
|
||||
.gitignore
|
||||
README.md
|
||||
LICENSE
|
||||
.vscode
|
||||
5
src/Vegasco-Web/.postcssrc.json
Normal file
5
src/Vegasco-Web/.postcssrc.json
Normal file
@@ -0,0 +1,5 @@
|
||||
{
|
||||
"plugins": {
|
||||
"@tailwindcss/postcss": {}
|
||||
}
|
||||
}
|
||||
3
src/Vegasco-Web/.vscode/launch.json
vendored
3
src/Vegasco-Web/.vscode/launch.json
vendored
@@ -3,10 +3,11 @@
|
||||
"version": "0.2.0",
|
||||
"configurations": [
|
||||
{
|
||||
"name": "Launch (Chrome)",
|
||||
"name": "Launch Web (Chrome)",
|
||||
"type": "chrome",
|
||||
"request": "launch",
|
||||
"preLaunchTask": "npm: start",
|
||||
"postDebugTask": "Terminate All Tasks",
|
||||
"url": "http://localhost:44200/",
|
||||
}
|
||||
]
|
||||
|
||||
16
src/Vegasco-Web/.vscode/tasks.json
vendored
16
src/Vegasco-Web/.vscode/tasks.json
vendored
@@ -2,13 +2,19 @@
|
||||
// For more information, visit: https://go.microsoft.com/fwlink/?LinkId=733558
|
||||
"version": "2.0.0",
|
||||
"tasks": [
|
||||
{
|
||||
"label": "Terminate All Tasks",
|
||||
"command": "echo ${input:terminate}",
|
||||
"type": "shell",
|
||||
"problemMatcher": []
|
||||
},
|
||||
{
|
||||
"type": "npm",
|
||||
"script": "start",
|
||||
"options": {
|
||||
"env": {
|
||||
"PORT": "44200",
|
||||
"services__Vegasco-Server-Api__https__0": "https://localhost:7098",
|
||||
"services__Api__https__0": "https://localhost:7098",
|
||||
"NODE_ENV": "development"
|
||||
}
|
||||
},
|
||||
@@ -45,5 +51,13 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"inputs": [
|
||||
{
|
||||
"id": "terminate",
|
||||
"type": "command",
|
||||
"command": "workbench.action.tasks.terminate",
|
||||
"args": "terminateAll"
|
||||
}
|
||||
]
|
||||
}
|
||||
|
||||
19
src/Vegasco-Web/Dockerfile
Normal file
19
src/Vegasco-Web/Dockerfile
Normal file
@@ -0,0 +1,19 @@
|
||||
FROM node:latest AS build
|
||||
RUN npm install -g pnpm
|
||||
ARG CONFIGURATION=development
|
||||
WORKDIR /usr/local/app
|
||||
COPY . .
|
||||
RUN pnpm install
|
||||
RUN pnpm "build:$CONFIGURATION"
|
||||
|
||||
FROM nginx:alpine
|
||||
RUN rm /etc/nginx/conf.d/*
|
||||
RUN apk add --update dos2unix
|
||||
ENV DOLLAR=$
|
||||
WORKDIR /usr/share/nginx/html
|
||||
COPY --from=build /usr/local/app/dist/Vegasco-Web/browser .
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
RUN dos2unix /etc/nginx/nginx.conf
|
||||
COPY webserver.conf.template /etc/nginx/templates/webserver.conf.template
|
||||
RUN dos2unix /etc/nginx/templates/webserver.conf.template
|
||||
EXPOSE 80
|
||||
@@ -12,6 +12,16 @@ ng serve
|
||||
|
||||
Once the server is running, open your browser and navigate to `http://localhost:4200/`. The application will automatically reload whenever you modify any of the source files.
|
||||
|
||||
## API Proxy
|
||||
|
||||
Because the solution utilizes Aspire which injects endpoint references for the API as environment variables, this application uses a proxy to access the API. The proxy is configured in the `proxy.config.js` file which is used in the `serve` section of the `angular.json` file. This makes the dev server provide a proxy when serving the application.
|
||||
|
||||
The environment variables for the API endpoint are named `services__Api__https__0` and `services__Api__http__0` for the https and the http endpoints respectively. If the https endpoint is not configured, the http endpoint is used. At least one of them has to be configured.
|
||||
|
||||
To allow the dev proxy to accept otherwise untrusted server certificates, set `NODE_ENV` to `development`. Otherwise the dev proxy rejects untrusted certificates.
|
||||
|
||||
When deploying the application elsewhere, another proxy has to be configured to provide the same functionality to ensure the application works correctly.
|
||||
|
||||
## Code scaffolding
|
||||
|
||||
Angular CLI includes powerful code scaffolding tools. To generate a new component, run:
|
||||
|
||||
@@ -17,7 +17,7 @@
|
||||
"build": {
|
||||
"builder": "@angular-devkit/build-angular:application",
|
||||
"options": {
|
||||
"outputPath": "dist/tmp",
|
||||
"outputPath": "dist/Vegasco-Web",
|
||||
"index": "src/index.html",
|
||||
"browser": "src/main.ts",
|
||||
"polyfills": [
|
||||
@@ -113,5 +113,8 @@
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"cli": {
|
||||
"analytics": false
|
||||
}
|
||||
}
|
||||
|
||||
8
src/Vegasco-Web/nginx.conf
Normal file
8
src/Vegasco-Web/nginx.conf
Normal file
@@ -0,0 +1,8 @@
|
||||
events { }
|
||||
http {
|
||||
include mime.types;
|
||||
|
||||
resolver 127.0.0.11;
|
||||
|
||||
include /etc/nginx/conf.d/webserver.conf;
|
||||
}
|
||||
@@ -6,7 +6,9 @@
|
||||
"start": "run-script-os",
|
||||
"start:win32": "ng serve --port %PORT% --configuration development",
|
||||
"start:default": "ng serve --port $PORT --configuration development",
|
||||
"build": "ng build",
|
||||
"build": "pnpm build:development",
|
||||
"build:development": "ng build",
|
||||
"build:production": "ng build --configuration production",
|
||||
"watch": "ng build --watch --configuration development",
|
||||
"test": "ng test"
|
||||
},
|
||||
@@ -18,10 +20,18 @@
|
||||
"@angular/forms": "^19.2.14",
|
||||
"@angular/platform-browser": "^19.2.14",
|
||||
"@angular/router": "^19.2.14",
|
||||
"@ng-icons/core": "^31.4.0",
|
||||
"@ng-icons/material-file-icons": "^31.4.0",
|
||||
"@ng-icons/material-icons": "^31.4.0",
|
||||
"@primeng/themes": "^19.1.3",
|
||||
"@tailwindcss/postcss": "^4.1.10",
|
||||
"dayjs": "^1.11.13",
|
||||
"keycloak-angular": "^19.0.2",
|
||||
"postcss": "^8.5.6",
|
||||
"primeng": "^19.1.3",
|
||||
"rxjs": "~7.8.2",
|
||||
"tailwindcss": "^4.1.10",
|
||||
"tailwindcss-primeui": "^0.6.1",
|
||||
"tslib": "^2.8.1",
|
||||
"zone.js": "~0.15.1"
|
||||
},
|
||||
|
||||
380
src/Vegasco-Web/pnpm-lock.yaml
generated
380
src/Vegasco-Web/pnpm-lock.yaml
generated
@@ -26,18 +26,42 @@ importers:
|
||||
'@angular/router':
|
||||
specifier: ^19.2.14
|
||||
version: 19.2.14(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@19.2.14(@angular/animations@19.2.14(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2)
|
||||
'@ng-icons/core':
|
||||
specifier: ^31.4.0
|
||||
version: 31.4.0(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)
|
||||
'@ng-icons/material-file-icons':
|
||||
specifier: ^31.4.0
|
||||
version: 31.4.0
|
||||
'@ng-icons/material-icons':
|
||||
specifier: ^31.4.0
|
||||
version: 31.4.0
|
||||
'@primeng/themes':
|
||||
specifier: ^19.1.3
|
||||
version: 19.1.3
|
||||
'@tailwindcss/postcss':
|
||||
specifier: ^4.1.10
|
||||
version: 4.1.10
|
||||
dayjs:
|
||||
specifier: ^1.11.13
|
||||
version: 1.11.13
|
||||
keycloak-angular:
|
||||
specifier: ^19.0.2
|
||||
version: 19.0.2(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(@angular/router@19.2.14(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(@angular/platform-browser@19.2.14(@angular/animations@19.2.14(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1)))(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1)))(rxjs@7.8.2))(keycloak-js@26.2.0)
|
||||
postcss:
|
||||
specifier: ^8.5.6
|
||||
version: 8.5.6
|
||||
primeng:
|
||||
specifier: ^19.1.3
|
||||
version: 19.1.3(47ee1c247593ea8ad66380722e410532)
|
||||
rxjs:
|
||||
specifier: ~7.8.2
|
||||
version: 7.8.2
|
||||
tailwindcss:
|
||||
specifier: ^4.1.10
|
||||
version: 4.1.10
|
||||
tailwindcss-primeui:
|
||||
specifier: ^0.6.1
|
||||
version: 0.6.1(tailwindcss@4.1.10)
|
||||
tslib:
|
||||
specifier: ^2.8.1
|
||||
version: 2.8.1
|
||||
@@ -47,7 +71,7 @@ importers:
|
||||
devDependencies:
|
||||
'@angular-devkit/build-angular':
|
||||
specifier: ^19.2.15
|
||||
version: 19.2.15(@angular/compiler-cli@19.2.14(@angular/compiler@19.2.14)(typescript@5.8.3))(@angular/compiler@19.2.14)(@types/node@24.0.3)(chokidar@4.0.3)(jiti@1.21.7)(karma@6.4.4)(typescript@5.8.3)(vite@6.2.7(@types/node@24.0.3)(jiti@1.21.7)(less@4.2.2)(sass@1.85.0)(terser@5.39.0))
|
||||
version: 19.2.15(@angular/compiler-cli@19.2.14(@angular/compiler@19.2.14)(typescript@5.8.3))(@angular/compiler@19.2.14)(@types/node@24.0.3)(chokidar@4.0.3)(jiti@2.4.2)(karma@6.4.4)(lightningcss@1.30.1)(tailwindcss@4.1.10)(typescript@5.8.3)(vite@6.2.7(@types/node@24.0.3)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.30.1)(sass@1.85.0)(terser@5.39.0))
|
||||
'@angular/cli':
|
||||
specifier: ^19.2.15
|
||||
version: 19.2.15(@types/node@24.0.3)(chokidar@4.0.3)
|
||||
@@ -84,6 +108,10 @@ importers:
|
||||
|
||||
packages:
|
||||
|
||||
'@alloc/quick-lru@5.2.0':
|
||||
resolution: {integrity: sha512-UrcABB+4bUrFABwbluTIBErXwvbsU/V7TZWfmbgJfbkwiBuziS9gxdODUyuiecfdGQ85jglMW6juS3+z5TsKLw==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==}
|
||||
engines: {node: '>=6.0.0'}
|
||||
@@ -1289,6 +1317,19 @@ packages:
|
||||
resolution: {integrity: sha512-zM0mVWSXE0a0h9aKACLwKmD6nHcRiKrPpCfvaKqG1CqDEyjEawId0ocXxVzPMCAm6kkWr2P025msfxXEnt8UGQ==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@ng-icons/core@31.4.0':
|
||||
resolution: {integrity: sha512-JfLiJGDX/ihWmawcnLGXtwyCqMi2qXz7gMJyXXWdUN5JA18EAnt3JnyuxDAGkoU/u7wRlcOI7irlXHU4spAKOg==}
|
||||
peerDependencies:
|
||||
'@angular/common': '>=18.0.0'
|
||||
'@angular/core': '>=18.0.0'
|
||||
rxjs: ^6.5.3 || ^7.4.0
|
||||
|
||||
'@ng-icons/material-file-icons@31.4.0':
|
||||
resolution: {integrity: sha512-Ffh61ghuuDRxelfTe/rHQ5IFCqUget/JeZ/NLq6QWLBycxUC6PjiEIIAXQvnVmYwCHNgxjBIRExP1/+vdHriNQ==}
|
||||
|
||||
'@ng-icons/material-icons@31.4.0':
|
||||
resolution: {integrity: sha512-JCxwM0LXwOgT5LD99p5TwPM6dPQ5x1BGieNzAstz7vk5+aiASg3fqs3rjNx7CbN3c2QjJ8+KuKrCCBzT9DCkOQ==}
|
||||
|
||||
'@ngtools/webpack@19.2.15':
|
||||
resolution: {integrity: sha512-H37nop/wWMkSgoU2VvrMzanHePdLRRrX52nC5tT2ZhH3qP25+PrnMyw11PoLDLv3iWXC68uB1AiKNIT+jiQbuQ==}
|
||||
engines: {node: ^18.19.1 || ^20.11.1 || >=22.0.0, npm: ^6.11.0 || ^7.5.6 || >=8.0.0, yarn: '>= 1.13.0'}
|
||||
@@ -1573,6 +1614,94 @@ packages:
|
||||
'@socket.io/component-emitter@3.1.2':
|
||||
resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==}
|
||||
|
||||
'@tailwindcss/node@4.1.10':
|
||||
resolution: {integrity: sha512-2ACf1znY5fpRBwRhMgj9ZXvb2XZW8qs+oTfotJ2C5xR0/WNL7UHZ7zXl6s+rUqedL1mNi+0O+WQr5awGowS3PQ==}
|
||||
|
||||
'@tailwindcss/oxide-android-arm64@4.1.10':
|
||||
resolution: {integrity: sha512-VGLazCoRQ7rtsCzThaI1UyDu/XRYVyH4/EWiaSX6tFglE+xZB5cvtC5Omt0OQ+FfiIVP98su16jDVHDEIuH4iQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [android]
|
||||
|
||||
'@tailwindcss/oxide-darwin-arm64@4.1.10':
|
||||
resolution: {integrity: sha512-ZIFqvR1irX2yNjWJzKCqTCcHZbgkSkSkZKbRM3BPzhDL/18idA8uWCoopYA2CSDdSGFlDAxYdU2yBHwAwx8euQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
'@tailwindcss/oxide-darwin-x64@4.1.10':
|
||||
resolution: {integrity: sha512-eCA4zbIhWUFDXoamNztmS0MjXHSEJYlvATzWnRiTqJkcUteSjO94PoRHJy1Xbwp9bptjeIxxBHh+zBWFhttbrQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
'@tailwindcss/oxide-freebsd-x64@4.1.10':
|
||||
resolution: {integrity: sha512-8/392Xu12R0cc93DpiJvNpJ4wYVSiciUlkiOHOSOQNH3adq9Gi/dtySK7dVQjXIOzlpSHjeCL89RUUI8/GTI6g==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.10':
|
||||
resolution: {integrity: sha512-t9rhmLT6EqeuPT+MXhWhlRYIMSfh5LZ6kBrC4FS6/+M1yXwfCtp24UumgCWOAJVyjQwG+lYva6wWZxrfvB+NhQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-gnu@4.1.10':
|
||||
resolution: {integrity: sha512-3oWrlNlxLRxXejQ8zImzrVLuZ/9Z2SeKoLhtCu0hpo38hTO2iL86eFOu4sVR8cZc6n3z7eRXXqtHJECa6mFOvA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.1.10':
|
||||
resolution: {integrity: sha512-saScU0cmWvg/Ez4gUmQWr9pvY9Kssxt+Xenfx1LG7LmqjcrvBnw4r9VjkFcqmbBb7GCBwYNcZi9X3/oMda9sqQ==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.1.10':
|
||||
resolution: {integrity: sha512-/G3ao/ybV9YEEgAXeEg28dyH6gs1QG8tvdN9c2MNZdUXYBaIY/Gx0N6RlJzfLy/7Nkdok4kaxKPHKJUlAaoTdA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.1.10':
|
||||
resolution: {integrity: sha512-LNr7X8fTiKGRtQGOerSayc2pWJp/9ptRYAa4G+U+cjw9kJZvkopav1AQc5HHD+U364f71tZv6XamaHKgrIoVzA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.1.10':
|
||||
resolution: {integrity: sha512-d6ekQpopFQJAcIK2i7ZzWOYGZ+A6NzzvQ3ozBvWFdeyqfOZdYHU66g5yr+/HC4ipP1ZgWsqa80+ISNILk+ae/Q==}
|
||||
engines: {node: '>=14.0.0'}
|
||||
cpu: [wasm32]
|
||||
bundledDependencies:
|
||||
- '@napi-rs/wasm-runtime'
|
||||
- '@emnapi/core'
|
||||
- '@emnapi/runtime'
|
||||
- '@tybys/wasm-util'
|
||||
- '@emnapi/wasi-threads'
|
||||
- tslib
|
||||
|
||||
'@tailwindcss/oxide-win32-arm64-msvc@4.1.10':
|
||||
resolution: {integrity: sha512-i1Iwg9gRbwNVOCYmnigWCCgow8nDWSFmeTUU5nbNx3rqbe4p0kRbEqLwLJbYZKmSSp23g4N6rCDmm7OuPBXhDA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/oxide-win32-x64-msvc@4.1.10':
|
||||
resolution: {integrity: sha512-sGiJTjcBSfGq2DVRtaSljq5ZgZS2SDHSIfhOylkBvHVjwOsodBhnb3HdmiKkVuUGKD0I7G63abMOVaskj1KpOA==}
|
||||
engines: {node: '>= 10'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@tailwindcss/oxide@4.1.10':
|
||||
resolution: {integrity: sha512-v0C43s7Pjw+B9w21htrQwuFObSkio2aV/qPx/mhrRldbqxbWJK6KizM+q7BF1/1CmuLqZqX3CeYF7s7P9fbA8Q==}
|
||||
engines: {node: '>= 10'}
|
||||
|
||||
'@tailwindcss/postcss@4.1.10':
|
||||
resolution: {integrity: sha512-B+7r7ABZbkXJwpvt2VMnS6ujcDoR2OOcFaqrLIo1xbcdxje4Vf+VgJdBzNNbrAjBj/rLZ66/tlQ1knIGNLKOBQ==}
|
||||
|
||||
'@tufjs/canonical-json@2.0.0':
|
||||
resolution: {integrity: sha512-yVtV8zsdo8qFHe+/3kw81dSLyF7D576A5cCFCi4X7B39tWT7SekaEFUnvnWJHz+9qO7qJTah1JbrDjWKqFtdWA==}
|
||||
engines: {node: ^16.14.0 || >=18.0.0}
|
||||
@@ -2103,6 +2232,9 @@ packages:
|
||||
resolution: {integrity: sha512-39BOQLs9ZjKh0/patS9nrT8wc3ioX3/eA/zgbKNopnF2wCqJEoxywwwElATYvRsXdnOxA/OQeQoFZ3rFjVajhg==}
|
||||
engines: {node: '>=4.0'}
|
||||
|
||||
dayjs@1.11.13:
|
||||
resolution: {integrity: sha512-oaMBel6gjolK862uaPQOVTA7q3TZhuSvuMQAAglQDOWYO9A91IrAOUJEyKVlqJlHE0vq5p5UXxzdPfMH/x6xNg==}
|
||||
|
||||
debug@2.6.9:
|
||||
resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==}
|
||||
peerDependencies:
|
||||
@@ -2787,6 +2919,10 @@ packages:
|
||||
resolution: {integrity: sha512-/imKNG4EbWNrVjoNC/1H5/9GFy+tqjGBHCaSsN+P2RnPqjsLmv6UD3Ej+Kj8nBWaRAwyk7kK5ZUc+OEatnTR3A==}
|
||||
hasBin: true
|
||||
|
||||
jiti@2.4.2:
|
||||
resolution: {integrity: sha512-rg9zJN+G4n2nfJl5MW3BMygZX56zKPNVEYYqq7adpmMh4Jn2QNEwhvQlFy6jPVdcod7txZtKHWnyZiA3a0zP7A==}
|
||||
hasBin: true
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -2904,6 +3040,70 @@ packages:
|
||||
webpack:
|
||||
optional: true
|
||||
|
||||
lightningcss-darwin-arm64@1.30.1:
|
||||
resolution: {integrity: sha512-c8JK7hyE65X1MHMN+Viq9n11RRC7hgin3HhYKhrMyaXflk5GVplZ60IxyoVtzILeKr+xAJwg6zK6sjTBJ0FKYQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [darwin]
|
||||
|
||||
lightningcss-darwin-x64@1.30.1:
|
||||
resolution: {integrity: sha512-k1EvjakfumAQoTfcXUcHQZhSpLlkAuEkdMBsI/ivWw9hL+7FtilQc0Cy3hrx0AAQrVtQAbMI7YjCgYgvn37PzA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [darwin]
|
||||
|
||||
lightningcss-freebsd-x64@1.30.1:
|
||||
resolution: {integrity: sha512-kmW6UGCGg2PcyUE59K5r0kWfKPAVy4SltVeut+umLCFoJ53RdCUWxcRDzO1eTaxf/7Q2H7LTquFHPL5R+Gjyig==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [freebsd]
|
||||
|
||||
lightningcss-linux-arm-gnueabihf@1.30.1:
|
||||
resolution: {integrity: sha512-MjxUShl1v8pit+6D/zSPq9S9dQ2NPFSQwGvxBCYaBYLPlCWuPh9/t1MRS8iUaR8i+a6w7aps+B4N0S1TYP/R+Q==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-linux-arm64-gnu@1.30.1:
|
||||
resolution: {integrity: sha512-gB72maP8rmrKsnKYy8XUuXi/4OctJiuQjcuqWNlJQ6jZiWqtPvqFziskH3hnajfvKB27ynbVCucKSm2rkQp4Bw==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-linux-arm64-musl@1.30.1:
|
||||
resolution: {integrity: sha512-jmUQVx4331m6LIX+0wUhBbmMX7TCfjF5FoOH6SD1CttzuYlGNVpA7QnrmLxrsub43ClTINfGSYyHe2HWeLl5CQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-linux-x64-gnu@1.30.1:
|
||||
resolution: {integrity: sha512-piWx3z4wN8J8z3+O5kO74+yr6ze/dKmPnI7vLqfSqI8bccaTGY5xiSGVIJBDd5K5BHlvVLpUB3S2YCfelyJ1bw==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-linux-x64-musl@1.30.1:
|
||||
resolution: {integrity: sha512-rRomAK7eIkL+tHY0YPxbc5Dra2gXlI63HL+v1Pdi1a3sC+tJTcFrHX+E86sulgAXeI7rSzDYhPSeHHjqFhqfeQ==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [linux]
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.30.1:
|
||||
resolution: {integrity: sha512-mSL4rqPi4iXq5YVqzSsJgMVFENoa4nGTT/GjO2c0Yl9OuQfPsIfncvLrEW6RbbB24WtZ3xP/2CCmI3tNkNV4oA==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [arm64]
|
||||
os: [win32]
|
||||
|
||||
lightningcss-win32-x64-msvc@1.30.1:
|
||||
resolution: {integrity: sha512-PVqXh48wh4T53F/1CCu8PIPCxLzWyCnn/9T5W1Jpmdy5h9Cwd+0YQS6/LwhHXSafuc61/xg9Lv5OrCby6a++jg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
lightningcss@1.30.1:
|
||||
resolution: {integrity: sha512-xi6IyHML+c9+Q3W0S4fCQJOym42pyurFiJUHEcEyHS0CeKzia4yZDEsLlqOFykxOdHpNy0NmvVO31vcSqAxJCg==}
|
||||
engines: {node: '>= 12.0.0'}
|
||||
|
||||
lines-and-columns@1.2.4:
|
||||
resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==}
|
||||
|
||||
@@ -3865,6 +4065,14 @@ packages:
|
||||
resolution: {integrity: sha512-b19dMThMV4HVFynSAM1++gBHAbk2Tc/osgLIBZMKsyqh34jb2e8Os7T6ZW/Bt3pJFdBTd2JwAnAAEQV7rSNvcQ==}
|
||||
engines: {node: '>=0.10'}
|
||||
|
||||
tailwindcss-primeui@0.6.1:
|
||||
resolution: {integrity: sha512-T69Rylcrmnt8zy9ik+qZvsLuRIrS9/k6rYJSIgZ1trnbEzGDDQSCIdmfyZknevqiHwpSJHSmQ9XT2C+S/hJY4A==}
|
||||
peerDependencies:
|
||||
tailwindcss: '>=3.1.0'
|
||||
|
||||
tailwindcss@4.1.10:
|
||||
resolution: {integrity: sha512-P3nr6WkvKV/ONsTzj6Gb57sWPMX29EPNPopo7+FcpkQaNsrNpZ1pv8QmrYI2RqEKD7mlGqLnGovlcYnBK0IqUA==}
|
||||
|
||||
tapable@2.2.2:
|
||||
resolution: {integrity: sha512-Re10+NauLTMCudc7T5WLFLAwDhQ0JWdrMK+9B2M8zR5hRExKmsRDCBA7/aV/pNJFltmBFO5BAMlQFi/vq3nKOg==}
|
||||
engines: {node: '>=6'}
|
||||
@@ -4249,6 +4457,8 @@ packages:
|
||||
|
||||
snapshots:
|
||||
|
||||
'@alloc/quick-lru@5.2.0': {}
|
||||
|
||||
'@ampproject/remapping@2.3.0':
|
||||
dependencies:
|
||||
'@jridgewell/gen-mapping': 0.3.8
|
||||
@@ -4261,13 +4471,13 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- chokidar
|
||||
|
||||
'@angular-devkit/build-angular@19.2.15(@angular/compiler-cli@19.2.14(@angular/compiler@19.2.14)(typescript@5.8.3))(@angular/compiler@19.2.14)(@types/node@24.0.3)(chokidar@4.0.3)(jiti@1.21.7)(karma@6.4.4)(typescript@5.8.3)(vite@6.2.7(@types/node@24.0.3)(jiti@1.21.7)(less@4.2.2)(sass@1.85.0)(terser@5.39.0))':
|
||||
'@angular-devkit/build-angular@19.2.15(@angular/compiler-cli@19.2.14(@angular/compiler@19.2.14)(typescript@5.8.3))(@angular/compiler@19.2.14)(@types/node@24.0.3)(chokidar@4.0.3)(jiti@2.4.2)(karma@6.4.4)(lightningcss@1.30.1)(tailwindcss@4.1.10)(typescript@5.8.3)(vite@6.2.7(@types/node@24.0.3)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.30.1)(sass@1.85.0)(terser@5.39.0))':
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
'@angular-devkit/architect': 0.1902.15(chokidar@4.0.3)
|
||||
'@angular-devkit/build-webpack': 0.1902.15(chokidar@4.0.3)(webpack-dev-server@5.2.2(webpack@5.98.0))(webpack@5.98.0(esbuild@0.25.4))
|
||||
'@angular-devkit/core': 19.2.15(chokidar@4.0.3)
|
||||
'@angular/build': 19.2.15(@angular/compiler-cli@19.2.14(@angular/compiler@19.2.14)(typescript@5.8.3))(@angular/compiler@19.2.14)(@types/node@24.0.3)(chokidar@4.0.3)(jiti@1.21.7)(karma@6.4.4)(less@4.2.2)(postcss@8.5.2)(terser@5.39.0)(typescript@5.8.3)
|
||||
'@angular/build': 19.2.15(@angular/compiler-cli@19.2.14(@angular/compiler@19.2.14)(typescript@5.8.3))(@angular/compiler@19.2.14)(@types/node@24.0.3)(chokidar@4.0.3)(jiti@2.4.2)(karma@6.4.4)(less@4.2.2)(lightningcss@1.30.1)(postcss@8.5.2)(tailwindcss@4.1.10)(terser@5.39.0)(typescript@5.8.3)
|
||||
'@angular/compiler-cli': 19.2.14(@angular/compiler@19.2.14)(typescript@5.8.3)
|
||||
'@babel/core': 7.26.10
|
||||
'@babel/generator': 7.26.10
|
||||
@@ -4280,7 +4490,7 @@ snapshots:
|
||||
'@babel/runtime': 7.26.10
|
||||
'@discoveryjs/json-ext': 0.6.3
|
||||
'@ngtools/webpack': 19.2.15(@angular/compiler-cli@19.2.14(@angular/compiler@19.2.14)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.98.0(esbuild@0.25.4))
|
||||
'@vitejs/plugin-basic-ssl': 1.2.0(vite@6.2.7(@types/node@24.0.3)(jiti@1.21.7)(less@4.2.2)(sass@1.85.0)(terser@5.39.0))
|
||||
'@vitejs/plugin-basic-ssl': 1.2.0(vite@6.2.7(@types/node@24.0.3)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.30.1)(sass@1.85.0)(terser@5.39.0))
|
||||
ansi-colors: 4.1.3
|
||||
autoprefixer: 10.4.20(postcss@8.5.2)
|
||||
babel-loader: 9.2.1(@babel/core@7.26.10)(webpack@5.98.0(esbuild@0.25.4))
|
||||
@@ -4323,6 +4533,7 @@ snapshots:
|
||||
optionalDependencies:
|
||||
esbuild: 0.25.4
|
||||
karma: 6.4.4
|
||||
tailwindcss: 4.1.10
|
||||
transitivePeerDependencies:
|
||||
- '@angular/compiler'
|
||||
- '@rspack/core'
|
||||
@@ -4382,7 +4593,7 @@ snapshots:
|
||||
'@angular/core': 19.2.14(rxjs@7.8.2)(zone.js@0.15.1)
|
||||
tslib: 2.8.1
|
||||
|
||||
'@angular/build@19.2.15(@angular/compiler-cli@19.2.14(@angular/compiler@19.2.14)(typescript@5.8.3))(@angular/compiler@19.2.14)(@types/node@24.0.3)(chokidar@4.0.3)(jiti@1.21.7)(karma@6.4.4)(less@4.2.2)(postcss@8.5.2)(terser@5.39.0)(typescript@5.8.3)':
|
||||
'@angular/build@19.2.15(@angular/compiler-cli@19.2.14(@angular/compiler@19.2.14)(typescript@5.8.3))(@angular/compiler@19.2.14)(@types/node@24.0.3)(chokidar@4.0.3)(jiti@2.4.2)(karma@6.4.4)(less@4.2.2)(lightningcss@1.30.1)(postcss@8.5.2)(tailwindcss@4.1.10)(terser@5.39.0)(typescript@5.8.3)':
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
'@angular-devkit/architect': 0.1902.15(chokidar@4.0.3)
|
||||
@@ -4393,7 +4604,7 @@ snapshots:
|
||||
'@babel/helper-split-export-declaration': 7.24.7
|
||||
'@babel/plugin-syntax-import-attributes': 7.26.0(@babel/core@7.26.10)
|
||||
'@inquirer/confirm': 5.1.6(@types/node@24.0.3)
|
||||
'@vitejs/plugin-basic-ssl': 1.2.0(vite@6.2.7(@types/node@24.0.3)(jiti@1.21.7)(less@4.2.2)(sass@1.85.0)(terser@5.39.0))
|
||||
'@vitejs/plugin-basic-ssl': 1.2.0(vite@6.2.7(@types/node@24.0.3)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.30.1)(sass@1.85.0)(terser@5.39.0))
|
||||
beasties: 0.3.2
|
||||
browserslist: 4.25.0
|
||||
esbuild: 0.25.4
|
||||
@@ -4411,13 +4622,14 @@ snapshots:
|
||||
semver: 7.7.1
|
||||
source-map-support: 0.5.21
|
||||
typescript: 5.8.3
|
||||
vite: 6.2.7(@types/node@24.0.3)(jiti@1.21.7)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)
|
||||
vite: 6.2.7(@types/node@24.0.3)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.30.1)(sass@1.85.0)(terser@5.39.0)
|
||||
watchpack: 2.4.2
|
||||
optionalDependencies:
|
||||
karma: 6.4.4
|
||||
less: 4.2.2
|
||||
lmdb: 3.2.6
|
||||
postcss: 8.5.2
|
||||
tailwindcss: 4.1.10
|
||||
transitivePeerDependencies:
|
||||
- '@types/node'
|
||||
- chokidar
|
||||
@@ -5613,6 +5825,21 @@ snapshots:
|
||||
'@napi-rs/nice-win32-x64-msvc': 1.0.1
|
||||
optional: true
|
||||
|
||||
'@ng-icons/core@31.4.0(@angular/common@19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2))(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)':
|
||||
dependencies:
|
||||
'@angular/common': 19.2.14(@angular/core@19.2.14(rxjs@7.8.2)(zone.js@0.15.1))(rxjs@7.8.2)
|
||||
'@angular/core': 19.2.14(rxjs@7.8.2)(zone.js@0.15.1)
|
||||
rxjs: 7.8.2
|
||||
tslib: 2.8.1
|
||||
|
||||
'@ng-icons/material-file-icons@31.4.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@ng-icons/material-icons@31.4.0':
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
'@ngtools/webpack@19.2.15(@angular/compiler-cli@19.2.14(@angular/compiler@19.2.14)(typescript@5.8.3))(typescript@5.8.3)(webpack@5.98.0(esbuild@0.25.4))':
|
||||
dependencies:
|
||||
'@angular/compiler-cli': 19.2.14(@angular/compiler@19.2.14)(typescript@5.8.3)
|
||||
@@ -5865,6 +6092,78 @@ snapshots:
|
||||
|
||||
'@socket.io/component-emitter@3.1.2': {}
|
||||
|
||||
'@tailwindcss/node@4.1.10':
|
||||
dependencies:
|
||||
'@ampproject/remapping': 2.3.0
|
||||
enhanced-resolve: 5.18.1
|
||||
jiti: 2.4.2
|
||||
lightningcss: 1.30.1
|
||||
magic-string: 0.30.17
|
||||
source-map-js: 1.2.1
|
||||
tailwindcss: 4.1.10
|
||||
|
||||
'@tailwindcss/oxide-android-arm64@4.1.10':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-darwin-arm64@4.1.10':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-darwin-x64@4.1.10':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-freebsd-x64@4.1.10':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf@4.1.10':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-gnu@4.1.10':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-arm64-musl@4.1.10':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-gnu@4.1.10':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-linux-x64-musl@4.1.10':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-wasm32-wasi@4.1.10':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-win32-arm64-msvc@4.1.10':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide-win32-x64-msvc@4.1.10':
|
||||
optional: true
|
||||
|
||||
'@tailwindcss/oxide@4.1.10':
|
||||
dependencies:
|
||||
detect-libc: 2.0.4
|
||||
tar: 7.4.3
|
||||
optionalDependencies:
|
||||
'@tailwindcss/oxide-android-arm64': 4.1.10
|
||||
'@tailwindcss/oxide-darwin-arm64': 4.1.10
|
||||
'@tailwindcss/oxide-darwin-x64': 4.1.10
|
||||
'@tailwindcss/oxide-freebsd-x64': 4.1.10
|
||||
'@tailwindcss/oxide-linux-arm-gnueabihf': 4.1.10
|
||||
'@tailwindcss/oxide-linux-arm64-gnu': 4.1.10
|
||||
'@tailwindcss/oxide-linux-arm64-musl': 4.1.10
|
||||
'@tailwindcss/oxide-linux-x64-gnu': 4.1.10
|
||||
'@tailwindcss/oxide-linux-x64-musl': 4.1.10
|
||||
'@tailwindcss/oxide-wasm32-wasi': 4.1.10
|
||||
'@tailwindcss/oxide-win32-arm64-msvc': 4.1.10
|
||||
'@tailwindcss/oxide-win32-x64-msvc': 4.1.10
|
||||
|
||||
'@tailwindcss/postcss@4.1.10':
|
||||
dependencies:
|
||||
'@alloc/quick-lru': 5.2.0
|
||||
'@tailwindcss/node': 4.1.10
|
||||
'@tailwindcss/oxide': 4.1.10
|
||||
postcss: 8.5.6
|
||||
tailwindcss: 4.1.10
|
||||
|
||||
'@tufjs/canonical-json@2.0.0': {}
|
||||
|
||||
'@tufjs/models@3.0.1':
|
||||
@@ -5969,9 +6268,9 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 24.0.3
|
||||
|
||||
'@vitejs/plugin-basic-ssl@1.2.0(vite@6.2.7(@types/node@24.0.3)(jiti@1.21.7)(less@4.2.2)(sass@1.85.0)(terser@5.39.0))':
|
||||
'@vitejs/plugin-basic-ssl@1.2.0(vite@6.2.7(@types/node@24.0.3)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.30.1)(sass@1.85.0)(terser@5.39.0))':
|
||||
dependencies:
|
||||
vite: 6.2.7(@types/node@24.0.3)(jiti@1.21.7)(less@4.2.2)(sass@1.85.0)(terser@5.39.0)
|
||||
vite: 6.2.7(@types/node@24.0.3)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.30.1)(sass@1.85.0)(terser@5.39.0)
|
||||
|
||||
'@webassemblyjs/ast@1.14.1':
|
||||
dependencies:
|
||||
@@ -6470,6 +6769,8 @@ snapshots:
|
||||
|
||||
date-format@4.0.14: {}
|
||||
|
||||
dayjs@1.11.13: {}
|
||||
|
||||
debug@2.6.9:
|
||||
dependencies:
|
||||
ms: 2.0.0
|
||||
@@ -6504,8 +6805,7 @@ snapshots:
|
||||
detect-libc@1.0.3:
|
||||
optional: true
|
||||
|
||||
detect-libc@2.0.4:
|
||||
optional: true
|
||||
detect-libc@2.0.4: {}
|
||||
|
||||
detect-node@2.1.0: {}
|
||||
|
||||
@@ -7182,6 +7482,8 @@ snapshots:
|
||||
|
||||
jiti@1.21.7: {}
|
||||
|
||||
jiti@2.4.2: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@4.1.0:
|
||||
@@ -7315,6 +7617,51 @@ snapshots:
|
||||
optionalDependencies:
|
||||
webpack: 5.98.0(esbuild@0.25.4)
|
||||
|
||||
lightningcss-darwin-arm64@1.30.1:
|
||||
optional: true
|
||||
|
||||
lightningcss-darwin-x64@1.30.1:
|
||||
optional: true
|
||||
|
||||
lightningcss-freebsd-x64@1.30.1:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-arm-gnueabihf@1.30.1:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-arm64-gnu@1.30.1:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-arm64-musl@1.30.1:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-x64-gnu@1.30.1:
|
||||
optional: true
|
||||
|
||||
lightningcss-linux-x64-musl@1.30.1:
|
||||
optional: true
|
||||
|
||||
lightningcss-win32-arm64-msvc@1.30.1:
|
||||
optional: true
|
||||
|
||||
lightningcss-win32-x64-msvc@1.30.1:
|
||||
optional: true
|
||||
|
||||
lightningcss@1.30.1:
|
||||
dependencies:
|
||||
detect-libc: 2.0.4
|
||||
optionalDependencies:
|
||||
lightningcss-darwin-arm64: 1.30.1
|
||||
lightningcss-darwin-x64: 1.30.1
|
||||
lightningcss-freebsd-x64: 1.30.1
|
||||
lightningcss-linux-arm-gnueabihf: 1.30.1
|
||||
lightningcss-linux-arm64-gnu: 1.30.1
|
||||
lightningcss-linux-arm64-musl: 1.30.1
|
||||
lightningcss-linux-x64-gnu: 1.30.1
|
||||
lightningcss-linux-x64-musl: 1.30.1
|
||||
lightningcss-win32-arm64-msvc: 1.30.1
|
||||
lightningcss-win32-x64-msvc: 1.30.1
|
||||
|
||||
lines-and-columns@1.2.4: {}
|
||||
|
||||
listr2@8.2.5:
|
||||
@@ -8385,6 +8732,12 @@ snapshots:
|
||||
|
||||
symbol-observable@4.0.0: {}
|
||||
|
||||
tailwindcss-primeui@0.6.1(tailwindcss@4.1.10):
|
||||
dependencies:
|
||||
tailwindcss: 4.1.10
|
||||
|
||||
tailwindcss@4.1.10: {}
|
||||
|
||||
tapable@2.2.2: {}
|
||||
|
||||
tar@6.2.1:
|
||||
@@ -8523,7 +8876,7 @@ snapshots:
|
||||
|
||||
vary@1.1.2: {}
|
||||
|
||||
vite@6.2.7(@types/node@24.0.3)(jiti@1.21.7)(less@4.2.2)(sass@1.85.0)(terser@5.39.0):
|
||||
vite@6.2.7(@types/node@24.0.3)(jiti@2.4.2)(less@4.2.2)(lightningcss@1.30.1)(sass@1.85.0)(terser@5.39.0):
|
||||
dependencies:
|
||||
esbuild: 0.25.4
|
||||
postcss: 8.5.6
|
||||
@@ -8531,8 +8884,9 @@ snapshots:
|
||||
optionalDependencies:
|
||||
'@types/node': 24.0.3
|
||||
fsevents: 2.3.3
|
||||
jiti: 1.21.7
|
||||
jiti: 2.4.2
|
||||
less: 4.2.2
|
||||
lightningcss: 1.30.1
|
||||
sass: 1.85.0
|
||||
terser: 5.39.0
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
module.exports = {
|
||||
"/api": {
|
||||
target:
|
||||
process.env["services__Vegasco-Server-Api__https__0"] ||
|
||||
process.env["services__Vegasco-Server-Api__http__0"],
|
||||
process.env["services__Api__https__0"] ||
|
||||
process.env["services__Api__http__0"],
|
||||
secure: process.env["NODE_ENV"] !== "development",
|
||||
pathRewrite: {
|
||||
"^/api": "",
|
||||
|
||||
6
src/Vegasco-Web/src/app/api/api-base-path.ts
Normal file
6
src/Vegasco-Web/src/app/api/api-base-path.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import { InjectionToken } from "@angular/core";
|
||||
|
||||
/**
|
||||
* The base path for all API requests, e.g. when using a proxy on the origin's address.
|
||||
*/
|
||||
export const API_BASE_PATH = new InjectionToken<string>('API_BASE_PATH');
|
||||
35
src/Vegasco-Web/src/app/api/cars/car-client.ts
Normal file
35
src/Vegasco-Web/src/app/api/cars/car-client.ts
Normal file
@@ -0,0 +1,35 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { map, Observable } from 'rxjs';
|
||||
import { API_BASE_PATH } from "../api-base-path";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class CarClient {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly apiBasePath = inject(API_BASE_PATH, { optional: true });
|
||||
|
||||
getAll(): Observable<GetCarsResponse> {
|
||||
return this.http.get<GetCarsResponse>(`${this.apiBasePath}/v1/cars`);
|
||||
}
|
||||
|
||||
getSingle(id: string): Observable<Car> {
|
||||
return this.http.get<Car>(`${this.apiBasePath}/v1/cars/${id}`);
|
||||
}
|
||||
|
||||
create(request: CreateCarRequest): Observable<Car> {
|
||||
return this.http.post<Car>(`${this.apiBasePath}/v1/cars`, request);
|
||||
}
|
||||
|
||||
update(id: string, request: UpdateCarRequest): Observable<Car> {
|
||||
return this.http.put<Car>(`${this.apiBasePath}/v1/cars/${id}`, request);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete(`${this.apiBasePath}/v1/cars/${id}`)
|
||||
.pipe(
|
||||
map(_ => undefined)
|
||||
);
|
||||
}
|
||||
}
|
||||
4
src/Vegasco-Web/src/app/api/cars/car.ts
Normal file
4
src/Vegasco-Web/src/app/api/cars/car.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
interface Car {
|
||||
id: string;
|
||||
name: string;
|
||||
}
|
||||
3
src/Vegasco-Web/src/app/api/cars/create-car-request.ts
Normal file
3
src/Vegasco-Web/src/app/api/cars/create-car-request.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
interface CreateCarRequest {
|
||||
name: string;
|
||||
}
|
||||
3
src/Vegasco-Web/src/app/api/cars/get-cars-response.ts
Normal file
3
src/Vegasco-Web/src/app/api/cars/get-cars-response.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
interface GetCarsResponse {
|
||||
cars: Car[];
|
||||
}
|
||||
3
src/Vegasco-Web/src/app/api/cars/update-car-request.ts
Normal file
3
src/Vegasco-Web/src/app/api/cars/update-car-request.ts
Normal file
@@ -0,0 +1,3 @@
|
||||
interface UpdateCarRequest {
|
||||
name: string;
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { HttpClient } from '@angular/common/http';
|
||||
import { inject, Injectable } from '@angular/core';
|
||||
import { map, Observable } from 'rxjs';
|
||||
import { API_BASE_PATH } from '../api-base-path';
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root',
|
||||
})
|
||||
export class ConsumptionClient {
|
||||
private readonly http = inject(HttpClient);
|
||||
private readonly apiBasePath = inject(API_BASE_PATH, { optional: true });
|
||||
|
||||
getAll(): Observable<GetConsumptionEntriesResponse> {
|
||||
return this.http.get<GetConsumptionEntriesResponse>(`${this.apiBasePath}/v1/consumptions`);
|
||||
}
|
||||
|
||||
getSingle(id: string): Observable<ConsumptionEntry> {
|
||||
return this.http.get<ConsumptionEntry>(`${this.apiBasePath}/v1/consumptions/${id}`);
|
||||
}
|
||||
|
||||
create(request: CreateConsumptionEntry): Observable<ConsumptionEntry> {
|
||||
return this.http.post<ConsumptionEntry>(`${this.apiBasePath}/v1/consumptions`, request);
|
||||
}
|
||||
|
||||
update(id: string, request: UpdateConsumptionEntry): Observable<ConsumptionEntry> {
|
||||
return this.http.put<ConsumptionEntry>(`${this.apiBasePath}/v1/consumptions/${id}`, request);
|
||||
}
|
||||
|
||||
delete(id: string): Observable<void> {
|
||||
return this.http.delete(`${this.apiBasePath}/v1/consumptions/${id}`)
|
||||
.pipe(
|
||||
map(_ => undefined),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,5 @@ interface ConsumptionEntry {
|
||||
dateTime: string;
|
||||
distance: number;
|
||||
amount: number;
|
||||
ignoreInCalculation: boolean;
|
||||
carId: string;
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
interface CreateConsumptionEntry {
|
||||
dateTime: string;
|
||||
distance: number;
|
||||
amount: number;
|
||||
carId: string;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
interface GetConsumptionEntriesEntry {
|
||||
id: string;
|
||||
dateTime: string;
|
||||
distance: number;
|
||||
amount: number;
|
||||
car: {
|
||||
id: string;
|
||||
name: string;
|
||||
};
|
||||
literPer100Km: number | null;
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
interface GetConsumptionEntriesResponse {
|
||||
consumptions: GetConsumptionEntriesEntry[];
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
interface UpdateConsumptionEntry {
|
||||
dateTime: string;
|
||||
distance: number;
|
||||
amount: number;
|
||||
carId: string;
|
||||
}
|
||||
@@ -1,3 +0,0 @@
|
||||
interface GetConsumptionEntriesResponse {
|
||||
consumptions: ConsumptionEntry[];
|
||||
}
|
||||
@@ -7,6 +7,7 @@ import { includeBearerTokenInterceptor } from 'keycloak-angular';
|
||||
import { providePrimeNG } from 'primeng/config';
|
||||
import { routes } from './app.routes';
|
||||
import { provideKeycloakAngular } from './auth/auth.config';
|
||||
import {API_BASE_PATH} from './api/api-base-path';
|
||||
|
||||
export const appConfig: ApplicationConfig = {
|
||||
providers: [
|
||||
@@ -21,5 +22,9 @@ export const appConfig: ApplicationConfig = {
|
||||
},
|
||||
ripple: true
|
||||
}),
|
||||
{
|
||||
provide: API_BASE_PATH,
|
||||
useValue: '/api'
|
||||
}
|
||||
]
|
||||
};
|
||||
|
||||
@@ -1,5 +1,21 @@
|
||||
<main class="main">
|
||||
<div class="content">
|
||||
<router-outlet/>
|
||||
<header class="h-12 bg-primary text-primary-contrast">
|
||||
<div class="header max-content-width mx-auto flex items-center justify-between">
|
||||
<a routerLink="/" class="reset cursor-pointer">
|
||||
Vegasco
|
||||
</a>
|
||||
<span class="flex items-center gap-4">
|
||||
<a routerLink="/entries" class="reset cursor-pointer">
|
||||
Einträge
|
||||
</a>
|
||||
<a routerLink="/cars" class="reset cursor-pointer">
|
||||
Autos
|
||||
</a>
|
||||
</span>
|
||||
</div>
|
||||
</header>
|
||||
<div class="content max-content-width mx-auto">
|
||||
<p-toast />
|
||||
<router-outlet></router-outlet>
|
||||
</div>
|
||||
</main>
|
||||
</main>
|
||||
@@ -9,5 +9,9 @@ export const routes: Routes = [
|
||||
{
|
||||
path: 'entries',
|
||||
loadChildren: () => import('./modules/entries/entries.routes').then(m => m.routes)
|
||||
},
|
||||
{
|
||||
path: 'cars',
|
||||
loadChildren: () => import('./modules/cars/cars.routes').then(m => m.routes)
|
||||
}
|
||||
];
|
||||
|
||||
@@ -0,0 +1,10 @@
|
||||
.content {
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.header {
|
||||
padding: 0 1rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
@@ -1,12 +1,15 @@
|
||||
import { Component } from '@angular/core';
|
||||
import { RouterOutlet } from '@angular/router';
|
||||
import { RouterLink, RouterOutlet } from '@angular/router';
|
||||
import { MessageService } from 'primeng/api';
|
||||
import { ToastModule } from 'primeng/toast';
|
||||
|
||||
|
||||
@Component({
|
||||
selector: 'app-root',
|
||||
imports: [RouterOutlet],
|
||||
imports: [RouterLink, RouterOutlet, ToastModule],
|
||||
providers: [MessageService],
|
||||
templateUrl: './app.html',
|
||||
styleUrl: './app.scss'
|
||||
})
|
||||
export class App {
|
||||
protected title = 'Vegasco-Web';
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
import { environment } from '../../environments/environment';
|
||||
import { environment } from '@vegasco-web/environments/environment';
|
||||
import {
|
||||
provideKeycloak,
|
||||
createInterceptorCondition,
|
||||
IncludeBearerTokenCondition,
|
||||
INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG,
|
||||
withAutoRefreshToken,
|
||||
AutoRefreshTokenService,
|
||||
UserActivityService
|
||||
createInterceptorCondition,
|
||||
INCLUDE_BEARER_TOKEN_INTERCEPTOR_CONFIG,
|
||||
IncludeBearerTokenCondition,
|
||||
provideKeycloak,
|
||||
UserActivityService,
|
||||
withAutoRefreshToken
|
||||
} from 'keycloak-angular';
|
||||
|
||||
const serverHostBearerInterceptorCondition = createInterceptorCondition<IncludeBearerTokenCondition>({
|
||||
|
||||
17
src/Vegasco-Web/src/app/modules/cars/cars.routes.ts
Normal file
17
src/Vegasco-Web/src/app/modules/cars/cars.routes.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
import { Routes } from "@angular/router";
|
||||
import { CarsComponent } from "./cars/cars.component";
|
||||
|
||||
export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: CarsComponent
|
||||
},
|
||||
{
|
||||
path: 'create',
|
||||
loadComponent: () => import('./edit-car/edit-car.component').then(m => m.EditCarComponent)
|
||||
},
|
||||
{
|
||||
path: 'edit/:id',
|
||||
loadComponent: () => import('./edit-car/edit-car.component').then(m => m.EditCarComponent)
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,38 @@
|
||||
<section>
|
||||
<p-scrollTop />
|
||||
<div class="mb-4 flex gap-2 md:justify-end">
|
||||
<div>
|
||||
<p-button label="Erstellen" routerLink="/cars/create">
|
||||
<ng-icon name="matAddSharp"></ng-icon>
|
||||
</p-button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
@if (nonDeletedCars$ | async; as cars) {
|
||||
<p-dataView
|
||||
[value]="cars"
|
||||
[paginator]="true"
|
||||
[rows]="25"
|
||||
[rowsPerPageOptions]="[10, 25, 50, 100]"
|
||||
[pageLinks]="0"
|
||||
[showCurrentPageReport]="true"
|
||||
currentPageReportTemplate="{currentPage} / {totalPages}"
|
||||
layout="list">
|
||||
<ng-template #list let-cars>
|
||||
<div class="flex flex-col gap-2">
|
||||
@for (car of cars; track car.id) {
|
||||
<app-car-card [car]="car"
|
||||
(carDeleted)="onCarDeleted($event)" />
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
</p-dataView>
|
||||
} @else {
|
||||
<div class="flex flex-col gap-2">
|
||||
@for (_ of skeletonsIterationSource; track $index) {
|
||||
<p-skeleton height="4rem" styleClass="mb-2" />
|
||||
}
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</section>
|
||||
@@ -0,0 +1,3 @@
|
||||
th, td {
|
||||
padding: 0.5rem;
|
||||
}
|
||||
127
src/Vegasco-Web/src/app/modules/cars/cars/cars.component.ts
Normal file
127
src/Vegasco-Web/src/app/modules/cars/cars/cars.component.ts
Normal file
@@ -0,0 +1,127 @@
|
||||
import { AsyncPipe, CommonModule } from '@angular/common';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, DestroyRef, inject } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
matAddSharp,
|
||||
} from '@ng-icons/material-icons/sharp';
|
||||
import { CarClient } from '@vegasco-web/api/cars/car-client';
|
||||
import { MessageService } from 'primeng/api';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { DataViewModule } from 'primeng/dataview';
|
||||
import { ScrollTopModule } from 'primeng/scrolltop';
|
||||
import { SelectModule } from 'primeng/select';
|
||||
import { SkeletonModule } from 'primeng/skeleton';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
catchError,
|
||||
combineLatest,
|
||||
EMPTY,
|
||||
map,
|
||||
Observable,
|
||||
throwError
|
||||
} from 'rxjs';
|
||||
import { CarCardComponent } from './components/car-card/car-card.component';
|
||||
import { SelectedCarService } from '@vegasco-web/modules/entries/services/selected-car.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-entries',
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
ButtonModule,
|
||||
CommonModule,
|
||||
DataViewModule,
|
||||
CarCardComponent,
|
||||
NgIconComponent,
|
||||
ReactiveFormsModule,
|
||||
RouterLink,
|
||||
ScrollTopModule,
|
||||
SelectModule,
|
||||
SkeletonModule,
|
||||
],
|
||||
providers: [
|
||||
provideIcons({
|
||||
matAddSharp,
|
||||
}),
|
||||
],
|
||||
templateUrl: './cars.component.html',
|
||||
styleUrl: './cars.component.scss'
|
||||
})
|
||||
export class CarsComponent {
|
||||
private readonly carClient = inject(CarClient);
|
||||
private readonly messageService = inject(MessageService);
|
||||
private readonly selectedCarService = inject(SelectedCarService);
|
||||
|
||||
protected readonly nonDeletedCars$: Observable<Car[]>;
|
||||
|
||||
protected readonly skeletonsIterationSource = Array(10).fill(0);
|
||||
|
||||
private readonly deletedCars$ = new BehaviorSubject(<string[]>[]);
|
||||
|
||||
constructor() {
|
||||
const cars$ = this.carClient.getAll()
|
||||
.pipe(
|
||||
map(response => response.cars),
|
||||
map((cars) => cars
|
||||
.sort((a, b) => a.name.localeCompare(b.name))),
|
||||
);
|
||||
|
||||
this.nonDeletedCars$ = combineLatest([
|
||||
cars$,
|
||||
this.deletedCars$
|
||||
])
|
||||
.pipe(
|
||||
takeUntilDestroyed(),
|
||||
map(([cars, deletedCars]) => cars.filter(car => !deletedCars.includes(car.id))),
|
||||
catchError((error) => this.handleGetCarsError(error)),
|
||||
);
|
||||
}
|
||||
|
||||
onCarDeleted(car: Car): void {
|
||||
this.deletedCars$.next([...this.deletedCars$.value, car.id]);
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Auto gelöscht',
|
||||
detail: 'Das Auto wurde erfolgreich gelöscht.',
|
||||
});
|
||||
this.resetSelectedCarIfDeleted(car);
|
||||
}
|
||||
|
||||
private resetSelectedCarIfDeleted(car: Car) {
|
||||
const selectedCarId = this.selectedCarService.getSelectedCarId();
|
||||
if (selectedCarId === car.id) {
|
||||
this.selectedCarService.setSelectedCarId(null);
|
||||
}
|
||||
}
|
||||
|
||||
private handleGetCarsError(error: unknown): Observable<never> {
|
||||
if (!(error instanceof HttpErrorResponse)) {
|
||||
return throwError(() => new Error('An unexpected error occurred'));
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case error.status >= 500 && error.status <= 599:
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Serverfehler',
|
||||
detail:
|
||||
'Beim Abrufen der Einträge ist ein Fehler aufgetreten. Bitte versuche es erneut.',
|
||||
});
|
||||
break;
|
||||
default:
|
||||
console.error(error);
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Unerwarteter Fehler',
|
||||
detail:
|
||||
'Beim Abrufen der Einträge hat der Server eine unerwartete Antwort zurückgegeben.',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
<p-confirmDialog></p-confirmDialog>
|
||||
|
||||
<div class="flex rounded-border shadow">
|
||||
<div class="grow p-4 pos-relative edit-button" (click)="navigateToEdit()" role="button"
|
||||
aria-roledescription="Bearbeite diesen Eintrag">
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
|
||||
<div class="col-span-4 sm:col-span-2 md:col-span-1 flex my-auto items-center justify-center">
|
||||
<div class="flex gap-2 items-center">
|
||||
<ng-icon name="matDirectionsCarOutline" />
|
||||
<div>{{ car().name }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-red-500 text-white rounded-r text-center flex flex-col justify-center">
|
||||
<button type="button" title="Löschen" class="reset cursor-pointer primary-color-text p-4 h-full rounded-r"
|
||||
(click)="confirmDeleteCar()">
|
||||
<ng-icon name="matDeleteSharp"></ng-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
.edit-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, DestroyRef, inject, input, output } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
matDirectionsCarOutline,
|
||||
} from '@ng-icons/material-icons/outline';
|
||||
import {
|
||||
matDeleteSharp
|
||||
} from '@ng-icons/material-icons/sharp';
|
||||
import { CarClient } from '@vegasco-web/api/cars/car-client';
|
||||
import { RoutingService } from '@vegasco-web/services/routing.service';
|
||||
import { ConfirmationService, MessageService } from 'primeng/api';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { CardModule } from 'primeng/card';
|
||||
import { ConfirmDialogModule } from 'primeng/confirmdialog';
|
||||
import { catchError, EMPTY, Observable, tap, throwError } from 'rxjs';
|
||||
|
||||
@Component({
|
||||
selector: 'app-car-card',
|
||||
imports: [
|
||||
ButtonModule,
|
||||
CardModule,
|
||||
ConfirmDialogModule,
|
||||
NgIconComponent,
|
||||
],
|
||||
providers: [
|
||||
provideIcons({
|
||||
matDeleteSharp,
|
||||
matDirectionsCarOutline,
|
||||
}),
|
||||
ConfirmationService,
|
||||
],
|
||||
templateUrl: './car-card.component.html',
|
||||
styleUrl: './car-card.component.scss'
|
||||
})
|
||||
export class CarCardComponent {
|
||||
readonly car = input.required<Car>();
|
||||
|
||||
readonly carDeleted = output<Car>();
|
||||
|
||||
private readonly routingService = inject(RoutingService);
|
||||
private readonly carClient = inject(CarClient);
|
||||
private readonly messageService = inject(MessageService);
|
||||
private readonly confirmationService = inject(ConfirmationService);
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
async navigateToEdit(): Promise<void> {
|
||||
await this.routingService.navigateToEditCar(this.car().id);
|
||||
}
|
||||
|
||||
confirmDeleteCar(): void {
|
||||
this.confirmationService.confirm({
|
||||
closeOnEscape: true,
|
||||
dismissableMask: true,
|
||||
header: 'Bist du sicher?',
|
||||
message: `Möchtest du das Auto "${this.car().name}" wirklich löschen?`,
|
||||
acceptButtonProps: {
|
||||
label: 'Löschen',
|
||||
severity: 'danger',
|
||||
},
|
||||
rejectButtonProps: {
|
||||
label: 'Abbrechen',
|
||||
outlined: true,
|
||||
},
|
||||
accept: () => this.deleteCar(),
|
||||
});
|
||||
}
|
||||
|
||||
deleteCar(): void {
|
||||
this.carClient.delete(this.car().id)
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
tap(() => this.carDeleted.emit(this.car())),
|
||||
catchError((error) => this.handleError(error)),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
private handleError(error: unknown): Observable<never> {
|
||||
if (!(error instanceof HttpErrorResponse)) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case error.status >= 500 && error.status <= 599:
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Serverfehler',
|
||||
detail:
|
||||
'Beim Löschen des Autos ist ein Fehler aufgetreten. Bitte versuche es erneut.',
|
||||
});
|
||||
break;
|
||||
case error.status === 400:
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Clientfehler',
|
||||
detail:
|
||||
'Die Anwendung scheint falsche Daten an den Server zu senden.',
|
||||
});
|
||||
break;
|
||||
default:
|
||||
console.error(error);
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Unerwarteter Fehler',
|
||||
detail:
|
||||
'Beim Löschen des Autos hat der Server eine unerwartete Antwort zurückgegeben.',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<span class="required">*</span>
|
||||
@@ -0,0 +1,3 @@
|
||||
.required {
|
||||
color: red;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-required-marker',
|
||||
imports: [
|
||||
],
|
||||
templateUrl: './required-marker.component.html',
|
||||
styleUrl: './required-marker.component.scss'
|
||||
})
|
||||
export class RequiredMarkerComponent {
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
@if (!isCarDataLoaded()) {
|
||||
<div class="flex flex-col gap-6">
|
||||
<p-skeleton height="3.5rem" />
|
||||
<div class="flex flex-row gap-4">
|
||||
<p-skeleton height="3.5rem" width="10rem" />
|
||||
<p-skeleton height="3.5rem" width="10rem" />
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<form [formGroup]="formGroup" class="flex flex-col gap-4" (ngSubmit)="onSubmit()">
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label [for]="formFieldNames.name">
|
||||
Name
|
||||
<app-required-marker />
|
||||
</label>
|
||||
<input
|
||||
id="name"
|
||||
placeholder="Name eingeben"
|
||||
type="text"
|
||||
pInputText
|
||||
[formControlName]="formFieldNames.name" />
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
<p-button type="button" label="Abbrechen" (click)="navigateToOverviewPage()" severity="warn" />
|
||||
<p-button type="submit" label="Abschicken" severity="success" [disabled]="formGroup.invalid" />
|
||||
</div>
|
||||
|
||||
</form>
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, DestroyRef, inject, input, OnInit, signal } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import { CarClient } from '@vegasco-web/api/cars/car-client';
|
||||
import { RoutingService } from '@vegasco-web/services/routing.service';
|
||||
import { MessageService } from 'primeng/api';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { ChipModule } from 'primeng/chip';
|
||||
import { DatePickerModule } from 'primeng/datepicker';
|
||||
import { FloatLabelModule } from 'primeng/floatlabel';
|
||||
import { InputGroupModule } from 'primeng/inputgroup';
|
||||
import { InputGroupAddonModule } from 'primeng/inputgroupaddon';
|
||||
import { InputNumberModule } from 'primeng/inputnumber';
|
||||
import { InputTextModule } from 'primeng/inputtext';
|
||||
import { MultiSelectModule } from 'primeng/multiselect';
|
||||
import { SelectModule } from 'primeng/select';
|
||||
import { SkeletonModule } from 'primeng/skeleton';
|
||||
import { catchError, EMPTY, Observable, switchMap, tap, throwError } from 'rxjs';
|
||||
import { RequiredMarkerComponent } from './components/required-marker.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-edit-entry',
|
||||
imports: [
|
||||
ButtonModule,
|
||||
ChipModule,
|
||||
DatePickerModule,
|
||||
FloatLabelModule,
|
||||
InputGroupAddonModule,
|
||||
InputGroupModule,
|
||||
InputNumberModule,
|
||||
InputTextModule,
|
||||
MultiSelectModule,
|
||||
ReactiveFormsModule,
|
||||
RequiredMarkerComponent,
|
||||
SelectModule,
|
||||
SkeletonModule,
|
||||
],
|
||||
templateUrl: './edit-car.component.html',
|
||||
styleUrl: './edit-car.component.scss'
|
||||
})
|
||||
export class EditCarComponent implements OnInit {
|
||||
private readonly carClient = inject(CarClient);
|
||||
private readonly routingService = inject(RoutingService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly messageService = inject(MessageService);
|
||||
|
||||
protected readonly id = input<string | undefined>(undefined);
|
||||
|
||||
protected readonly today = new Date();
|
||||
|
||||
protected readonly formFieldNames = {
|
||||
name: 'name',
|
||||
} as const;
|
||||
|
||||
protected readonly formGroup = new FormGroup({
|
||||
[this.formFieldNames.name]: new FormControl<string | null>({ value: null, disabled: true }, [Validators.required]),
|
||||
});
|
||||
|
||||
protected readonly isCarDataLoaded = signal(false);
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadEntryDetailsAndEnableControls();
|
||||
}
|
||||
|
||||
private loadEntryDetailsAndEnableControls() {
|
||||
const carId = this.id();
|
||||
|
||||
if (carId === undefined || carId === null) {
|
||||
this.enableFormControls();
|
||||
this.isCarDataLoaded.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
this.carClient
|
||||
.getSingle(carId)
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
catchError((error) => this.handleGetError(error)),
|
||||
tap((car) => {
|
||||
this.formGroup.patchValue({
|
||||
[this.formFieldNames.name]: car.name,
|
||||
});
|
||||
}),
|
||||
tap(() => {
|
||||
this.enableFormControls();
|
||||
this.isCarDataLoaded.set(true);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
|
||||
}
|
||||
|
||||
private enableFormControls(): void {
|
||||
for (const controlName of Object.values(this.formFieldNames)) {
|
||||
const control = this.formGroup.get(controlName);
|
||||
if (control) {
|
||||
control.enable();
|
||||
} else {
|
||||
console.warn(`Form control '${controlName}' not found.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async navigateToOverviewPage(): Promise<void> {
|
||||
await this.routingService.navigateToCars();
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.formGroup.invalid) {
|
||||
this.formGroup.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
var carId = this.id();
|
||||
if (carId === undefined || carId === null) {
|
||||
this.createCar();
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateCar(carId);
|
||||
}
|
||||
|
||||
private getFormData() {
|
||||
return {
|
||||
name: this.formGroup.controls[this.formFieldNames.name].value!,
|
||||
};
|
||||
}
|
||||
|
||||
createCar() {
|
||||
var request: CreateCarRequest = this.getFormData();
|
||||
this.carClient.create(request)
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
catchError((error) => this.handleCreateOrUpdateError(error, false)),
|
||||
switchMap(() => this.routingService.navigateToCars())
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
updateCar(id: string) {
|
||||
var request: UpdateCarRequest = this.getFormData();
|
||||
this.carClient.update(id, request)
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
catchError((error) => this.handleCreateOrUpdateError(error, true)),
|
||||
switchMap(() => this.routingService.navigateToCars())
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
private handleGetError(error: unknown): Observable<never> {
|
||||
if (!(error instanceof HttpErrorResponse)) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case error.status >= 500 && error.status <= 599:
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Serverfehler',
|
||||
detail:
|
||||
'Beim Abrufen des Autos ist ein Fehler aufgetreten. Bitte versuche es erneut.',
|
||||
});
|
||||
break;
|
||||
default:
|
||||
console.error(error);
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Unerwarteter Fehler',
|
||||
detail:
|
||||
'Beim Abrufen des Autos hat der Server eine unerwartete Antwort zurückgegeben.',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
private handleCreateOrUpdateError(error: unknown, isUpdate: boolean): Observable<never> {
|
||||
if (!(error instanceof HttpErrorResponse)) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
const action = isUpdate ? 'Aktualisieren' : 'Erstellen';
|
||||
|
||||
switch (true) {
|
||||
case error.status >= 500 && error.status <= 599:
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Serverfehler',
|
||||
detail:
|
||||
`Beim ${action} des Eintrags ist ein Fehler aufgetreten. Bitte versuche es erneut.`,
|
||||
});
|
||||
break;
|
||||
case error.status === 400:
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Clientfehler',
|
||||
detail:
|
||||
'Die Anwendung scheint falsche Daten an den Server zu senden.',
|
||||
});
|
||||
break;
|
||||
case error.status === 409:
|
||||
this.messageService.add({
|
||||
severity: 'warn',
|
||||
summary: 'Konflikt',
|
||||
detail:
|
||||
'Es existiert bereits ein Auto mit diesem Namen. Bitte wähle einen anderen Namen.',
|
||||
});
|
||||
break;
|
||||
default:
|
||||
console.error(error);
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Unerwarteter Fehler',
|
||||
detail:
|
||||
`Beim ${action} des Eintrags hat der Server eine unerwartete Antwort zurückgegeben.`,
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
<span class="required">*</span>
|
||||
@@ -0,0 +1,3 @@
|
||||
.required {
|
||||
color: red;
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import { Component } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-required-marker',
|
||||
imports: [
|
||||
],
|
||||
templateUrl: './required-marker.component.html',
|
||||
styleUrl: './required-marker.component.scss'
|
||||
})
|
||||
export class RequiredMarkerComponent {
|
||||
}
|
||||
@@ -1,83 +1,84 @@
|
||||
@if (!loaded) {
|
||||
<p-skeleton height="4rem" styleClass="mb-2" />
|
||||
@if (isLoading()) {
|
||||
<div class="flex flex-col gap-6">
|
||||
<p-skeleton height="3.5rem" />
|
||||
<p-skeleton height="3.5rem" />
|
||||
<p-skeleton height="3.5rem" />
|
||||
<p-skeleton height="3.5rem" />
|
||||
<div class="flex flex-row gap-4">
|
||||
<p-skeleton height="3.5rem" width="10rem" />
|
||||
<p-skeleton height="3.5rem" width="10rem" />
|
||||
</div>
|
||||
</div>
|
||||
} @else {
|
||||
<form [formGroup]="formGroup" class="flex flex-col gap-4" (ngSubmit)="onSubmit()">
|
||||
|
||||
<div class="flex gap-2 w-full">
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<label for="date"> Datum </label>
|
||||
<p-datepicker [iconDisplay]="'input'"
|
||||
[firstDayOfWeek]="1"
|
||||
placeholder="Datum auswählen"
|
||||
[showIcon]="true"
|
||||
inputId="date"
|
||||
formControlName="date"
|
||||
styleClass="w-full"
|
||||
dateFormat="dd.mm.yy" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2 w-full">
|
||||
<label for="time"> Uhrzeit </label>
|
||||
<p-datepicker [iconDisplay]="'input'"
|
||||
[showIcon]="true"
|
||||
[timeOnly]="true"
|
||||
placeholder="Uhrzeit auswählen"
|
||||
inputId="time"
|
||||
formControlName="time"
|
||||
styleClass="w-full"
|
||||
inputStyleClass="w-full" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="rabbit"> Kaninchen </label>
|
||||
@if (rabbits$ | async; as rabbits) {
|
||||
<label [for]="formFieldNames.car">
|
||||
Auto
|
||||
<app-required-marker />
|
||||
</label>
|
||||
@if (cars(); as cars) {
|
||||
<p-select
|
||||
[options]="rabbits"
|
||||
placeholder="Kaninchen auswählen"
|
||||
formControlName="rabbit"
|
||||
[options]="cars"
|
||||
placeholder="Auto auswählen"
|
||||
[formControlName]="formFieldNames.car"
|
||||
optionLabel="name"
|
||||
inputId="rabbit"
|
||||
[inputId]="formFieldNames.car"
|
||||
styleClass="w-full" />
|
||||
}
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="medicines"> Medizin </label>
|
||||
@if (medicines$ | async; as medicines) {
|
||||
<p-multiSelect
|
||||
[options]="medicines"
|
||||
[showToggleAll]="false"
|
||||
[showHeader]="false"
|
||||
[autoOptionFocus]="false"
|
||||
[filter]="false"
|
||||
[showClear]="true"
|
||||
[maxSelectedLabels]="medicines.length"
|
||||
optionLabel="abbreviation"
|
||||
formControlName="medicines"
|
||||
inputId="medicines"
|
||||
placeholder="Medizin auswählen"
|
||||
display="chip"
|
||||
styleClass="w-full">
|
||||
<ng-template let-medicine pTemplate="item">
|
||||
{{ medicine.name }} ({{ medicine.abbreviation }})
|
||||
</ng-template>
|
||||
</p-multiSelect>
|
||||
}
|
||||
<div class="flex gap-2 items-center">
|
||||
<label [for]="formFieldNames.date">
|
||||
Datum
|
||||
<app-required-marker />
|
||||
</label>
|
||||
</div>
|
||||
<p-datepicker [iconDisplay]="'input'"
|
||||
[firstDayOfWeek]="1"
|
||||
placeholder="Datum auswählen"
|
||||
[showIcon]="true"
|
||||
[maxDate]="today"
|
||||
[defaultDate]="today"
|
||||
[inputId]="formFieldNames.date"
|
||||
[formControlName]="formFieldNames.date"
|
||||
styleClass="w-full"
|
||||
dateFormat="dd.mm.yy" />
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="weight"> Gewicht </label>
|
||||
<label [for]="formFieldNames.mileage">
|
||||
Kilometerstand
|
||||
<app-required-marker />
|
||||
</label>
|
||||
<p-inputGroup>
|
||||
<input id="weight" placeholder="Gewicht eingeben" type="number" min="1" pInputText formControlName="weight" />
|
||||
<p-inputGroupAddon>g</p-inputGroupAddon>
|
||||
<input
|
||||
id="mileage"
|
||||
placeholder="Kilometerstand eingeben"
|
||||
type="number"
|
||||
min="1"
|
||||
pInputText
|
||||
[formControlName]="formFieldNames.mileage" />
|
||||
<p-inputGroupAddon>km</p-inputGroupAddon>
|
||||
</p-inputGroup>
|
||||
</div>
|
||||
|
||||
<div class="flex flex-col gap-2">
|
||||
<label for="comment"> Kommentar </label>
|
||||
<input pInputText id="comment" placeholder="Kommentar eingeben" type="text" formControlName="comment"
|
||||
class="w-full" />
|
||||
<label [for]="formFieldNames.amount">
|
||||
Menge
|
||||
<app-required-marker />
|
||||
</label>
|
||||
<p-inputGroup>
|
||||
<input
|
||||
id="amount"
|
||||
placeholder="Menge eingeben"
|
||||
type="number"
|
||||
min="1"
|
||||
pInputText
|
||||
[formControlName]="formFieldNames.amount" />
|
||||
<p-inputGroupAddon>ℓ</p-inputGroupAddon>
|
||||
</p-inputGroup>
|
||||
</div>
|
||||
|
||||
<div class="flex gap-2">
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { AsyncPipe } from '@angular/common';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { FormBuilder, ReactiveFormsModule, Validators } from '@angular/forms';
|
||||
import dayjs from 'dayjs';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, computed, DestroyRef, inject, input, OnInit, signal, Signal } from '@angular/core';
|
||||
import { takeUntilDestroyed, toSignal } from '@angular/core/rxjs-interop';
|
||||
import { FormControl, FormGroup, ReactiveFormsModule, ValidationErrors, Validators } from '@angular/forms';
|
||||
import { CarClient } from '@vegasco-web/api/cars/car-client';
|
||||
import { ConsumptionClient } from '@vegasco-web/api/consumptions/consumption-client';
|
||||
import { RoutingService } from '@vegasco-web/services/routing.service';
|
||||
import { MessageService } from 'primeng/api';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { ChipModule } from 'primeng/chip';
|
||||
import { DatePickerModule } from 'primeng/datepicker';
|
||||
@@ -12,11 +18,13 @@ import { InputTextModule } from 'primeng/inputtext';
|
||||
import { MultiSelectModule } from 'primeng/multiselect';
|
||||
import { SelectModule } from 'primeng/select';
|
||||
import { SkeletonModule } from 'primeng/skeleton';
|
||||
import { catchError, combineLatest, EMPTY, filter, map, Observable, switchMap, tap, throwError } from 'rxjs';
|
||||
import { RequiredMarkerComponent } from './components/required-marker.component';
|
||||
import { SelectedCarService } from '../services/selected-car.service';
|
||||
|
||||
@Component({
|
||||
selector: 'app-edit-entry',
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
ButtonModule,
|
||||
ChipModule,
|
||||
DatePickerModule,
|
||||
@@ -27,24 +35,259 @@ import { SkeletonModule } from 'primeng/skeleton';
|
||||
InputTextModule,
|
||||
MultiSelectModule,
|
||||
ReactiveFormsModule,
|
||||
RequiredMarkerComponent,
|
||||
SelectModule,
|
||||
SkeletonModule,
|
||||
],
|
||||
templateUrl: './edit-entry.component.html',
|
||||
styleUrl: './edit-entry.component.scss'
|
||||
})
|
||||
export class EditEntryComponent {
|
||||
private readonly formBuilder = inject(FormBuilder);
|
||||
export class EditEntryComponent implements OnInit {
|
||||
private readonly carClient = inject(CarClient);
|
||||
private readonly consumptionClient = inject(ConsumptionClient);
|
||||
private readonly routingService = inject(RoutingService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
private readonly messageService = inject(MessageService);
|
||||
private readonly selectedCarService = inject(SelectedCarService);
|
||||
|
||||
protected readonly formGroup = this.formBuilder.group({
|
||||
date: [<Date | null>null],
|
||||
time: [<Date | null>null],
|
||||
rabbit: [<Rabbit | null>null, Validators.required],
|
||||
weight: [
|
||||
<number | null>null,
|
||||
Validators.min(1),
|
||||
],
|
||||
medicines: [<Medicine[]>[]],
|
||||
comment: [<string | null>null],
|
||||
protected readonly id = input<string | undefined>(undefined);
|
||||
|
||||
protected readonly today = new Date();
|
||||
|
||||
protected readonly formFieldNames = {
|
||||
car: 'car',
|
||||
date: 'date',
|
||||
mileage: 'mileage',
|
||||
amount: 'amount',
|
||||
} as const;
|
||||
|
||||
protected readonly formGroup = new FormGroup({
|
||||
[this.formFieldNames.car]: new FormControl<Car | null>({ value: null, disabled: true }, [Validators.required]),
|
||||
[this.formFieldNames.date]: new FormControl<Date>({ value: new Date(), disabled: true }, [Validators.required, this.dateTimeGreaterThanOrEqualToTodayValidator]),
|
||||
[this.formFieldNames.mileage]: new FormControl<number | null>({ value: null, disabled: true }, [Validators.required, Validators.min(1)]),
|
||||
[this.formFieldNames.amount]: new FormControl<number | null>({ value: null, disabled: true }, [Validators.required, Validators.min(1)]),
|
||||
});
|
||||
|
||||
private readonly cars$: Observable<Car[]>;
|
||||
protected readonly cars: Signal<Car[] | undefined>;
|
||||
|
||||
private readonly isEntryDataLoaded = signal(false);
|
||||
|
||||
protected readonly isLoading = computed(() => {
|
||||
var cars = this.cars();
|
||||
var isEntryDataLoaded = this.isEntryDataLoaded();
|
||||
return cars === undefined || !isEntryDataLoaded;
|
||||
});
|
||||
|
||||
constructor() {
|
||||
this.cars$ = this.carClient
|
||||
.getAll()
|
||||
.pipe(
|
||||
takeUntilDestroyed(),
|
||||
map(response => response.cars
|
||||
.sort((a, b) => a.name.localeCompare(b.name))),
|
||||
tap(cars => {
|
||||
const selectedCarId = this.selectedCarService.getSelectedCarId();
|
||||
|
||||
if (selectedCarId === null) {
|
||||
const firstCar = cars[0];
|
||||
this.formGroup.controls[this.formFieldNames.car].setValue(firstCar);
|
||||
this.selectedCarService.setSelectedCarId(firstCar?.id ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCar = cars.find(car => car.id === selectedCarId);
|
||||
this.formGroup.controls[this.formFieldNames.car].setValue(selectedCar ?? null);
|
||||
this.selectedCarService.setSelectedCarId(selectedCar?.id ?? null);
|
||||
}),
|
||||
);
|
||||
this.cars = toSignal(this.cars$);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.loadEntryDetailsAndEnableControls();
|
||||
|
||||
this.formGroup.controls[this.formFieldNames.car]
|
||||
.valueChanges
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
tap((car) => {
|
||||
this.selectedCarService.setSelectedCarId(car?.id ?? null);
|
||||
})
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
private loadEntryDetailsAndEnableControls() {
|
||||
const entryId = this.id();
|
||||
|
||||
if (entryId === undefined || entryId === null) {
|
||||
this.enableFormControls();
|
||||
this.isEntryDataLoaded.set(true);
|
||||
return;
|
||||
}
|
||||
|
||||
const consumption$ = this.consumptionClient
|
||||
.getSingle(entryId);
|
||||
|
||||
combineLatest([
|
||||
consumption$, this.cars$
|
||||
])
|
||||
.pipe(
|
||||
filter(([_, cars]) => cars !== undefined),
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
catchError((error) => this.handleGetError(error)),
|
||||
tap(([consumption, cars]) => {
|
||||
this.formGroup.patchValue({
|
||||
[this.formFieldNames.car]: cars!.find(c => c.id === consumption.carId) ?? null,
|
||||
[this.formFieldNames.date]: new Date(consumption.dateTime),
|
||||
[this.formFieldNames.mileage]: consumption.distance,
|
||||
[this.formFieldNames.amount]: consumption.amount,
|
||||
});
|
||||
}),
|
||||
tap(() => {
|
||||
this.enableFormControls();
|
||||
this.isEntryDataLoaded.set(true);
|
||||
}),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
private enableFormControls(): void {
|
||||
for (const controlName of Object.values(this.formFieldNames)) {
|
||||
const control = this.formGroup.get(controlName);
|
||||
if (control) {
|
||||
control.enable();
|
||||
} else {
|
||||
console.warn(`Form control '${controlName}' not found.`);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async navigateToOverviewPage(): Promise<void> {
|
||||
await this.routingService.navigateToEntries();
|
||||
}
|
||||
|
||||
onSubmit(): void {
|
||||
if (this.formGroup.invalid) {
|
||||
this.formGroup.markAllAsTouched();
|
||||
return;
|
||||
}
|
||||
|
||||
var entryId = this.id();
|
||||
if (entryId === undefined || entryId === null) {
|
||||
this.createEntry();
|
||||
return;
|
||||
}
|
||||
|
||||
this.updateEntry(entryId);
|
||||
}
|
||||
|
||||
private getFormData() {
|
||||
var dateTime = new Date((this.formGroup.controls[this.formFieldNames.date].value ?? new Date).setHours(0, 0, 0, 0));
|
||||
|
||||
return {
|
||||
carId: this.formGroup.controls[this.formFieldNames.car].value!.id,
|
||||
dateTime: dateTime.toISOString(),
|
||||
distance: this.formGroup.controls[this.formFieldNames.mileage].value!,
|
||||
amount: this.formGroup.controls[this.formFieldNames.amount].value!,
|
||||
};
|
||||
}
|
||||
|
||||
createEntry() {
|
||||
var request: CreateConsumptionEntry = this.getFormData();
|
||||
this.consumptionClient.create(request)
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
catchError((error) => this.handleCreateOrUpdateError(error)),
|
||||
switchMap(() => this.routingService.navigateToEntries())
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
updateEntry(id: string) {
|
||||
var request: UpdateConsumptionEntry = this.getFormData();
|
||||
this.consumptionClient.update(id, request)
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
catchError((error) => this.handleCreateOrUpdateError(error)),
|
||||
switchMap(() => this.routingService.navigateToEntries())
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
private handleGetError(error: unknown): Observable<never> {
|
||||
if (!(error instanceof HttpErrorResponse)) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case error.status >= 500 && error.status <= 599:
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Serverfehler',
|
||||
detail:
|
||||
'Beim Erstellen des Eintrags ist ein Fehler aufgetreten. Bitte versuche es erneut.',
|
||||
});
|
||||
break;
|
||||
default:
|
||||
console.error(error);
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Unerwarteter Fehler',
|
||||
detail:
|
||||
'Beim Erstellen des Eintrags hat der Server eine unerwartete Antwort zurückgegeben.',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
private handleCreateOrUpdateError(error: unknown): Observable<never> {
|
||||
if (!(error instanceof HttpErrorResponse)) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case error.status >= 500 && error.status <= 599:
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Serverfehler',
|
||||
detail:
|
||||
'Beim Erstellen des Eintrags ist ein Fehler aufgetreten. Bitte versuche es erneut.',
|
||||
});
|
||||
break;
|
||||
case error.status === 400:
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Clientfehler',
|
||||
detail:
|
||||
'Die Anwendung scheint falsche Daten an den Server zu senden.',
|
||||
});
|
||||
break;
|
||||
default:
|
||||
console.error(error);
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Unerwarteter Fehler',
|
||||
detail:
|
||||
'Beim Erstellen des Eintrags hat der Server eine unerwartete Antwort zurückgegeben.',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return EMPTY;
|
||||
}
|
||||
|
||||
private dateTimeGreaterThanOrEqualToTodayValidator(control: FormControl<Date>): ValidationErrors | null {
|
||||
const tomorrowStartOfDay = dayjs().add(1, 'day').startOf('day');
|
||||
const controlDate = dayjs(control.value);
|
||||
|
||||
if (controlDate.isBefore(tomorrowStartOfDay)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { dateTimeGreaterThanOrEqualToToday: true };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,5 +5,13 @@ export const routes: Routes = [
|
||||
{
|
||||
path: '',
|
||||
component: EntriesComponent
|
||||
},
|
||||
{
|
||||
path: 'create',
|
||||
loadComponent: () => import('./edit-entry/edit-entry.component').then(m => m.EditEntryComponent)
|
||||
},
|
||||
{
|
||||
path: 'edit/:id',
|
||||
loadComponent: () => import('./edit-entry/edit-entry.component').then(m => m.EditEntryComponent)
|
||||
}
|
||||
];
|
||||
@@ -0,0 +1,49 @@
|
||||
<p-confirmDialog></p-confirmDialog>
|
||||
|
||||
<div class="flex rounded-border shadow">
|
||||
<div class="grow p-4 pos-relative edit-button" (click)="navigateToEdit()" role="button"
|
||||
aria-roledescription="Bearbeite diesen Eintrag">
|
||||
<div class="grid grid-cols-4 gap-4">
|
||||
|
||||
<div class="col-span-4 sm:col-span-2 md:col-span-1 flex my-auto items-center justify-center">
|
||||
<div class="flex gap-2 items-center">
|
||||
<ng-icon name="matCalendarMonthSharp" />
|
||||
<div>{{ entry().dateTime | date:"dd.MM.yyyy" }}</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-4 sm:col-span-2 md:col-span-1 flex my-auto items-center justify-center">
|
||||
<div class="flex gap-2 items-center">
|
||||
<ng-icon name="matStraightenSharp" />
|
||||
<div>{{entry().distance }} km</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-4 sm:col-span-2 md:col-span-1 flex my-auto items-center justify-center">
|
||||
<div class="flex gap-2 items-center">
|
||||
<ng-icon name="matLocalGasStationSharp" />
|
||||
<div>{{entry().amount }} ℓ</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="col-span-4 sm:col-span-2 md:col-span-1 flex my-auto items-center justify-center">
|
||||
@if (formattedLiterPer100Km(); as formattedLiterPer100Km) {
|
||||
<div class="flex gap-2 items-center">
|
||||
<ng-icon name="matSpeedSharp" />
|
||||
<div class="flex items-center gap-1">
|
||||
{{ formattedLiterPer100Km }}
|
||||
<app-fraction numerator="ℓ" [denominator]="'100km'" />
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="bg-red-500 text-white rounded-r text-center flex flex-col justify-center">
|
||||
|
||||
<button type="button" title="Löschen" class="reset cursor-pointer primary-color-text p-4 h-full rounded-r"
|
||||
(click)="confirmDeleteEntry()">
|
||||
<ng-icon name="matDeleteSharp"></ng-icon>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
.edit-button {
|
||||
cursor: pointer;
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import { DatePipe } from '@angular/common';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, computed, DestroyRef, inject, input, output } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
matCalendarMonthSharp,
|
||||
matDeleteSharp,
|
||||
matLocalGasStationSharp,
|
||||
matSpeedSharp,
|
||||
matStraightenSharp,
|
||||
} from '@ng-icons/material-icons/sharp';
|
||||
import { ConsumptionClient } from '@vegasco-web/api/consumptions/consumption-client';
|
||||
import { RoutingService } from '@vegasco-web/services/routing.service';
|
||||
import { ConfirmationService, MessageService } from 'primeng/api';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { CardModule } from 'primeng/card';
|
||||
import { ConfirmDialogModule } from 'primeng/confirmdialog';
|
||||
import { catchError, EMPTY, Observable, tap, throwError } from 'rxjs';
|
||||
import { FractionComponent } from "../fraction/fraction.component";
|
||||
|
||||
@Component({
|
||||
selector: 'app-entry-card',
|
||||
imports: [
|
||||
ButtonModule,
|
||||
CardModule,
|
||||
ConfirmDialogModule,
|
||||
DatePipe,
|
||||
NgIconComponent,
|
||||
FractionComponent
|
||||
],
|
||||
providers: [
|
||||
provideIcons({
|
||||
matDeleteSharp,
|
||||
matCalendarMonthSharp,
|
||||
matSpeedSharp,
|
||||
matStraightenSharp,
|
||||
matLocalGasStationSharp,
|
||||
}),
|
||||
ConfirmationService,
|
||||
],
|
||||
templateUrl: './entry-card.component.html',
|
||||
styleUrl: './entry-card.component.scss'
|
||||
})
|
||||
export class EntryCardComponent {
|
||||
readonly entry = input.required<GetConsumptionEntriesEntry>();
|
||||
|
||||
protected readonly formattedLiterPer100Km = computed(() => {
|
||||
const entry = this.entry();
|
||||
|
||||
const formatted = entry.literPer100Km
|
||||
?.toFixed(2)
|
||||
.replace('.', ',');
|
||||
return formatted;
|
||||
})
|
||||
|
||||
readonly entryDeleted = output<GetConsumptionEntriesEntry>();
|
||||
|
||||
private readonly routingService = inject(RoutingService);
|
||||
private readonly consumptionClient = inject(ConsumptionClient);
|
||||
private readonly messageService = inject(MessageService);
|
||||
private readonly confirmationService = inject(ConfirmationService);
|
||||
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
async navigateToEdit(): Promise<void> {
|
||||
await this.routingService.navigateToEditEntry(this.entry().id);
|
||||
}
|
||||
|
||||
confirmDeleteEntry(): void {
|
||||
const weighedAt = new Date(
|
||||
Date.parse(this.entry().dateTime),
|
||||
).toLocaleString('de-DE', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit',
|
||||
});
|
||||
|
||||
this.confirmationService.confirm({
|
||||
closeOnEscape: true,
|
||||
dismissableMask: true,
|
||||
header: 'Bist du sicher?',
|
||||
message: `Möchtest du diesen Eintrag (${weighedAt} für ${this.entry().car.name}) wirklich löschen?`,
|
||||
acceptButtonProps: {
|
||||
label: 'Löschen',
|
||||
severity: 'danger',
|
||||
},
|
||||
rejectButtonProps: {
|
||||
label: 'Abbrechen',
|
||||
outlined: true,
|
||||
},
|
||||
accept: () => this.deleteEntry(),
|
||||
});
|
||||
}
|
||||
|
||||
deleteEntry(): void {
|
||||
this.consumptionClient.delete(this.entry().id)
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
tap(() => this.entryDeleted.emit(this.entry())),
|
||||
catchError((error) => this.handleError(error)),
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
private handleError(error: unknown): Observable<never> {
|
||||
if (!(error instanceof HttpErrorResponse)) {
|
||||
return throwError(() => error);
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case error.status >= 500 && error.status <= 599:
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Serverfehler',
|
||||
detail:
|
||||
'Beim Löschen des Eintrags ist ein Fehler aufgetreten. Bitte versuche es erneut.',
|
||||
});
|
||||
break;
|
||||
case error.status === 400:
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Clientfehler',
|
||||
detail:
|
||||
'Die Anwendung scheint falsche Daten an den Server zu senden.',
|
||||
});
|
||||
break;
|
||||
default:
|
||||
console.error(error);
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Unerwarteter Fehler',
|
||||
detail:
|
||||
'Beim Löschen des Eintrags hat der Server eine unerwartete Antwort zurückgegeben.',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
<div class="flex flex-col items-center text-half-size">
|
||||
<span>
|
||||
{{ numerator() }}
|
||||
</span>
|
||||
<span class="separator"></span>
|
||||
<span>
|
||||
{{ denominator() }}
|
||||
</span>
|
||||
</div>
|
||||
@@ -0,0 +1,11 @@
|
||||
.separator {
|
||||
border-bottom-width: 1px;
|
||||
width: 100%
|
||||
}
|
||||
|
||||
.text-half-size {
|
||||
// Specifically use em here to allow the parent to control the font size
|
||||
// The font size here should be smaller because it is a fraction and would
|
||||
// otherwise look too large
|
||||
font-size: 0.75em;
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
import { Component, input } from '@angular/core';
|
||||
|
||||
@Component({
|
||||
selector: 'app-fraction',
|
||||
imports: [],
|
||||
templateUrl: './fraction.component.html',
|
||||
styleUrl: './fraction.component.scss'
|
||||
})
|
||||
export class FractionComponent {
|
||||
readonly numerator = input.required<number | string>();
|
||||
readonly denominator = input.required<number | string>();
|
||||
}
|
||||
@@ -2,12 +2,12 @@
|
||||
<p-scrollTop />
|
||||
<div class="mb-4 flex gap-2 md:justify-between">
|
||||
<div class="basis-full lg:basis-1/4 md:basis-1/2 p-0">
|
||||
<!-- <p-select styleClass="w-full" [formControl]="selectedRabbit" placeholder="Kaninchen" [showClear]="true"
|
||||
[options]="(rabbits$ | async)!" optionLabel="name" /> -->
|
||||
<p-select styleClass="w-full" [formControl]="selectedCar" placeholder="Auto" [showClear]="false"
|
||||
[options]="(cars$ | async)!" optionLabel="name" />
|
||||
</div>
|
||||
<div>
|
||||
<p-button label="Erstellen" routerLink="/weight-entries/create">
|
||||
<!-- <ng-icon name="matAddSharp"></ng-icon> -->
|
||||
<p-button label="Erstellen" routerLink="/entries/create">
|
||||
<ng-icon name="matAddSharp"></ng-icon>
|
||||
</p-button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -16,19 +16,18 @@
|
||||
<p-dataView
|
||||
[value]="entries"
|
||||
[paginator]="true"
|
||||
[rows]="rowsPerPageDefaultOption"
|
||||
[rowsPerPageOptions]="rowsPerPageOptions"
|
||||
[rows]="25"
|
||||
[rowsPerPageOptions]="[10, 25, 50, 100]"
|
||||
[pageLinks]="0"
|
||||
[showCurrentPageReport]="true"
|
||||
[currentPageReportTemplate]="currentPageReportTemplate"
|
||||
currentPageReportTemplate="{currentPage} / {totalPages}"
|
||||
layout="list"
|
||||
>
|
||||
<ng-template #list let-entries>
|
||||
<div class="flex flex-col gap-2">
|
||||
@for (entry of entries; track entry.id) {
|
||||
{{ entry | json }}
|
||||
<!-- <app-weight-entry-card [weightEntry]="weightEntry"
|
||||
(entryDeleted)="onEntryDeleted($event)"></app-weight-entry-card> -->
|
||||
<app-entry-card [entry]="entry"
|
||||
(entryDeleted)="onEntryDeleted($event)" />
|
||||
}
|
||||
</div>
|
||||
</ng-template>
|
||||
|
||||
@@ -1,54 +1,171 @@
|
||||
import { CommonModule } from '@angular/common';
|
||||
import { Component, inject } from '@angular/core';
|
||||
import { AsyncPipe, CommonModule } from '@angular/common';
|
||||
import { HttpErrorResponse } from '@angular/common/http';
|
||||
import { Component, DestroyRef, inject, OnInit } from '@angular/core';
|
||||
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
|
||||
import { ReactiveFormsModule } from '@angular/forms';
|
||||
import { FormControl, ReactiveFormsModule } from '@angular/forms';
|
||||
import { RouterLink } from '@angular/router';
|
||||
import { NgIconComponent, provideIcons } from '@ng-icons/core';
|
||||
import {
|
||||
matAddSharp,
|
||||
} from '@ng-icons/material-icons/sharp';
|
||||
import { CarClient } from '@vegasco-web/api/cars/car-client';
|
||||
import { ConsumptionClient } from '@vegasco-web/api/consumptions/consumption-client';
|
||||
import { MessageService } from 'primeng/api';
|
||||
import { ButtonModule } from 'primeng/button';
|
||||
import { DataViewModule } from 'primeng/dataview';
|
||||
import { ScrollTopModule } from 'primeng/scrolltop';
|
||||
import { SelectModule } from 'primeng/select';
|
||||
import { SkeletonModule } from 'primeng/skeleton';
|
||||
import {
|
||||
BehaviorSubject,
|
||||
catchError,
|
||||
combineLatest,
|
||||
EMPTY,
|
||||
map,
|
||||
Observable,
|
||||
tap
|
||||
startWith,
|
||||
tap,
|
||||
throwError
|
||||
} from 'rxjs';
|
||||
import { Client, GetConsumptions_ResponseDto } from '../../../shared/api/swagger.generated';
|
||||
import { SelectedCarService } from '../services/selected-car.service';
|
||||
import { EntryCardComponent } from './components/entry-card/entry-card.component';
|
||||
|
||||
@Component({
|
||||
selector: 'app-entries',
|
||||
imports: [
|
||||
AsyncPipe,
|
||||
ButtonModule,
|
||||
CommonModule,
|
||||
DataViewModule,
|
||||
SkeletonModule,
|
||||
SelectModule,
|
||||
EntryCardComponent,
|
||||
NgIconComponent,
|
||||
ReactiveFormsModule,
|
||||
RouterLink,
|
||||
ScrollTopModule,
|
||||
SelectModule,
|
||||
SkeletonModule,
|
||||
],
|
||||
providers: [
|
||||
provideIcons({
|
||||
matAddSharp,
|
||||
}),
|
||||
],
|
||||
templateUrl: './entries.component.html',
|
||||
styleUrl: './entries.component.scss'
|
||||
})
|
||||
export class EntriesComponent {
|
||||
private readonly client = inject(Client);
|
||||
export class EntriesComponent implements OnInit {
|
||||
private readonly carClient = inject(CarClient);
|
||||
private readonly consumptionClient = inject(ConsumptionClient);
|
||||
private readonly messageService = inject(MessageService);
|
||||
private readonly selectedCarService = inject(SelectedCarService);
|
||||
private readonly destroyRef = inject(DestroyRef);
|
||||
|
||||
protected readonly consumptionEntries$: Observable<GetConsumptions_ResponseDto[] | undefined>;
|
||||
|
||||
protected readonly rowsPerPageDefaultOption = 25;
|
||||
protected readonly rowsPerPageOptions = [10, 25, 50, 100];
|
||||
protected readonly currentPageReportTemplate = '{currentPage} / {totalPages}';
|
||||
protected readonly consumptionEntries$: Observable<GetConsumptionEntriesEntry[]>;
|
||||
protected readonly cars$: Observable<Car[]>;
|
||||
|
||||
protected readonly skeletonsIterationSource = Array(10).fill(0);
|
||||
|
||||
protected readonly selectedCar = new FormControl<Car | null | undefined>(null);
|
||||
|
||||
private readonly deletedEntries$ = new BehaviorSubject(<string[]>[]);
|
||||
|
||||
constructor() {
|
||||
this.consumptionEntries$ = this.client.consumptionsGET()
|
||||
const entries = this.consumptionClient.getAll()
|
||||
.pipe(
|
||||
takeUntilDestroyed(),
|
||||
tap((response) => {
|
||||
console.log('Entries response:', response);
|
||||
map(response => response.consumptions.sort((a, b) => b.dateTime.localeCompare(a.dateTime))),
|
||||
catchError((error) => this.handleGetEntriesError(error))
|
||||
);
|
||||
|
||||
this.consumptionEntries$ = combineLatest([
|
||||
entries,
|
||||
this.selectedCar.valueChanges.pipe(startWith(null)),
|
||||
this.deletedEntries$,
|
||||
])
|
||||
.pipe(
|
||||
takeUntilDestroyed(),
|
||||
map(([entries, selectedCar, deletedEntries]) => {
|
||||
const nonDeletedEntries =
|
||||
deletedEntries.length === 0
|
||||
? entries
|
||||
: entries.filter(entry => !deletedEntries.includes(entry.id));
|
||||
|
||||
if (!selectedCar) {
|
||||
return nonDeletedEntries;
|
||||
}
|
||||
|
||||
return nonDeletedEntries.filter(entry => entry.car.id === selectedCar.id);
|
||||
}),
|
||||
map((response) => response.consumptions)
|
||||
);
|
||||
|
||||
this.cars$ = this.carClient.getAll()
|
||||
.pipe(
|
||||
takeUntilDestroyed(),
|
||||
map(response => response.cars),
|
||||
map((cars) => cars
|
||||
.sort((a, b) => a.name.localeCompare(b.name))),
|
||||
tap((cars) => {
|
||||
const selectedCarId = this.selectedCarService.getSelectedCarId();
|
||||
|
||||
if (selectedCarId === null) {
|
||||
const firstCar = cars[0];
|
||||
this.selectedCar.setValue(firstCar);
|
||||
this.selectedCarService.setSelectedCarId(firstCar?.id ?? null);
|
||||
return;
|
||||
}
|
||||
|
||||
const selectedCar = cars.find(car => car.id === selectedCarId);
|
||||
this.selectedCar.setValue(selectedCar ?? null);
|
||||
this.selectedCarService.setSelectedCarId(selectedCar?.id ?? null);
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
ngOnInit(): void {
|
||||
this.selectedCar.valueChanges
|
||||
.pipe(
|
||||
takeUntilDestroyed(this.destroyRef),
|
||||
tap((car) => {
|
||||
this.selectedCarService.setSelectedCarId(car?.id ?? null);
|
||||
})
|
||||
)
|
||||
.subscribe();
|
||||
}
|
||||
|
||||
onEntryDeleted(entry: GetConsumptionEntriesEntry): void {
|
||||
this.deletedEntries$.next([...this.deletedEntries$.value, entry.id]);
|
||||
this.messageService.add({
|
||||
severity: 'success',
|
||||
summary: 'Eintrag gelöscht',
|
||||
detail: 'Der Eintrag wurde erfolgreich gelöscht.',
|
||||
});
|
||||
}
|
||||
|
||||
private handleGetEntriesError(error: unknown): Observable<never> {
|
||||
if (!(error instanceof HttpErrorResponse)) {
|
||||
return throwError(() => new Error('An unexpected error occurred'));
|
||||
}
|
||||
|
||||
switch (true) {
|
||||
case error.status >= 500 && error.status <= 599:
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Serverfehler',
|
||||
detail:
|
||||
'Beim Abrufen der Einträge ist ein Fehler aufgetreten. Bitte versuche es erneut.',
|
||||
});
|
||||
break;
|
||||
default:
|
||||
console.error(error);
|
||||
this.messageService.add({
|
||||
severity: 'error',
|
||||
summary: 'Unerwarteter Fehler',
|
||||
detail:
|
||||
'Beim Abrufen der Einträge hat der Server eine unerwartete Antwort zurückgegeben.',
|
||||
});
|
||||
break;
|
||||
}
|
||||
|
||||
return EMPTY;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,33 @@
|
||||
import { Injectable } from "@angular/core";
|
||||
import { takeUntilDestroyed } from "@angular/core/rxjs-interop";
|
||||
import { BehaviorSubject, tap } from "rxjs";
|
||||
|
||||
@Injectable({
|
||||
providedIn: "root",
|
||||
})
|
||||
export class SelectedCarService {
|
||||
static readonly SELECTED_CAR_ID_KEY = "SELECTED_CAR_ID";
|
||||
|
||||
private selectedCarId: string | null = null;
|
||||
|
||||
constructor() {
|
||||
this.loadStoredCarId();
|
||||
}
|
||||
|
||||
private loadStoredCarId(): void {
|
||||
this.selectedCarId = localStorage.getItem(SelectedCarService.SELECTED_CAR_ID_KEY);
|
||||
}
|
||||
|
||||
getSelectedCarId() {
|
||||
return this.selectedCarId;
|
||||
}
|
||||
|
||||
setSelectedCarId(carId: string | null): void {
|
||||
this.selectedCarId = carId;
|
||||
if (carId === null) {
|
||||
localStorage.removeItem(SelectedCarService.SELECTED_CAR_ID_KEY);
|
||||
} else {
|
||||
localStorage.setItem(SelectedCarService.SELECTED_CAR_ID_KEY, carId);
|
||||
}
|
||||
}
|
||||
}
|
||||
33
src/Vegasco-Web/src/app/services/routing.service.ts
Normal file
33
src/Vegasco-Web/src/app/services/routing.service.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import { inject, Injectable } from "@angular/core";
|
||||
import { Router } from "@angular/router";
|
||||
|
||||
@Injectable({
|
||||
providedIn: 'root'
|
||||
})
|
||||
export class RoutingService {
|
||||
private readonly router = inject(Router);
|
||||
|
||||
async navigateToEntries(): Promise<void> {
|
||||
await this.router.navigateByUrl('/entries');
|
||||
}
|
||||
|
||||
async navigateToEditEntry(entryId: string): Promise<void> {
|
||||
await this.router.navigate(['entries', 'edit', entryId]);
|
||||
}
|
||||
|
||||
async navigateToCreateEntry(): Promise<void> {
|
||||
await this.router.navigate(['entries', 'create']);
|
||||
}
|
||||
|
||||
async navigateToCars(): Promise<void> {
|
||||
await this.router.navigateByUrl('/cars');
|
||||
}
|
||||
|
||||
async navigateToEditCar(entryId: string): Promise<void> {
|
||||
await this.router.navigate(['cars', 'edit', entryId]);
|
||||
}
|
||||
|
||||
async navigateToCreateCar(): Promise<void> {
|
||||
await this.router.navigate(['cars', 'create']);
|
||||
}
|
||||
}
|
||||
@@ -1 +1,39 @@
|
||||
/* You can add global styles to this file, and also import other style files */
|
||||
@use "tailwindcss";
|
||||
@plugin "tailwindcss-primeui";
|
||||
|
||||
html,
|
||||
body {
|
||||
height: 100%;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
body {
|
||||
font-family: 'Lucida Sans', 'Lucida Sans Regular', 'Lucida Grande', 'Lucida Sans Unicode', Geneva, Verdana, sans-serif
|
||||
}
|
||||
|
||||
.max-content-width {
|
||||
max-width: 1200px;
|
||||
}
|
||||
|
||||
.pos-absolute {
|
||||
position: absolute;
|
||||
}
|
||||
|
||||
.pos-relative {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.trbl-0 {
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
.primary-color-text {
|
||||
color: var(--primary-color-text);
|
||||
}
|
||||
|
||||
.visually-hidden {
|
||||
visibility: hidden;
|
||||
}
|
||||
|
||||
@@ -3,6 +3,12 @@
|
||||
{
|
||||
"compileOnSave": false,
|
||||
"compilerOptions": {
|
||||
"baseUrl": "./",
|
||||
"paths": {
|
||||
"@vegasco-web/*": ["src/app/*"],
|
||||
"@vegasco-web/assets/*": ["assets/*"],
|
||||
"@vegasco-web/environments/*": ["src/environments/*"]
|
||||
},
|
||||
"strict": true,
|
||||
"noImplicitOverride": true,
|
||||
"noPropertyAccessFromIndexSignature": true,
|
||||
|
||||
12
src/Vegasco-Web/webserver.conf.template
Normal file
12
src/Vegasco-Web/webserver.conf.template
Normal file
@@ -0,0 +1,12 @@
|
||||
server {
|
||||
listen 80;
|
||||
|
||||
location ~ ^/api/(.*) {
|
||||
proxy_pass ${apiUrl}/${DOLLAR}1;
|
||||
}
|
||||
|
||||
location / {
|
||||
root /usr/share/nginx/html;
|
||||
try_files ${DOLLAR}uri ${DOLLAR}uri/ /index.html =404;
|
||||
}
|
||||
}
|
||||
@@ -1,5 +1,6 @@
|
||||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Vegasco.Server.Api.Authentication;
|
||||
using Vegasco.Server.Api.Common;
|
||||
using Vegasco.Server.Api.Persistence;
|
||||
@@ -10,13 +11,18 @@ namespace Vegasco.Server.Api.Cars;
|
||||
public static class CreateCar
|
||||
{
|
||||
public record Request(string Name);
|
||||
|
||||
public record Response(Guid Id, string Name);
|
||||
|
||||
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
|
||||
{
|
||||
return builder
|
||||
.MapPost("cars", Endpoint)
|
||||
.WithTags("Cars");
|
||||
.WithTags("Cars")
|
||||
.WithDescription("Creates a new car")
|
||||
.Produces<Response>(201)
|
||||
.ProducesValidationProblem()
|
||||
.Produces(409);
|
||||
}
|
||||
|
||||
public class Validator : AbstractValidator<Request>
|
||||
@@ -29,41 +35,62 @@ public static class CreateCar
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<IResult> Endpoint(
|
||||
private static async Task<IResult> Endpoint(
|
||||
Request request,
|
||||
IEnumerable<IValidator<Request>> validators,
|
||||
ApplicationDbContext dbContext,
|
||||
UserAccessor userAccessor,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken: cancellationToken);
|
||||
ILogger logger = loggerFactory.CreateLogger(typeof(CreateCar));
|
||||
|
||||
List<ValidationResult> failedValidations =
|
||||
await validators.ValidateAllAsync(request, cancellationToken: cancellationToken);
|
||||
if (failedValidations.Count > 0)
|
||||
{
|
||||
string[] errors = failedValidations
|
||||
.Where(x => !x.IsValid)
|
||||
.SelectMany(x => x.Errors)
|
||||
.Select(x => x.ErrorMessage)
|
||||
.ToArray();
|
||||
|
||||
logger.LogDebug(
|
||||
"Validation failed for request {@Request} with errors {@Errors}",
|
||||
request,
|
||||
errors);
|
||||
|
||||
return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
|
||||
}
|
||||
|
||||
bool isDuplicate = await dbContext.Cars
|
||||
.AnyAsync(x => x.Name.ToUpper() == request.Name.ToUpper(), cancellationToken);
|
||||
|
||||
if (isDuplicate)
|
||||
{
|
||||
logger.LogDebug("Car with name '{CarName}' (case insensitive) already exists", request.Name);
|
||||
return TypedResults.Conflict();
|
||||
}
|
||||
|
||||
string userId = userAccessor.GetUserId();
|
||||
|
||||
User? user = await dbContext.Users.FindAsync([userId], cancellationToken: cancellationToken);
|
||||
if (user is null)
|
||||
{
|
||||
user = new User
|
||||
{
|
||||
Id = userId
|
||||
};
|
||||
logger.LogDebug("User with ID '{UserId}' not found, creating new user", userId);
|
||||
|
||||
user = new User { Id = userId };
|
||||
await dbContext.Users.AddAsync(user, cancellationToken);
|
||||
}
|
||||
|
||||
Car car = new()
|
||||
{
|
||||
Name = request.Name,
|
||||
UserId = userId
|
||||
};
|
||||
Car car = new() { Name = request.Name.Trim(), UserId = userId };
|
||||
|
||||
await dbContext.Cars.AddAsync(car, cancellationToken);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogTrace("Created new car: {@Car}", car);
|
||||
|
||||
Response response = new(car.Id.Value, car.Name);
|
||||
return TypedResults.Created($"/v1/cars/{car.Id}", response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using Vegasco.Server.Api.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Diagnostics;
|
||||
using Vegasco.Server.Api.Persistence;
|
||||
|
||||
namespace Vegasco.Server.Api.Cars;
|
||||
|
||||
@@ -8,23 +10,35 @@ public static class DeleteCar
|
||||
{
|
||||
return builder
|
||||
.MapDelete("cars/{id:guid}", Endpoint)
|
||||
.WithTags("Cars");
|
||||
.WithTags("Cars")
|
||||
.WithDescription("Deletes a car by ID")
|
||||
.Produces(204)
|
||||
.Produces(404);
|
||||
}
|
||||
|
||||
public static async Task<IResult> Endpoint(
|
||||
private static async Task<IResult> Endpoint(
|
||||
Guid id,
|
||||
ApplicationDbContext dbContext,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Car? car = await dbContext.Cars.FindAsync([new CarId(id)], cancellationToken: cancellationToken);
|
||||
Activity? activity = Activity.Current;
|
||||
activity?.SetTag("id", id);
|
||||
|
||||
if (car is null)
|
||||
int rows = await dbContext.Cars
|
||||
.Where(x => x.Id == new CarId(id))
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
if (rows == 0)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
dbContext.Cars.Remove(car);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (rows > 1)
|
||||
{
|
||||
ILogger logger = loggerFactory.CreateLogger(typeof(DeleteCar));
|
||||
logger.LogWarning("Deleted '{DeletedRowCount}' rows for id '{CarId}'", rows, id);
|
||||
}
|
||||
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
using Vegasco.Server.Api.Persistence;
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Vegasco.Server.Api.Persistence;
|
||||
|
||||
namespace Vegasco.Server.Api.Cars;
|
||||
|
||||
@@ -10,7 +11,10 @@ public static class GetCar
|
||||
{
|
||||
return builder
|
||||
.MapGet("cars/{id:guid}", Endpoint)
|
||||
.WithTags("Cars");
|
||||
.WithDescription("Returns a single car by ID")
|
||||
.WithTags("Cars")
|
||||
.Produces<Response>()
|
||||
.Produces(404);
|
||||
}
|
||||
|
||||
private static async Task<IResult> Endpoint(
|
||||
@@ -25,7 +29,7 @@ public static class GetCar
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var response = new Response(car.Id.Value, car.Name);
|
||||
Response response = new Response(car.Id.Value, car.Name);
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Diagnostics;
|
||||
using Vegasco.Server.Api.Persistence;
|
||||
|
||||
namespace Vegasco.Server.Api.Cars;
|
||||
@@ -25,19 +26,24 @@ public static class GetCars
|
||||
return builder
|
||||
.MapGet("cars", Endpoint)
|
||||
.WithDescription("Returns all cars")
|
||||
.WithTags("Cars");
|
||||
.WithTags("Cars")
|
||||
.Produces<ApiResponse>();
|
||||
}
|
||||
|
||||
private static async Task<Ok<ApiResponse>> Endpoint(
|
||||
private static async Task<IResult> Endpoint(
|
||||
[AsParameters] Request request,
|
||||
ApplicationDbContext dbContext,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Activity? activity = Activity.Current;
|
||||
|
||||
List<ResponseDto> cars = await dbContext.Cars
|
||||
.Select(x => new ResponseDto(x.Id.Value, x.Name))
|
||||
.ToListAsync(cancellationToken);
|
||||
|
||||
var response = new ApiResponse
|
||||
activity?.SetTag("carCount", cars.Count);
|
||||
|
||||
ApiResponse response = new()
|
||||
{
|
||||
Cars = cars
|
||||
};
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Vegasco.Server.Api.Authentication;
|
||||
using Vegasco.Server.Api.Common;
|
||||
using Vegasco.Server.Api.Persistence;
|
||||
@@ -9,13 +10,19 @@ namespace Vegasco.Server.Api.Cars;
|
||||
public static class UpdateCar
|
||||
{
|
||||
public record Request(string Name);
|
||||
|
||||
public record Response(Guid Id, string Name);
|
||||
|
||||
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
|
||||
{
|
||||
return builder
|
||||
.MapPut("cars/{id:guid}", Endpoint)
|
||||
.WithTags("Cars");
|
||||
.WithTags("Cars")
|
||||
.WithDescription("Updates a car by ID")
|
||||
.Produces<Response>()
|
||||
.ProducesValidationProblem()
|
||||
.Produces(404)
|
||||
.Produces(409);
|
||||
}
|
||||
|
||||
public class Validator : AbstractValidator<Request>
|
||||
@@ -28,17 +35,31 @@ public static class UpdateCar
|
||||
}
|
||||
}
|
||||
|
||||
public static async Task<IResult> Endpoint(
|
||||
private static async Task<IResult> Endpoint(
|
||||
Guid id,
|
||||
Request request,
|
||||
IEnumerable<IValidator<Request>> validators,
|
||||
ApplicationDbContext dbContext,
|
||||
UserAccessor userAccessor,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ILogger logger = loggerFactory.CreateLogger(typeof(UpdateCar));
|
||||
|
||||
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken);
|
||||
if (failedValidations.Count > 0)
|
||||
{
|
||||
string[] errors = failedValidations
|
||||
.Where(x => !x.IsValid)
|
||||
.SelectMany(x => x.Errors)
|
||||
.Select(x => x.ErrorMessage)
|
||||
.ToArray();
|
||||
|
||||
logger.LogDebug(
|
||||
"Validation failed for request {@Request} with errors {@Errors}",
|
||||
request,
|
||||
errors);
|
||||
|
||||
return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
|
||||
}
|
||||
|
||||
@@ -49,10 +70,21 @@ public static class UpdateCar
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
car.Name = request.Name;
|
||||
bool isDuplicate = await dbContext.Cars
|
||||
.AnyAsync(x => x.Name.ToUpper() == request.Name.ToUpper(), cancellationToken);
|
||||
|
||||
if (isDuplicate)
|
||||
{
|
||||
logger.LogDebug("Car with name '{CarName}' (case insensitive) already exists", request.Name);
|
||||
return TypedResults.Conflict();
|
||||
}
|
||||
|
||||
car.Name = request.Name.Trim();
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogTrace("Updated car: {@Car}", car);
|
||||
|
||||
Response response = new(car.Id.Value, car.Name);
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,6 +18,8 @@ public static class DependencyInjectionExtensions
|
||||
/// <param name="builder"></param>
|
||||
public static void AddApiServices(this IHostApplicationBuilder builder)
|
||||
{
|
||||
builder.AddBuilderServices();
|
||||
|
||||
builder.Services
|
||||
.AddMiscellaneousServices()
|
||||
.AddCustomOpenApi()
|
||||
@@ -27,6 +29,24 @@ public static class DependencyInjectionExtensions
|
||||
builder.AddDbContext();
|
||||
}
|
||||
|
||||
private static IHostApplicationBuilder AddBuilderServices(this IHostApplicationBuilder builder)
|
||||
{
|
||||
string? seqHost = builder.Configuration.GetConnectionString("seq");
|
||||
if (!string.IsNullOrEmpty(seqHost))
|
||||
{
|
||||
builder.AddSeqEndpoint("seq", o =>
|
||||
{
|
||||
var apiKey = builder.Configuration.GetValue<string>("seq-api-key");
|
||||
if (!string.IsNullOrEmpty(apiKey))
|
||||
{
|
||||
o.ApiKey = apiKey;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
return builder;
|
||||
}
|
||||
|
||||
private static IServiceCollection AddMiscellaneousServices(this IServiceCollection services)
|
||||
{
|
||||
services.AddSingleton(() =>
|
||||
@@ -121,7 +141,7 @@ public static class DependencyInjectionExtensions
|
||||
.ValidateFluently()
|
||||
.ValidateOnStart();
|
||||
|
||||
var jwtOptions = services.BuildServiceProvider().GetRequiredService<IOptions<JwtOptions>>();
|
||||
IOptions<JwtOptions> jwtOptions = services.BuildServiceProvider().GetRequiredService<IOptions<JwtOptions>>();
|
||||
|
||||
services.AddAuthentication(JwtBearerDefaults.AuthenticationScheme)
|
||||
.AddJwtBearer(JwtBearerDefaults.AuthenticationScheme, o =>
|
||||
|
||||
@@ -14,8 +14,6 @@ public class Consumption
|
||||
|
||||
public double Amount { get; set; }
|
||||
|
||||
public bool IgnoreInCalculation { get; set; }
|
||||
|
||||
public CarId CarId { get; set; }
|
||||
|
||||
public virtual Car Car { get; set; } = null!;
|
||||
@@ -39,9 +37,6 @@ public class ConsumptionTableConfiguration : IEntityTypeConfiguration<Consumptio
|
||||
builder.Property(x => x.Amount)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(x => x.IgnoreInCalculation)
|
||||
.IsRequired();
|
||||
|
||||
builder.Property(x => x.CarId)
|
||||
.IsRequired()
|
||||
.HasConversion<CarId.EfCoreValueConverter>();
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
using FluentValidation;
|
||||
using FluentValidation.Results;
|
||||
using System.Diagnostics;
|
||||
using Vegasco.Server.Api.Cars;
|
||||
using Vegasco.Server.Api.Common;
|
||||
using Vegasco.Server.Api.Persistence;
|
||||
@@ -8,23 +9,30 @@ namespace Vegasco.Server.Api.Consumptions;
|
||||
|
||||
public static class CreateConsumption
|
||||
{
|
||||
public record Request(DateTimeOffset DateTime, double Distance, double Amount, bool IgnoreInCalculation, Guid CarId);
|
||||
public record Request(DateTimeOffset DateTime, double Distance, double Amount, Guid CarId);
|
||||
|
||||
public record Response(Guid Id, DateTimeOffset DateTime, double Distance, double Amount, bool IgnoreInCalculation, Guid CarId);
|
||||
public record Response(Guid Id, DateTimeOffset DateTime, double Distance, double Amount, Guid CarId);
|
||||
|
||||
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
|
||||
{
|
||||
return builder
|
||||
.MapPost("consumptions", Endpoint)
|
||||
.WithTags("Consumptions");
|
||||
.WithTags("Consumptions")
|
||||
.WithDescription("Creates a new consumption entry")
|
||||
.Produces<Response>(201);
|
||||
}
|
||||
|
||||
public class Validator : AbstractValidator<Request>
|
||||
{
|
||||
public Validator(TimeProvider timeProvider)
|
||||
{
|
||||
Func<DateTimeOffset> getTodayEndOfDay = () => timeProvider.GetUtcNow()
|
||||
.Date
|
||||
.AddDays(1)
|
||||
.AddTicks(-1);
|
||||
|
||||
RuleFor(x => x.DateTime.ToUniversalTime())
|
||||
.LessThanOrEqualTo(timeProvider.GetUtcNow())
|
||||
.LessThanOrEqualTo(_ => getTodayEndOfDay())
|
||||
.WithName(nameof(Request.DateTime));
|
||||
|
||||
RuleFor(x => x.Distance)
|
||||
@@ -42,11 +50,25 @@ public static class CreateConsumption
|
||||
ApplicationDbContext dbContext,
|
||||
Request request,
|
||||
IEnumerable<IValidator<Request>> validators,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ILogger logger = loggerFactory.CreateLogger(typeof(CreateConsumption));
|
||||
|
||||
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken);
|
||||
if (failedValidations.Count > 0)
|
||||
{
|
||||
string[] errors = failedValidations
|
||||
.Where(x => !x.IsValid)
|
||||
.SelectMany(x => x.Errors)
|
||||
.Select(x => x.ErrorMessage)
|
||||
.ToArray();
|
||||
|
||||
logger.LogDebug(
|
||||
"Validation failed for request {@Request} with errors {@Errors}",
|
||||
request,
|
||||
errors);
|
||||
|
||||
return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
|
||||
}
|
||||
|
||||
@@ -56,19 +78,21 @@ public static class CreateConsumption
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var consumption = new Consumption
|
||||
Consumption consumption = new()
|
||||
{
|
||||
DateTime = request.DateTime.ToUniversalTime(),
|
||||
Distance = request.Distance,
|
||||
Amount = request.Amount,
|
||||
IgnoreInCalculation = request.IgnoreInCalculation,
|
||||
CarId = new CarId(request.CarId)
|
||||
};
|
||||
|
||||
dbContext.Consumptions.Add(consumption);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
logger.LogTrace("Created new consumption: {@Consumption}", consumption);
|
||||
|
||||
return TypedResults.Created($"consumptions/{consumption.Id.Value}",
|
||||
new Response(consumption.Id.Value, consumption.DateTime, consumption.Distance, consumption.Amount, consumption.IgnoreInCalculation, consumption.CarId.Value));
|
||||
new Response(consumption.Id.Value, consumption.DateTime, consumption.Distance, consumption.Amount,
|
||||
consumption.CarId.Value));
|
||||
}
|
||||
}
|
||||
@@ -1,4 +1,6 @@
|
||||
using Vegasco.Server.Api.Persistence;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Diagnostics;
|
||||
using Vegasco.Server.Api.Persistence;
|
||||
|
||||
namespace Vegasco.Server.Api.Consumptions;
|
||||
|
||||
@@ -8,22 +10,35 @@ public static class DeleteConsumption
|
||||
{
|
||||
return builder
|
||||
.MapDelete("consumptions/{id:guid}", Endpoint)
|
||||
.WithTags("Consumptions");
|
||||
.WithTags("Consumptions")
|
||||
.WithDescription("Deletes a consumption entry by ID")
|
||||
.Produces(204)
|
||||
.Produces(404);
|
||||
}
|
||||
|
||||
private static async Task<IResult> Endpoint(
|
||||
ApplicationDbContext dbContext,
|
||||
Guid id,
|
||||
ApplicationDbContext dbContext,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
Consumption? consumption = await dbContext.Consumptions.FindAsync([new ConsumptionId(id)], cancellationToken);
|
||||
if (consumption is null)
|
||||
Activity? activity = Activity.Current;
|
||||
activity?.SetTag("id", id);
|
||||
|
||||
int rows = await dbContext.Consumptions
|
||||
.Where(x => x.Id == new ConsumptionId(id))
|
||||
.ExecuteDeleteAsync(cancellationToken);
|
||||
|
||||
if (rows == 0)
|
||||
{
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
dbContext.Consumptions.Remove(consumption);
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
if (rows > 1)
|
||||
{
|
||||
ILogger logger = loggerFactory.CreateLogger(typeof(DeleteConsumption));
|
||||
logger.LogWarning("Deleted '{DeletedRowCount}' rows for id '{ConsumptionId}'", rows, id);
|
||||
}
|
||||
|
||||
return TypedResults.NoContent();
|
||||
}
|
||||
|
||||
@@ -4,13 +4,16 @@ namespace Vegasco.Server.Api.Consumptions;
|
||||
|
||||
public static class GetConsumption
|
||||
{
|
||||
public record Response(Guid Id, DateTimeOffset DateTime, double Distance, double Amount, bool IgnoreInCalculation, Guid CarId);
|
||||
public record Response(Guid Id, DateTimeOffset DateTime, double Distance, double Amount, Guid CarId);
|
||||
|
||||
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
|
||||
{
|
||||
return builder
|
||||
.MapGet("consumptions/{id:guid}", Endpoint)
|
||||
.WithTags("Consumptions");
|
||||
.WithTags("Consumptions")
|
||||
.WithDescription("Returns a single consumption entry by ID")
|
||||
.Produces<Response>()
|
||||
.Produces(404);
|
||||
}
|
||||
|
||||
private static async Task<IResult> Endpoint(
|
||||
@@ -25,8 +28,12 @@ public static class GetConsumption
|
||||
return TypedResults.NotFound();
|
||||
}
|
||||
|
||||
var response = new Response(consumption.Id.Value, consumption.DateTime, consumption.Distance,
|
||||
consumption.Amount, consumption.IgnoreInCalculation, consumption.CarId.Value);
|
||||
Response response = new(
|
||||
consumption.Id.Value,
|
||||
consumption.DateTime,
|
||||
consumption.Distance,
|
||||
consumption.Amount,
|
||||
consumption.CarId.Value);
|
||||
return TypedResults.Ok(response);
|
||||
}
|
||||
}
|
||||
@@ -1,6 +1,8 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using System.Diagnostics;
|
||||
using Vegasco.Server.Api.Cars;
|
||||
using Vegasco.Server.Api.Persistence;
|
||||
|
||||
namespace Vegasco.Server.Api.Consumptions;
|
||||
@@ -17,8 +19,18 @@ public static class GetConsumptions
|
||||
DateTimeOffset DateTime,
|
||||
double Distance,
|
||||
double Amount,
|
||||
bool IgnoreInCalculation,
|
||||
Guid CarId);
|
||||
CarDto Car,
|
||||
double? LiterPer100Km);
|
||||
|
||||
public record CarDto(
|
||||
Guid Id,
|
||||
string Name)
|
||||
{
|
||||
public static CarDto FromCar(Car car)
|
||||
{
|
||||
return new CarDto(car.Id.Value, car.Name);
|
||||
}
|
||||
}
|
||||
|
||||
public class Request
|
||||
{
|
||||
@@ -31,22 +43,59 @@ public static class GetConsumptions
|
||||
return builder
|
||||
.MapGet("consumptions", Endpoint)
|
||||
.WithDescription("Returns all consumption entries")
|
||||
.WithTags("Consumptions");
|
||||
.WithTags("Consumptions")
|
||||
.Produces<ApiResponse>();
|
||||
}
|
||||
|
||||
private static async Task<Ok<ApiResponse>> Endpoint(
|
||||
[AsParameters] Request request,
|
||||
ApplicationDbContext dbContext,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
List<ResponseDto> consumptions = await dbContext.Consumptions
|
||||
.Select(x =>
|
||||
new ResponseDto(x.Id.Value, x.DateTime, x.Distance, x.Amount, x.IgnoreInCalculation, x.CarId.Value))
|
||||
.ToListAsync(cancellationToken);
|
||||
ILogger logger = loggerFactory.CreateLogger(typeof(GetConsumptions));
|
||||
|
||||
logger.LogTrace("Received request to get consumptions with parameters: {@Request}", request);
|
||||
Activity? activity = Activity.Current;
|
||||
|
||||
Dictionary<CarId, List<Consumption>> consumptionsByCar = await dbContext.Consumptions
|
||||
.Include(x => x.Car)
|
||||
.GroupBy(x => x.CarId)
|
||||
.ToDictionaryAsync(x => x.Key, x => x.OrderByDescending(x => x.DateTime).ToList(), cancellationToken);
|
||||
|
||||
var apiResponse = new ApiResponse
|
||||
List<ResponseDto> responses = [];
|
||||
|
||||
foreach (List<Consumption> consumptions in consumptionsByCar.Select(x => x.Value))
|
||||
{
|
||||
Consumptions = consumptions
|
||||
for (int i = 0; i < consumptions.Count; i++)
|
||||
{
|
||||
Consumption consumption = consumptions[i];
|
||||
|
||||
double? literPer100Km = null;
|
||||
|
||||
bool isLast = i == consumptions.Count - 1;
|
||||
if (!isLast)
|
||||
{
|
||||
Consumption previousConsumption = consumptions[i + 1];
|
||||
double distanceDiff = consumption.Distance - previousConsumption.Distance;
|
||||
literPer100Km = consumption.Amount / (distanceDiff / 100);
|
||||
}
|
||||
|
||||
responses.Add(new ResponseDto(
|
||||
consumption.Id.Value,
|
||||
consumption.DateTime,
|
||||
consumption.Distance,
|
||||
consumption.Amount,
|
||||
CarDto.FromCar(consumption.Car),
|
||||
literPer100Km));
|
||||
}
|
||||
}
|
||||
|
||||
activity?.SetTag("consumptionCount", responses.Count);
|
||||
|
||||
ApiResponse apiResponse = new()
|
||||
{
|
||||
Consumptions = responses
|
||||
};
|
||||
return TypedResults.Ok(apiResponse);
|
||||
}
|
||||
|
||||
@@ -7,23 +7,32 @@ namespace Vegasco.Server.Api.Consumptions;
|
||||
|
||||
public static class UpdateConsumption
|
||||
{
|
||||
public record Request(DateTimeOffset DateTime, double Distance, double Amount, bool IgnoreInCalculation);
|
||||
public record Request(DateTimeOffset DateTime, double Distance, double Amount);
|
||||
|
||||
public record Response(Guid Id, DateTimeOffset DateTime, double Distance, double Amount, bool IgnoreInCalculation, Guid CarId);
|
||||
public record Response(Guid Id, DateTimeOffset DateTime, double Distance, double Amount, Guid CarId);
|
||||
|
||||
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
|
||||
{
|
||||
return builder
|
||||
.MapPut("consumptions/{id:guid}", Endpoint)
|
||||
.WithTags("Consumptions");
|
||||
.WithTags("Consumptions")
|
||||
.WithDescription("Updates a consumption entry by ID")
|
||||
.Produces<Response>()
|
||||
.ProducesValidationProblem()
|
||||
.Produces(404);
|
||||
}
|
||||
|
||||
public class Validator : AbstractValidator<Request>
|
||||
{
|
||||
public Validator(TimeProvider timeProvider)
|
||||
{
|
||||
Func<DateTimeOffset> getTodayEndOfDay = () => timeProvider.GetUtcNow()
|
||||
.Date
|
||||
.AddDays(1)
|
||||
.AddTicks(-1);
|
||||
|
||||
RuleFor(x => x.DateTime.ToUniversalTime())
|
||||
.LessThanOrEqualTo(timeProvider.GetUtcNow())
|
||||
.LessThanOrEqualTo(_ => getTodayEndOfDay())
|
||||
.WithName(nameof(Request.DateTime));
|
||||
|
||||
RuleFor(x => x.Distance)
|
||||
@@ -39,11 +48,25 @@ public static class UpdateConsumption
|
||||
Guid id,
|
||||
Request request,
|
||||
IEnumerable<IValidator<Request>> validators,
|
||||
ILoggerFactory loggerFactory,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
ILogger logger = loggerFactory.CreateLogger(typeof(UpdateConsumption));
|
||||
|
||||
List<ValidationResult> failedValidations = await validators.ValidateAllAsync(request, cancellationToken);
|
||||
if (failedValidations.Count > 0)
|
||||
{
|
||||
string[] errors = failedValidations
|
||||
.Where(x => !x.IsValid)
|
||||
.SelectMany(x => x.Errors)
|
||||
.Select(x => x.ErrorMessage)
|
||||
.ToArray();
|
||||
|
||||
logger.LogDebug(
|
||||
"Validation failed for request {@Request} with errors {@Errors}",
|
||||
request,
|
||||
errors);
|
||||
|
||||
return TypedResults.BadRequest(new HttpValidationProblemDetails(failedValidations.ToCombinedDictionary()));
|
||||
}
|
||||
|
||||
@@ -56,10 +79,12 @@ public static class UpdateConsumption
|
||||
consumption.DateTime = request.DateTime.ToUniversalTime();
|
||||
consumption.Distance = request.Distance;
|
||||
consumption.Amount = request.Amount;
|
||||
consumption.IgnoreInCalculation = request.IgnoreInCalculation;
|
||||
|
||||
await dbContext.SaveChangesAsync(cancellationToken);
|
||||
|
||||
return TypedResults.Ok(new Response(consumption.Id.Value, consumption.DateTime, consumption.Distance, consumption.Amount, consumption.IgnoreInCalculation, consumption.CarId.Value));
|
||||
logger.LogTrace("Updated consumption: {@Consumption}", consumption);
|
||||
|
||||
return TypedResults.Ok(new Response(consumption.Id.Value, consumption.DateTime, consumption.Distance,
|
||||
consumption.Amount, consumption.CarId.Value));
|
||||
}
|
||||
}
|
||||
@@ -41,5 +41,6 @@ public static class EndpointExtensions
|
||||
.RequireAuthorization(Constants.Authorization.RequireAuthenticatedUserPolicy);
|
||||
|
||||
GetServerInfo.MapEndpoint(versionedApis);
|
||||
GetCurrentTime.MapEndpoint(versionedApis);
|
||||
}
|
||||
}
|
||||
|
||||
21
src/Vegasco.Server.Api/Info/GetCurrentTime.cs
Normal file
21
src/Vegasco.Server.Api/Info/GetCurrentTime.cs
Normal file
@@ -0,0 +1,21 @@
|
||||
using Microsoft.AspNetCore.Http.HttpResults;
|
||||
|
||||
namespace Vegasco.Server.Api.Info;
|
||||
|
||||
public static class GetCurrentTime
|
||||
{
|
||||
public record Response(DateTimeOffset CurrentTime);
|
||||
|
||||
public static RouteHandlerBuilder MapEndpoint(IEndpointRouteBuilder builder)
|
||||
{
|
||||
return builder
|
||||
.MapGet("info/time", Endpoint)
|
||||
.WithTags("Info");
|
||||
}
|
||||
|
||||
private static Ok<Response> Endpoint(
|
||||
TimeProvider timeProvider)
|
||||
{
|
||||
return TypedResults.Ok(new Response(timeProvider.GetUtcNow()));
|
||||
}
|
||||
}
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
namespace Vegasco.Server.Api.Info;
|
||||
|
||||
public class GetServerInfo
|
||||
public static class GetServerInfo
|
||||
{
|
||||
public record Response(
|
||||
string FullVersion,
|
||||
|
||||
@@ -11,12 +11,12 @@ public class ApplyMigrationsService(
|
||||
{
|
||||
public async Task StartAsync(CancellationToken cancellationToken)
|
||||
{
|
||||
using var activity = activitySource.StartActivity("ApplyMigrations");
|
||||
using Activity? activity = activitySource.StartActivity("ApplyMigrations");
|
||||
|
||||
logger.LogInformation("Starting migrations");
|
||||
|
||||
using IServiceScope scope = scopeFactory.CreateScope();
|
||||
await using var dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
await using ApplicationDbContext dbContext = scope.ServiceProvider.GetRequiredService<ApplicationDbContext>();
|
||||
await dbContext.Database.MigrateAsync(cancellationToken);
|
||||
}
|
||||
|
||||
|
||||
117
src/Vegasco.Server.Api/Persistence/Migrations/20250622085121_DropIgnoreInCalculation.Designer.cs
generated
Normal file
117
src/Vegasco.Server.Api/Persistence/Migrations/20250622085121_DropIgnoreInCalculation.Designer.cs
generated
Normal file
@@ -0,0 +1,117 @@
|
||||
// <auto-generated />
|
||||
using System;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Vegasco.Server.Api.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Vegasco.Server.Api.Persistence.Migrations
|
||||
{
|
||||
[DbContext(typeof(ApplicationDbContext))]
|
||||
[Migration("20250622085121_DropIgnoreInCalculation")]
|
||||
partial class DropIgnoreInCalculation
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void BuildTargetModel(ModelBuilder modelBuilder)
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "9.0.5")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
|
||||
modelBuilder.Entity("Vegasco.Server.Api.Cars.Car", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<string>("UserId")
|
||||
.IsRequired()
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("UserId");
|
||||
|
||||
b.ToTable("Cars");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Vegasco.Server.Api.Consumptions.Consumption", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<double>("Amount")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<Guid>("CarId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTimeOffset>("DateTime")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<double>("Distance")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CarId");
|
||||
|
||||
b.ToTable("Consumptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Vegasco.Server.Api.Users.User", b =>
|
||||
{
|
||||
b.Property<string>("Id")
|
||||
.HasColumnType("text");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.ToTable("Users");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Vegasco.Server.Api.Cars.Car", b =>
|
||||
{
|
||||
b.HasOne("Vegasco.Server.Api.Users.User", "User")
|
||||
.WithMany("Cars")
|
||||
.HasForeignKey("UserId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("User");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Vegasco.Server.Api.Consumptions.Consumption", b =>
|
||||
{
|
||||
b.HasOne("Vegasco.Server.Api.Cars.Car", "Car")
|
||||
.WithMany("Consumptions")
|
||||
.HasForeignKey("CarId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Car");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Vegasco.Server.Api.Cars.Car", b =>
|
||||
{
|
||||
b.Navigation("Consumptions");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("Vegasco.Server.Api.Users.User", b =>
|
||||
{
|
||||
b.Navigation("Cars");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Vegasco.Server.Api.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class DropIgnoreInCalculation : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropColumn(
|
||||
name: "IgnoreInCalculation",
|
||||
table: "Consumptions");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.AddColumn<bool>(
|
||||
name: "IgnoreInCalculation",
|
||||
table: "Consumptions",
|
||||
type: "boolean",
|
||||
nullable: false,
|
||||
defaultValue: false);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,6 @@ using Microsoft.EntityFrameworkCore.Storage.ValueConversion;
|
||||
using Npgsql.EntityFrameworkCore.PostgreSQL.Metadata;
|
||||
using Vegasco.Server.Api.Persistence;
|
||||
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace Vegasco.Server.Api.Persistence.Migrations
|
||||
@@ -18,7 +17,7 @@ namespace Vegasco.Server.Api.Persistence.Migrations
|
||||
{
|
||||
#pragma warning disable 612, 618
|
||||
modelBuilder
|
||||
.HasAnnotation("ProductVersion", "8.0.8")
|
||||
.HasAnnotation("ProductVersion", "9.0.5")
|
||||
.HasAnnotation("Relational:MaxIdentifierLength", 63);
|
||||
|
||||
NpgsqlModelBuilderExtensions.UseIdentityByDefaultColumns(modelBuilder);
|
||||
@@ -61,9 +60,6 @@ namespace Vegasco.Server.Api.Persistence.Migrations
|
||||
b.Property<double>("Distance")
|
||||
.HasColumnType("double precision");
|
||||
|
||||
b.Property<bool>("IgnoreInCalculation")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CarId");
|
||||
|
||||
@@ -13,23 +13,24 @@
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Asp.Versioning.Http" Version="8.1.0" />
|
||||
<PackageReference Include="Asp.Versioning.Mvc.ApiExplorer" Version="8.1.0" />
|
||||
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.3.0" />
|
||||
<PackageReference Include="Aspire.Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.5.1" />
|
||||
<PackageReference Include="Aspire.Seq" Version="9.5.1" />
|
||||
<PackageReference Include="FluentValidation.DependencyInjectionExtensions" Version="12.0.0" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.5" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.5" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.5">
|
||||
<PackageReference Include="Microsoft.AspNetCore.Authentication.JwtBearer" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.AspNetCore.OpenApi" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore" Version="9.0.10" />
|
||||
<PackageReference Include="Microsoft.EntityFrameworkCore.Design" Version="9.0.10">
|
||||
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||
<PrivateAssets>all</PrivateAssets>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.21.2" />
|
||||
<PackageReference Include="Microsoft.VisualStudio.Azure.Containers.Tools.Targets" Version="1.22.1" />
|
||||
<PackageReference Include="Npgsql.EntityFrameworkCore.PostgreSQL" Version="9.0.4" />
|
||||
<PackageReference Include="OpenTelemetry" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry" Version="1.13.1" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.1" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.13.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.4.16" />
|
||||
<PackageReference Include="Scalar.AspNetCore" Version="2.9.0" />
|
||||
<PackageReference Include="StronglyTypedId" Version="1.0.0-beta08" PrivateAssets="all" ExcludeAssets="runtime" />
|
||||
<PackageReference Include="StronglyTypedId.Templates" Version="1.0.0-beta08" />
|
||||
</ItemGroup>
|
||||
@@ -40,7 +41,7 @@
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Nerdbank.GitVersioning" Version="3.7.115" />
|
||||
<PackageReference Update="Nerdbank.GitVersioning" Version="3.8.118" />
|
||||
</ItemGroup>
|
||||
|
||||
</Project>
|
||||
|
||||
85
src/Vegasco.Server.Api/migrations/migration.sql
Normal file
85
src/Vegasco.Server.Api/migrations/migration.sql
Normal file
@@ -0,0 +1,85 @@
|
||||
CREATE TABLE IF NOT EXISTS "__EFMigrationsHistory" (
|
||||
"MigrationId" character varying(150) NOT NULL,
|
||||
"ProductVersion" character varying(32) NOT NULL,
|
||||
CONSTRAINT "PK___EFMigrationsHistory" PRIMARY KEY ("MigrationId")
|
||||
);
|
||||
|
||||
START TRANSACTION;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240818105918_Initial') THEN
|
||||
CREATE TABLE "Users" (
|
||||
"Id" text NOT NULL,
|
||||
CONSTRAINT "PK_Users" PRIMARY KEY ("Id")
|
||||
);
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240818105918_Initial') THEN
|
||||
CREATE TABLE "Cars" (
|
||||
"Id" uuid NOT NULL,
|
||||
"Name" character varying(50) NOT NULL,
|
||||
"UserId" text NOT NULL,
|
||||
CONSTRAINT "PK_Cars" PRIMARY KEY ("Id"),
|
||||
CONSTRAINT "FK_Cars_Users_UserId" FOREIGN KEY ("UserId") REFERENCES "Users" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240818105918_Initial') THEN
|
||||
CREATE TABLE "Consumptions" (
|
||||
"Id" uuid NOT NULL,
|
||||
"DateTime" timestamp with time zone NOT NULL,
|
||||
"Distance" double precision NOT NULL,
|
||||
"Amount" double precision NOT NULL,
|
||||
"IgnoreInCalculation" boolean NOT NULL,
|
||||
"CarId" uuid NOT NULL,
|
||||
CONSTRAINT "PK_Consumptions" PRIMARY KEY ("Id"),
|
||||
CONSTRAINT "FK_Consumptions_Cars_CarId" FOREIGN KEY ("CarId") REFERENCES "Cars" ("Id") ON DELETE CASCADE
|
||||
);
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240818105918_Initial') THEN
|
||||
CREATE INDEX "IX_Cars_UserId" ON "Cars" ("UserId");
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240818105918_Initial') THEN
|
||||
CREATE INDEX "IX_Consumptions_CarId" ON "Consumptions" ("CarId");
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20240818105918_Initial') THEN
|
||||
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
|
||||
VALUES ('20240818105918_Initial', '9.0.5');
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20250622085121_DropIgnoreInCalculation') THEN
|
||||
ALTER TABLE "Consumptions" DROP COLUMN "IgnoreInCalculation";
|
||||
END IF;
|
||||
END $EF$;
|
||||
|
||||
DO $EF$
|
||||
BEGIN
|
||||
IF NOT EXISTS(SELECT 1 FROM "__EFMigrationsHistory" WHERE "MigrationId" = '20250622085121_DropIgnoreInCalculation') THEN
|
||||
INSERT INTO "__EFMigrationsHistory" ("MigrationId", "ProductVersion")
|
||||
VALUES ('20250622085121_DropIgnoreInCalculation', '9.0.5');
|
||||
END IF;
|
||||
END $EF$;
|
||||
COMMIT;
|
||||
|
||||
@@ -4,7 +4,7 @@ public static class Constants
|
||||
{
|
||||
public static class Projects
|
||||
{
|
||||
public const string Api = "Vegasco-Server-Api";
|
||||
public const string Api = "Api";
|
||||
}
|
||||
|
||||
public static class Database
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Update="Nerdbank.GitVersioning">
|
||||
<Version>3.7.115</Version>
|
||||
<Version>3.8.118</Version>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -1,22 +1,40 @@
|
||||
using Microsoft.Extensions.Hosting;
|
||||
using Vegasco.Server.AppHost.Shared;
|
||||
|
||||
IDistributedApplicationBuilder builder = DistributedApplication.CreateBuilder(args);
|
||||
|
||||
IResourceBuilder<PostgresDatabaseResource> postgres = builder.AddPostgres(Constants.Database.ServiceName)
|
||||
IResourceBuilder<PostgresServerResource> postgresBuilder = builder.AddPostgres(Constants.Database.ServiceName)
|
||||
.WithLifetime(ContainerLifetime.Persistent)
|
||||
.WithDataVolume();
|
||||
|
||||
if (builder.Environment.IsDevelopment())
|
||||
{
|
||||
postgresBuilder = postgresBuilder
|
||||
.WithPgWeb()
|
||||
.WithPgAdmin();
|
||||
}
|
||||
|
||||
IResourceBuilder<SeqResource> seq = builder.AddSeq("seq")
|
||||
.WithLifetime(ContainerLifetime.Persistent)
|
||||
.WithDataVolume()
|
||||
.WithExternalHttpEndpoints()
|
||||
.WithImageTag("latest");
|
||||
|
||||
IResourceBuilder<PostgresDatabaseResource> postgres = postgresBuilder
|
||||
.AddDatabase(Constants.Database.Name);
|
||||
|
||||
IResourceBuilder<ProjectResource> api = builder
|
||||
.AddProject<Projects.Vegasco_Server_Api>(Constants.Projects.Api)
|
||||
.WithReference(postgres)
|
||||
.WaitFor(postgres);
|
||||
.WaitFor(postgres)
|
||||
.WithReference(seq)
|
||||
.WaitFor(seq);
|
||||
|
||||
builder
|
||||
.AddNpmApp("Vegasco-Web", "../Vegasco-Web")
|
||||
.WithReference(api)
|
||||
.WaitFor(api)
|
||||
.WithHttpEndpoint(port: 44200, env: "PORT", isProxied: false)
|
||||
.WithHttpEndpoint(port: 44200, env: "PORT")
|
||||
.WithExternalHttpEndpoints()
|
||||
.WithHttpHealthCheck("/", 200);
|
||||
|
||||
|
||||
@@ -12,12 +12,13 @@
|
||||
</PropertyGroup>
|
||||
|
||||
<ItemGroup>
|
||||
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.3.0" />
|
||||
<PackageReference Include="Aspire.Hosting.NodeJs" Version="9.3.1" />
|
||||
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="9.3.0" />
|
||||
<PackageReference Include="Aspire.Hosting.AppHost" Version="9.5.1" />
|
||||
<PackageReference Include="Aspire.Hosting.NodeJs" Version="9.5.1" />
|
||||
<PackageReference Include="Aspire.Hosting.PostgreSQL" Version="9.5.1" />
|
||||
<PackageReference Update="Nerdbank.GitVersioning">
|
||||
<Version>3.7.115</Version>
|
||||
<Version>3.8.118</Version>
|
||||
</PackageReference>
|
||||
<PackageReference Include="Aspire.Hosting.Seq" Version="9.5.1" />
|
||||
</ItemGroup>
|
||||
|
||||
<ItemGroup>
|
||||
@@ -25,4 +26,14 @@
|
||||
<ProjectReference Include="..\Vegasco.Server.Api\Vegasco.Server.Api.csproj" />
|
||||
</ItemGroup>
|
||||
|
||||
<Target Name="RestoreNpm" BeforeTargets="Build" Condition=" '$(DesignTimeBuild)' != 'true' ">
|
||||
<ItemGroup>
|
||||
<PackageJsons Include="..\*\package.json" />
|
||||
</ItemGroup>
|
||||
|
||||
<!-- Install npm packages if node_modules is missing -->
|
||||
<Message Importance="Normal" Text="Installing npm packages for %(PackageJsons.RelativeDir)" Condition="!Exists('%(PackageJsons.RootDir)%(PackageJsons.Directory)/node_modules')" />
|
||||
<Exec Command="pnpm install" WorkingDirectory="%(PackageJsons.RootDir)%(PackageJsons.Directory)" Condition="!Exists('%(PackageJsons.RootDir)%(PackageJsons.Directory)/node_modules')" />
|
||||
</Target>
|
||||
|
||||
</Project>
|
||||
|
||||
@@ -10,15 +10,15 @@
|
||||
<ItemGroup>
|
||||
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.5.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="9.3.0" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.12.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.Http.Resilience" Version="9.10.0" />
|
||||
<PackageReference Include="Microsoft.Extensions.ServiceDiscovery" Version="9.5.1" />
|
||||
<PackageReference Include="OpenTelemetry.Exporter.OpenTelemetryProtocol" Version="1.13.1" />
|
||||
<PackageReference Include="OpenTelemetry.Extensions.Hosting" Version="1.13.1" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.AspNetCore" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Http" Version="1.12.0" />
|
||||
<PackageReference Include="OpenTelemetry.Instrumentation.Runtime" Version="1.12.0" />
|
||||
<PackageReference Update="Nerdbank.GitVersioning">
|
||||
<Version>3.7.115</Version>
|
||||
<Version>3.8.118</Version>
|
||||
</PackageReference>
|
||||
</ItemGroup>
|
||||
|
||||
|
||||
@@ -5,15 +5,15 @@ namespace Vegasco.Server.Api.Tests.Integration;
|
||||
|
||||
internal class CarFaker
|
||||
{
|
||||
private readonly Faker _faker = new();
|
||||
|
||||
internal CreateCar.Request CreateCarRequest()
|
||||
{
|
||||
return new CreateCar.Request(_faker.Vehicle.Model());
|
||||
Faker faker = new();
|
||||
return new CreateCar.Request(faker.Person.FirstName);
|
||||
}
|
||||
|
||||
internal UpdateCar.Request UpdateCarRequest()
|
||||
{
|
||||
return new UpdateCar.Request(_faker.Vehicle.Model());
|
||||
Faker faker = new();
|
||||
return new UpdateCar.Request(faker.Person.FirstName);
|
||||
}
|
||||
}
|
||||
@@ -35,7 +35,7 @@ public class CreateCarTests : IAsyncLifetime
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
var createdCar = await response.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
CreateCar.Response? createdCar = await response.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
createdCar.Should().BeEquivalentTo(createCarRequest, o => o.ExcludingMissingMembers());
|
||||
|
||||
_dbContext.Cars.Should().ContainEquivalentOf(createdCar, o => o.Excluding(x => x!.Id))
|
||||
@@ -46,14 +46,14 @@ public class CreateCarTests : IAsyncLifetime
|
||||
public async Task CreateCar_ShouldReturnValidationProblems_WhenRequestIsNotValid()
|
||||
{
|
||||
// Arrange
|
||||
var createCarRequest = new CreateCar.Request("");
|
||||
CreateCar.Request createCarRequest = new CreateCar.Request("");
|
||||
|
||||
// Act
|
||||
HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
var validationProblemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
|
||||
ValidationProblemDetails? validationProblemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
|
||||
validationProblemDetails!.Errors.Keys.Should().Contain(x =>
|
||||
x.Equals(nameof(CreateCar.Request.Name), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ public class DeleteCarTests : IAsyncLifetime
|
||||
public async Task DeleteCar_ShouldReturnNotFound_WhenCarDoesNotExist()
|
||||
{
|
||||
// Arrange
|
||||
var randomCarId = Guid.NewGuid();
|
||||
Guid randomCarId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
HttpResponseMessage response = await _factory.HttpClient.DeleteAsync($"v1/cars/{randomCarId}");
|
||||
@@ -43,7 +43,7 @@ public class DeleteCarTests : IAsyncLifetime
|
||||
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
|
||||
HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
|
||||
createCarResponse.EnsureSuccessStatusCode();
|
||||
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
CreateCar.Response? createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
|
||||
// Act
|
||||
HttpResponseMessage response = await _factory.HttpClient.DeleteAsync($"v1/cars/{createdCar!.Id}");
|
||||
|
||||
@@ -21,7 +21,7 @@ public class GetCarTests : IAsyncLifetime
|
||||
public async Task GetCar_ShouldReturnNotFound_WhenCarDoesNotExist()
|
||||
{
|
||||
// Arrange
|
||||
var randomCarId = Guid.NewGuid();
|
||||
Guid randomCarId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
HttpResponseMessage response = await _factory.HttpClient.GetAsync($"v1/cars/{randomCarId}");
|
||||
@@ -37,14 +37,14 @@ public class GetCarTests : IAsyncLifetime
|
||||
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
|
||||
HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
|
||||
createCarResponse.EnsureSuccessStatusCode();
|
||||
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
CreateCar.Response? createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
|
||||
// Act
|
||||
HttpResponseMessage response = await _factory.HttpClient.GetAsync($"v1/cars/{createdCar!.Id}");
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var car = await response.Content.ReadFromJsonAsync<GetCar.Response>();
|
||||
GetCar.Response? car = await response.Content.ReadFromJsonAsync<GetCar.Response>();
|
||||
car.Should().BeEquivalentTo(createdCar);
|
||||
}
|
||||
|
||||
|
||||
@@ -27,7 +27,7 @@ public class GetCarsTests : IAsyncLifetime
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var apiResponse = await response.Content.ReadFromJsonAsync<GetCars.ApiResponse>();
|
||||
GetCars.ApiResponse? apiResponse = await response.Content.ReadFromJsonAsync<GetCars.ApiResponse>();
|
||||
apiResponse!.Cars.Should().BeEmpty();
|
||||
}
|
||||
|
||||
@@ -38,13 +38,13 @@ public class GetCarsTests : IAsyncLifetime
|
||||
List<CreateCar.Response> createdCars = [];
|
||||
|
||||
const int numberOfCars = 5;
|
||||
for (var i = 0; i < numberOfCars; i++)
|
||||
for (int i = 0; i < numberOfCars; i++)
|
||||
{
|
||||
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
|
||||
HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
|
||||
createCarResponse.EnsureSuccessStatusCode();
|
||||
|
||||
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
CreateCar.Response? createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
createdCars.Add(createdCar!);
|
||||
}
|
||||
|
||||
@@ -53,7 +53,7 @@ public class GetCarsTests : IAsyncLifetime
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var apiResponse = await response.Content.ReadFromJsonAsync<GetCars.ApiResponse>();
|
||||
GetCars.ApiResponse? apiResponse = await response.Content.ReadFromJsonAsync<GetCars.ApiResponse>();
|
||||
apiResponse!.Cars.Should().BeEquivalentTo(createdCars);
|
||||
}
|
||||
|
||||
|
||||
@@ -31,7 +31,7 @@ public class UpdateCarTests : IAsyncLifetime
|
||||
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
|
||||
HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
|
||||
createCarResponse.EnsureSuccessStatusCode();
|
||||
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
CreateCar.Response? createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
|
||||
UpdateCar.Request updateCarRequest = _carFaker.UpdateCarRequest();
|
||||
|
||||
@@ -40,7 +40,7 @@ public class UpdateCarTests : IAsyncLifetime
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.OK);
|
||||
var updatedCar = await response.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
CreateCar.Response? updatedCar = await response.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
updatedCar!.Id.Should().Be(createdCar.Id);
|
||||
updatedCar.Should().BeEquivalentTo(updateCarRequest, o => o.ExcludingMissingMembers());
|
||||
|
||||
@@ -57,16 +57,16 @@ public class UpdateCarTests : IAsyncLifetime
|
||||
CreateCar.Request createCarRequest = _carFaker.CreateCarRequest();
|
||||
HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
|
||||
createCarResponse.EnsureSuccessStatusCode();
|
||||
var createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
CreateCar.Response? createdCar = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
|
||||
var updateCarRequest = new UpdateCar.Request("");
|
||||
UpdateCar.Request updateCarRequest = new UpdateCar.Request("");
|
||||
|
||||
// Act
|
||||
HttpResponseMessage response = await _factory.HttpClient.PutAsJsonAsync($"v1/cars/{createdCar!.Id}", updateCarRequest);
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
var validationProblemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
|
||||
ValidationProblemDetails? validationProblemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
|
||||
validationProblemDetails!.Errors.Keys.Should().Contain(x =>
|
||||
x.Equals(nameof(CreateCar.Request.Name), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
@@ -80,7 +80,7 @@ public class UpdateCarTests : IAsyncLifetime
|
||||
{
|
||||
// Arrange
|
||||
UpdateCar.Request updateCarRequest = _carFaker.UpdateCarRequest();
|
||||
var randomCarId = Guid.NewGuid();
|
||||
Guid randomCarId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
HttpResponseMessage response = await _factory.HttpClient.PutAsJsonAsync($"v1/cars/{randomCarId}", updateCarRequest);
|
||||
|
||||
@@ -5,25 +5,22 @@ namespace Vegasco.Server.Api.Tests.Integration;
|
||||
|
||||
internal class ConsumptionFaker
|
||||
{
|
||||
private readonly Faker _faker = new();
|
||||
|
||||
internal CreateConsumption.Request CreateConsumptionRequest(Guid carId)
|
||||
{
|
||||
Faker faker = new();
|
||||
return new CreateConsumption.Request(
|
||||
_faker.Date.RecentOffset(),
|
||||
_faker.Random.Int(1, 1_000),
|
||||
_faker.Random.Int(20, 70),
|
||||
_faker.Random.Bool(),
|
||||
faker.Date.RecentOffset(),
|
||||
faker.Random.Int(1, 1_000),
|
||||
faker.Random.Int(20, 70),
|
||||
carId);
|
||||
}
|
||||
|
||||
internal UpdateConsumption.Request UpdateConsumptionRequest()
|
||||
{
|
||||
CreateConsumption.Request createRequest = CreateConsumptionRequest(default);
|
||||
CreateConsumption.Request createRequest = CreateConsumptionRequest(Guid.Empty);
|
||||
return new UpdateConsumption.Request(
|
||||
createRequest.DateTime,
|
||||
createRequest.Distance,
|
||||
createRequest.Amount,
|
||||
createRequest.IgnoreInCalculation);
|
||||
createRequest.Amount);
|
||||
}
|
||||
}
|
||||
@@ -39,7 +39,7 @@ public class CreateConsumptionTests : IAsyncLifetime
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.Created);
|
||||
var createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>();
|
||||
CreateConsumption.Response? createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>();
|
||||
createdConsumption.Should().BeEquivalentTo(createConsumptionRequest, o => o.ExcludingMissingMembers());
|
||||
|
||||
_dbContext.Consumptions.Should().HaveCount(1)
|
||||
@@ -64,7 +64,7 @@ public class CreateConsumptionTests : IAsyncLifetime
|
||||
|
||||
// Assert
|
||||
response.StatusCode.Should().Be(HttpStatusCode.BadRequest);
|
||||
var validationProblemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
|
||||
ValidationProblemDetails? validationProblemDetails = await response.Content.ReadFromJsonAsync<ValidationProblemDetails>();
|
||||
validationProblemDetails!.Errors.Keys.Should().Contain(x =>
|
||||
x.Equals(nameof(createConsumptionRequest.CarId), StringComparison.OrdinalIgnoreCase));
|
||||
|
||||
@@ -76,7 +76,7 @@ public class CreateConsumptionTests : IAsyncLifetime
|
||||
CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest();
|
||||
using HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
|
||||
createCarResponse.EnsureSuccessStatusCode();
|
||||
var createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
CreateCar.Response? createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
return createdCarResponse!;
|
||||
}
|
||||
|
||||
|
||||
@@ -43,7 +43,7 @@ public class DeleteConsumptionTests : IAsyncLifetime
|
||||
public async Task DeleteConsumption_ShouldReturnNotFound_WhenConsumptionDoesNotExist()
|
||||
{
|
||||
// Arrange
|
||||
var consumptionId = Guid.NewGuid();
|
||||
Guid consumptionId = Guid.NewGuid();
|
||||
|
||||
// Act
|
||||
using HttpResponseMessage response = await _factory.HttpClient.DeleteAsync($"v1/consumptions/{consumptionId}");
|
||||
@@ -58,7 +58,7 @@ public class DeleteConsumptionTests : IAsyncLifetime
|
||||
CreateConsumption.Request createConsumptionRequest = _consumptionFaker.CreateConsumptionRequest(createdCarResponse.Id);
|
||||
using HttpResponseMessage response = await _factory.HttpClient.PostAsJsonAsync("v1/consumptions", createConsumptionRequest);
|
||||
response.EnsureSuccessStatusCode();
|
||||
var createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>();
|
||||
CreateConsumption.Response? createdConsumption = await response.Content.ReadFromJsonAsync<CreateConsumption.Response>();
|
||||
return createdConsumption!;
|
||||
}
|
||||
|
||||
@@ -67,7 +67,7 @@ public class DeleteConsumptionTests : IAsyncLifetime
|
||||
CreateCar.Request createCarRequest = new CarFaker().CreateCarRequest();
|
||||
using HttpResponseMessage createCarResponse = await _factory.HttpClient.PostAsJsonAsync("v1/cars", createCarRequest);
|
||||
createCarResponse.EnsureSuccessStatusCode();
|
||||
var createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
CreateCar.Response? createdCarResponse = await createCarResponse.Content.ReadFromJsonAsync<CreateCar.Response>();
|
||||
return createdCarResponse!;
|
||||
}
|
||||
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user