From ecd89a68f837eb9a2e69f8c2a54f457f3f04b9e6 Mon Sep 17 00:00:00 2001 From: Julian Lechner Date: Wed, 24 Jun 2026 08:33:23 +0200 Subject: [PATCH] Project to GitHub --- .config/dotnet-tools.json | 11 + .editorconfig | 22 + .gitattributes | 1 + .gitignore | 298 +++++ Bsevita.Library.sln | 56 + Directory.Build.props | 10 + Directory.Packages.props | 20 + README.md | 39 + database/create_database.sql | 126 ++ database/smoke_test.sql | 16 + docs/API.md | 51 + docs/ARCHITECTURE.md | 50 + global.json | 6 + .../Bsevita.Library.Api.csproj | 20 + .../Controllers/BooksController.cs | 136 ++ .../Controllers/LoansController.cs | 48 + .../Controllers/ReportsController.cs | 84 ++ .../Controllers/ReturnsController.cs | 43 + .../Controllers/StudentsController.cs | 128 ++ .../Data/Generated/Entities/Book.cs | 20 + .../Data/Generated/Entities/Loan.cs | 16 + .../Data/Generated/Entities/Student.cs | 18 + .../Data/Generated/LibraryDbContext.cs | 68 + .../Data/LibraryDbContextFactory.cs | 20 + .../Infrastructure/ApiExceptionHandler.cs | 56 + .../Infrastructure/ApiExceptions.cs | 19 + .../Infrastructure/StringNormalizer.cs | 23 + src/Bsevita.Library.Api/Program.cs | 86 ++ .../Services/BookService.cs | 231 ++++ .../Services/ContractMapper.cs | 55 + .../Services/LoanService.cs | 88 ++ .../Services/ReportService.cs | 159 +++ .../Services/ReturnService.cs | 89 ++ .../Services/StudentService.cs | 162 +++ .../appsettings.Development.json | 9 + src/Bsevita.Library.Api/appsettings.json | 20 + src/Bsevita.Library.Maui/App.xaml | 10 + src/Bsevita.Library.Maui/App.xaml.cs | 24 + .../Bsevita.Library.Maui.csproj | 43 + src/Bsevita.Library.Maui/MainPage.xaml | 15 + src/Bsevita.Library.Maui/MainPage.xaml.cs | 9 + src/Bsevita.Library.Maui/MauiProgram.cs | 31 + .../Platforms/Android/AndroidManifest.xml | 6 + .../Platforms/Android/MainActivity.cs | 10 + .../Platforms/Android/MainApplication.cs | 10 + .../Android/Resources/values/colors.xml | 2 + .../Platforms/MacCatalyst/AppDelegate.cs | 9 + .../Platforms/MacCatalyst/Entitlements.plist | 3 + .../Platforms/MacCatalyst/Info.plist | 7 + .../Platforms/MacCatalyst/Program.cs | 8 + .../Platforms/Windows/App.xaml | 4 + .../Platforms/Windows/App.xaml.cs | 7 + .../Platforms/Windows/Package.appxmanifest | 9 + .../Platforms/iOS/AppDelegate.cs | 9 + .../Platforms/iOS/Info.plist | 9 + .../Platforms/iOS/Program.cs | 8 + .../Resources/AppIcon/appicon.svg | 1 + .../Resources/AppIcon/appiconfg.svg | 1 + .../Resources/Raw/README.txt | 1 + .../Resources/Splash/splash.svg | 1 + .../Services/ApiEndpointResolver.cs | 19 + src/Bsevita.Library.Maui/wwwroot/index.html | 17 + .../Books/BookContracts.cs | 63 + .../Bsevita.Library.Models.csproj | 8 + .../Common/LibraryRules.cs | 15 + .../Loans/LoanContracts.cs | 73 ++ .../Reports/ReportContracts.cs | 40 + .../Returns/ReturnContracts.cs | 50 + .../Students/StudentContracts.cs | 51 + src/Bsevita.Library.Ui/AppRoutes.razor | 6 + .../Bsevita.Library.Ui.csproj | 13 + .../Components/AlertMessage.razor | 11 + .../Components/AlertMessage.razor.cs | 10 + .../Components/ConfirmDialog.razor | 18 + .../Components/ConfirmDialog.razor.cs | 13 + src/Bsevita.Library.Ui/Components/Icon.razor | 27 + .../Components/LoadingIndicator.razor | 4 + .../Components/LoadingIndicator.razor.cs | 6 + .../Components/PageHeader.razor | 9 + .../Components/PageHeader.razor.cs | 7 + .../Components/StatCard.razor | 7 + .../Components/StatCard.razor.cs | 9 + src/Bsevita.Library.Ui/GlobalUsings.cs | 9 + .../Layout/MainLayout.razor | 22 + .../Layout/MainLayout.razor.cs | 8 + src/Bsevita.Library.Ui/Layout/NavMenu.razor | 27 + .../Layout/NavMenu.razor.cs | 7 + src/Bsevita.Library.Ui/Pages/Books.razor | 86 ++ src/Bsevita.Library.Ui/Pages/Books.razor.cs | 199 +++ src/Bsevita.Library.Ui/Pages/Dashboard.razor | 82 ++ .../Pages/Dashboard.razor.cs | 36 + src/Bsevita.Library.Ui/Pages/Loans.razor | 81 ++ src/Bsevita.Library.Ui/Pages/Loans.razor.cs | 127 ++ src/Bsevita.Library.Ui/Pages/NotFound.razor | 11 + src/Bsevita.Library.Ui/Pages/Reports.razor | 92 ++ src/Bsevita.Library.Ui/Pages/Reports.razor.cs | 31 + src/Bsevita.Library.Ui/Pages/Returns.razor | 65 + src/Bsevita.Library.Ui/Pages/Returns.razor.cs | 61 + src/Bsevita.Library.Ui/Pages/Students.razor | 92 ++ .../Pages/Students.razor.cs | 117 ++ .../Services/ApiClientBase.cs | 60 + .../Services/ApiProblemException.cs | 10 + .../Services/BookApiClient.cs | 37 + .../Services/LoanApiClient.cs | 10 + .../Services/ReportApiClient.cs | 19 + .../Services/ReturnApiClient.cs | 10 + .../Services/ServiceCollectionExtensions.cs | 16 + .../Services/StudentApiClient.cs | 27 + src/Bsevita.Library.Ui/_Imports.razor | 15 + src/Bsevita.Library.Ui/wwwroot/css/app.css | 1140 +++++++++++++++++ .../Bsevita.Library.Web.csproj | 9 + src/Bsevita.Library.Web/Components/App.razor | 14 + .../Components/_Imports.razor | 6 + src/Bsevita.Library.Web/Program.cs | 30 + src/Bsevita.Library.Web/appsettings.json | 10 + .../ApiIntegrationTests.cs | 198 +++ .../Bsevita.Library.Api.Tests.csproj | 25 + .../FixedTimeProvider.cs | 6 + .../LibraryWorkflowTests.cs | 645 ++++++++++ .../Bsevita.Library.Api.Tests/TestDatabase.cs | 23 + tests/Bsevita.Library.Api.Tests/Usings.cs | 1 + 121 files changed, 6481 insertions(+) create mode 100644 .config/dotnet-tools.json create mode 100644 .editorconfig create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 Bsevita.Library.sln create mode 100644 Directory.Build.props create mode 100644 Directory.Packages.props create mode 100644 README.md create mode 100644 database/create_database.sql create mode 100644 database/smoke_test.sql create mode 100644 docs/API.md create mode 100644 docs/ARCHITECTURE.md create mode 100644 global.json create mode 100644 src/Bsevita.Library.Api/Bsevita.Library.Api.csproj create mode 100644 src/Bsevita.Library.Api/Controllers/BooksController.cs create mode 100644 src/Bsevita.Library.Api/Controllers/LoansController.cs create mode 100644 src/Bsevita.Library.Api/Controllers/ReportsController.cs create mode 100644 src/Bsevita.Library.Api/Controllers/ReturnsController.cs create mode 100644 src/Bsevita.Library.Api/Controllers/StudentsController.cs create mode 100644 src/Bsevita.Library.Api/Data/Generated/Entities/Book.cs create mode 100644 src/Bsevita.Library.Api/Data/Generated/Entities/Loan.cs create mode 100644 src/Bsevita.Library.Api/Data/Generated/Entities/Student.cs create mode 100644 src/Bsevita.Library.Api/Data/Generated/LibraryDbContext.cs create mode 100644 src/Bsevita.Library.Api/Data/LibraryDbContextFactory.cs create mode 100644 src/Bsevita.Library.Api/Infrastructure/ApiExceptionHandler.cs create mode 100644 src/Bsevita.Library.Api/Infrastructure/ApiExceptions.cs create mode 100644 src/Bsevita.Library.Api/Infrastructure/StringNormalizer.cs create mode 100644 src/Bsevita.Library.Api/Program.cs create mode 100644 src/Bsevita.Library.Api/Services/BookService.cs create mode 100644 src/Bsevita.Library.Api/Services/ContractMapper.cs create mode 100644 src/Bsevita.Library.Api/Services/LoanService.cs create mode 100644 src/Bsevita.Library.Api/Services/ReportService.cs create mode 100644 src/Bsevita.Library.Api/Services/ReturnService.cs create mode 100644 src/Bsevita.Library.Api/Services/StudentService.cs create mode 100644 src/Bsevita.Library.Api/appsettings.Development.json create mode 100644 src/Bsevita.Library.Api/appsettings.json create mode 100644 src/Bsevita.Library.Maui/App.xaml create mode 100644 src/Bsevita.Library.Maui/App.xaml.cs create mode 100644 src/Bsevita.Library.Maui/Bsevita.Library.Maui.csproj create mode 100644 src/Bsevita.Library.Maui/MainPage.xaml create mode 100644 src/Bsevita.Library.Maui/MainPage.xaml.cs create mode 100644 src/Bsevita.Library.Maui/MauiProgram.cs create mode 100644 src/Bsevita.Library.Maui/Platforms/Android/AndroidManifest.xml create mode 100644 src/Bsevita.Library.Maui/Platforms/Android/MainActivity.cs create mode 100644 src/Bsevita.Library.Maui/Platforms/Android/MainApplication.cs create mode 100644 src/Bsevita.Library.Maui/Platforms/Android/Resources/values/colors.xml create mode 100644 src/Bsevita.Library.Maui/Platforms/MacCatalyst/AppDelegate.cs create mode 100644 src/Bsevita.Library.Maui/Platforms/MacCatalyst/Entitlements.plist create mode 100644 src/Bsevita.Library.Maui/Platforms/MacCatalyst/Info.plist create mode 100644 src/Bsevita.Library.Maui/Platforms/MacCatalyst/Program.cs create mode 100644 src/Bsevita.Library.Maui/Platforms/Windows/App.xaml create mode 100644 src/Bsevita.Library.Maui/Platforms/Windows/App.xaml.cs create mode 100644 src/Bsevita.Library.Maui/Platforms/Windows/Package.appxmanifest create mode 100644 src/Bsevita.Library.Maui/Platforms/iOS/AppDelegate.cs create mode 100644 src/Bsevita.Library.Maui/Platforms/iOS/Info.plist create mode 100644 src/Bsevita.Library.Maui/Platforms/iOS/Program.cs create mode 100644 src/Bsevita.Library.Maui/Resources/AppIcon/appicon.svg create mode 100644 src/Bsevita.Library.Maui/Resources/AppIcon/appiconfg.svg create mode 100644 src/Bsevita.Library.Maui/Resources/Raw/README.txt create mode 100644 src/Bsevita.Library.Maui/Resources/Splash/splash.svg create mode 100644 src/Bsevita.Library.Maui/Services/ApiEndpointResolver.cs create mode 100644 src/Bsevita.Library.Maui/wwwroot/index.html create mode 100644 src/Bsevita.Library.Models/Books/BookContracts.cs create mode 100644 src/Bsevita.Library.Models/Bsevita.Library.Models.csproj create mode 100644 src/Bsevita.Library.Models/Common/LibraryRules.cs create mode 100644 src/Bsevita.Library.Models/Loans/LoanContracts.cs create mode 100644 src/Bsevita.Library.Models/Reports/ReportContracts.cs create mode 100644 src/Bsevita.Library.Models/Returns/ReturnContracts.cs create mode 100644 src/Bsevita.Library.Models/Students/StudentContracts.cs create mode 100644 src/Bsevita.Library.Ui/AppRoutes.razor create mode 100644 src/Bsevita.Library.Ui/Bsevita.Library.Ui.csproj create mode 100644 src/Bsevita.Library.Ui/Components/AlertMessage.razor create mode 100644 src/Bsevita.Library.Ui/Components/AlertMessage.razor.cs create mode 100644 src/Bsevita.Library.Ui/Components/ConfirmDialog.razor create mode 100644 src/Bsevita.Library.Ui/Components/ConfirmDialog.razor.cs create mode 100644 src/Bsevita.Library.Ui/Components/Icon.razor create mode 100644 src/Bsevita.Library.Ui/Components/LoadingIndicator.razor create mode 100644 src/Bsevita.Library.Ui/Components/LoadingIndicator.razor.cs create mode 100644 src/Bsevita.Library.Ui/Components/PageHeader.razor create mode 100644 src/Bsevita.Library.Ui/Components/PageHeader.razor.cs create mode 100644 src/Bsevita.Library.Ui/Components/StatCard.razor create mode 100644 src/Bsevita.Library.Ui/Components/StatCard.razor.cs create mode 100644 src/Bsevita.Library.Ui/GlobalUsings.cs create mode 100644 src/Bsevita.Library.Ui/Layout/MainLayout.razor create mode 100644 src/Bsevita.Library.Ui/Layout/MainLayout.razor.cs create mode 100644 src/Bsevita.Library.Ui/Layout/NavMenu.razor create mode 100644 src/Bsevita.Library.Ui/Layout/NavMenu.razor.cs create mode 100644 src/Bsevita.Library.Ui/Pages/Books.razor create mode 100644 src/Bsevita.Library.Ui/Pages/Books.razor.cs create mode 100644 src/Bsevita.Library.Ui/Pages/Dashboard.razor create mode 100644 src/Bsevita.Library.Ui/Pages/Dashboard.razor.cs create mode 100644 src/Bsevita.Library.Ui/Pages/Loans.razor create mode 100644 src/Bsevita.Library.Ui/Pages/Loans.razor.cs create mode 100644 src/Bsevita.Library.Ui/Pages/NotFound.razor create mode 100644 src/Bsevita.Library.Ui/Pages/Reports.razor create mode 100644 src/Bsevita.Library.Ui/Pages/Reports.razor.cs create mode 100644 src/Bsevita.Library.Ui/Pages/Returns.razor create mode 100644 src/Bsevita.Library.Ui/Pages/Returns.razor.cs create mode 100644 src/Bsevita.Library.Ui/Pages/Students.razor create mode 100644 src/Bsevita.Library.Ui/Pages/Students.razor.cs create mode 100644 src/Bsevita.Library.Ui/Services/ApiClientBase.cs create mode 100644 src/Bsevita.Library.Ui/Services/ApiProblemException.cs create mode 100644 src/Bsevita.Library.Ui/Services/BookApiClient.cs create mode 100644 src/Bsevita.Library.Ui/Services/LoanApiClient.cs create mode 100644 src/Bsevita.Library.Ui/Services/ReportApiClient.cs create mode 100644 src/Bsevita.Library.Ui/Services/ReturnApiClient.cs create mode 100644 src/Bsevita.Library.Ui/Services/ServiceCollectionExtensions.cs create mode 100644 src/Bsevita.Library.Ui/Services/StudentApiClient.cs create mode 100644 src/Bsevita.Library.Ui/_Imports.razor create mode 100644 src/Bsevita.Library.Ui/wwwroot/css/app.css create mode 100644 src/Bsevita.Library.Web/Bsevita.Library.Web.csproj create mode 100644 src/Bsevita.Library.Web/Components/App.razor create mode 100644 src/Bsevita.Library.Web/Components/_Imports.razor create mode 100644 src/Bsevita.Library.Web/Program.cs create mode 100644 src/Bsevita.Library.Web/appsettings.json create mode 100644 tests/Bsevita.Library.Api.Tests/ApiIntegrationTests.cs create mode 100644 tests/Bsevita.Library.Api.Tests/Bsevita.Library.Api.Tests.csproj create mode 100644 tests/Bsevita.Library.Api.Tests/FixedTimeProvider.cs create mode 100644 tests/Bsevita.Library.Api.Tests/LibraryWorkflowTests.cs create mode 100644 tests/Bsevita.Library.Api.Tests/TestDatabase.cs create mode 100644 tests/Bsevita.Library.Api.Tests/Usings.cs diff --git a/.config/dotnet-tools.json b/.config/dotnet-tools.json new file mode 100644 index 0000000..0cfa2d1 --- /dev/null +++ b/.config/dotnet-tools.json @@ -0,0 +1,11 @@ +{ + "version": 1, + "isRoot": true, + "tools": { + "dotnet-ef": { + "version": "10.0.0", + "commands": ["dotnet-ef"], + "rollForward": false + } + } +} diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000..2747b03 --- /dev/null +++ b/.editorconfig @@ -0,0 +1,22 @@ +root = true + +[*] +charset = utf-8 +end_of_line = crlf +insert_final_newline = true +trim_trailing_whitespace = true + +[*.{cs,razor}] +indent_style = space +indent_size = 4 + +[*.{css.json,yml,yaml,xml,csproj,props,targets,xaml}] +indent_style = space +indent_size = 2 + +[*.cs] +dotnet_sort_system_directives_first = true +dotnet_separate_import_directive_groups = false +csharp_style_var_for_built_in_types = true:suggestion +csharp_style_var_when_type_is_apparent = true:suggestion +csharp_style_var_elsewhere = true:suggestion diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..92be83e --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +* text eol=crlf diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..53f1c64 --- /dev/null +++ b/.gitignore @@ -0,0 +1,298 @@ +## Ignore Visual Studio temporary files, build results, and +## files generated by popular Visual Studio add-ons. +## +## Get latest from https://github.com/github/gitignore/blob/master/VisualStudio.gitignore + +# User-specific files +*.suo +*.user +*.userosscache +*.sln.docstates + +# User-specific files (MonoDevelop/Xamarin Studio) +*.userprefs + +# Build results +[Dd]ebug/ +[Dd]ebugPublic/ +[Rr]elease/ +x64/ +x86/ +bld/ +[Bb]in/ +[Oo]bj/ +[Ll]og/ + +# Visual Studio 2015 cache/options directory +.vs/ +# Uncomment if you have tasks that create the project's static files in wwwroot +#wwwroot/ + +# MSTest test Results +[Tt]est[Rr]esult*/ +[Bb]uild[Ll]og.* + +# NUNIT +*.VisualState.xml +TestResult.xml + +# Build Results of an ATL Project +[Dd]ebugPS/ +[Rr]eleasePS/ +dlldata.c + +# .NET Core +project.lock.json +project.fragment.lock.json +artifacts/ +**/Properties/launchSettings.json + +# VS Code +.vscode/ + +*_i.c +*_p.c +*_i.h +*.ilk +*.meta +*.obj +*.pch +*.pdb +*.pgc +*.pgd +*.rsp +*.sbr +*.tlb +*.tli +*.tlh +*.tmp +*.tmp_proj +*.log +*.vspscc +*.vssscc +.builds +*.pidb +*.svclog +*.scc + +# Chutzpah Test files +_Chutzpah* + +# Visual C++ cache files +ipch/ +*.aps +*.ncb +*.opendb +*.opensdf +*.sdf +*.cachefile +*.VC.db +*.VC.VC.opendb + +# Visual Studio profiler +*.psess +*.vsp +*.vspx +*.sap + +# TFS 2012 Local Workspace +$tf/ + +# Guidance Automation Toolkit +*.gpState + +# ReSharper is a .NET coding add-in +_ReSharper*/ +*.[Rr]e[Ss]harper +*.DotSettings.user + +# JustCode is a .NET coding add-in +.JustCode + +# TeamCity is a build add-in +_TeamCity* + +# DotCover is a Code Coverage Tool +*.dotCover + +# Visual Studio code coverage results +*.coverage +*.coveragexml + +# NCrunch +_NCrunch_* +.*crunch*.local.xml +nCrunchTemp_* + +# MightyMoose +*.mm.* +AutoTest.Net/ + +# Web workbench (sass) +.sass-cache/ + +# Installshield output folder +[Ee]xpress/ + +# DocProject is a documentation generator add-in +DocProject/buildhelp/ +DocProject/Help/*.HxT +DocProject/Help/*.HxC +DocProject/Help/*.hhc +DocProject/Help/*.hhk +DocProject/Help/*.hhp +DocProject/Help/Html2 +DocProject/Help/html + +# Click-Once directory +publish/ + +# Publish Web Output +*.[Pp]ublish.xml +*.azurePubxml +# TODO: Comment the next line if you want to checkin your web deploy settings +# but database connection strings (with potential passwords) will be unencrypted +*.pubxml +*.publishproj + +# Microsoft Azure Web App publish settings. Comment the next line if you want to +# checkin your Azure Web App publish settings, but sensitive information contained +# in these scripts will be unencrypted +PublishScripts/ + +# NuGet Packages +*.nupkg +# The packages folder can be ignored because of Package Restore +**/packages/* +# except build/, which is used as an MSBuild target. +!**/packages/build/ +# Uncomment if necessary however generally it will be regenerated when needed +#!**/packages/repositories.config +# NuGet v3's project.json files produces more ignorable files +*.nuget.props +*.nuget.targets + +# Microsoft Azure Build Output +csx/ +*.build.csdef + +# Microsoft Azure Emulator +ecf/ +rcf/ + +# Windows Store app package directories and files +AppPackages/ +BundleArtifacts/ +Package.StoreAssociation.xml +_pkginfo.txt + +# Visual Studio cache files +# files ending in .cache can be ignored +*.[Cc]ache +# but keep track of directories ending in .cache +!*.[Cc]ache/ + +# Others +ClientBin/ +~$* +*~ +*.dbmdl +*.dbproj.schemaview +*.jfm +*.pfx +*.publishsettings +orleans.codegen.cs + +# Since there are multiple workflows, uncomment next line to ignore bower_components +# (https://github.com/github/gitignore/pull/1529#issuecomment-104372622) +#bower_components/ + +# RIA/Silverlight projects +Generated_Code/ + +# Backup & report files from converting an old project file +# to a newer Visual Studio version. Backup files are not needed, +# because we have git ;-) +_UpgradeReport_Files/ +Backup*/ +UpgradeLog*.XML +UpgradeLog*.htm + +# SQL Server files +*.mdf +*.ldf + +# Business Intelligence projects +*.rdl.data +*.bim.layout +*.bim_*.settings + +# Microsoft Fakes +FakesAssemblies/ + +# GhostDoc plugin setting file +*.GhostDoc.xml + +# Node.js Tools for Visual Studio +.ntvs_analysis.dat +node_modules/ + +# Typescript v1 declaration files +typings/ + +# Visual Studio 6 build log +*.plg + +# Visual Studio 6 workspace options file +*.opt + +# Visual Studio 6 auto-generated workspace file (contains which files were open etc.) +*.vbw + +# Visual Studio LightSwitch build output +**/*.HTMLClient/GeneratedArtifacts +**/*.DesktopClient/GeneratedArtifacts +**/*.DesktopClient/ModelManifest.xml +**/*.Server/GeneratedArtifacts +**/*.Server/ModelManifest.xml +_Pvt_Extensions + +# Paket dependency manager +.paket/paket.exe +paket-files/ + +# FAKE - F# Make +.fake/ + +# JetBrains Rider +.idea/ +*.sln.iml + +# CodeRush +.cr/ + +# Python Tools for Visual Studio (PTVS) +__pycache__/ +*.pyc + +# Cake - Uncomment if you are using it +# tools/** +# !tools/packages.config + +# Telerik's JustMock configuration file +*.jmconfig + +# BizTalk build output +*.btp.cs +*.btm.cs +*.odx.cs +*.xsd.cs + +# Other +**/bin/ +**/obj/ +*.db +*.db-shm +*.db-wal +TestResults/ +.env diff --git a/Bsevita.Library.sln b/Bsevita.Library.sln new file mode 100644 index 0000000..486d7e0 --- /dev/null +++ b/Bsevita.Library.sln @@ -0,0 +1,56 @@ +Microsoft Visual Studio Solution File, Format Version 12.00 +# Visual Studio Version 17 +VisualStudioVersion = 17.0.31903.59 +MinimumVisualStudioVersion = 10.0.40219.1 +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bsevita.Library.Models", "src\Bsevita.Library.Models\Bsevita.Library.Models.csproj", "{85EC888E-0CA0-4966-9AFD-F5346F65BB02}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bsevita.Library.Api", "src\Bsevita.Library.Api\Bsevita.Library.Api.csproj", "{14F5FA6C-0F68-4FEB-908A-7BF63201886F}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bsevita.Library.Ui", "src\Bsevita.Library.Ui\Bsevita.Library.Ui.csproj", "{B8BB3BB0-6661-413B-A5FC-30C41E0607AD}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bsevita.Library.Web", "src\Bsevita.Library.Web\Bsevita.Library.Web.csproj", "{9A0841E9-CE16-4978-B74A-19ED20F9DD21}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bsevita.Library.Maui", "src\Bsevita.Library.Maui\Bsevita.Library.Maui.csproj", "{5579CB78-497F-4EE2-9F4A-38388CECF371}" +EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Bsevita.Library.Api.Tests", "tests\Bsevita.Library.Api.Tests\Bsevita.Library.Api.Tests.csproj", "{D3A8B599-8913-4D58-9C15-D22E4531CEFF}" +EndProject +Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "Tests", "Tests", "{679EE508-FCF4-4651-A99E-AA19E53BCCFA}" +EndProject +Global + GlobalSection(SolutionConfigurationPlatforms) = preSolution + Debug|Any CPU = Debug|Any CPU + Release|Any CPU = Release|Any CPU + EndGlobalSection + GlobalSection(ProjectConfigurationPlatforms) = postSolution + {85EC888E-0CA0-4966-9AFD-F5346F65BB02}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {85EC888E-0CA0-4966-9AFD-F5346F65BB02}.Debug|Any CPU.Build.0 = Debug|Any CPU + {85EC888E-0CA0-4966-9AFD-F5346F65BB02}.Release|Any CPU.ActiveCfg = Release|Any CPU + {85EC888E-0CA0-4966-9AFD-F5346F65BB02}.Release|Any CPU.Build.0 = Release|Any CPU + {14F5FA6C-0F68-4FEB-908A-7BF63201886F}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {14F5FA6C-0F68-4FEB-908A-7BF63201886F}.Debug|Any CPU.Build.0 = Debug|Any CPU + {14F5FA6C-0F68-4FEB-908A-7BF63201886F}.Release|Any CPU.ActiveCfg = Release|Any CPU + {14F5FA6C-0F68-4FEB-908A-7BF63201886F}.Release|Any CPU.Build.0 = Release|Any CPU + {B8BB3BB0-6661-413B-A5FC-30C41E0607AD}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {B8BB3BB0-6661-413B-A5FC-30C41E0607AD}.Debug|Any CPU.Build.0 = Debug|Any CPU + {B8BB3BB0-6661-413B-A5FC-30C41E0607AD}.Release|Any CPU.ActiveCfg = Release|Any CPU + {B8BB3BB0-6661-413B-A5FC-30C41E0607AD}.Release|Any CPU.Build.0 = Release|Any CPU + {9A0841E9-CE16-4978-B74A-19ED20F9DD21}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {9A0841E9-CE16-4978-B74A-19ED20F9DD21}.Debug|Any CPU.Build.0 = Debug|Any CPU + {9A0841E9-CE16-4978-B74A-19ED20F9DD21}.Release|Any CPU.ActiveCfg = Release|Any CPU + {9A0841E9-CE16-4978-B74A-19ED20F9DD21}.Release|Any CPU.Build.0 = Release|Any CPU + {5579CB78-497F-4EE2-9F4A-38388CECF371}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {5579CB78-497F-4EE2-9F4A-38388CECF371}.Debug|Any CPU.Build.0 = Debug|Any CPU + {5579CB78-497F-4EE2-9F4A-38388CECF371}.Release|Any CPU.ActiveCfg = Release|Any CPU + {5579CB78-497F-4EE2-9F4A-38388CECF371}.Release|Any CPU.Build.0 = Release|Any CPU + {D3A8B599-8913-4D58-9C15-D22E4531CEFF}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {D3A8B599-8913-4D58-9C15-D22E4531CEFF}.Debug|Any CPU.Build.0 = Debug|Any CPU + {D3A8B599-8913-4D58-9C15-D22E4531CEFF}.Release|Any CPU.ActiveCfg = Release|Any CPU + {D3A8B599-8913-4D58-9C15-D22E4531CEFF}.Release|Any CPU.Build.0 = Release|Any CPU + EndGlobalSection + GlobalSection(SolutionProperties) = preSolution + HideSolutionNode = FALSE + EndGlobalSection + GlobalSection(NestedProjects) = preSolution + {D3A8B599-8913-4D58-9C15-D22E4531CEFF} = {679EE508-FCF4-4651-A99E-AA19E53BCCFA} + EndGlobalSection +EndGlobal diff --git a/Directory.Build.props b/Directory.Build.props new file mode 100644 index 0000000..3f2bb79 --- /dev/null +++ b/Directory.Build.props @@ -0,0 +1,10 @@ + + + 14.0 + enable + enable + latest + true + true + + diff --git a/Directory.Packages.props b/Directory.Packages.props new file mode 100644 index 0000000..20cfdda --- /dev/null +++ b/Directory.Packages.props @@ -0,0 +1,20 @@ + + + true + 10.0.71 + 10.0.9 + + + + + + + + + + + + + + + diff --git a/README.md b/README.md new file mode 100644 index 0000000..dee934c --- /dev/null +++ b/README.md @@ -0,0 +1,39 @@ +# BSEVITA Library +dies ist ein Projekt das im Rahmen meiner Ausbildung in der Schule abgearbeitet wurde. + +Schulbibliotheksverwaltung mit .NET 10, ASP.NET Core API, EF Core/MS SQL Server und gemeinsamer Blazor-UI für Web und .NET MAUI. + +## Projekte + +- `src/Bsevita.Library.Api` - REST-API +- `src/Bsevita.Library.Ui` - gemeinsame Razor-UI +- `src/Bsevita.Library.Web` - Browser-Host +- `src/Bsevita.Library.Maui` - nativer MAUI-Host +- `src/Bsevita.Library.Models` - Request-/Response-Modelle +- `tests/Bsevita.Library.Api.Tests` - API-Tests + +## Start + +```bash +dotnet restore Bsevita.Library.sln +dotnet build Bsevita.Library.sln +dotnet test tests/Bsevita.Library.Api.Tests +``` + +Danach starten: + +- Datenbank: `database/create_database.sql` auf `(localdb)\MSSQLLocalDB` ausführen +- API: `Bsevita.Library.Api` auf `http://localhost:5180` +- Web: `Bsevita.Library.Web` auf `http://localhost:5190` +- MAUI: `Bsevita.Library.Maui` + +Android-Emulatoren verwenden `http://10.0.2.2:5180`; andere lokale Hosts verwenden `http://localhost:5180`. + +## Datenbank + +`database/create_database.sql` ist die Schema-Quelle. Standardverbindung: `(localdb)\MSSQLLocalDB`, Datenbank `BsevitaLibrary`. Es gibt keine EF-Core-Migrationshistorie; generierter EF-Code liegt unter `src/Bsevita.Library.Api/Data/Generated`. + +## Doku + +- [Architektur](docs/ARCHITECTURE.md) +- [API](docs/API.md) diff --git a/database/create_database.sql b/database/create_database.sql new file mode 100644 index 0000000..358f9de --- /dev/null +++ b/database/create_database.sql @@ -0,0 +1,126 @@ +IF DB_ID(N'BsevitaLibrary') IS NULL +BEGIN + EXEC(N'CREATE DATABASE [BsevitaLibrary]'); +END; +GO + +USE [BsevitaLibrary]; +GO + +SET ANSI_NULLS ON; +SET QUOTED_IDENTIFIER ON; +SET XACT_ABORT ON; +GO + +IF OBJECT_ID(N'dbo.Students', N'U') IS NULL +BEGIN + CREATE TABLE dbo.Students + ( + StudentId UNIQUEIDENTIFIER NOT NULL CONSTRAINT DF_Students_StudentId DEFAULT NEWSEQUENTIALID(), + CardNumber NVARCHAR(32) NOT NULL, + FirstName NVARCHAR(100) NOT NULL, + LastName NVARCHAR(100) NOT NULL, + ClassName NVARCHAR(32) NOT NULL, + Email NVARCHAR(254) NULL, + IsActive BIT NOT NULL CONSTRAINT DF_Students_IsActive DEFAULT (1), + CreatedAt DATETIMEOFFSET(0) NOT NULL CONSTRAINT DF_Students_CreatedAt DEFAULT SYSUTCDATETIME(), + UpdatedAt DATETIMEOFFSET(0) NOT NULL CONSTRAINT DF_Students_UpdatedAt DEFAULT SYSUTCDATETIME(), + RowVersion ROWVERSION NOT NULL, + CONSTRAINT PK_Students PRIMARY KEY CLUSTERED (StudentId), + CONSTRAINT CK_Students_CardNumber_NotBlank CHECK (LEN(LTRIM(RTRIM(CardNumber))) > 0), + CONSTRAINT CK_Students_FirstName_NotBlank CHECK (LEN(LTRIM(RTRIM(FirstName))) > 0), + CONSTRAINT CK_Students_LastName_NotBlank CHECK (LEN(LTRIM(RTRIM(LastName))) > 0), + CONSTRAINT CK_Students_ClassName_NotBlank CHECK (LEN(LTRIM(RTRIM(ClassName))) > 0) + ); + CREATE UNIQUE INDEX UX_Students_CardNumber ON dbo.Students(CardNumber); + CREATE INDEX IX_Students_IsActive_Name ON dbo.Students(IsActive, LastName, FirstName); +END; +GO + +IF OBJECT_ID(N'dbo.Books', N'U') IS NULL +BEGIN + CREATE TABLE dbo.Books + ( + BookId UNIQUEIDENTIFIER NOT NULL CONSTRAINT DF_Books_BookId DEFAULT NEWSEQUENTIALID(), + BookNumber NVARCHAR(32) NOT NULL, + Isbn NVARCHAR(20) NULL, + Title NVARCHAR(240) NOT NULL, + Author NVARCHAR(160) NOT NULL, + Subject NVARCHAR(120) NOT NULL, + Publisher NVARCHAR(160) NULL, + PublicationYear SMALLINT NULL, + IsActive BIT NOT NULL CONSTRAINT DF_Books_IsActive DEFAULT (1), + CreatedAt DATETIMEOFFSET(0) NOT NULL CONSTRAINT DF_Books_CreatedAt DEFAULT SYSUTCDATETIME(), + UpdatedAt DATETIMEOFFSET(0) NOT NULL CONSTRAINT DF_Books_UpdatedAt DEFAULT SYSUTCDATETIME(), + RowVersion ROWVERSION NOT NULL, + CONSTRAINT PK_Books PRIMARY KEY CLUSTERED (BookId), + CONSTRAINT CK_Books_BookNumber_NotBlank CHECK (LEN(LTRIM(RTRIM(BookNumber))) > 0), + CONSTRAINT CK_Books_Title_NotBlank CHECK (LEN(LTRIM(RTRIM(Title))) > 0), + CONSTRAINT CK_Books_Author_NotBlank CHECK (LEN(LTRIM(RTRIM(Author))) > 0), + CONSTRAINT CK_Books_Subject_NotBlank CHECK (LEN(LTRIM(RTRIM(Subject))) > 0), + CONSTRAINT CK_Books_PublicationYear CHECK (PublicationYear IS NULL OR PublicationYear BETWEEN 1000 AND 2200) + ); + CREATE UNIQUE INDEX UX_Books_BookNumber ON dbo.Books(BookNumber); + CREATE UNIQUE INDEX UX_Books_Isbn ON dbo.Books(Isbn) WHERE Isbn IS NOT NULL; + CREATE INDEX IX_Books_IsActive_Title ON dbo.Books(IsActive, Title); +END; +GO + +IF OBJECT_ID(N'dbo.Loans', N'U') IS NULL +BEGIN + CREATE TABLE dbo.Loans + ( + LoanId UNIQUEIDENTIFIER NOT NULL CONSTRAINT DF_Loans_LoanId DEFAULT NEWSEQUENTIALID(), + StudentId UNIQUEIDENTIFIER NOT NULL, + BookId UNIQUEIDENTIFIER NOT NULL, + LoanedAt DATETIMEOFFSET(0) NOT NULL CONSTRAINT DF_Loans_LoanedAt DEFAULT SYSUTCDATETIME(), + DueAt DATETIMEOFFSET(0) NOT NULL, + ReturnedAt DATETIMEOFFSET(0) NULL, + RowVersion ROWVERSION NOT NULL, + CONSTRAINT PK_Loans PRIMARY KEY CLUSTERED (LoanId), + CONSTRAINT FK_Loans_Students FOREIGN KEY (StudentId) REFERENCES dbo.Students(StudentId), + CONSTRAINT FK_Loans_Books FOREIGN KEY (BookId) REFERENCES dbo.Books(BookId), + CONSTRAINT CK_Loans_DueAfterLoan CHECK (DueAt > LoanedAt), + CONSTRAINT CK_Loans_ReturnAfterLoan CHECK (ReturnedAt IS NULL OR ReturnedAt >= LoanedAt) + ); + CREATE INDEX IX_Loans_StudentId ON dbo.Loans(StudentId); + CREATE INDEX IX_Loans_Active_DueAt ON dbo.Loans(DueAt) WHERE ReturnedAt IS NULL; + CREATE UNIQUE INDEX UX_Loans_ActiveBook ON dbo.Loans(BookId) WHERE ReturnedAt IS NULL; +END; +GO + +-- Idempotent sample data for immediate manual testing. +IF NOT EXISTS (SELECT 1 FROM dbo.Students WHERE CardNumber = N'S-1001') +BEGIN + INSERT dbo.Students (StudentId, CardNumber, FirstName, LastName, ClassName, Email) + VALUES + ('11111111-1111-1111-1111-111111111111', N'S-1001', N'Max', N'Mustermann', N'3AHIT', N'max.mustermann@schule.local'), + ('22222222-2222-2222-2222-222222222222', N'S-1002', N'Anna', N'Berger', N'2BHIF', N'anna.berger@schule.local'), + ('33333333-3333-3333-3333-333333333333', N'S-1003', N'Leon', N'Fischer', N'1AHIT', NULL); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM dbo.Books WHERE BookNumber = N'B-1001') +BEGIN + INSERT dbo.Books (BookId, BookNumber, Isbn, Title, Author, Subject, Publisher, PublicationYear) + VALUES + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa1', N'B-1001', N'978-3-8362-9500-1', N'C# Grundlagen', N'Ralph Steyer', N'Informatik', N'Rheinwerk', 2024), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2', N'B-1002', N'978-0-13-235088-4', N'Clean Code', N'Robert C. Martin', N'Softwareentwicklung', N'Prentice Hall', 2008), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa3', N'B-1003', N'978-3-86680-192-0', N'Die Welt der Physik', N'Andrea Lenz', N'Physik', N'Verita', 2022), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa4', N'B-1004', NULL, N'Mathematik kompakt', N'Sabine Kern', N'Mathematik', N'Schulbuchverlag', 2023), + ('aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa5', N'B-1005', NULL, N'Geschichte Europas', N'Paul Winter', N'Geschichte', N'Verita', 2021); +END; +GO + +IF NOT EXISTS (SELECT 1 FROM dbo.Loans WHERE LoanId = 'bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb1') +BEGIN + INSERT dbo.Loans (LoanId, StudentId, BookId, LoanedAt, DueAt, ReturnedAt) + VALUES + ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb1', '11111111-1111-1111-1111-111111111111', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa2', DATEADD(DAY, -20, SYSUTCDATETIME()), DATEADD(DAY, -6, SYSUTCDATETIME()), NULL), + ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb2', '22222222-2222-2222-2222-222222222222', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa3', DATEADD(DAY, -4, SYSUTCDATETIME()), DATEADD(DAY, 10, SYSUTCDATETIME()), NULL), + ('bbbbbbbb-bbbb-bbbb-bbbb-bbbbbbbbbbb3', '33333333-3333-3333-3333-333333333333', 'aaaaaaaa-aaaa-aaaa-aaaa-aaaaaaaaaaa4', DATEADD(DAY, -30, SYSUTCDATETIME()), DATEADD(DAY, -16, SYSUTCDATETIME()), DATEADD(DAY, -15, SYSUTCDATETIME())); +END; +GO + +PRINT N'BsevitaLibrary database is ready.'; +GO diff --git a/database/smoke_test.sql b/database/smoke_test.sql new file mode 100644 index 0000000..c115466 --- /dev/null +++ b/database/smoke_test.sql @@ -0,0 +1,16 @@ +USE [BsevitaLibrary]; +GO +SET NOCOUNT ON; + +SELECT 'Students' AS Entity, COUNT(*) AS Total FROM dbo.Students WHERE IsActive = 1 +UNION ALL SELECT 'Books', COUNT(*) FROM dbo.Books WHERE IsActive = 1 +UNION ALL SELECT 'ActiveLoans', COUNT(*) FROM dbo.Loans WHERE ReturnedAt IS NULL +UNION ALL SELECT 'OverdueLoans', COUNT(*) FROM dbo.Loans WHERE ReturnedAt IS NULL AND DueAt < SYSUTCDATETIME(); + +SELECT l.LoanId, s.CardNumber, CONCAT(s.FirstName, ' ', s.LastName) AS StudentName, + b.BookNumber, b.Title, l.LoanedAt, l.DueAt, l.ReturnedAt +FROM dbo.Loans l +JOIN dbo.Students s ON s.StudentId = l.StudentId +JOIN dbo.Books b ON b.BookId = l.BookId +ORDER BY l.LoanedAt DESC; +GO diff --git a/docs/API.md b/docs/API.md new file mode 100644 index 0000000..6b092d6 --- /dev/null +++ b/docs/API.md @@ -0,0 +1,51 @@ +# REST-API + +Basisroute: `/api` + +Die genaue Endpoint-, Request- und Response-Dokumentation steht im Development in der Swagger UI unter `/swagger`. + +## Schüler + +- `GET /students?skip=0&take=100` +- `GET /students/{id}` +- `GET /students/by-card/{nr}` +- `GET /students/search?name=` +- `POST /students` +- `PUT /students/{id}` +- `DELETE /students/{id}` + +## Bücher + +- `GET /books?availableOnly=&skip=0&take=100` +- `GET /books/{id}` +- `GET /books/by-number/{nr}` +- `GET /books/search?title=` +- `GET /books/search?author=` +- `GET /books/search?subject=` +- `POST /books` +- `PUT /books/{id}` +- `DELETE /books/{id}` + +Buch- und Schülersuche werden datenbankseitig gefiltert. + +## Ausleihe + +- `POST /loans/verify` prüft Schüler, Buch und Verfügbarkeit ohne Datenänderung. +- `POST /loans` prüft erneut und legt die Ausleihe transaktional an. + +## Rückgabe + +- `GET /returns/verify/{nr}` liefert die aktive Ausleihe und den Überfälligkeitsstatus. +- `POST /returns` schließt die aktive Ausleihe transaktional ab. + +## Reports + +- `GET /reports` +- `GET /reports/active-loans?skip=0&take=100` +- `GET /reports/active-students?skip=0&take=100` +- `GET /reports/overdue?skip=0&take=100` +- `GET /reports/statistics` + +`GET /reports` liefert die ersten 100 Einträge je Liste plus vollständige Statistiken. + +Fehler verwenden `application/problem+json` mit `status`, `title`, `detail` und `instance`. diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md new file mode 100644 index 0000000..8b16dd5 --- /dev/null +++ b/docs/ARCHITECTURE.md @@ -0,0 +1,50 @@ +# Architektur + +Die Anwendung trennt Hosts, UI, API, Verträge und Datenbankzugriff strikt. + +```text +Bsevita.Library.Maui -> Bsevita.Library.Ui -> Bsevita.Library.Models +Bsevita.Library.Web -> Bsevita.Library.Ui -> Bsevita.Library.Models + +Bsevita.Library.Api -> Bsevita.Library.Models +Bsevita.Library.Api -> EF Core -> SQL Server LocalDB +``` + +## Projekte + +- `Bsevita.Library.Models`: gemeinsame Request-/Response-Modelle, keine Datenbankabhängigkeit. +- `Bsevita.Library.Ui`: Razor Class Library mit Seiten, Layouts, Komponenten und API-Clients. +- `Bsevita.Library.Web`: Browser-Host für die UI; registriert `HttpClient` zur lokalen API. +- `Bsevita.Library.Maui`: nativer Host für dieselbe UI; löst die API-Adresse je Plattform auf. +- `Bsevita.Library.Api`: Controller, Services, EF Core und zentrale Fehlerbehandlung. +- `Bsevita.Library.Api.Tests`: API-Workflowtests mit Testdatenbank. + +## Datenfluss + +1. UI-Seiten rufen typed API-Clients aus `Bsevita.Library.Ui.Services` auf. +2. API-Clients senden HTTP-Requests an `/api/...` und lesen JSON oder ProblemDetails. +3. Controller delegieren an Services; Fachlogik liegt nicht im Controller. +4. Services verwenden `LibraryDbContext` und mappen Entities auf Models. +5. SQL Server speichert das Database-First-Schema aus `database/create_database.sql`. + +## Datenbank + +`database/create_database.sql` ist die Schema-Quelle. Der generierte EF-Code liegt unter `src/Bsevita.Library.Api/Data/Generated` und wird nicht für Fachlogik erweitert. + +Wichtige Regeln liegen zusätzlich in der Datenbank: + +- `UX_Students_CardNumber`: eindeutige Ausweisnummern. +- `UX_Books_BookNumber`: eindeutige Buchnummern. +- `UX_Loans_ActiveBook`: höchstens eine aktive Ausleihe pro Buch. + +## Transaktionen und Fehler + +Ausleihe, Rückgabe und Löschoperationen laufen in serialisierbaren Transaktionen, wenn eine relationale Datenbank verwendet wird. Vor dem Schreiben wird der aktuelle Zustand erneut geprüft. + +Fachfehler werden zentral in `application/problem+json` übersetzt. Die UI zeigt diese Fehler über die gemeinsamen API-Clients an. + +## Grenzen + +- Die UI referenziert weder `Bsevita.Library.Api` noch `LibraryDbContext`. +- API-Verträge sind nicht die EF-Entities. +- Löschen deaktiviert Schüler und Bücher, damit historische Ausleihen erhalten bleiben. diff --git a/global.json b/global.json new file mode 100644 index 0000000..512142d --- /dev/null +++ b/global.json @@ -0,0 +1,6 @@ +{ + "sdk": { + "version": "10.0.100", + "rollForward": "latestFeature" + } +} diff --git a/src/Bsevita.Library.Api/Bsevita.Library.Api.csproj b/src/Bsevita.Library.Api/Bsevita.Library.Api.csproj new file mode 100644 index 0000000..3b45db4 --- /dev/null +++ b/src/Bsevita.Library.Api/Bsevita.Library.Api.csproj @@ -0,0 +1,20 @@ + + + net10.0 + Bsevita.Library.Api + bsevita-library-api + true + $(NoWarn);1573;1591 + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + + + diff --git a/src/Bsevita.Library.Api/Controllers/BooksController.cs b/src/Bsevita.Library.Api/Controllers/BooksController.cs new file mode 100644 index 0000000..188eefb --- /dev/null +++ b/src/Bsevita.Library.Api/Controllers/BooksController.cs @@ -0,0 +1,136 @@ +using Bsevita.Library.Api.Services; +using Bsevita.Library.Models.Books; +using Bsevita.Library.Models.Common; +using Microsoft.AspNetCore.Mvc; + +namespace Bsevita.Library.Api.Controllers; + +/// Verwaltet Bücher im Bibliotheksbestand. +[ApiController] +[Route("api/books")] +public sealed class BooksController(BookService service) : ControllerBase +{ + /// Liefert Bücher seitenweise. + /// Filtert optional nach verfügbaren oder ausgeliehenen Büchern. + /// Anzahl zu überspringender Einträge. + /// Maximale Anzahl Einträge. + /// Bücherliste. + /// Bücher wurden geliefert. + /// Query-Parameter sind ungültig. + /// Interner Serverfehler. + [HttpGet] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetAll( + [FromQuery] bool? availableOnly, + [FromQuery] int skip = 0, + [FromQuery] int take = LibraryRules.DefaultPageSize, + CancellationToken cancellationToken = default) => + Ok(await service.GetAllAsync(availableOnly, skip, take, cancellationToken)); + + /// Liefert ein Buch anhand der ID. + /// Buch-ID. + /// Gefundenes Buch. + /// Buch wurde gefunden. + /// ID ist ungültig. + /// Buch wurde nicht gefunden. + /// Interner Serverfehler. + [HttpGet("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetById(Guid id, CancellationToken cancellationToken) => + Ok(await service.GetByIdAsync(id, cancellationToken)); + + /// Liefert ein Buch anhand der Buchnummer. + /// Buchnummer. + /// Gefundenes Buch. + /// Buch wurde gefunden. + /// Buchnummer ist ungültig. + /// Buch wurde nicht gefunden. + /// Interner Serverfehler. + [HttpGet("by-number/{bookNumber}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetByNumber(string bookNumber, CancellationToken cancellationToken) => + Ok(await service.GetByNumberAsync(bookNumber, cancellationToken)); + + /// Sucht aktive Bücher nach Titel, Autor oder Fach. + /// Optionaler Titelfilter. + /// Optionaler Autorfilter. + /// Optionaler Fachfilter. + /// Passende Bücher. + /// Suche wurde ausgeführt. + /// Mindestens ein Suchfilter fehlt oder ist ungültig. + /// Interner Serverfehler. + [HttpGet("search")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> Search( + [FromQuery] string? title, + [FromQuery] string? author, + [FromQuery] string? subject, + CancellationToken cancellationToken) => + Ok(await service.SearchAsync(title, author, subject, cancellationToken)); + + /// Legt ein Buch an. + /// Buchdaten im Body. + /// Angelegtes Buch. + /// Buch wurde angelegt. + /// Body ist ungültig. + /// Buchnummer oder ISBN ist bereits vergeben. + /// Interner Serverfehler. + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Create(SaveBookRequest request, CancellationToken cancellationToken) + { + var created = await service.CreateAsync(request, cancellationToken); + return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); + } + + /// Aktualisiert ein Buch. + /// Buch-ID. + /// Neue Buchdaten im Body. + /// Aktualisiertes Buch. + /// Buch wurde aktualisiert. + /// ID oder Body ist ungültig. + /// Buch wurde nicht gefunden. + /// Buchnummer oder ISBN ist bereits vergeben. + /// Interner Serverfehler. + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Update(Guid id, SaveBookRequest request, CancellationToken cancellationToken) => + Ok(await service.UpdateAsync(id, request, cancellationToken)); + + /// Deaktiviert ein Buch. + /// Buch-ID. + /// Kein Inhalt. + /// Buch wurde deaktiviert. + /// ID ist ungültig. + /// Buch wurde nicht gefunden. + /// Buch ist noch ausgeliehen. + /// Interner Serverfehler. + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Delete(Guid id, CancellationToken cancellationToken) + { + await service.DeleteAsync(id, cancellationToken); + return NoContent(); + } +} diff --git a/src/Bsevita.Library.Api/Controllers/LoansController.cs b/src/Bsevita.Library.Api/Controllers/LoansController.cs new file mode 100644 index 0000000..8114c22 --- /dev/null +++ b/src/Bsevita.Library.Api/Controllers/LoansController.cs @@ -0,0 +1,48 @@ +using Bsevita.Library.Api.Services; +using Bsevita.Library.Models.Loans; +using Microsoft.AspNetCore.Mvc; + +namespace Bsevita.Library.Api.Controllers; + +/// Prüft und erstellt Ausleihen. +[ApiController] +[Route("api/loans")] +public sealed class LoansController(LoanService service) : ControllerBase +{ + /// Prüft, ob ein Buch ausgeliehen werden kann. + /// Ausweis- und Buchnummer im Body. + /// Ergebnis der Ausleihpruefung. + /// Ausleihe ist möglich. + /// Body ist ungültig. + /// Schüler oder Buch wurde nicht gefunden. + /// Buch ist bereits ausgeliehen. + /// Interner Serverfehler. + [HttpPost("verify")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Verify(VerifyLoanRequest request, CancellationToken cancellationToken) => + Ok(await service.VerifyAsync(request, cancellationToken)); + + /// Erstellt eine Ausleihe. + /// Ausleihdaten im Body. + /// Angelegte Ausleihe. + /// Ausleihe wurde angelegt. + /// Body ist ungültig. + /// Schüler oder Buch wurde nicht gefunden. + /// Buch ist bereits ausgeliehen. + /// Interner Serverfehler. + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Create(CreateLoanRequest request, CancellationToken cancellationToken) + { + var created = await service.CreateAsync(request, cancellationToken); + return StatusCode(StatusCodes.Status201Created, created); + } +} diff --git a/src/Bsevita.Library.Api/Controllers/ReportsController.cs b/src/Bsevita.Library.Api/Controllers/ReportsController.cs new file mode 100644 index 0000000..3c9a346 --- /dev/null +++ b/src/Bsevita.Library.Api/Controllers/ReportsController.cs @@ -0,0 +1,84 @@ +using Bsevita.Library.Api.Services; +using Bsevita.Library.Models.Common; +using Bsevita.Library.Models.Loans; +using Bsevita.Library.Models.Reports; +using Microsoft.AspNetCore.Mvc; + +namespace Bsevita.Library.Api.Controllers; + +/// Liefert Auswertungen zum Bibliotheksbetrieb. +[ApiController] +[Route("api/reports")] +public sealed class ReportsController(ReportService service) : ControllerBase +{ + /// Liefert die wichtigsten Reports gebündelt. + /// Dashboard-Reports. + /// Reports wurden geliefert. + /// Interner Serverfehler. + [HttpGet] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Reports(CancellationToken cancellationToken) => + Ok(await service.GetReportsAsync(cancellationToken)); + + /// Liefert aktive Ausleihen seitenweise. + /// Anzahl zu überspringender Einträge. + /// Maximale Anzahl Einträge. + /// Aktive Ausleihen. + /// Aktive Ausleihen wurden geliefert. + /// Query-Parameter sind ungültig. + /// Interner Serverfehler. + [HttpGet("active-loans")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> ActiveLoans( + [FromQuery] int skip = 0, + [FromQuery] int take = LibraryRules.DefaultPageSize, + CancellationToken cancellationToken = default) => + Ok(await service.GetActiveLoansAsync(skip, take, cancellationToken)); + + /// Liefert Schüler mit aktiven Ausleihen seitenweise. + /// Anzahl zu überspringender Einträge. + /// Maximale Anzahl Einträge. + /// Schüler mit aktiven Ausleihen. + /// Schüler wurden geliefert. + /// Query-Parameter sind ungültig. + /// Interner Serverfehler. + [HttpGet("active-students")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> ActiveStudents( + [FromQuery] int skip = 0, + [FromQuery] int take = LibraryRules.DefaultPageSize, + CancellationToken cancellationToken = default) => + Ok(await service.GetActiveStudentsAsync(skip, take, cancellationToken)); + + /// Liefert überfällige Ausleihen seitenweise. + /// Anzahl zu überspringender Einträge. + /// Maximale Anzahl Einträge. + /// Ueberfällige Ausleihen. + /// Ueberfällige Ausleihen wurden geliefert. + /// Query-Parameter sind ungültig. + /// Interner Serverfehler. + [HttpGet("overdue")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> Overdue( + [FromQuery] int skip = 0, + [FromQuery] int take = LibraryRules.DefaultPageSize, + CancellationToken cancellationToken = default) => + Ok(await service.GetOverdueAsync(skip, take, cancellationToken)); + + /// Liefert Bestands- und Ausleihstatistiken. + /// Bibliotheksstatistiken. + /// Statistiken wurden geliefert. + /// Interner Serverfehler. + [HttpGet("statistics")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Statistics(CancellationToken cancellationToken) => + Ok(await service.GetStatisticsAsync(cancellationToken)); +} diff --git a/src/Bsevita.Library.Api/Controllers/ReturnsController.cs b/src/Bsevita.Library.Api/Controllers/ReturnsController.cs new file mode 100644 index 0000000..4d19423 --- /dev/null +++ b/src/Bsevita.Library.Api/Controllers/ReturnsController.cs @@ -0,0 +1,43 @@ +using Bsevita.Library.Api.Services; +using Bsevita.Library.Models.Returns; +using Microsoft.AspNetCore.Mvc; + +namespace Bsevita.Library.Api.Controllers; + +/// Prüft und verarbeitet Rückgaben. +[ApiController] +[Route("api/returns")] +public sealed class ReturnsController(ReturnService service) : ControllerBase +{ + /// Prüft die aktive Ausleihe eines Buchs. + /// Buchnummer. + /// Aktive Ausleihe mit Rückgabestatus. + /// Aktive Ausleihe wurde gefunden. + /// Buchnummer ist ungültig. + /// Keine aktive Ausleihe gefunden. + /// Interner Serverfehler. + [HttpGet("verify/{bookNumber}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Verify(string bookNumber, CancellationToken cancellationToken) => + Ok(await service.VerifyAsync(bookNumber, cancellationToken)); + + /// Schließt die aktive Ausleihe eines Buchs ab. + /// Buchnummer im Body. + /// Ergebnis der Rückgabe. + /// Rückgabe wurde abgeschlossen. + /// Body ist ungültig. + /// Keine aktive Ausleihe gefunden. + /// Datenbankkonflikt bei paralleler Änderung. + /// Interner Serverfehler. + [HttpPost] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Return(ReturnBookRequest request, CancellationToken cancellationToken) => + Ok(await service.ReturnAsync(request, cancellationToken)); +} diff --git a/src/Bsevita.Library.Api/Controllers/StudentsController.cs b/src/Bsevita.Library.Api/Controllers/StudentsController.cs new file mode 100644 index 0000000..2008fab --- /dev/null +++ b/src/Bsevita.Library.Api/Controllers/StudentsController.cs @@ -0,0 +1,128 @@ +using Bsevita.Library.Api.Services; +using Bsevita.Library.Models.Common; +using Bsevita.Library.Models.Students; +using Microsoft.AspNetCore.Mvc; + +namespace Bsevita.Library.Api.Controllers; + +/// Verwaltet Schüler und ihre Bibliotheksausweise. +[ApiController] +[Route("api/students")] +public sealed class StudentsController(StudentService service) : ControllerBase +{ + /// Liefert Schüler seitenweise. + /// Anzahl zu überspringender Einträge. + /// Maximale Anzahl Einträge. + /// Schülerliste. + /// Schüler wurden geliefert. + /// Query-Parameter sind ungültig. + /// Interner Serverfehler. + [HttpGet] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> GetAll( + [FromQuery] int skip = 0, + [FromQuery] int take = LibraryRules.DefaultPageSize, + CancellationToken cancellationToken = default) => + Ok(await service.GetAllAsync(skip, take, cancellationToken)); + + /// Liefert einen Schüler anhand der ID. + /// Schüler-ID. + /// Gefundener Schüler. + /// Schüler wurde gefunden. + /// ID ist ungültig. + /// Schüler wurde nicht gefunden. + /// Interner Serverfehler. + [HttpGet("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetById(Guid id, CancellationToken cancellationToken) => + Ok(await service.GetByIdAsync(id, cancellationToken)); + + /// Liefert einen Schüler anhand der Ausweisnummer. + /// Ausweisnummer. + /// Gefundener Schüler. + /// Schüler wurde gefunden. + /// Ausweisnummer ist ungültig. + /// Schüler wurde nicht gefunden. + /// Interner Serverfehler. + [HttpGet("by-card/{cardNumber}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> GetByCard(string cardNumber, CancellationToken cancellationToken) => + Ok(await service.GetByCardAsync(cardNumber, cancellationToken)); + + /// Sucht aktive Schüler nach Name. + /// Suchbegriff. + /// Passende Schüler. + /// Suche wurde ausgeführt. + /// Suchbegriff fehlt oder ist ungültig. + /// Interner Serverfehler. + [HttpGet("search")] + [ProducesResponseType>(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task>> Search([FromQuery] string name, CancellationToken cancellationToken) => + Ok(await service.SearchAsync(name, cancellationToken)); + + /// Legt einen Schüler an. + /// Schülerdaten im Body. + /// Angelegter Schüler. + /// Schüler wurde angelegt. + /// Body ist ungültig. + /// Ausweisnummer ist bereits vergeben. + /// Interner Serverfehler. + [HttpPost] + [ProducesResponseType(StatusCodes.Status201Created)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Create(SaveStudentRequest request, CancellationToken cancellationToken) + { + var created = await service.CreateAsync(request, cancellationToken); + return CreatedAtAction(nameof(GetById), new { id = created.Id }, created); + } + + /// Aktualisiert einen Schüler. + /// Schüler-ID. + /// Neue Schülerdaten im Body. + /// Aktualisierter Schüler. + /// Schüler wurde aktualisiert. + /// ID oder Body ist ungültig. + /// Schüler wurde nicht gefunden. + /// Ausweisnummer ist bereits vergeben. + /// Interner Serverfehler. + [HttpPut("{id:guid}")] + [ProducesResponseType(StatusCodes.Status200OK)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task> Update(Guid id, SaveStudentRequest request, CancellationToken cancellationToken) => + Ok(await service.UpdateAsync(id, request, cancellationToken)); + + /// Deaktiviert einen Schüler. + /// Schüler-ID. + /// Kein Inhalt. + /// Schüler wurde deaktiviert. + /// ID ist ungültig. + /// Schüler wurde nicht gefunden. + /// Schüler hat aktive Ausleihen. + /// Interner Serverfehler. + [HttpDelete("{id:guid}")] + [ProducesResponseType(StatusCodes.Status204NoContent)] + [ProducesResponseType(StatusCodes.Status400BadRequest)] + [ProducesResponseType(StatusCodes.Status404NotFound)] + [ProducesResponseType(StatusCodes.Status409Conflict)] + [ProducesResponseType(StatusCodes.Status500InternalServerError)] + public async Task Delete(Guid id, CancellationToken cancellationToken) + { + await service.DeleteAsync(id, cancellationToken); + return NoContent(); + } +} diff --git a/src/Bsevita.Library.Api/Data/Generated/Entities/Book.cs b/src/Bsevita.Library.Api/Data/Generated/Entities/Book.cs new file mode 100644 index 0000000..81083b9 --- /dev/null +++ b/src/Bsevita.Library.Api/Data/Generated/Entities/Book.cs @@ -0,0 +1,20 @@ +// Generated from database/001_create_database.sql. +#nullable enable +namespace Bsevita.Library.Api.Data.Generated.Entities; + +public partial class Book +{ + public Guid BookId { get; set; } + public string BookNumber { get; set; } = null!; + public string? Isbn { get; set; } + public string Title { get; set; } = null!; + public string Author { get; set; } = null!; + public string Subject { get; set; } = null!; + public string? Publisher { get; set; } + public short? PublicationYear { get; set; } + public bool IsActive { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + public byte[]? RowVersion { get; set; } + public virtual ICollection Loans { get; set; } = new List(); +} diff --git a/src/Bsevita.Library.Api/Data/Generated/Entities/Loan.cs b/src/Bsevita.Library.Api/Data/Generated/Entities/Loan.cs new file mode 100644 index 0000000..9bedf05 --- /dev/null +++ b/src/Bsevita.Library.Api/Data/Generated/Entities/Loan.cs @@ -0,0 +1,16 @@ +// Generated from database/001_create_database.sql. +#nullable enable +namespace Bsevita.Library.Api.Data.Generated.Entities; + +public partial class Loan +{ + public Guid LoanId { get; set; } + public Guid StudentId { get; set; } + public Guid BookId { get; set; } + public DateTimeOffset LoanedAt { get; set; } + public DateTimeOffset DueAt { get; set; } + public DateTimeOffset? ReturnedAt { get; set; } + public byte[]? RowVersion { get; set; } + public virtual Book Book { get; set; } = null!; + public virtual Student Student { get; set; } = null!; +} diff --git a/src/Bsevita.Library.Api/Data/Generated/Entities/Student.cs b/src/Bsevita.Library.Api/Data/Generated/Entities/Student.cs new file mode 100644 index 0000000..671da0a --- /dev/null +++ b/src/Bsevita.Library.Api/Data/Generated/Entities/Student.cs @@ -0,0 +1,18 @@ +// Generated from database/001_create_database.sql. +#nullable enable +namespace Bsevita.Library.Api.Data.Generated.Entities; + +public partial class Student +{ + public Guid StudentId { get; set; } + public string CardNumber { get; set; } = null!; + public string FirstName { get; set; } = null!; + public string LastName { get; set; } = null!; + public string ClassName { get; set; } = null!; + public string? Email { get; set; } + public bool IsActive { get; set; } + public DateTimeOffset CreatedAt { get; set; } + public DateTimeOffset UpdatedAt { get; set; } + public byte[]? RowVersion { get; set; } + public virtual ICollection Loans { get; set; } = new List(); +} diff --git a/src/Bsevita.Library.Api/Data/Generated/LibraryDbContext.cs b/src/Bsevita.Library.Api/Data/Generated/LibraryDbContext.cs new file mode 100644 index 0000000..eba4d52 --- /dev/null +++ b/src/Bsevita.Library.Api/Data/Generated/LibraryDbContext.cs @@ -0,0 +1,68 @@ +// Generated from database/001_create_database.sql. +using Bsevita.Library.Api.Data.Generated.Entities; +using Microsoft.EntityFrameworkCore; + +namespace Bsevita.Library.Api.Data.Generated; + +public partial class LibraryDbContext(DbContextOptions options) : DbContext(options) +{ + public virtual DbSet Books { get; set; } = null!; + public virtual DbSet Loans { get; set; } = null!; + public virtual DbSet Students { get; set; } = null!; + + protected override void OnModelCreating(ModelBuilder modelBuilder) + { + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.BookId).HasName("PK_Books"); + entity.HasIndex(e => e.BookNumber).IsUnique().HasDatabaseName("UX_Books_BookNumber"); + entity.HasIndex(e => e.Isbn).IsUnique().HasFilter("[Isbn] IS NOT NULL").HasDatabaseName("UX_Books_Isbn"); + entity.HasIndex(e => new { e.IsActive, e.Title }).HasDatabaseName("IX_Books_IsActive_Title"); + entity.Property(e => e.BookId).HasDefaultValueSql("(newsequentialid())"); + entity.Property(e => e.BookNumber).HasMaxLength(32); + entity.Property(e => e.Isbn).HasMaxLength(20); + entity.Property(e => e.Title).HasMaxLength(240); + entity.Property(e => e.Author).HasMaxLength(160); + entity.Property(e => e.Subject).HasMaxLength(120); + entity.Property(e => e.Publisher).HasMaxLength(160); + entity.Property(e => e.IsActive).HasDefaultValue(true); + entity.Property(e => e.CreatedAt).HasDefaultValueSql("(sysutcdatetime())"); + entity.Property(e => e.UpdatedAt).HasDefaultValueSql("(sysutcdatetime())"); + entity.Property(e => e.RowVersion).IsRowVersion().IsConcurrencyToken(); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.StudentId).HasName("PK_Students"); + entity.HasIndex(e => e.CardNumber).IsUnique().HasDatabaseName("UX_Students_CardNumber"); + entity.HasIndex(e => new { e.IsActive, e.LastName, e.FirstName }).HasDatabaseName("IX_Students_IsActive_Name"); + entity.Property(e => e.StudentId).HasDefaultValueSql("(newsequentialid())"); + entity.Property(e => e.CardNumber).HasMaxLength(32); + entity.Property(e => e.FirstName).HasMaxLength(100); + entity.Property(e => e.LastName).HasMaxLength(100); + entity.Property(e => e.ClassName).HasMaxLength(32); + entity.Property(e => e.Email).HasMaxLength(254); + entity.Property(e => e.IsActive).HasDefaultValue(true); + entity.Property(e => e.CreatedAt).HasDefaultValueSql("(sysutcdatetime())"); + entity.Property(e => e.UpdatedAt).HasDefaultValueSql("(sysutcdatetime())"); + entity.Property(e => e.RowVersion).IsRowVersion().IsConcurrencyToken(); + }); + + modelBuilder.Entity(entity => + { + entity.HasKey(e => e.LoanId).HasName("PK_Loans"); + entity.HasIndex(e => e.StudentId).HasDatabaseName("IX_Loans_StudentId"); + entity.HasIndex(e => e.DueAt).HasFilter("[ReturnedAt] IS NULL").HasDatabaseName("IX_Loans_Active_DueAt"); + entity.HasIndex(e => e.BookId).IsUnique().HasFilter("[ReturnedAt] IS NULL").HasDatabaseName("UX_Loans_ActiveBook"); + entity.Property(e => e.LoanId).HasDefaultValueSql("(newsequentialid())"); + entity.Property(e => e.LoanedAt).HasDefaultValueSql("(sysutcdatetime())"); + entity.Property(e => e.RowVersion).IsRowVersion().IsConcurrencyToken(); + entity.HasOne(e => e.Book).WithMany(e => e.Loans).HasForeignKey(e => e.BookId).OnDelete(DeleteBehavior.Restrict).HasConstraintName("FK_Loans_Books"); + entity.HasOne(e => e.Student).WithMany(e => e.Loans).HasForeignKey(e => e.StudentId).OnDelete(DeleteBehavior.Restrict).HasConstraintName("FK_Loans_Students"); + }); + + OnModelCreatingPartial(modelBuilder); + } + + partial void OnModelCreatingPartial(ModelBuilder modelBuilder); +} diff --git a/src/Bsevita.Library.Api/Data/LibraryDbContextFactory.cs b/src/Bsevita.Library.Api/Data/LibraryDbContextFactory.cs new file mode 100644 index 0000000..250ad64 --- /dev/null +++ b/src/Bsevita.Library.Api/Data/LibraryDbContextFactory.cs @@ -0,0 +1,20 @@ +using Bsevita.Library.Api.Data.Generated; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Design; + +namespace Bsevita.Library.Api.Data; + +public sealed class LibraryDbContextFactory : IDesignTimeDbContextFactory +{ + public LibraryDbContext CreateDbContext(string[] args) + { + var connectionString = Environment.GetEnvironmentVariable("BSEVITA_DB_CONNECTION") + ?? @"Server=(localdb)\MSSQLLocalDB;Database=BsevitaLibrary;Trusted_Connection=True;TrustServerCertificate=True"; + + var options = new DbContextOptionsBuilder() + .UseSqlServer(connectionString) + .Options; + + return new LibraryDbContext(options); + } +} diff --git a/src/Bsevita.Library.Api/Infrastructure/ApiExceptionHandler.cs b/src/Bsevita.Library.Api/Infrastructure/ApiExceptionHandler.cs new file mode 100644 index 0000000..60632b2 --- /dev/null +++ b/src/Bsevita.Library.Api/Infrastructure/ApiExceptionHandler.cs @@ -0,0 +1,56 @@ +using Microsoft.AspNetCore.Diagnostics; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace Bsevita.Library.Api.Infrastructure; + +public sealed class ApiExceptionHandler( + IProblemDetailsService problemDetailsService, + ILogger logger) : IExceptionHandler +{ + public async ValueTask TryHandleAsync( + HttpContext httpContext, + Exception exception, + CancellationToken cancellationToken) + { + (int status, string title, string detail) = exception switch + { + ApiException apiException => (apiException.StatusCode, apiException.Title, apiException.Message), + DbUpdateConcurrencyException => ( + StatusCodes.Status409Conflict, + "Paralleländerung erkannt", + "Der Datensatz wurde zwischenzeitlich geändert. Bitte Daten neu laden."), + DbUpdateException => ( + StatusCodes.Status409Conflict, + "Datenbankkonflikt", + "Die Änderung verletzt eine Datenbankregel. Prüfen Sie eindeutige Nummern und aktive Ausleihen."), + _ => ( + StatusCodes.Status500InternalServerError, + "Interner Serverfehler", + "Die Anfrage konnte nicht verarbeitet werden.") + }; + + if (status >= 500) + { + logger.LogError(exception, "Unhandled exception for {Method} {Path}", httpContext.Request.Method, httpContext.Request.Path); + } + else + { + logger.LogWarning(exception, "Request failed with {StatusCode} for {Method} {Path}", status, httpContext.Request.Method, httpContext.Request.Path); + } + + httpContext.Response.StatusCode = status; + return await problemDetailsService.TryWriteAsync(new ProblemDetailsContext + { + HttpContext = httpContext, + ProblemDetails = new ProblemDetails + { + Status = status, + Title = title, + Detail = detail, + Instance = httpContext.Request.Path + }, + Exception = exception + }); + } +} diff --git a/src/Bsevita.Library.Api/Infrastructure/ApiExceptions.cs b/src/Bsevita.Library.Api/Infrastructure/ApiExceptions.cs new file mode 100644 index 0000000..dbdbb09 --- /dev/null +++ b/src/Bsevita.Library.Api/Infrastructure/ApiExceptions.cs @@ -0,0 +1,19 @@ +namespace Bsevita.Library.Api.Infrastructure; + +public abstract class ApiException(int statusCode, string title, string detail) : Exception(detail) +{ + public int StatusCode { get; } = statusCode; + public string Title { get; } = title; +} + +public sealed class ResourceNotFoundException(string detail) + : ApiException(StatusCodes.Status404NotFound, "Nicht gefunden", detail) +{ } + +public sealed class ResourceConflictException(string detail) + : ApiException(StatusCodes.Status409Conflict, "Konflikt", detail) +{ } + +public sealed class RequestValidationException(string detail) + : ApiException(StatusCodes.Status400BadRequest, "Ungültige Anfrage", detail) +{ } diff --git a/src/Bsevita.Library.Api/Infrastructure/StringNormalizer.cs b/src/Bsevita.Library.Api/Infrastructure/StringNormalizer.cs new file mode 100644 index 0000000..c5cfab7 --- /dev/null +++ b/src/Bsevita.Library.Api/Infrastructure/StringNormalizer.cs @@ -0,0 +1,23 @@ +namespace Bsevita.Library.Api.Infrastructure; + +public static class StringNormalizer +{ + public static string Required(string value, string fieldName) + { + var normalized = value.Trim(); + if (string.IsNullOrWhiteSpace(normalized)) + { + throw new RequestValidationException($"{fieldName} darf nicht leer sein."); + } + + return normalized; + } + + public static string? Optional(string? value) + { + var normalized = value?.Trim(); + return string.IsNullOrWhiteSpace(normalized) ? null : normalized; + } + + public static string Key(string value, string fieldName) => Required(value, fieldName).ToUpperInvariant(); +} diff --git a/src/Bsevita.Library.Api/Program.cs b/src/Bsevita.Library.Api/Program.cs new file mode 100644 index 0000000..01c4e84 --- /dev/null +++ b/src/Bsevita.Library.Api/Program.cs @@ -0,0 +1,86 @@ +using Bsevita.Library.Api.Data.Generated; +using Bsevita.Library.Api.Infrastructure; +using Bsevita.Library.Api.Services; +using Microsoft.EntityFrameworkCore; + +var builder = WebApplication.CreateBuilder(args); + +var connectionString = builder.Configuration.GetConnectionString("LibraryDatabase") + ?? throw new InvalidOperationException("Connection string 'LibraryDatabase' is missing."); + +builder.Services.AddProblemDetails(); +builder.Services.AddExceptionHandler(); +builder.Services.AddControllers(); +builder.Services.AddEndpointsApiExplorer(); +builder.Services.AddSwaggerGen(options => +{ + foreach (var xmlFile in Directory.GetFiles(AppContext.BaseDirectory, "Bsevita.Library.*.xml")) + { + options.IncludeXmlComments(xmlFile); + } +}); +builder.Services.AddHealthChecks(); +builder.Services.AddCors(options => +{ + options.AddPolicy("Frontend", policy => + { + var origins = builder.Configuration.GetSection("AllowedOrigins").Get() ?? []; + if (origins.Length > 0) + { + policy.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod(); + } + }); +}); +builder.Services.AddDbContext(options => + options.UseSqlServer(connectionString, sql => sql.EnableRetryOnFailure(5, TimeSpan.FromSeconds(5), null))); +builder.Services.AddSingleton(TimeProvider.System); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); +builder.Services.AddScoped(); + +var app = builder.Build(); +app.UseExceptionHandler(); +if (!app.Environment.IsDevelopment()) +{ + app.UseHttpsRedirection(); +} +app.UseCors("Frontend"); +app.MapControllers(); +app.MapHealthChecks("/health") + .WithName("Health") + .WithSummary("Prüft den API-Status.") + .WithDescription("Liefert den Health-Status der API.") + .WithMetadata( + new ProducesResponseTypeMetadata(StatusCodes.Status200OK, typeof(string), ["text/plain"]), + new ProducesResponseTypeMetadata(StatusCodes.Status503ServiceUnavailable, typeof(string), ["text/plain"])); +if (app.Environment.IsDevelopment()) +{ + app.UseSwagger(); + app.UseSwaggerUI(); +} + +if (app.Configuration.GetValue("Database:ValidateOnStartup", true)) +{ + await ValidateDatabaseAsync(app.Services, app.Logger, app.Lifetime.ApplicationStopping); +} + +await app.RunAsync(); + +static async Task ValidateDatabaseAsync(IServiceProvider services, ILogger logger, CancellationToken cancellationToken) +{ + await using var scope = services.CreateAsyncScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + if (!await dbContext.Database.CanConnectAsync(cancellationToken)) + { + var connection = dbContext.Database.GetDbConnection(); + throw new InvalidOperationException( + $"Database is unavailable at '{connection.DataSource}' for database '{connection.Database}'. " + + "Start SQL Server and initialize it with 'docker compose up -d sqlserver database-init'."); + } + + logger.LogInformation("Database connection validated successfully."); +} + +public partial class Program { } diff --git a/src/Bsevita.Library.Api/Services/BookService.cs b/src/Bsevita.Library.Api/Services/BookService.cs new file mode 100644 index 0000000..8daf818 --- /dev/null +++ b/src/Bsevita.Library.Api/Services/BookService.cs @@ -0,0 +1,231 @@ +using System.Data; +using Bsevita.Library.Api.Data.Generated; +using Bsevita.Library.Api.Data.Generated.Entities; +using Bsevita.Library.Api.Infrastructure; +using Bsevita.Library.Models.Books; +using Bsevita.Library.Models.Common; +using Microsoft.EntityFrameworkCore; + +namespace Bsevita.Library.Api.Services; + +public sealed class BookService(LibraryDbContext dbContext, TimeProvider timeProvider) +{ + public Task> GetAllAsync( + bool? availableOnly, + int skip, + int take, + CancellationToken cancellationToken) => + QueryAsync(null, null, null, availableOnly, skip, take, cancellationToken); + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken) + { + var book = await FindActiveByIdAsync(id, cancellationToken); + var isBorrowed = await dbContext.Loans.AsNoTracking().AnyAsync(loan => loan.BookId == id && loan.ReturnedAt == null, cancellationToken); + return book.ToDto(!isBorrowed); + } + + public async Task GetByNumberAsync(string bookNumber, CancellationToken cancellationToken) + { + var key = StringNormalizer.Key(bookNumber, "Buchnummer"); + var book = await dbContext.Books.AsNoTracking().SingleOrDefaultAsync(item => item.IsActive && item.BookNumber == key, cancellationToken); + if (book is null) + { + throw new ResourceNotFoundException($"Kein aktives Buch mit Buchnummer '{bookNumber}' gefunden."); + } + + var isBorrowed = await dbContext.Loans.AsNoTracking().AnyAsync(loan => loan.BookId == book.BookId && loan.ReturnedAt == null, cancellationToken); + return book.ToDto(!isBorrowed); + } + + public async Task> SearchAsync( + string? title, + string? author, + string? subject, + CancellationToken cancellationToken) + { + if (string.IsNullOrWhiteSpace(title) && string.IsNullOrWhiteSpace(author) && string.IsNullOrWhiteSpace(subject)) + { + throw new RequestValidationException("Mindestens ein Suchfilter muss angegeben werden."); + } + + return await QueryAsync(title, author, subject, null, 0, LibraryRules.DefaultPageSize, cancellationToken); + } + + public async Task CreateAsync(SaveBookRequest request, CancellationToken cancellationToken) + { + var bookNumber = StringNormalizer.Key(request.BookNumber, "Buchnummer"); + var isbn = StringNormalizer.Optional(request.Isbn); + await EnsureIdentifiersAvailableAsync(bookNumber, isbn, null, cancellationToken); + var now = timeProvider.GetUtcNow(); + var book = new Book + { + BookId = Guid.NewGuid(), + BookNumber = bookNumber, + Isbn = isbn, + Title = StringNormalizer.Required(request.Title, "Titel"), + Author = StringNormalizer.Required(request.Author, "Autor"), + Subject = StringNormalizer.Required(request.Subject, "Sachgebiet"), + Publisher = StringNormalizer.Optional(request.Publisher), + PublicationYear = request.PublicationYear is int publicationYear ? checked((short)publicationYear) : null, + IsActive = true, + CreatedAt = now, + UpdatedAt = now + }; + + dbContext.Books.Add(book); + await dbContext.SaveChangesAsync(cancellationToken); + return book.ToDto(true); + } + + public async Task UpdateAsync(Guid id, SaveBookRequest request, CancellationToken cancellationToken) + { + var book = await FindActiveByIdAsync(id, cancellationToken); + var bookNumber = StringNormalizer.Key(request.BookNumber, "Buchnummer"); + var isbn = StringNormalizer.Optional(request.Isbn); + await EnsureIdentifiersAvailableAsync(bookNumber, isbn, id, cancellationToken); + + book.BookNumber = bookNumber; + book.Isbn = isbn; + book.Title = StringNormalizer.Required(request.Title, "Titel"); + book.Author = StringNormalizer.Required(request.Author, "Autor"); + book.Subject = StringNormalizer.Required(request.Subject, "Sachgebiet"); + book.Publisher = StringNormalizer.Optional(request.Publisher); + book.PublicationYear = request.PublicationYear is int publicationYear ? checked((short)publicationYear) : null; + book.UpdatedAt = timeProvider.GetUtcNow(); + await dbContext.SaveChangesAsync(cancellationToken); + return await GetByIdAsync(id, cancellationToken); + } + + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken) + { + var strategy = dbContext.Database.CreateExecutionStrategy(); + await strategy.ExecuteAsync(async () => + { + await using var transaction = dbContext.Database.IsRelational() + ? await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken) + : null; + var book = await FindActiveByIdAsync(id, cancellationToken); + var hasActiveLoan = await dbContext.Loans.AsNoTracking() + .AnyAsync(loan => loan.BookId == id && loan.ReturnedAt == null, cancellationToken); + if (hasActiveLoan) + { + throw new ResourceConflictException("Ein ausgeliehenes Buch kann nicht gelöscht werden."); + } + + book.IsActive = false; + book.UpdatedAt = timeProvider.GetUtcNow(); + await dbContext.SaveChangesAsync(cancellationToken); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + }); + } + + private async Task> QueryAsync( + string? title, + string? author, + string? subject, + bool? availableOnly, + int skip, + int take, + CancellationToken cancellationToken) + { + skip = Math.Max(0, skip); + take = Math.Clamp(take, 1, LibraryRules.MaxPageSize); + var query = dbContext.Books.AsNoTracking().Where(book => book.IsActive); + if (!string.IsNullOrWhiteSpace(title)) + { + query = ApplyTextSearch(query, SearchField.Title, title, "Titel"); + } + if (!string.IsNullOrWhiteSpace(author)) + { + query = ApplyTextSearch(query, SearchField.Author, author, "Autor"); + } + if (!string.IsNullOrWhiteSpace(subject)) + { + query = ApplyTextSearch(query, SearchField.Subject, subject, "Sachgebiet"); + } + if (availableOnly is true) + { + query = query.Where(book => !book.Loans.Any(loan => loan.ReturnedAt == null)); + } + else if (availableOnly is false) + { + query = query.Where(book => book.Loans.Any(loan => loan.ReturnedAt == null)); + } + + var books = await query + .OrderBy(book => book.Title) + .Skip(skip) + .Take(take) + .ToListAsync(cancellationToken); + + var bookIds = books.Select(book => book.BookId).ToList(); + var borrowedBookIds = await dbContext.Loans.AsNoTracking() + .Where(loan => loan.ReturnedAt == null && bookIds.Contains(loan.BookId)) + .Select(loan => loan.BookId) + .ToListAsync(cancellationToken); + var borrowed = borrowedBookIds.ToHashSet(); + return books.Select(book => book.ToDto(!borrowed.Contains(book.BookId))).ToList(); + } + + private IQueryable ApplyTextSearch( + IQueryable query, + SearchField field, + string value, + string fieldName) + { + var term = StringNormalizer.Required(value, fieldName); + if (!dbContext.Database.IsRelational()) + { + var fallbackTerm = term.ToUpperInvariant(); + return field switch + { + SearchField.Title => query.Where(book => book.Title.ToUpper().Contains(fallbackTerm)), + SearchField.Author => query.Where(book => book.Author.ToUpper().Contains(fallbackTerm)), + _ => query.Where(book => book.Subject.ToUpper().Contains(fallbackTerm)) + }; + } + + return field switch + { + SearchField.Title => query.Where(book => book.Title.Contains(term)), + SearchField.Author => query.Where(book => book.Author.Contains(term)), + _ => query.Where(book => book.Subject.Contains(term)) + }; + } + + private enum SearchField + { + Title, + Author, + Subject + } + + private async Task FindActiveByIdAsync(Guid id, CancellationToken cancellationToken) => + await dbContext.Books.SingleOrDefaultAsync(book => book.BookId == id && book.IsActive, cancellationToken) + ?? throw new ResourceNotFoundException("Buch wurde nicht gefunden."); + + private async Task EnsureIdentifiersAvailableAsync(string bookNumber, string? isbn, Guid? ignoredId, CancellationToken cancellationToken) + { + var numberExists = await dbContext.Books.AsNoTracking().AnyAsync( + book => book.BookNumber == bookNumber && (!ignoredId.HasValue || book.BookId != ignoredId.Value), + cancellationToken); + if (numberExists) + { + throw new ResourceConflictException($"Die Buchnummer '{bookNumber}' ist bereits vergeben."); + } + + if (isbn is not null) + { + var isbnExists = await dbContext.Books.AsNoTracking().AnyAsync( + book => book.Isbn == isbn && (!ignoredId.HasValue || book.BookId != ignoredId.Value), + cancellationToken); + if (isbnExists) + { + throw new ResourceConflictException($"Die ISBN '{isbn}' ist bereits vergeben."); + } + } + } +} diff --git a/src/Bsevita.Library.Api/Services/ContractMapper.cs b/src/Bsevita.Library.Api/Services/ContractMapper.cs new file mode 100644 index 0000000..1ba04da --- /dev/null +++ b/src/Bsevita.Library.Api/Services/ContractMapper.cs @@ -0,0 +1,55 @@ +using Bsevita.Library.Api.Data.Generated.Entities; +using Bsevita.Library.Models.Books; +using Bsevita.Library.Models.Loans; +using Bsevita.Library.Models.Students; + +namespace Bsevita.Library.Api.Services; + +internal static class ContractMapper +{ + public static StudentDto ToDto(this Student student) => new( + student.StudentId, + student.CardNumber, + student.FirstName, + student.LastName, + $"{student.FirstName} {student.LastName}", + student.ClassName, + student.Email, + student.IsActive, + student.CreatedAt, + student.UpdatedAt); + + public static BookDto ToDto(this Book book, bool isAvailable) => new( + book.BookId, + book.BookNumber, + book.Isbn, + book.Title, + book.Author, + book.Subject, + book.Publisher, + book.PublicationYear, + isAvailable, + book.IsActive, + book.CreatedAt, + book.UpdatedAt); + + public static LoanDto ToDto(this Loan loan, DateTimeOffset now) + { + var isOverdue = loan.ReturnedAt is null && loan.DueAt < now; + var daysOverdue = isOverdue ? Math.Max(1, (int)Math.Floor((now - loan.DueAt).TotalDays)) : 0; + + return new LoanDto( + loan.LoanId, + loan.StudentId, + $"{loan.Student.FirstName} {loan.Student.LastName}", + loan.Student.CardNumber, + loan.BookId, + loan.Book.Title, + loan.Book.BookNumber, + loan.LoanedAt, + loan.DueAt, + loan.ReturnedAt, + isOverdue, + daysOverdue); + } +} diff --git a/src/Bsevita.Library.Api/Services/LoanService.cs b/src/Bsevita.Library.Api/Services/LoanService.cs new file mode 100644 index 0000000..0736ce4 --- /dev/null +++ b/src/Bsevita.Library.Api/Services/LoanService.cs @@ -0,0 +1,88 @@ +using System.Data; +using Bsevita.Library.Api.Data.Generated; +using Bsevita.Library.Api.Data.Generated.Entities; +using Bsevita.Library.Api.Infrastructure; +using Bsevita.Library.Models.Common; +using Bsevita.Library.Models.Loans; +using Microsoft.EntityFrameworkCore; + +namespace Bsevita.Library.Api.Services; + +public sealed class LoanService(LibraryDbContext dbContext, TimeProvider timeProvider) +{ + public async Task VerifyAsync(VerifyLoanRequest request, CancellationToken cancellationToken) + { + var (student, book) = await ResolveAsync(request.CardNumber, request.BookNumber, asTracking: false, cancellationToken); + var isBorrowed = await dbContext.Loans.AsNoTracking() + .AnyAsync(loan => loan.BookId == book.BookId && loan.ReturnedAt == null, cancellationToken); + if (isBorrowed) + { + throw new ResourceConflictException($"Das Buch '{book.Title}' ist bereits ausgeliehen."); + } + + var dueAt = timeProvider.GetUtcNow().AddDays(LibraryRules.DefaultLoanDays); + return new LoanVerificationDto(student.ToDto(), book.ToDto(true), dueAt, true, "Schüler und Buch wurden erfolgreich verifiziert."); + } + + public async Task CreateAsync(CreateLoanRequest request, CancellationToken cancellationToken) + { + var strategy = dbContext.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + await using var transaction = dbContext.Database.IsRelational() + ? await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken) + : null; + var (student, book) = await ResolveAsync(request.CardNumber, request.BookNumber, asTracking: true, cancellationToken); + var isBorrowed = await dbContext.Loans.AsNoTracking() + .AnyAsync(loan => loan.BookId == book.BookId && loan.ReturnedAt == null, cancellationToken); + if (isBorrowed) + { + throw new ResourceConflictException($"Das Buch '{book.Title}' wurde zwischenzeitlich ausgeliehen."); + } + + var now = timeProvider.GetUtcNow(); + var dueAt = request.DueAt ?? now.AddDays(LibraryRules.DefaultLoanDays); + if (dueAt < now) + { + throw new RequestValidationException("Das Fälligkeitsdatum darf nicht in der Vergangenheit liegen."); + } + + var loan = new Loan + { + LoanId = Guid.NewGuid(), + StudentId = student.StudentId, + BookId = book.BookId, + LoanedAt = now, + DueAt = dueAt, + Student = student, + Book = book + }; + dbContext.Loans.Add(loan); + await dbContext.SaveChangesAsync(cancellationToken); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + return loan.ToDto(now); + }); + } + + private async Task<(Student Student, Book Book)> ResolveAsync( + string cardNumber, + string bookNumber, + bool asTracking, + CancellationToken cancellationToken) + { + var cardKey = StringNormalizer.Key(cardNumber, "Ausweisnummer"); + var bookKey = StringNormalizer.Key(bookNumber, "Buchnummer"); + var students = asTracking ? dbContext.Students : dbContext.Students.AsNoTracking(); + var books = asTracking ? dbContext.Books : dbContext.Books.AsNoTracking(); + var student = await students.SingleOrDefaultAsync( + item => item.IsActive && item.CardNumber == cardKey, + cancellationToken) ?? throw new ResourceNotFoundException($"Schüler mit Ausweisnummer '{cardNumber}' wurde nicht gefunden."); + var book = await books.SingleOrDefaultAsync( + item => item.IsActive && item.BookNumber == bookKey, + cancellationToken) ?? throw new ResourceNotFoundException($"Buch mit Buchnummer '{bookNumber}' wurde nicht gefunden."); + return (student, book); + } +} diff --git a/src/Bsevita.Library.Api/Services/ReportService.cs b/src/Bsevita.Library.Api/Services/ReportService.cs new file mode 100644 index 0000000..3ea087d --- /dev/null +++ b/src/Bsevita.Library.Api/Services/ReportService.cs @@ -0,0 +1,159 @@ +using Bsevita.Library.Api.Data.Generated; +using Bsevita.Library.Models.Common; +using Bsevita.Library.Models.Loans; +using Bsevita.Library.Models.Reports; +using Microsoft.EntityFrameworkCore; + +namespace Bsevita.Library.Api.Services; + +public sealed class ReportService(LibraryDbContext dbContext, TimeProvider timeProvider) +{ + public async Task GetReportsAsync(CancellationToken cancellationToken) + { + var activeLoans = await GetActiveLoansAsync(0, LibraryRules.DefaultPageSize, cancellationToken); + var overdueLoans = await GetOverdueAsync(0, LibraryRules.DefaultPageSize, cancellationToken); + return new ReportsDto( + activeLoans, + overdueLoans, + await GetActiveStudentsAsync(0, LibraryRules.DefaultPageSize, cancellationToken), + await GetStatisticsAsync(cancellationToken)); + } + + public async Task> GetActiveLoansAsync( + int skip, + int take, + CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + var query = dbContext.Loans.AsNoTracking() + .Where(loan => loan.ReturnedAt == null) + .OrderBy(loan => loan.DueAt) + .Skip(Math.Max(0, skip)) + .Take(Math.Clamp(take, 1, LibraryRules.MaxPageSize)); + var loans = await query + .Select(loan => new + { + loan.LoanId, + loan.StudentId, + StudentName = loan.Student.FirstName + " " + loan.Student.LastName, + loan.Student.CardNumber, + loan.BookId, + BookTitle = loan.Book.Title, + loan.Book.BookNumber, + loan.LoanedAt, + loan.DueAt, + loan.ReturnedAt + }) + .ToListAsync(cancellationToken); + return loans.Select(loan => ToLoanDto( + loan.LoanId, + loan.StudentId, + loan.StudentName, + loan.CardNumber, + loan.BookId, + loan.BookTitle, + loan.BookNumber, + loan.LoanedAt, + loan.DueAt, + loan.ReturnedAt, + now)).ToList(); + } + + public Task> GetActiveLoansAsync(CancellationToken cancellationToken) => + GetActiveLoansAsync(0, LibraryRules.DefaultPageSize, cancellationToken); + + public async Task> GetOverdueAsync( + int skip, + int take, + CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + var query = dbContext.Loans.AsNoTracking() + .Where(loan => loan.ReturnedAt == null && loan.DueAt < now) + .OrderBy(loan => loan.DueAt) + .Skip(Math.Max(0, skip)) + .Take(Math.Clamp(take, 1, LibraryRules.MaxPageSize)); + var loans = await query + .Select(loan => new + { + loan.LoanId, + loan.StudentId, + StudentName = loan.Student.FirstName + " " + loan.Student.LastName, + loan.Student.CardNumber, + loan.BookId, + BookTitle = loan.Book.Title, + loan.Book.BookNumber, + loan.LoanedAt, + loan.DueAt, + loan.ReturnedAt + }) + .ToListAsync(cancellationToken); + return loans.Select(loan => ToLoanDto( + loan.LoanId, + loan.StudentId, + loan.StudentName, + loan.CardNumber, + loan.BookId, + loan.BookTitle, + loan.BookNumber, + loan.LoanedAt, + loan.DueAt, + loan.ReturnedAt, + now)).ToList(); + } + + public Task> GetOverdueAsync(CancellationToken cancellationToken) => + GetOverdueAsync(0, LibraryRules.DefaultPageSize, cancellationToken); + + public async Task> GetActiveStudentsAsync( + int skip, + int take, + CancellationToken cancellationToken) => + await dbContext.Students.AsNoTracking() + .Where(student => student.IsActive && student.Loans.Any(loan => loan.ReturnedAt == null)) + .OrderBy(student => student.LastName) + .ThenBy(student => student.FirstName) + .Skip(Math.Max(0, skip)) + .Take(Math.Clamp(take, 1, LibraryRules.MaxPageSize)) + .Select(student => new ActiveStudentDto( + student.StudentId, + student.FirstName + " " + student.LastName, + student.CardNumber, + student.ClassName, + student.Loans.Count(loan => loan.ReturnedAt == null))) + .ToListAsync(cancellationToken); + + public Task> GetActiveStudentsAsync(CancellationToken cancellationToken) => + GetActiveStudentsAsync(0, LibraryRules.DefaultPageSize, cancellationToken); + + public async Task GetStatisticsAsync(CancellationToken cancellationToken) + { + var now = timeProvider.GetUtcNow(); + var totalBooks = await dbContext.Books.CountAsync(book => book.IsActive, cancellationToken); + var activeLoans = await dbContext.Loans.CountAsync(loan => loan.ReturnedAt == null, cancellationToken); + var availableBooks = await dbContext.Books.CountAsync( + book => book.IsActive && !book.Loans.Any(loan => loan.ReturnedAt == null), + cancellationToken); + var totalStudents = await dbContext.Students.CountAsync(student => student.IsActive, cancellationToken); + var overdueLoans = await dbContext.Loans.CountAsync(loan => loan.ReturnedAt == null && loan.DueAt < now, cancellationToken); + return new LibraryStatisticsDto(totalBooks, availableBooks, totalStudents, activeLoans, overdueLoans); + } + + private static LoanDto ToLoanDto( + Guid Id, + Guid StudentId, + string StudentName, + string CardNumber, + Guid BookId, + string BookTitle, + string BookNumber, + DateTimeOffset LoanedAt, + DateTimeOffset DueAt, + DateTimeOffset? ReturnedAt, + DateTimeOffset now) + { + var isOverdue = ReturnedAt is null && DueAt < now; + var daysOverdue = isOverdue ? Math.Max(1, (int)Math.Floor((now - DueAt).TotalDays)) : 0; + return new LoanDto(Id, StudentId, StudentName, CardNumber, BookId, BookTitle, BookNumber, LoanedAt, DueAt, ReturnedAt, isOverdue, daysOverdue); + } +} diff --git a/src/Bsevita.Library.Api/Services/ReturnService.cs b/src/Bsevita.Library.Api/Services/ReturnService.cs new file mode 100644 index 0000000..899d66d --- /dev/null +++ b/src/Bsevita.Library.Api/Services/ReturnService.cs @@ -0,0 +1,89 @@ +using System.Data; +using Bsevita.Library.Api.Data.Generated; +using Bsevita.Library.Api.Data.Generated.Entities; +using Bsevita.Library.Api.Infrastructure; +using Bsevita.Library.Models.Returns; +using Microsoft.EntityFrameworkCore; + +namespace Bsevita.Library.Api.Services; + +public sealed class ReturnService(LibraryDbContext dbContext, TimeProvider timeProvider) +{ + public async Task VerifyAsync(string bookNumber, CancellationToken cancellationToken) + { + var key = StringNormalizer.Key(bookNumber, "Buchnummer"); + var loan = await dbContext.Loans + .AsNoTracking() + .Where(loan => loan.Book.BookNumber == key && loan.Book.IsActive && loan.ReturnedAt == null) + .Select(loan => new + { + loan.LoanId, + StudentName = loan.Student.FirstName + " " + loan.Student.LastName, + loan.Student.CardNumber, + BookTitle = loan.Book.Title, + loan.Book.BookNumber, + loan.LoanedAt, + loan.DueAt + }) + .SingleOrDefaultAsync(cancellationToken) + ?? throw new ResourceNotFoundException($"Für Buchnummer '{bookNumber}' wurde keine aktive Ausleihe gefunden."); + var now = timeProvider.GetUtcNow(); + var isOverdue = loan.DueAt < now; + var daysOverdue = isOverdue ? Math.Max(1, (int)Math.Floor((now - loan.DueAt).TotalDays)) : 0; + return new ReturnVerificationDto( + loan.LoanId, + loan.StudentName, + loan.CardNumber, + loan.BookTitle, + loan.BookNumber, + loan.LoanedAt, + loan.DueAt, + isOverdue, + daysOverdue); + } + + public async Task ReturnAsync(ReturnBookRequest request, CancellationToken cancellationToken) + { + var strategy = dbContext.Database.CreateExecutionStrategy(); + return await strategy.ExecuteAsync(async () => + { + await using var transaction = dbContext.Database.IsRelational() + ? await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken) + : null; + var loan = await FindActiveLoanAsync(request.BookNumber, asTracking: true, cancellationToken); + var now = timeProvider.GetUtcNow(); + var wasOverdue = loan.DueAt < now; + var daysOverdue = wasOverdue ? Math.Max(1, (int)Math.Floor((now - loan.DueAt).TotalDays)) : 0; + loan.ReturnedAt = now; + await dbContext.SaveChangesAsync(cancellationToken); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + return new ReturnResultDto( + loan.LoanId, + $"{loan.Student.FirstName} {loan.Student.LastName}", + loan.Book.Title, + loan.Book.BookNumber, + now, + wasOverdue, + daysOverdue); + }); + } + + private async Task FindActiveLoanAsync(string bookNumber, bool asTracking, CancellationToken cancellationToken) + { + var key = StringNormalizer.Key(bookNumber, "Buchnummer"); + IQueryable query = dbContext.Loans + .Include(loan => loan.Student) + .Include(loan => loan.Book); + if (!asTracking) + { + query = query.AsNoTracking(); + } + + return await query.SingleOrDefaultAsync( + loan => loan.Book.BookNumber == key && loan.Book.IsActive && loan.ReturnedAt == null, + cancellationToken) ?? throw new ResourceNotFoundException($"Für Buchnummer '{bookNumber}' wurde keine aktive Ausleihe gefunden."); + } +} diff --git a/src/Bsevita.Library.Api/Services/StudentService.cs b/src/Bsevita.Library.Api/Services/StudentService.cs new file mode 100644 index 0000000..dfd4194 --- /dev/null +++ b/src/Bsevita.Library.Api/Services/StudentService.cs @@ -0,0 +1,162 @@ +using System.Data; +using Bsevita.Library.Api.Data.Generated; +using Bsevita.Library.Api.Data.Generated.Entities; +using Bsevita.Library.Api.Infrastructure; +using Bsevita.Library.Models.Common; +using Bsevita.Library.Models.Students; +using Microsoft.EntityFrameworkCore; + +namespace Bsevita.Library.Api.Services; + +public sealed class StudentService(LibraryDbContext dbContext, TimeProvider timeProvider) +{ + public async Task> GetAllAsync(int skip, int take, CancellationToken cancellationToken) => + await ActiveStudents() + .OrderBy(student => student.LastName) + .ThenBy(student => student.FirstName) + .Skip(Math.Max(0, skip)) + .Take(Math.Clamp(take, 1, LibraryRules.MaxPageSize)) + .Select(ToDto()) + .ToListAsync(cancellationToken); + + private IQueryable ActiveStudents() => + dbContext.Students + .AsNoTracking() + .Where(student => student.IsActive); + + private static System.Linq.Expressions.Expression> ToDto() => + student => new StudentDto( + student.StudentId, + student.CardNumber, + student.FirstName, + student.LastName, + student.FirstName + " " + student.LastName, + student.ClassName, + student.Email, + student.IsActive, + student.CreatedAt, + student.UpdatedAt); + + public async Task GetByIdAsync(Guid id, CancellationToken cancellationToken) => + await ActiveStudents() + .Where(student => student.StudentId == id) + .Select(ToDto()) + .SingleOrDefaultAsync(cancellationToken) + ?? throw new ResourceNotFoundException("Schüler wurde nicht gefunden."); + + public async Task GetByCardAsync(string cardNumber, CancellationToken cancellationToken) + { + var key = StringNormalizer.Key(cardNumber, "Ausweisnummer"); + var student = await dbContext.Students.AsNoTracking() + .SingleOrDefaultAsync(item => item.IsActive && item.CardNumber == key, cancellationToken); + return student?.ToDto() ?? throw new ResourceNotFoundException($"Kein aktiver Schüler mit Ausweisnummer '{cardNumber}' gefunden."); + } + + public async Task> SearchAsync(string name, CancellationToken cancellationToken) + { + var term = StringNormalizer.Required(name, "Suchbegriff"); + var query = ActiveStudents(); + if (dbContext.Database.IsRelational()) + { + query = query.Where(student => + student.FirstName.Contains(term) || + student.LastName.Contains(term) || + (student.FirstName + " " + student.LastName).Contains(term)); + } + else + { + var fallbackTerm = term.ToUpperInvariant(); + query = query.Where(student => student.FirstName.ToUpper().Contains(fallbackTerm) || + student.LastName.ToUpper().Contains(fallbackTerm) || + (student.FirstName + " " + student.LastName).ToUpper().Contains(fallbackTerm)); + } + + return await query + .OrderBy(student => student.LastName) + .ThenBy(student => student.FirstName) + .Take(LibraryRules.DefaultPageSize) + .Select(ToDto()) + .ToListAsync(cancellationToken); + } + + public async Task CreateAsync(SaveStudentRequest request, CancellationToken cancellationToken) + { + var cardNumber = StringNormalizer.Key(request.CardNumber, "Ausweisnummer"); + await EnsureCardNumberAvailableAsync(cardNumber, null, cancellationToken); + var now = timeProvider.GetUtcNow(); + var student = new Student + { + StudentId = Guid.NewGuid(), + CardNumber = cardNumber, + FirstName = StringNormalizer.Required(request.FirstName, "Vorname"), + LastName = StringNormalizer.Required(request.LastName, "Nachname"), + ClassName = StringNormalizer.Required(request.ClassName, "Klasse"), + Email = StringNormalizer.Optional(request.Email), + IsActive = true, + CreatedAt = now, + UpdatedAt = now + }; + + dbContext.Students.Add(student); + await dbContext.SaveChangesAsync(cancellationToken); + return student.ToDto(); + } + + public async Task UpdateAsync(Guid id, SaveStudentRequest request, CancellationToken cancellationToken) + { + var student = await FindActiveByIdAsync(id, cancellationToken); + var cardNumber = StringNormalizer.Key(request.CardNumber, "Ausweisnummer"); + await EnsureCardNumberAvailableAsync(cardNumber, id, cancellationToken); + + student.CardNumber = cardNumber; + student.FirstName = StringNormalizer.Required(request.FirstName, "Vorname"); + student.LastName = StringNormalizer.Required(request.LastName, "Nachname"); + student.ClassName = StringNormalizer.Required(request.ClassName, "Klasse"); + student.Email = StringNormalizer.Optional(request.Email); + student.UpdatedAt = timeProvider.GetUtcNow(); + await dbContext.SaveChangesAsync(cancellationToken); + return student.ToDto(); + } + + public async Task DeleteAsync(Guid id, CancellationToken cancellationToken) + { + var strategy = dbContext.Database.CreateExecutionStrategy(); + await strategy.ExecuteAsync(async () => + { + await using var transaction = dbContext.Database.IsRelational() + ? await dbContext.Database.BeginTransactionAsync(IsolationLevel.Serializable, cancellationToken) + : null; + var student = await FindActiveByIdAsync(id, cancellationToken); + var hasActiveLoans = await dbContext.Loans.AsNoTracking().AnyAsync( + loan => loan.StudentId == id && loan.ReturnedAt == null, + cancellationToken); + if (hasActiveLoans) + { + throw new ResourceConflictException("Der Schüler hat noch aktive Ausleihen und kann nicht gelöscht werden."); + } + + student.IsActive = false; + student.UpdatedAt = timeProvider.GetUtcNow(); + await dbContext.SaveChangesAsync(cancellationToken); + if (transaction is not null) + { + await transaction.CommitAsync(cancellationToken); + } + }); + } + + private async Task FindActiveByIdAsync(Guid id, CancellationToken cancellationToken) => + await dbContext.Students.SingleOrDefaultAsync(student => student.StudentId == id && student.IsActive, cancellationToken) + ?? throw new ResourceNotFoundException("Schüler wurde nicht gefunden."); + + private async Task EnsureCardNumberAvailableAsync(string cardNumber, Guid? ignoredId, CancellationToken cancellationToken) + { + var exists = await dbContext.Students.AsNoTracking().AnyAsync( + student => student.CardNumber == cardNumber && (!ignoredId.HasValue || student.StudentId != ignoredId.Value), + cancellationToken); + if (exists) + { + throw new ResourceConflictException($"Die Ausweisnummer '{cardNumber}' ist bereits vergeben."); + } + } +} diff --git a/src/Bsevita.Library.Api/appsettings.Development.json b/src/Bsevita.Library.Api/appsettings.Development.json new file mode 100644 index 0000000..9f8419c --- /dev/null +++ b/src/Bsevita.Library.Api/appsettings.Development.json @@ -0,0 +1,9 @@ +{ + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Information", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + } + } +} diff --git a/src/Bsevita.Library.Api/appsettings.json b/src/Bsevita.Library.Api/appsettings.json new file mode 100644 index 0000000..f0263bd --- /dev/null +++ b/src/Bsevita.Library.Api/appsettings.json @@ -0,0 +1,20 @@ +{ + "ConnectionStrings": { + "LibraryDatabase": "Server=(localdb)\\MSSQLLocalDB;Database=BsevitaLibrary;Trusted_Connection=True;TrustServerCertificate=True" + }, + "AllowedOrigins": [ + "http://localhost:5190", + "https://localhost:7190" + ], + "Database": { + "ValidateOnStartup": true + }, + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning", + "Microsoft.EntityFrameworkCore.Database.Command": "Information" + } + }, + "AllowedHosts": "*" +} diff --git a/src/Bsevita.Library.Maui/App.xaml b/src/Bsevita.Library.Maui/App.xaml new file mode 100644 index 0000000..1074842 --- /dev/null +++ b/src/Bsevita.Library.Maui/App.xaml @@ -0,0 +1,10 @@ + + + + + #F6F4EF + + + diff --git a/src/Bsevita.Library.Maui/App.xaml.cs b/src/Bsevita.Library.Maui/App.xaml.cs new file mode 100644 index 0000000..2294894 --- /dev/null +++ b/src/Bsevita.Library.Maui/App.xaml.cs @@ -0,0 +1,24 @@ +namespace Bsevita.Library.Maui; + +public partial class App : Application +{ + public App() + { + InitializeComponent(); + } + + protected override Window CreateWindow(IActivationState? activationState) + { + var window = new Window(new MainPage()) { Title = "BSEVITA Library" }; + + if (DeviceInfo.Platform == DevicePlatform.WinUI) + { + window.Width = 1280; + window.Height = 860; + window.MinimumWidth = 980; + window.MinimumHeight = 640; + } + + return window; + } +} diff --git a/src/Bsevita.Library.Maui/Bsevita.Library.Maui.csproj b/src/Bsevita.Library.Maui/Bsevita.Library.Maui.csproj new file mode 100644 index 0000000..b8a9793 --- /dev/null +++ b/src/Bsevita.Library.Maui/Bsevita.Library.Maui.csproj @@ -0,0 +1,43 @@ + + + net10.0-android + net10.0-windows10.0.19041.0 + net10.0-ios;net10.0-maccatalyst + Exe + Bsevita.Library.Maui + false + true + true + enable + enable + BSEVITA Library + top.lechner.bsevita.library + 1.0 + 1 + None + 15.0 + 15.0 + 24.0 + 10.0.17763.0 + 10.0.17763.0 + Platforms/MacCatalyst/Entitlements.plist + + + + + + + + + + + + + + + + + ..\..\..\..\..\..\..\Program Files\dotnet\packs\Microsoft.Android.Ref.36\36.1.30\ref\net10.0\Mono.Android.dll + + + diff --git a/src/Bsevita.Library.Maui/MainPage.xaml b/src/Bsevita.Library.Maui/MainPage.xaml new file mode 100644 index 0000000..3dbd04b --- /dev/null +++ b/src/Bsevita.Library.Maui/MainPage.xaml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/src/Bsevita.Library.Maui/MainPage.xaml.cs b/src/Bsevita.Library.Maui/MainPage.xaml.cs new file mode 100644 index 0000000..ee8c505 --- /dev/null +++ b/src/Bsevita.Library.Maui/MainPage.xaml.cs @@ -0,0 +1,9 @@ +namespace Bsevita.Library.Maui; + +public partial class MainPage : ContentPage +{ + public MainPage() + { + InitializeComponent(); + } +} diff --git a/src/Bsevita.Library.Maui/MauiProgram.cs b/src/Bsevita.Library.Maui/MauiProgram.cs new file mode 100644 index 0000000..8058dd1 --- /dev/null +++ b/src/Bsevita.Library.Maui/MauiProgram.cs @@ -0,0 +1,31 @@ +using Bsevita.Library.Maui.Services; +using Bsevita.Library.Ui.Services; +using Microsoft.Extensions.Logging; + +namespace Bsevita.Library.Maui; + +public static class MauiProgram +{ + public static MauiApp CreateMauiApp() + { + var builder = MauiApp.CreateBuilder(); + builder.UseMauiApp(); + builder.Services.AddMauiBlazorWebView(); + builder.Services.AddLibraryUi(); + builder.Services.AddSingleton(); + builder.Services.AddScoped(serviceProvider => + { + var resolver = serviceProvider.GetRequiredService(); + return new HttpClient + { + BaseAddress = resolver.GetBaseAddress(), + Timeout = TimeSpan.FromSeconds(20) + }; + }); +#if DEBUG + builder.Services.AddBlazorWebViewDeveloperTools(); + builder.Logging.AddDebug(); +#endif + return builder.Build(); + } +} diff --git a/src/Bsevita.Library.Maui/Platforms/Android/AndroidManifest.xml b/src/Bsevita.Library.Maui/Platforms/Android/AndroidManifest.xml new file mode 100644 index 0000000..edd00ae --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/Android/AndroidManifest.xml @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Bsevita.Library.Maui/Platforms/Android/MainActivity.cs b/src/Bsevita.Library.Maui/Platforms/Android/MainActivity.cs new file mode 100644 index 0000000..e6e8966 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/Android/MainActivity.cs @@ -0,0 +1,10 @@ +using Android.App; +using Android.Content.PM; +using Android.OS; + +namespace Bsevita.Library.Maui; + +[Activity(Theme = "@style/Maui.SplashTheme", MainLauncher = true, LaunchMode = LaunchMode.SingleTop, + ConfigurationChanges = ConfigChanges.ScreenSize | ConfigChanges.Orientation | ConfigChanges.UiMode | + ConfigChanges.ScreenLayout | ConfigChanges.SmallestScreenSize | ConfigChanges.Density)] +public class MainActivity : MauiAppCompatActivity { } diff --git a/src/Bsevita.Library.Maui/Platforms/Android/MainApplication.cs b/src/Bsevita.Library.Maui/Platforms/Android/MainApplication.cs new file mode 100644 index 0000000..be3f2b4 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/Android/MainApplication.cs @@ -0,0 +1,10 @@ +using Android.App; +using Android.Runtime; + +namespace Bsevita.Library.Maui; + +[Application] +public class MainApplication(nint handle, JniHandleOwnership ownership) : MauiApplication(handle, ownership) +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/src/Bsevita.Library.Maui/Platforms/Android/Resources/values/colors.xml b/src/Bsevita.Library.Maui/Platforms/Android/Resources/values/colors.xml new file mode 100644 index 0000000..5123ff7 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/Android/Resources/values/colors.xml @@ -0,0 +1,2 @@ + +#3157D5#172554#6941C6 diff --git a/src/Bsevita.Library.Maui/Platforms/MacCatalyst/AppDelegate.cs b/src/Bsevita.Library.Maui/Platforms/MacCatalyst/AppDelegate.cs new file mode 100644 index 0000000..0918c81 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/MacCatalyst/AppDelegate.cs @@ -0,0 +1,9 @@ +using Foundation; + +namespace Bsevita.Library.Maui; + +[Register("AppDelegate")] +public class AppDelegate : MauiUIApplicationDelegate +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/src/Bsevita.Library.Maui/Platforms/MacCatalyst/Entitlements.plist b/src/Bsevita.Library.Maui/Platforms/MacCatalyst/Entitlements.plist new file mode 100644 index 0000000..65a5b81 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/MacCatalyst/Entitlements.plist @@ -0,0 +1,3 @@ + + +com.apple.security.app-sandboxcom.apple.security.network.client diff --git a/src/Bsevita.Library.Maui/Platforms/MacCatalyst/Info.plist b/src/Bsevita.Library.Maui/Platforms/MacCatalyst/Info.plist new file mode 100644 index 0000000..06f5639 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/MacCatalyst/Info.plist @@ -0,0 +1,7 @@ + + + +UIDeviceFamily2 +UISupportedInterfaceOrientationsUIInterfaceOrientationPortraitUIInterfaceOrientationLandscapeLeftUIInterfaceOrientationLandscapeRight +NSAppTransportSecurityNSAllowsLocalNetworking + diff --git a/src/Bsevita.Library.Maui/Platforms/MacCatalyst/Program.cs b/src/Bsevita.Library.Maui/Platforms/MacCatalyst/Program.cs new file mode 100644 index 0000000..4e96795 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/MacCatalyst/Program.cs @@ -0,0 +1,8 @@ +using UIKit; + +namespace Bsevita.Library.Maui; + +public static class Program +{ + public static void Main(string[] args) => UIApplication.Main(args, null, typeof(AppDelegate)); +} diff --git a/src/Bsevita.Library.Maui/Platforms/Windows/App.xaml b/src/Bsevita.Library.Maui/Platforms/Windows/App.xaml new file mode 100644 index 0000000..4f1b1b2 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/Windows/App.xaml @@ -0,0 +1,4 @@ + diff --git a/src/Bsevita.Library.Maui/Platforms/Windows/App.xaml.cs b/src/Bsevita.Library.Maui/Platforms/Windows/App.xaml.cs new file mode 100644 index 0000000..39588e7 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/Windows/App.xaml.cs @@ -0,0 +1,7 @@ +namespace Bsevita.Library.Maui.WinUI; + +public partial class App : MauiWinUIApplication +{ + public App() => InitializeComponent(); + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/src/Bsevita.Library.Maui/Platforms/Windows/Package.appxmanifest b/src/Bsevita.Library.Maui/Platforms/Windows/Package.appxmanifest new file mode 100644 index 0000000..48b02d8 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/Windows/Package.appxmanifest @@ -0,0 +1,9 @@ + + + + + BSEVITA LibraryJulian Lechner$placeholder$.png + + + + diff --git a/src/Bsevita.Library.Maui/Platforms/iOS/AppDelegate.cs b/src/Bsevita.Library.Maui/Platforms/iOS/AppDelegate.cs new file mode 100644 index 0000000..0918c81 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/iOS/AppDelegate.cs @@ -0,0 +1,9 @@ +using Foundation; + +namespace Bsevita.Library.Maui; + +[Register("AppDelegate")] +public class AppDelegate : MauiUIApplicationDelegate +{ + protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp(); +} diff --git a/src/Bsevita.Library.Maui/Platforms/iOS/Info.plist b/src/Bsevita.Library.Maui/Platforms/iOS/Info.plist new file mode 100644 index 0000000..d9ad011 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/iOS/Info.plist @@ -0,0 +1,9 @@ + + + +LSRequiresIPhoneOS +UIDeviceFamily12 +UIRequiredDeviceCapabilitiesarm64 +UISupportedInterfaceOrientationsUIInterfaceOrientationPortraitUIInterfaceOrientationLandscapeLeftUIInterfaceOrientationLandscapeRight +NSAppTransportSecurityNSAllowsLocalNetworking + diff --git a/src/Bsevita.Library.Maui/Platforms/iOS/Program.cs b/src/Bsevita.Library.Maui/Platforms/iOS/Program.cs new file mode 100644 index 0000000..4e96795 --- /dev/null +++ b/src/Bsevita.Library.Maui/Platforms/iOS/Program.cs @@ -0,0 +1,8 @@ +using UIKit; + +namespace Bsevita.Library.Maui; + +public static class Program +{ + public static void Main(string[] args) => UIApplication.Main(args, null, typeof(AppDelegate)); +} diff --git a/src/Bsevita.Library.Maui/Resources/AppIcon/appicon.svg b/src/Bsevita.Library.Maui/Resources/AppIcon/appicon.svg new file mode 100644 index 0000000..7a18a99 --- /dev/null +++ b/src/Bsevita.Library.Maui/Resources/AppIcon/appicon.svg @@ -0,0 +1 @@ + diff --git a/src/Bsevita.Library.Maui/Resources/AppIcon/appiconfg.svg b/src/Bsevita.Library.Maui/Resources/AppIcon/appiconfg.svg new file mode 100644 index 0000000..7e3c119 --- /dev/null +++ b/src/Bsevita.Library.Maui/Resources/AppIcon/appiconfg.svg @@ -0,0 +1 @@ + diff --git a/src/Bsevita.Library.Maui/Resources/Raw/README.txt b/src/Bsevita.Library.Maui/Resources/Raw/README.txt new file mode 100644 index 0000000..6b35c6b --- /dev/null +++ b/src/Bsevita.Library.Maui/Resources/Raw/README.txt @@ -0,0 +1 @@ +This folder is reserved for MAUI raw assets. diff --git a/src/Bsevita.Library.Maui/Resources/Splash/splash.svg b/src/Bsevita.Library.Maui/Resources/Splash/splash.svg new file mode 100644 index 0000000..446ede5 --- /dev/null +++ b/src/Bsevita.Library.Maui/Resources/Splash/splash.svg @@ -0,0 +1 @@ + diff --git a/src/Bsevita.Library.Maui/Services/ApiEndpointResolver.cs b/src/Bsevita.Library.Maui/Services/ApiEndpointResolver.cs new file mode 100644 index 0000000..da1a3b2 --- /dev/null +++ b/src/Bsevita.Library.Maui/Services/ApiEndpointResolver.cs @@ -0,0 +1,19 @@ +namespace Bsevita.Library.Maui.Services; + +public sealed class ApiEndpointResolver +{ + public Uri GetBaseAddress() + { + var configured = Environment.GetEnvironmentVariable("BSEVITA_API_BASE_URL"); + if (Uri.TryCreate(configured, UriKind.Absolute, out var customUri)) + { + return EnsureTrailingSlash(customUri); + } + + var host = DeviceInfo.Platform == DevicePlatform.Android ? "10.0.2.2" : "localhost"; + return new Uri($"http://{host}:5180/"); + } + + private static Uri EnsureTrailingSlash(Uri uri) => + uri.AbsoluteUri.EndsWith('/') ? uri : new Uri(uri.AbsoluteUri + "/"); +} diff --git a/src/Bsevita.Library.Maui/wwwroot/index.html b/src/Bsevita.Library.Maui/wwwroot/index.html new file mode 100644 index 0000000..0a51181 --- /dev/null +++ b/src/Bsevita.Library.Maui/wwwroot/index.html @@ -0,0 +1,17 @@ + + + + + + BSEVITA Library + + + + +
+
Anwendung wird gestartet …
+
+ + + + diff --git a/src/Bsevita.Library.Models/Books/BookContracts.cs b/src/Bsevita.Library.Models/Books/BookContracts.cs new file mode 100644 index 0000000..43b19bc --- /dev/null +++ b/src/Bsevita.Library.Models/Books/BookContracts.cs @@ -0,0 +1,63 @@ +using System.ComponentModel.DataAnnotations; +using Bsevita.Library.Models.Common; + +namespace Bsevita.Library.Models.Books; + +/// Buch mit Bestands- und Verfügbarkeitsdaten. +/// Eindeutige Buch-ID. +/// Interne Buchnummer. +/// Optionale ISBN. +/// Titel. +/// Autor. +/// Fach oder Kategorie. +/// Optionaler Verlag. +/// Optionales Erscheinungsjahr. +/// Gibt an, ob das Buch ausleihbar ist. +/// Gibt an, ob das Buch aktiv im Bestand ist. +/// Erstellzeitpunkt. +/// Letzter Änderungszeitpunkt. +public sealed record BookDto( + Guid Id, + string BookNumber, + string? Isbn, + string Title, + string Author, + string Subject, + string? Publisher, + int? PublicationYear, + bool IsAvailable, + bool IsActive, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +/// Daten zum Anlegen oder Aktualisieren eines Buchs. +public sealed class SaveBookRequest +{ + /// Interne Buchnummer. + [Required, StringLength(LibraryRules.MaxBookNumberLength, MinimumLength = 1)] + public string BookNumber { get; set; } = string.Empty; + + /// Optionale ISBN. + [StringLength(20)] + public string? Isbn { get; set; } + + /// Titel. + [Required, StringLength(LibraryRules.MaxTitleLength, MinimumLength = 1)] + public string Title { get; set; } = string.Empty; + + /// Autor. + [Required, StringLength(LibraryRules.MaxAuthorLength, MinimumLength = 1)] + public string Author { get; set; } = string.Empty; + + /// Fach oder Kategorie. + [Required, StringLength(LibraryRules.MaxSubjectLength, MinimumLength = 1)] + public string Subject { get; set; } = string.Empty; + + /// Optionaler Verlag. + [StringLength(160)] + public string? Publisher { get; set; } + + /// Optionales Erscheinungsjahr. + [Range(1000, 2200)] + public int? PublicationYear { get; set; } +} diff --git a/src/Bsevita.Library.Models/Bsevita.Library.Models.csproj b/src/Bsevita.Library.Models/Bsevita.Library.Models.csproj new file mode 100644 index 0000000..d5a251a --- /dev/null +++ b/src/Bsevita.Library.Models/Bsevita.Library.Models.csproj @@ -0,0 +1,8 @@ + + + net10.0 + Bsevita.Library.Models + true + $(NoWarn);1591 + + diff --git a/src/Bsevita.Library.Models/Common/LibraryRules.cs b/src/Bsevita.Library.Models/Common/LibraryRules.cs new file mode 100644 index 0000000..dbcb97f --- /dev/null +++ b/src/Bsevita.Library.Models/Common/LibraryRules.cs @@ -0,0 +1,15 @@ +namespace Bsevita.Library.Models.Common; + +public static class LibraryRules +{ + public const int DefaultLoanDays = 14; + public const int DefaultPageSize = 100; + public const int MaxPageSize = 500; + public const int MaxCardNumberLength = 32; + public const int MaxBookNumberLength = 32; + public const int MaxNameLength = 100; + public const int MaxClassNameLength = 32; + public const int MaxTitleLength = 240; + public const int MaxAuthorLength = 160; + public const int MaxSubjectLength = 120; +} diff --git a/src/Bsevita.Library.Models/Loans/LoanContracts.cs b/src/Bsevita.Library.Models/Loans/LoanContracts.cs new file mode 100644 index 0000000..a0a9235 --- /dev/null +++ b/src/Bsevita.Library.Models/Loans/LoanContracts.cs @@ -0,0 +1,73 @@ +using System.ComponentModel.DataAnnotations; +using Bsevita.Library.Models.Books; +using Bsevita.Library.Models.Common; +using Bsevita.Library.Models.Students; + +namespace Bsevita.Library.Models.Loans; + +/// Daten zur Prüfung einer Ausleihe. +public sealed class VerifyLoanRequest +{ + /// Bibliotheksausweisnummer des Schülers. + [Required, StringLength(LibraryRules.MaxCardNumberLength)] + public string CardNumber { get; set; } = string.Empty; + + /// Buchnummer des auszuleihenden Buchs. + [Required, StringLength(LibraryRules.MaxBookNumberLength)] + public string BookNumber { get; set; } = string.Empty; +} + +/// Daten zum Erstellen einer Ausleihe. +public sealed class CreateLoanRequest +{ + /// Bibliotheksausweisnummer des Schülers. + [Required, StringLength(LibraryRules.MaxCardNumberLength)] + public string CardNumber { get; set; } = string.Empty; + + /// Buchnummer des auszuleihenden Buchs. + [Required, StringLength(LibraryRules.MaxBookNumberLength)] + public string BookNumber { get; set; } = string.Empty; + + /// Optionale Fälligkeit; ohne Wert wird die Standardfrist verwendet. + public DateTimeOffset? DueAt { get; set; } +} + +/// Ergebnis einer Ausleihpruefung. +/// Gefundener Schüler. +/// Gefundenes Buch. +/// Berechnetes Rückgabedatum. +/// Gibt an, ob die Ausleihe erlaubt ist. +/// Lesbare Statusmeldung. +public sealed record LoanVerificationDto( + StudentDto Student, + BookDto Book, + DateTimeOffset DueAt, + bool CanBorrow, + string StatusMessage); + +/// Ausleihe mit Schüler-, Buch- und Fristdaten. +/// Eindeutige Ausleih-ID. +/// ID des Schülers. +/// Name des Schülers. +/// Bibliotheksausweisnummer. +/// ID des Buchs. +/// Titel des Buchs. +/// Buchnummer. +/// Ausleihzeitpunkt. +/// Fälligkeit der Rückgabe. +/// Rückgabezeitpunkt, falls abgeschlossen. +/// Gibt an, ob die Ausleihe überfällig ist. +/// Anzahl überfälliger Tage. +public sealed record LoanDto( + Guid Id, + Guid StudentId, + string StudentName, + string CardNumber, + Guid BookId, + string BookTitle, + string BookNumber, + DateTimeOffset LoanedAt, + DateTimeOffset DueAt, + DateTimeOffset? ReturnedAt, + bool IsOverdue, + int DaysOverdue); diff --git a/src/Bsevita.Library.Models/Reports/ReportContracts.cs b/src/Bsevita.Library.Models/Reports/ReportContracts.cs new file mode 100644 index 0000000..e2f4c95 --- /dev/null +++ b/src/Bsevita.Library.Models/Reports/ReportContracts.cs @@ -0,0 +1,40 @@ +using Bsevita.Library.Models.Loans; + +namespace Bsevita.Library.Models.Reports; + +/// Schüler mit aktiven Ausleihen. +/// ID des Schülers. +/// Name des Schülers. +/// Bibliotheksausweisnummer. +/// Klasse oder Kurs. +/// Anzahl aktiver Ausleihen. +public sealed record ActiveStudentDto( + Guid StudentId, + string StudentName, + string CardNumber, + string ClassName, + int ActiveLoanCount); + +/// Aggregierte Bibliotheksstatistiken. +/// Anzahl aktiver Bücher. +/// Anzahl verfügbarer Bücher. +/// Anzahl aktiver Schüler. +/// Anzahl aktiver Ausleihen. +/// Anzahl überfälliger Ausleihen. +public sealed record LibraryStatisticsDto( + int TotalBooks, + int AvailableBooks, + int TotalStudents, + int ActiveLoans, + int OverdueLoans); + +/// Gebundelte Reports für das Dashboard. +/// Aktive Ausleihen. +/// Ueberfällige Ausleihen. +/// Schüler mit aktiven Ausleihen. +/// Aggregierte Statistiken. +public sealed record ReportsDto( + IReadOnlyList ActiveLoans, + IReadOnlyList OverdueLoans, + IReadOnlyList ActiveStudents, + LibraryStatisticsDto Statistics); diff --git a/src/Bsevita.Library.Models/Returns/ReturnContracts.cs b/src/Bsevita.Library.Models/Returns/ReturnContracts.cs new file mode 100644 index 0000000..442e251 --- /dev/null +++ b/src/Bsevita.Library.Models/Returns/ReturnContracts.cs @@ -0,0 +1,50 @@ +using System.ComponentModel.DataAnnotations; +using Bsevita.Library.Models.Common; + +namespace Bsevita.Library.Models.Returns; + +/// Daten zum Abschließen einer Rückgabe. +public sealed class ReturnBookRequest +{ + /// Buchnummer des zurückgegebenen Buchs. + [Required, StringLength(LibraryRules.MaxBookNumberLength)] + public string BookNumber { get; set; } = string.Empty; +} + +/// Ergebnis einer Rückgabeprüfung. +/// ID der aktiven Ausleihe. +/// Name des Schülers. +/// Bibliotheksausweisnummer. +/// Titel des Buchs. +/// Buchnummer. +/// Ausleihzeitpunkt. +/// Fälligkeit der Rückgabe. +/// Gibt an, ob die Ausleihe überfällig ist. +/// Anzahl überfälliger Tage. +public sealed record ReturnVerificationDto( + Guid LoanId, + string StudentName, + string CardNumber, + string BookTitle, + string BookNumber, + DateTimeOffset LoanedAt, + DateTimeOffset DueAt, + bool IsOverdue, + int DaysOverdue); + +/// Ergebnis einer abgeschlossenen Rückgabe. +/// ID der abgeschlossenen Ausleihe. +/// Name des Schülers. +/// Titel des Buchs. +/// Buchnummer. +/// Rückgabezeitpunkt. +/// Gibt an, ob die Ausleihe überfällig war. +/// Anzahl überfälliger Tage. +public sealed record ReturnResultDto( + Guid LoanId, + string StudentName, + string BookTitle, + string BookNumber, + DateTimeOffset ReturnedAt, + bool WasOverdue, + int DaysOverdue); diff --git a/src/Bsevita.Library.Models/Students/StudentContracts.cs b/src/Bsevita.Library.Models/Students/StudentContracts.cs new file mode 100644 index 0000000..a43c932 --- /dev/null +++ b/src/Bsevita.Library.Models/Students/StudentContracts.cs @@ -0,0 +1,51 @@ +using System.ComponentModel.DataAnnotations; +using Bsevita.Library.Models.Common; + +namespace Bsevita.Library.Models.Students; + +/// Schüler mit Ausweis- und Kontaktdaten. +/// Eindeutige Schüler-ID. +/// Bibliotheksausweisnummer. +/// Vorname. +/// Nachname. +/// Anzeigename aus Vor- und Nachname. +/// Klasse oder Kurs. +/// Optionale E-Mail-Adresse. +/// Gibt an, ob der Schüler aktiv ist. +/// Erstellzeitpunkt. +/// Letzter Änderungszeitpunkt. +public sealed record StudentDto( + Guid Id, + string CardNumber, + string FirstName, + string LastName, + string FullName, + string ClassName, + string? Email, + bool IsActive, + DateTimeOffset CreatedAt, + DateTimeOffset UpdatedAt); + +/// Daten zum Anlegen oder Aktualisieren eines Schülers. +public sealed class SaveStudentRequest +{ + /// Bibliotheksausweisnummer. + [Required, StringLength(LibraryRules.MaxCardNumberLength, MinimumLength = 2)] + public string CardNumber { get; set; } = string.Empty; + + /// Vorname. + [Required, StringLength(LibraryRules.MaxNameLength, MinimumLength = 1)] + public string FirstName { get; set; } = string.Empty; + + /// Nachname. + [Required, StringLength(LibraryRules.MaxNameLength, MinimumLength = 1)] + public string LastName { get; set; } = string.Empty; + + /// Klasse oder Kurs. + [Required, StringLength(LibraryRules.MaxClassNameLength, MinimumLength = 1)] + public string ClassName { get; set; } = string.Empty; + + /// Optionale E-Mail-Adresse. + [EmailAddress, StringLength(254)] + public string? Email { get; set; } +} diff --git a/src/Bsevita.Library.Ui/AppRoutes.razor b/src/Bsevita.Library.Ui/AppRoutes.razor new file mode 100644 index 0000000..94c2192 --- /dev/null +++ b/src/Bsevita.Library.Ui/AppRoutes.razor @@ -0,0 +1,6 @@ + + + + + + diff --git a/src/Bsevita.Library.Ui/Bsevita.Library.Ui.csproj b/src/Bsevita.Library.Ui/Bsevita.Library.Ui.csproj new file mode 100644 index 0000000..a996214 --- /dev/null +++ b/src/Bsevita.Library.Ui/Bsevita.Library.Ui.csproj @@ -0,0 +1,13 @@ + + + net10.0 + Bsevita.Library.Ui + true + + + + + + + + diff --git a/src/Bsevita.Library.Ui/Components/AlertMessage.razor b/src/Bsevita.Library.Ui/Components/AlertMessage.razor new file mode 100644 index 0000000..e945e08 --- /dev/null +++ b/src/Bsevita.Library.Ui/Components/AlertMessage.razor @@ -0,0 +1,11 @@ +@if (!string.IsNullOrWhiteSpace(Message)) +{ + +} diff --git a/src/Bsevita.Library.Ui/Components/AlertMessage.razor.cs b/src/Bsevita.Library.Ui/Components/AlertMessage.razor.cs new file mode 100644 index 0000000..2368569 --- /dev/null +++ b/src/Bsevita.Library.Ui/Components/AlertMessage.razor.cs @@ -0,0 +1,10 @@ +namespace Bsevita.Library.Ui.Components; + +public partial class AlertMessage +{ + [Parameter] public string? Message { get; set; } + [Parameter] public string Kind { get; set; } = "error"; + [Parameter] public bool Dismissible { get; set; } = true; + [Parameter] public EventCallback OnDismiss { get; set; } + private Task Dismiss() => OnDismiss.InvokeAsync(); +} diff --git a/src/Bsevita.Library.Ui/Components/ConfirmDialog.razor b/src/Bsevita.Library.Ui/Components/ConfirmDialog.razor new file mode 100644 index 0000000..7a6b179 --- /dev/null +++ b/src/Bsevita.Library.Ui/Components/ConfirmDialog.razor @@ -0,0 +1,18 @@ +@if (Visible) +{ + +} diff --git a/src/Bsevita.Library.Ui/Components/ConfirmDialog.razor.cs b/src/Bsevita.Library.Ui/Components/ConfirmDialog.razor.cs new file mode 100644 index 0000000..b2bf78c --- /dev/null +++ b/src/Bsevita.Library.Ui/Components/ConfirmDialog.razor.cs @@ -0,0 +1,13 @@ +namespace Bsevita.Library.Ui.Components; + +public partial class ConfirmDialog +{ + [Parameter] public bool Visible { get; set; } + [Parameter] public string Title { get; set; } = "Aktion bestätigen"; + [Parameter] public string Message { get; set; } = "Soll die Aktion wirklich ausgeführt werden?"; + [Parameter] public string ConfirmText { get; set; } = "Löschen"; + [Parameter] public EventCallback OnConfirm { get; set; } + [Parameter] public EventCallback OnCancel { get; set; } + private Task Confirm() => OnConfirm.InvokeAsync(); + private Task Cancel() => OnCancel.InvokeAsync(); +} diff --git a/src/Bsevita.Library.Ui/Components/Icon.razor b/src/Bsevita.Library.Ui/Components/Icon.razor new file mode 100644 index 0000000..fbf92f1 --- /dev/null +++ b/src/Bsevita.Library.Ui/Components/Icon.razor @@ -0,0 +1,27 @@ + + +@code { + [Parameter, EditorRequired] public string Name { get; set; } = string.Empty; + [Parameter] public string? Class { get; set; } + + private string CssClass => string.IsNullOrWhiteSpace(Class) ? "icon" : $"icon {Class}"; + + private string Path => Name switch + { + "alert-triangle" => """""", + "arrow-left-right" => """""", + "book-open" => """""", + "check" => """""", + "clipboard-list" => """""", + "graduation-cap" => """""", + "house" => """""", + "plus" => """""", + "rotate-ccw" => """""", + "search" => """""", + "x" => """""", + "x-circle" => """""", + _ => """""" + }; +} diff --git a/src/Bsevita.Library.Ui/Components/LoadingIndicator.razor b/src/Bsevita.Library.Ui/Components/LoadingIndicator.razor new file mode 100644 index 0000000..151f275 --- /dev/null +++ b/src/Bsevita.Library.Ui/Components/LoadingIndicator.razor @@ -0,0 +1,4 @@ +
+ + @Text +
diff --git a/src/Bsevita.Library.Ui/Components/LoadingIndicator.razor.cs b/src/Bsevita.Library.Ui/Components/LoadingIndicator.razor.cs new file mode 100644 index 0000000..a71f3e8 --- /dev/null +++ b/src/Bsevita.Library.Ui/Components/LoadingIndicator.razor.cs @@ -0,0 +1,6 @@ +namespace Bsevita.Library.Ui.Components; + +public partial class LoadingIndicator +{ + [Parameter] public string Text { get; set; } = "Daten werden geladen …"; +} diff --git a/src/Bsevita.Library.Ui/Components/PageHeader.razor b/src/Bsevita.Library.Ui/Components/PageHeader.razor new file mode 100644 index 0000000..81872ee --- /dev/null +++ b/src/Bsevita.Library.Ui/Components/PageHeader.razor @@ -0,0 +1,9 @@ + diff --git a/src/Bsevita.Library.Ui/Components/PageHeader.razor.cs b/src/Bsevita.Library.Ui/Components/PageHeader.razor.cs new file mode 100644 index 0000000..59ae6b8 --- /dev/null +++ b/src/Bsevita.Library.Ui/Components/PageHeader.razor.cs @@ -0,0 +1,7 @@ +namespace Bsevita.Library.Ui.Components; + +public partial class PageHeader +{ + [Parameter, EditorRequired] public string Title { get; set; } = string.Empty; + [Parameter] public RenderFragment? Actions { get; set; } +} diff --git a/src/Bsevita.Library.Ui/Components/StatCard.razor b/src/Bsevita.Library.Ui/Components/StatCard.razor new file mode 100644 index 0000000..139a837 --- /dev/null +++ b/src/Bsevita.Library.Ui/Components/StatCard.razor @@ -0,0 +1,7 @@ +
+
+
+
@Value
+
@Label
+
+
diff --git a/src/Bsevita.Library.Ui/Components/StatCard.razor.cs b/src/Bsevita.Library.Ui/Components/StatCard.razor.cs new file mode 100644 index 0000000..c267e50 --- /dev/null +++ b/src/Bsevita.Library.Ui/Components/StatCard.razor.cs @@ -0,0 +1,9 @@ +namespace Bsevita.Library.Ui.Components; + +public partial class StatCard +{ + [Parameter, EditorRequired] public string Label { get; set; } = string.Empty; + [Parameter, EditorRequired] public string Value { get; set; } = string.Empty; + [Parameter] public string Icon { get; set; } = "#"; + [Parameter] public string Tone { get; set; } = "primary"; +} diff --git a/src/Bsevita.Library.Ui/GlobalUsings.cs b/src/Bsevita.Library.Ui/GlobalUsings.cs new file mode 100644 index 0000000..5200d7e --- /dev/null +++ b/src/Bsevita.Library.Ui/GlobalUsings.cs @@ -0,0 +1,9 @@ +global using System.Net; +global using Bsevita.Library.Models.Books; +global using Bsevita.Library.Models.Loans; +global using Bsevita.Library.Models.Reports; +global using Bsevita.Library.Models.Returns; +global using Bsevita.Library.Models.Students; +global using Bsevita.Library.Ui.Services; +global using Microsoft.AspNetCore.Components; +global using Microsoft.AspNetCore.Components.Web; diff --git a/src/Bsevita.Library.Ui/Layout/MainLayout.razor b/src/Bsevita.Library.Ui/Layout/MainLayout.razor new file mode 100644 index 0000000..77e5b62 --- /dev/null +++ b/src/Bsevita.Library.Ui/Layout/MainLayout.razor @@ -0,0 +1,22 @@ +@inherits LayoutComponentBase + +
+ + @if (_mobileMenuOpen) + { + + } +
+
+
+ Library System +
+ Bereit +
+
+ @Body +
+
+
diff --git a/src/Bsevita.Library.Ui/Layout/MainLayout.razor.cs b/src/Bsevita.Library.Ui/Layout/MainLayout.razor.cs new file mode 100644 index 0000000..edc2871 --- /dev/null +++ b/src/Bsevita.Library.Ui/Layout/MainLayout.razor.cs @@ -0,0 +1,8 @@ +namespace Bsevita.Library.Ui.Layout; + +public partial class MainLayout +{ + private bool _mobileMenuOpen; + private void ToggleMenu() => _mobileMenuOpen = !_mobileMenuOpen; + private void CloseMenu() => _mobileMenuOpen = false; +} diff --git a/src/Bsevita.Library.Ui/Layout/NavMenu.razor b/src/Bsevita.Library.Ui/Layout/NavMenu.razor new file mode 100644 index 0000000..e91e70e --- /dev/null +++ b/src/Bsevita.Library.Ui/Layout/NavMenu.razor @@ -0,0 +1,27 @@ +
+
B
+
+
BSEVITA
+
+
+ + diff --git a/src/Bsevita.Library.Ui/Layout/NavMenu.razor.cs b/src/Bsevita.Library.Ui/Layout/NavMenu.razor.cs new file mode 100644 index 0000000..04ad62e --- /dev/null +++ b/src/Bsevita.Library.Ui/Layout/NavMenu.razor.cs @@ -0,0 +1,7 @@ +namespace Bsevita.Library.Ui.Layout; + +public partial class NavMenu +{ + [Parameter] public EventCallback OnNavigate { get; set; } + private Task Navigate() => OnNavigate.InvokeAsync(); +} diff --git a/src/Bsevita.Library.Ui/Pages/Books.razor b/src/Bsevita.Library.Ui/Pages/Books.razor new file mode 100644 index 0000000..7f1417a --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Books.razor @@ -0,0 +1,86 @@ +@page "/books" +@inject BookApiClient BooksApi + +Bücher - Library + + + + + + + +
+
+
+ + + + + + @if (HasSearchText) { } +
+ @_books.Count Bücher +
+ + @if (_isLoading) + { + + } + else if (_books.Count == 0) + { +
+
+

Keine Bücher gefunden

+
+ } + else + { +
+ + + + @foreach (var book in _books) + { + + + + + + + + + } + +
TitelAutorSachgebietBuchnummerStatusAktionen
@book.Title@if (HasIsbn(book)) { ISBN @book.Isbn }@book.Author@book.Subject@book.BookNumber@(book.IsAvailable ? "Verfügbar" : "Ausgeliehen") + + +
+
+ } +
+ +@if (_showEditor) +{ + +} + + diff --git a/src/Bsevita.Library.Ui/Pages/Books.razor.cs b/src/Bsevita.Library.Ui/Pages/Books.razor.cs new file mode 100644 index 0000000..e4c314f --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Books.razor.cs @@ -0,0 +1,199 @@ +namespace Bsevita.Library.Ui.Pages; + +public partial class Books +{ + [SupplyParameterFromQuery(Name = "focus")] + public string? Focus { get; set; } + + private List _books = []; + private string _searchText = string.Empty; + private string _searchFilter = "title"; + private bool _isLoading = true; + private bool _isSaving; + private bool _showEditor; + private Guid? _editingId; + private SaveBookRequest _editModel = new(); + private BookDto? _bookToDelete; + private string? _errorMessage; + private string? _successMessage; + + private bool HasSearchText => !string.IsNullOrWhiteSpace(_searchText); + private bool ShouldFocusSearch => string.Equals(Focus, "search", StringComparison.OrdinalIgnoreCase); + private string EditorTitle => _editingId is null ? "Neu anlegen" : "Bearbeiten"; + private string SaveButtonText => _isSaving ? "Speichert ..." : "Speichern"; + private string DeleteMessage => _bookToDelete is null + ? string.Empty + : $"Soll '{_bookToDelete.Title}' wirklich gelöscht werden? Ausgeliehene Bücher können nicht gelöscht werden."; + + protected override Task OnInitializedAsync() => LoadAsync(); + + private async Task LoadAsync() + { + _isLoading = true; + try + { + _books = (await BooksApi.GetAllAsync()).ToList(); + } + catch (Exception exception) + { + _errorMessage = Error(exception); + _books = []; + } + finally + { + _isLoading = false; + } + } + + private async Task SearchAsync() + { + if (string.IsNullOrWhiteSpace(_searchText)) + { + await LoadAsync(); + return; + } + + _isLoading = true; + try + { + var searchText = _searchText.Trim(); + if (_searchFilter == "number") + { + var book = await BooksApi.GetByNumberAsync(searchText); + _books = [book]; + } + else + { + _books = (await BooksApi.SearchAsync(_searchFilter, searchText)).ToList(); + } + } + catch (Exception exception) + { + _errorMessage = Error(exception); + _books = []; + } + finally + { + _isLoading = false; + } + } + + private async Task ResetSearchAsync() + { + _searchText = string.Empty; + await LoadAsync(); + } + + private async Task HandleSearchKey(KeyboardEventArgs args) + { + if (args.Key == "Enter") + { + await SearchAsync(); + } + } + + private void OpenCreate() + { + _editingId = null; + _editModel = new SaveBookRequest(); + _showEditor = true; + ClearMessages(); + } + + private async Task OpenEditAsync(Guid id) + { + ClearMessages(); + try + { + var book = await BooksApi.GetByIdAsync(id); + _editingId = book.Id; + _editModel = new SaveBookRequest + { + BookNumber = book.BookNumber, + Isbn = book.Isbn, + Title = book.Title, + Author = book.Author, + Subject = book.Subject, + Publisher = book.Publisher, + PublicationYear = book.PublicationYear + }; + _showEditor = true; + } + catch (Exception exception) + { + _errorMessage = Error(exception); + } + } + + private void CloseEditor() + { + if (!_isSaving) + { + _showEditor = false; + } + } + + private async Task SaveAsync() + { + _isSaving = true; + try + { + if (_editingId is Guid id) + { + await BooksApi.UpdateAsync(id, _editModel); + } + else + { + await BooksApi.CreateAsync(_editModel); + } + + _showEditor = false; + _successMessage = _editingId is null ? "Buch wurde angelegt." : "Buch wurde gespeichert."; + await LoadAsync(); + } + catch (Exception exception) + { + _errorMessage = Error(exception); + } + finally + { + _isSaving = false; + } + } + + private void AskDelete(BookDto book) => _bookToDelete = book; + + private void CancelDelete() => _bookToDelete = null; + + private async Task DeleteAsync() + { + if (_bookToDelete is null) + { + return; + } + + try + { + await BooksApi.DeleteBookAsync(_bookToDelete.Id); + _successMessage = $"'{_bookToDelete.Title}' wurde gelöscht."; + _bookToDelete = null; + await LoadAsync(); + } + catch (Exception exception) + { + _errorMessage = Error(exception); + _bookToDelete = null; + } + } + + private void ClearMessages() + { + _errorMessage = null; + _successMessage = null; + } + + private static bool HasIsbn(BookDto book) => !string.IsNullOrWhiteSpace(book.Isbn); + + private static string Error(Exception exception) => + exception is ApiProblemException api ? api.Message : "Die Buchdaten konnten nicht verarbeitet werden."; +} diff --git a/src/Bsevita.Library.Ui/Pages/Dashboard.razor b/src/Bsevita.Library.Ui/Pages/Dashboard.razor new file mode 100644 index 0000000..352b1db --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Dashboard.razor @@ -0,0 +1,82 @@ +@page "/" +@inject ReportApiClient Reports + +Übersicht - Library + + + + +@if (_isLoading) +{ + +} +else if (_statistics is not null) +{ +
+ + + + +
+ +
+ + +
+
+
+

Überfällige Bücher

+
+ Alle Listen +
+ @if (_overdueLoans.Count == 0) + { +

Alles im grünen Bereich.

+ } + else + { +
+ @foreach (var loan in VisibleOverdueLoans) + { +
+
@loan.BookTitle@loan.StudentName · @loan.BookNumber
+ @loan.DaysOverdue Tag@(loan.DaysOverdue == 1 ? "" : "e") +
+ } +
+ } +
+
+} +else +{ +
+
+

Übersicht konnte nicht geladen werden

+ +
+} diff --git a/src/Bsevita.Library.Ui/Pages/Dashboard.razor.cs b/src/Bsevita.Library.Ui/Pages/Dashboard.razor.cs new file mode 100644 index 0000000..7c38e94 --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Dashboard.razor.cs @@ -0,0 +1,36 @@ +namespace Bsevita.Library.Ui.Pages; + +public partial class Dashboard +{ + private LibraryStatisticsDto? _statistics; + private IReadOnlyList _overdueLoans = []; + private bool _isLoading = true; + private string? _errorMessage; + private IEnumerable VisibleOverdueLoans => _overdueLoans.Take(5); + + protected override Task OnInitializedAsync() => LoadAsync(); + + private async Task LoadAsync() + { + _isLoading = true; + _errorMessage = null; + _statistics = null; + try + { + var reports = await Reports.GetReportsAsync(); + _statistics = reports.Statistics; + _overdueLoans = reports.OverdueLoans; + } + catch (Exception exception) + { + _errorMessage = GetMessage(exception); + } + finally + { + _isLoading = false; + } + } + + private void ClearError() => _errorMessage = null; + private static string GetMessage(Exception exception) => exception is ApiProblemException api ? api.Message : "Übersicht konnte nicht geladen werden. Bitte Verbindung prüfen."; +} diff --git a/src/Bsevita.Library.Ui/Pages/Loans.razor b/src/Bsevita.Library.Ui/Pages/Loans.razor new file mode 100644 index 0000000..95ccecd --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Loans.razor @@ -0,0 +1,81 @@ +@page "/loans" +@inject LoanApiClient LoansApi +@inject StudentApiClient StudentsApi +@inject BookApiClient BooksApi + +Ausleihe - Library + + + + + + +
+ @foreach (var step in _steps) + { +
@step.Number@step.Label
+ } +
+ +
+ @if (_verification is null) + { +

Wer leiht welches Buch aus?

+ + +
+
+ + + + @if (CardSuggestions.Count > 0) + { +
+ @foreach (var student in CardSuggestions) + { + + } +
+ } +
+
+ + + + @if (BookSuggestions.Count > 0) + { +
+ @foreach (var book in BookSuggestions) + { + + } +
+ } +
+
+
+
+ } + else + { +

Stimmt alles?

+
+
Bereit zum Ausleihen
+
+
Schüler
@_verification.Student.FullName
+
Klasse / Ausweis
@_verification.Student.ClassName · @_verification.Student.CardNumber
+
Buch
@_verification.Book.Title
+
Autor / Nummer
@_verification.Book.Author · @_verification.Book.BookNumber
+
Fällig am
+
Status
Verfügbar
+
+
+
+ } +
diff --git a/src/Bsevita.Library.Ui/Pages/Loans.razor.cs b/src/Bsevita.Library.Ui/Pages/Loans.razor.cs new file mode 100644 index 0000000..498041b --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Loans.razor.cs @@ -0,0 +1,127 @@ +namespace Bsevita.Library.Ui.Pages; + +public partial class Loans +{ + private readonly (int Number, string Label)[] _steps = [(1, "Angaben"), (2, "Bestätigen")]; + private VerifyLoanRequest _verifyModel = new(); + private LoanVerificationDto? _verification; + private List _students = []; + private List _availableBooks = []; + private bool _isWorking; + private string? _errorMessage; + private string? _warningMessage; + private string? _successMessage; + private string _dueDate = string.Empty; + private string _defaultDueDate = string.Empty; + private int CurrentStep => _verification is null ? 1 : 2; + private List CardSuggestions => _students + .Where(student => Matches(_verifyModel.CardNumber, student.CardNumber, student.FullName, student.ClassName)) + .Where(student => !string.Equals(student.CardNumber, _verifyModel.CardNumber, StringComparison.OrdinalIgnoreCase)) + .Take(6) + .ToList(); + private List BookSuggestions => _availableBooks + .Where(book => Matches(_verifyModel.BookNumber, book.BookNumber, book.Title, book.Author)) + .Where(book => !string.Equals(book.BookNumber, _verifyModel.BookNumber, StringComparison.OrdinalIgnoreCase)) + .Take(6) + .ToList(); + + protected override async Task OnInitializedAsync() + { + try + { + _students = (await StudentsApi.GetAllAsync()).ToList(); + _availableBooks = (await BooksApi.GetAllAsync(availableOnly: true)).ToList(); + } + catch (Exception exception) + { + _errorMessage = Error(exception); + } + } + + private async Task VerifyAsync() + { + ClearMessages(); _isWorking = true; + try + { + _verification = await LoansApi.VerifyAsync(_verifyModel); + _dueDate = _defaultDueDate = ToDateInputValue(_verification.DueAt); + } + catch (ApiProblemException exception) when (exception.StatusCode == (int)HttpStatusCode.Conflict) + { + _warningMessage = exception.Message; + _verification = null; + } + catch (Exception exception) + { + _errorMessage = Error(exception); + _verification = null; + } + finally + { + _isWorking = false; + } + } + + private async Task ConfirmAsync() + { + if (_verification is null) return; + _isWorking = true; + try + { + var customDueAt = (DateTimeOffset?)null; + if (!string.Equals(_dueDate, _defaultDueDate, StringComparison.Ordinal)) + { + if (!TryGetDueAt(out var parsedDueAt)) + { + _errorMessage = "Bitte ein gültiges Fälligkeitsdatum wählen."; + return; + } + + customDueAt = parsedDueAt; + } + + var loan = await LoansApi.CreateAsync(new CreateLoanRequest { CardNumber = _verifyModel.CardNumber, BookNumber = _verifyModel.BookNumber, DueAt = customDueAt }); + _successMessage = $"{loan.BookTitle} wurde erfolgreich an {loan.StudentName} ausgeliehen. Fällig am {loan.DueAt.ToLocalTime():dd.MM.yyyy}."; + _availableBooks.RemoveAll(book => book.BookNumber == loan.BookNumber); + _verifyModel = new VerifyLoanRequest(); + _verification = null; + _dueDate = _defaultDueDate = string.Empty; + } + catch (ApiProblemException exception) when (exception.StatusCode == (int)HttpStatusCode.Conflict) + { + _warningMessage = exception.Message; + _verification = null; + } + catch (Exception exception) + { + _errorMessage = Error(exception); + _verification = null; + } + finally + { + _isWorking = false; + } + } + + private void OnCardInput(ChangeEventArgs args) => _verifyModel.CardNumber = args.Value?.ToString() ?? string.Empty; + private void OnBookInput(ChangeEventArgs args) => _verifyModel.BookNumber = args.Value?.ToString() ?? string.Empty; + private void OnDueDateChanged(ChangeEventArgs args) => _dueDate = args.Value?.ToString() ?? string.Empty; + private void SelectStudent(StudentDto student) => _verifyModel.CardNumber = student.CardNumber; + private void SelectBook(BookDto book) => _verifyModel.BookNumber = book.BookNumber; + private void ResetVerification() => _verification = null; + private void ClearMessages() { _errorMessage = null; _warningMessage = null; _successMessage = null; } + private static bool Matches(string input, params string[] values) => !string.IsNullOrWhiteSpace(input) && values.Any(value => value.Contains(input.Trim(), StringComparison.OrdinalIgnoreCase)); + private bool TryGetDueAt(out DateTimeOffset dueAt) + { + if (DateOnly.TryParseExact(_dueDate, "yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture, System.Globalization.DateTimeStyles.None, out var date)) + { + dueAt = new DateTimeOffset(date.ToDateTime(TimeOnly.MinValue, DateTimeKind.Local)); + return true; + } + + dueAt = default; + return false; + } + private static string ToDateInputValue(DateTimeOffset value) => value.ToLocalTime().ToString("yyyy-MM-dd", System.Globalization.CultureInfo.InvariantCulture); + private static string Error(Exception exception) => exception is ApiProblemException api ? api.Message : "Die Ausleihe konnte nicht verarbeitet werden."; +} diff --git a/src/Bsevita.Library.Ui/Pages/NotFound.razor b/src/Bsevita.Library.Ui/Pages/NotFound.razor new file mode 100644 index 0000000..5d0e3c2 --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/NotFound.razor @@ -0,0 +1,11 @@ +@page "/not-found" +@layout MainLayout + +Seite nicht gefunden + +
+
404
+

Seite nicht gefunden

+

Die angeforderte Seite existiert nicht.

+ Zur Übersicht +
diff --git a/src/Bsevita.Library.Ui/Pages/Reports.razor b/src/Bsevita.Library.Ui/Pages/Reports.razor new file mode 100644 index 0000000..257a6e2 --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Reports.razor @@ -0,0 +1,92 @@ +@page "/reports" +@inject ReportApiClient ReportsApi + +Listen - Library + + + + +@if (_isLoading) +{ + +} +else if (_statistics is not null) +{ +
+ + + + +
+ +
+
+

Offene Ausleihen

@_activeLoans.Count
+
+ + @if (_activeLoans.Count == 0) + { + + } + else + { + @foreach (var loan in _activeLoans) + { + + } + } +
SchülerBuchAusgeliehenFälligStatus
Keine aktiven Ausleihen.
@loan.StudentName@loan.CardNumber@loan.BookTitle@loan.BookNumber@loan.LoanedAt.ToLocalTime().ToString("dd.MM.yyyy")@loan.DueAt.ToLocalTime().ToString("dd.MM.yyyy")@(loan.IsOverdue ? $"{loan.DaysOverdue} Tage überfällig" : "Aktiv")
+
+
+ +
+

Schüler mit Ausleihen

+ @if (_activeStudents.Count == 0) + { +

Keine aktiven Schüler.

+ } + else + { +
+ @foreach (var student in _activeStudents) + { +
+
+ @student.StudentName + @student.ClassName · @student.CardNumber +
+ @student.ActiveLoanCount +
+ } +
+ } +
+ +
+

Überfällige Ausleihen

@_overdueLoans.Count offen
+
+ + @if (_overdueLoans.Count == 0) + { + + } + else + { + @foreach (var loan in _overdueLoans) + { + + } + } +
VerzugSchülerBuchFällig seit
Keine überfälligen Ausleihen.
@loan.DaysOverdue Tage@loan.StudentName@loan.CardNumber@loan.BookTitle@loan.BookNumber@loan.DueAt.ToLocalTime().ToString("dd.MM.yyyy")
+
+
+
+} +else +{ +
+
+

Listen konnten nicht geladen werden

+ +
+} diff --git a/src/Bsevita.Library.Ui/Pages/Reports.razor.cs b/src/Bsevita.Library.Ui/Pages/Reports.razor.cs new file mode 100644 index 0000000..3a09ff5 --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Reports.razor.cs @@ -0,0 +1,31 @@ +namespace Bsevita.Library.Ui.Pages; + +public partial class Reports +{ + private IReadOnlyList _activeLoans = []; + private IReadOnlyList _overdueLoans = []; + private IReadOnlyList _activeStudents = []; + private LibraryStatisticsDto? _statistics; + private bool _isLoading = true; + private string? _errorMessage; + + protected override Task OnInitializedAsync() => LoadAsync(); + + private async Task LoadAsync() + { + _isLoading = true; + _errorMessage = null; + _statistics = null; + try + { + var reports = await ReportsApi.GetReportsAsync(); + _activeLoans = reports.ActiveLoans; + _overdueLoans = reports.OverdueLoans; + _activeStudents = reports.ActiveStudents; + _statistics = reports.Statistics; + } + catch (Exception e) { _errorMessage = e is ApiProblemException api ? api.Message : "Listen konnten nicht geladen werden."; } + finally { _isLoading = false; } + } + private void ClearError() => _errorMessage = null; +} diff --git a/src/Bsevita.Library.Ui/Pages/Returns.razor b/src/Bsevita.Library.Ui/Pages/Returns.razor new file mode 100644 index 0000000..c510536 --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Returns.razor @@ -0,0 +1,65 @@ +@page "/returns" +@inject ReturnApiClient ReturnsApi +@inject BookApiClient BooksApi + +Rückgabe - Library + + + + + +
+
1Buchnummer
+
2Details
+
3Speichern
+
+ +
+ + + @if (_verification is not null) + { +
+
@(_verification.IsOverdue ? "Überfällige Ausleihe" : "Aktive Ausleihe")
+
+
Schüler
@_verification.StudentName
+
Ausweisnummer
@_verification.CardNumber
+
Buch
@_verification.BookTitle
+
Ausgeliehen am
@_verification.LoanedAt.ToLocalTime().ToString("dd.MM.yyyy")
+
Fällig am
@_verification.DueAt.ToLocalTime().ToString("dd.MM.yyyy")
+
+
Status
+
+ @if (_verification.IsOverdue) + { + @_verification.DaysOverdue Tage überfällig + } + else + { + Fristgerecht + } +
+
+
+
+
+ } +
diff --git a/src/Bsevita.Library.Ui/Pages/Returns.razor.cs b/src/Bsevita.Library.Ui/Pages/Returns.razor.cs new file mode 100644 index 0000000..b01eb10 --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Returns.razor.cs @@ -0,0 +1,61 @@ +namespace Bsevita.Library.Ui.Pages; + +public partial class Returns +{ + private string _bookNumber = string.Empty; + private ReturnVerificationDto? _verification; + private List _borrowedBooks = []; + private bool _isWorking; + private string? _errorMessage; + private string? _successMessage; + private List BookSuggestions => _borrowedBooks + .Where(book => Matches(_bookNumber, book.BookNumber, book.Title, book.Author)) + .Where(book => !string.Equals(book.BookNumber, _bookNumber, StringComparison.OrdinalIgnoreCase)) + .Take(6) + .ToList(); + + protected override async Task OnInitializedAsync() + { + try + { + _borrowedBooks = (await BooksApi.GetAllAsync(availableOnly: false)).ToList(); + } + catch (Exception exception) + { + _errorMessage = Error(exception); + } + } + + private async Task VerifyAsync() + { + ClearMessages(); _verification = null; + if (string.IsNullOrWhiteSpace(_bookNumber)) { _errorMessage = "Bitte eine Buchnummer eingeben."; return; } + _isWorking = true; + try { _verification = await ReturnsApi.VerifyAsync(_bookNumber.Trim()); } + catch (Exception e) { _errorMessage = Error(e); } + finally { _isWorking = false; } + } + + private async Task ConfirmAsync() + { + if (_verification is null) return; + _isWorking = true; + try + { + var result = await ReturnsApi.ReturnAsync(new ReturnBookRequest { BookNumber = _verification.BookNumber }); + _successMessage = $"{result.BookTitle} wurde erfolgreich zurückgegeben." + (result.WasOverdue ? $" Die Ausleihe war {result.DaysOverdue} Tage überfällig." : string.Empty); + _borrowedBooks.RemoveAll(book => book.BookNumber == result.BookNumber); + _bookNumber = string.Empty; + _verification = null; + } + catch (Exception e) { _errorMessage = Error(e); _verification = null; } + finally { _isWorking = false; } + } + + private void OnBookInput(ChangeEventArgs args) => _bookNumber = args.Value?.ToString() ?? string.Empty; + private void SelectBook(BookDto book) => _bookNumber = book.BookNumber; + private async Task HandleKey(KeyboardEventArgs args) { if (args.Key == "Enter") await VerifyAsync(); } + private void ClearMessages() { _errorMessage = null; _successMessage = null; } + private static bool Matches(string input, params string[] values) => !string.IsNullOrWhiteSpace(input) && values.Any(value => value.Contains(input.Trim(), StringComparison.OrdinalIgnoreCase)); + private static string Error(Exception exception) => exception is ApiProblemException api ? api.Message : "Die Rückgabe konnte nicht verarbeitet werden."; +} diff --git a/src/Bsevita.Library.Ui/Pages/Students.razor b/src/Bsevita.Library.Ui/Pages/Students.razor new file mode 100644 index 0000000..26340e1 --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Students.razor @@ -0,0 +1,92 @@ +@page "/students" +@inject StudentApiClient StudentsApi + +Schüler - Library + + + + + + + + + +
+
+
+ + + + + + @if (HasSearchText) + { + + } +
+ @_students.Count gefunden +
+ + @if (_isLoading) + { + + } + else if (_students.Count == 0) + { +

Keine Schüler gefunden

+ } + else + { +
+ + + + @foreach (var student in _students) + { + + + + + + + + } + +
NameKlasseAusweisnummerE-MailAktionen
@student.FullName@student.ClassName@student.CardNumber@(student.Email ?? "–") + + +
+
+ } +
+ +@if (_showEditor) +{ + +} + + diff --git a/src/Bsevita.Library.Ui/Pages/Students.razor.cs b/src/Bsevita.Library.Ui/Pages/Students.razor.cs new file mode 100644 index 0000000..b778f2e --- /dev/null +++ b/src/Bsevita.Library.Ui/Pages/Students.razor.cs @@ -0,0 +1,117 @@ +namespace Bsevita.Library.Ui.Pages; + +public partial class Students +{ + [SupplyParameterFromQuery(Name = "focus")] public string? Focus { get; set; } + private List _students = []; + private string _searchText = string.Empty; + private string _searchMode = "name"; + private bool _isLoading = true; + private bool _isSaving; + private bool _showEditor; + private Guid? _editingId; + private SaveStudentRequest _editModel = new(); + private StudentDto? _studentToDelete; + private string? _errorMessage; + private string? _successMessage; + private bool HasSearchText => !string.IsNullOrWhiteSpace(_searchText); + private bool ShouldFocusSearch => string.Equals(Focus, "search", StringComparison.OrdinalIgnoreCase); + private string EditorTitle => _editingId is null ? "Neu anlegen" : "Bearbeiten"; + private string SaveButtonText => _isSaving ? "Speichert …" : "Speichern"; + private string DeleteMessage => _studentToDelete is null ? string.Empty : $"Soll {_studentToDelete.FullName} wirklich gelöscht werden? Dieser Schritt deaktiviert den Datensatz."; + + protected override Task OnInitializedAsync() => LoadAsync(); + + private async Task LoadAsync() + { + _isLoading = true; + try + { + _students = (await StudentsApi.GetAllAsync()).ToList(); + } + catch (Exception exception) { _errorMessage = Error(exception); _students = []; } + finally { _isLoading = false; } + } + + private async Task SearchAsync() + { + if (string.IsNullOrWhiteSpace(_searchText)) { await LoadAsync(); return; } + _isLoading = true; + try + { + if (_searchMode == "card") + { + var student = await StudentsApi.GetByCardAsync(_searchText.Trim()); + _students = [student]; + } + else + { + _students = (await StudentsApi.SearchAsync(_searchText.Trim())).ToList(); + } + } + catch (Exception exception) { _errorMessage = Error(exception); _students = []; } + finally { _isLoading = false; } + } + + private async Task ResetSearchAsync() { _searchText = string.Empty; await LoadAsync(); } + private async Task HandleSearchKey(KeyboardEventArgs args) { if (args.Key == "Enter") await SearchAsync(); } + + private void OpenCreate() + { + _editingId = null; + _editModel = new SaveStudentRequest(); + _showEditor = true; + ClearMessages(); + } + + private async Task OpenEditAsync(Guid id) + { + ClearMessages(); + try + { + var student = await StudentsApi.GetByIdAsync(id); + _editingId = student.Id; + _editModel = new SaveStudentRequest { CardNumber = student.CardNumber, FirstName = student.FirstName, LastName = student.LastName, ClassName = student.ClassName, Email = student.Email }; + _showEditor = true; + } + catch (Exception exception) + { + _errorMessage = Error(exception); + } + } + + private void CloseEditor() { if (!_isSaving) _showEditor = false; } + + private async Task SaveAsync() + { + _isSaving = true; + try + { + if (_editingId is { } id) await StudentsApi.UpdateAsync(id, _editModel); else await StudentsApi.CreateAsync(_editModel); + _showEditor = false; + _successMessage = _editingId is null ? "Schüler wurde angelegt." : "Schüler wurde gespeichert."; + await LoadAsync(); + } + catch (Exception exception) { _errorMessage = Error(exception); } + finally { _isSaving = false; } + } + + private void AskDelete(StudentDto student) => _studentToDelete = student; + private void CancelDelete() => _studentToDelete = null; + + private async Task DeleteAsync() + { + if (_studentToDelete is null) return; + try + { + await StudentsApi.DeleteStudentAsync(_studentToDelete.Id); + _successMessage = $"{_studentToDelete.FullName} wurde gelöscht."; + _studentToDelete = null; + await LoadAsync(); + } + catch (Exception exception) { _errorMessage = Error(exception); _studentToDelete = null; } + } + + private void ClearMessages() { _errorMessage = null; _successMessage = null; } + private static string Error(Exception exception) => exception is ApiProblemException api ? api.Message : "Die Schülerdaten konnten nicht verarbeitet werden."; +} diff --git a/src/Bsevita.Library.Ui/Services/ApiClientBase.cs b/src/Bsevita.Library.Ui/Services/ApiClientBase.cs new file mode 100644 index 0000000..147ab26 --- /dev/null +++ b/src/Bsevita.Library.Ui/Services/ApiClientBase.cs @@ -0,0 +1,60 @@ +using System.Net.Http.Json; +using System.Text.Json; +using Microsoft.AspNetCore.Mvc; + +namespace Bsevita.Library.Ui.Services; + +public abstract class ApiClientBase(HttpClient httpClient) +{ + private HttpClient HttpClient { get; } = httpClient; + + protected async Task GetAsync(string uri, CancellationToken cancellationToken = default) => + await SendAsync(new HttpRequestMessage(HttpMethod.Get, uri), cancellationToken); + + protected async Task PostAsync(string uri, TRequest request, CancellationToken cancellationToken = default) => + await SendAsync(CreateJsonRequest(HttpMethod.Post, uri, request), cancellationToken); + + protected async Task PutAsync(string uri, TRequest request, CancellationToken cancellationToken = default) => + await SendAsync(CreateJsonRequest(HttpMethod.Put, uri, request), cancellationToken); + + protected async Task DeleteResourceAsync(string uri, CancellationToken cancellationToken = default) + { + using var response = await HttpClient.DeleteAsync(uri, cancellationToken); + await EnsureSuccessAsync(response, cancellationToken); + } + + private static HttpRequestMessage CreateJsonRequest(HttpMethod method, string uri, TRequest request) => + new(method, uri) { Content = JsonContent.Create(request) }; + + private async Task SendAsync(HttpRequestMessage request, CancellationToken cancellationToken) + { + using (request) + using (var response = await HttpClient.SendAsync(request, HttpCompletionOption.ResponseHeadersRead, cancellationToken)) + { + await EnsureSuccessAsync(response, cancellationToken); + return await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken) + ?? throw new ApiProblemException((int)response.StatusCode, "Die API hat eine leere Antwort geliefert."); + } + } + + private static async Task EnsureSuccessAsync(HttpResponseMessage response, CancellationToken cancellationToken) + { + if (response.IsSuccessStatusCode) + { + return; + } + + var problem = (ProblemDetails?)null; + try + { + problem = await response.Content.ReadFromJsonAsync(cancellationToken: cancellationToken); + } + catch (JsonException) + { + // Fall back to a generic message when a proxy returns non-JSON content. + } + + var message = problem?.Detail ?? problem?.Title ?? $"API-Fehler ({(int)response.StatusCode})."; + throw new ApiProblemException((int)response.StatusCode, message, problem); + } +} diff --git a/src/Bsevita.Library.Ui/Services/ApiProblemException.cs b/src/Bsevita.Library.Ui/Services/ApiProblemException.cs new file mode 100644 index 0000000..e2a1305 --- /dev/null +++ b/src/Bsevita.Library.Ui/Services/ApiProblemException.cs @@ -0,0 +1,10 @@ +using Microsoft.AspNetCore.Mvc; + +namespace Bsevita.Library.Ui.Services; + +public sealed class ApiProblemException(int statusCode, string message, ProblemDetails? problem = null) + : Exception(message) +{ + public int StatusCode { get; } = statusCode; + public ProblemDetails? Problem { get; } = problem; +} diff --git a/src/Bsevita.Library.Ui/Services/BookApiClient.cs b/src/Bsevita.Library.Ui/Services/BookApiClient.cs new file mode 100644 index 0000000..9399791 --- /dev/null +++ b/src/Bsevita.Library.Ui/Services/BookApiClient.cs @@ -0,0 +1,37 @@ +using Bsevita.Library.Models.Common; + +namespace Bsevita.Library.Ui.Services; + +public sealed class BookApiClient(HttpClient httpClient) : ApiClientBase(httpClient) +{ + public Task> GetAllAsync(bool? availableOnly = null, CancellationToken cancellationToken = default) + { + var separator = availableOnly is null ? "?" : "&"; + var query = availableOnly is null + ? string.Empty + : $"?availableOnly={availableOnly.Value.ToString().ToLowerInvariant()}"; + query += $"{separator}take={LibraryRules.MaxPageSize}"; + return GetAsync>($"api/books{query}", cancellationToken); + } + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) => + GetAsync($"api/books/{id}", cancellationToken); + + public Task GetByNumberAsync(string bookNumber, CancellationToken cancellationToken = default) => + GetAsync($"api/books/by-number/{Uri.EscapeDataString(bookNumber)}", cancellationToken); + + public Task> SearchAsync(string filter, string value, CancellationToken cancellationToken = default) + { + var query = $"{Uri.EscapeDataString(filter)}={Uri.EscapeDataString(value)}"; + return GetAsync>($"api/books/search?{query}", cancellationToken); + } + + public Task CreateAsync(SaveBookRequest request, CancellationToken cancellationToken = default) => + PostAsync("api/books", request, cancellationToken); + + public Task UpdateAsync(Guid id, SaveBookRequest request, CancellationToken cancellationToken = default) => + PutAsync($"api/books/{id}", request, cancellationToken); + + public Task DeleteBookAsync(Guid id, CancellationToken cancellationToken = default) => + DeleteResourceAsync($"api/books/{id}", cancellationToken); +} diff --git a/src/Bsevita.Library.Ui/Services/LoanApiClient.cs b/src/Bsevita.Library.Ui/Services/LoanApiClient.cs new file mode 100644 index 0000000..099e798 --- /dev/null +++ b/src/Bsevita.Library.Ui/Services/LoanApiClient.cs @@ -0,0 +1,10 @@ +namespace Bsevita.Library.Ui.Services; + +public sealed class LoanApiClient(HttpClient httpClient) : ApiClientBase(httpClient) +{ + public Task VerifyAsync(VerifyLoanRequest request, CancellationToken cancellationToken = default) => + PostAsync("api/loans/verify", request, cancellationToken); + + public Task CreateAsync(CreateLoanRequest request, CancellationToken cancellationToken = default) => + PostAsync("api/loans", request, cancellationToken); +} diff --git a/src/Bsevita.Library.Ui/Services/ReportApiClient.cs b/src/Bsevita.Library.Ui/Services/ReportApiClient.cs new file mode 100644 index 0000000..fe7b63d --- /dev/null +++ b/src/Bsevita.Library.Ui/Services/ReportApiClient.cs @@ -0,0 +1,19 @@ +namespace Bsevita.Library.Ui.Services; + +public sealed class ReportApiClient(HttpClient httpClient) : ApiClientBase(httpClient) +{ + public Task GetReportsAsync(CancellationToken cancellationToken = default) => + GetAsync("api/reports", cancellationToken); + + public Task> GetActiveLoansAsync(CancellationToken cancellationToken = default) => + GetAsync>("api/reports/active-loans", cancellationToken); + + public Task> GetOverdueAsync(CancellationToken cancellationToken = default) => + GetAsync>("api/reports/overdue", cancellationToken); + + public Task> GetActiveStudentsAsync(CancellationToken cancellationToken = default) => + GetAsync>("api/reports/active-students", cancellationToken); + + public Task GetStatisticsAsync(CancellationToken cancellationToken = default) => + GetAsync("api/reports/statistics", cancellationToken); +} diff --git a/src/Bsevita.Library.Ui/Services/ReturnApiClient.cs b/src/Bsevita.Library.Ui/Services/ReturnApiClient.cs new file mode 100644 index 0000000..3c5ff73 --- /dev/null +++ b/src/Bsevita.Library.Ui/Services/ReturnApiClient.cs @@ -0,0 +1,10 @@ +namespace Bsevita.Library.Ui.Services; + +public sealed class ReturnApiClient(HttpClient httpClient) : ApiClientBase(httpClient) +{ + public Task VerifyAsync(string bookNumber, CancellationToken cancellationToken = default) => + GetAsync($"api/returns/verify/{Uri.EscapeDataString(bookNumber)}", cancellationToken); + + public Task ReturnAsync(ReturnBookRequest request, CancellationToken cancellationToken = default) => + PostAsync("api/returns", request, cancellationToken); +} diff --git a/src/Bsevita.Library.Ui/Services/ServiceCollectionExtensions.cs b/src/Bsevita.Library.Ui/Services/ServiceCollectionExtensions.cs new file mode 100644 index 0000000..d4fc3ca --- /dev/null +++ b/src/Bsevita.Library.Ui/Services/ServiceCollectionExtensions.cs @@ -0,0 +1,16 @@ +using Microsoft.Extensions.DependencyInjection; + +namespace Bsevita.Library.Ui.Services; + +public static class ServiceCollectionExtensions +{ + public static IServiceCollection AddLibraryUi(this IServiceCollection services) + { + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + services.AddScoped(); + return services; + } +} diff --git a/src/Bsevita.Library.Ui/Services/StudentApiClient.cs b/src/Bsevita.Library.Ui/Services/StudentApiClient.cs new file mode 100644 index 0000000..4420923 --- /dev/null +++ b/src/Bsevita.Library.Ui/Services/StudentApiClient.cs @@ -0,0 +1,27 @@ +using Bsevita.Library.Models.Common; + +namespace Bsevita.Library.Ui.Services; + +public sealed class StudentApiClient(HttpClient httpClient) : ApiClientBase(httpClient) +{ + public Task> GetAllAsync(CancellationToken cancellationToken = default) => + GetAsync>($"api/students?take={LibraryRules.MaxPageSize}", cancellationToken); + + public Task GetByIdAsync(Guid id, CancellationToken cancellationToken = default) => + GetAsync($"api/students/{id}", cancellationToken); + + public Task GetByCardAsync(string cardNumber, CancellationToken cancellationToken = default) => + GetAsync($"api/students/by-card/{Uri.EscapeDataString(cardNumber)}", cancellationToken); + + public Task> SearchAsync(string name, CancellationToken cancellationToken = default) => + GetAsync>($"api/students/search?name={Uri.EscapeDataString(name)}", cancellationToken); + + public Task CreateAsync(SaveStudentRequest request, CancellationToken cancellationToken = default) => + PostAsync("api/students", request, cancellationToken); + + public Task UpdateAsync(Guid id, SaveStudentRequest request, CancellationToken cancellationToken = default) => + PutAsync($"api/students/{id}", request, cancellationToken); + + public Task DeleteStudentAsync(Guid id, CancellationToken cancellationToken = default) => + DeleteResourceAsync($"api/students/{id}", cancellationToken); +} diff --git a/src/Bsevita.Library.Ui/_Imports.razor b/src/Bsevita.Library.Ui/_Imports.razor new file mode 100644 index 0000000..ac1023c --- /dev/null +++ b/src/Bsevita.Library.Ui/_Imports.razor @@ -0,0 +1,15 @@ +@using System.ComponentModel.DataAnnotations +@using System.Net +@using System.Net.Http.Json +@using Microsoft.AspNetCore.Components +@using Microsoft.AspNetCore.Components.Forms +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using Bsevita.Library.Models.Books +@using Bsevita.Library.Models.Loans +@using Bsevita.Library.Models.Reports +@using Bsevita.Library.Models.Returns +@using Bsevita.Library.Models.Students +@using Bsevita.Library.Ui.Components +@using Bsevita.Library.Ui.Layout +@using Bsevita.Library.Ui.Services diff --git a/src/Bsevita.Library.Ui/wwwroot/css/app.css b/src/Bsevita.Library.Ui/wwwroot/css/app.css new file mode 100644 index 0000000..5fc4a5f --- /dev/null +++ b/src/Bsevita.Library.Ui/wwwroot/css/app.css @@ -0,0 +1,1140 @@ +:root { + --bg: #f6f9fc; + --surface: #ffffff; + --surface-muted: #f8fafc; + --text: #172033; + --text-muted: #64748b; + --border: #dbe5f0; + --primary: #2563eb; + --primary-dark: #1d4ed8; + --primary-soft: #eff6ff; + --success: #15803d; + --success-soft: #ecfdf3; + --danger: #dc2626; + --danger-soft: #fef2f2; + --warning: #b45309; + --warning-soft: #fffbeb; + --shadow: 0 12px 34px rgba(15, 23, 42, .07); + --radius: 8px; + font-family: Inter, ui-sans-serif, system-ui, -apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif; + color: var(--text); + background: var(--bg); +} +* { + box-sizing: border-box; +} +html, body, #app { + width: 100%; + height: 100%; + margin: 0; +} +html, body { + overflow: hidden; + overscroll-behavior: none; +} +body { + background: var(--bg); + color: var(--text); +} +a { + color: inherit; + text-decoration: none; +} +button, input, select { + font: inherit; +} +button { + cursor: pointer; +} +button:disabled { + cursor: not-allowed; + opacity: .62; +} +code { + font: 700 .84rem ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; + color: #334155; + background: #eef2f7; + border-radius: 6px; + padding: .18rem .4rem; +} +.icon { + width: 1em; + height: 1em; + display: block; + flex: 0 0 auto; +} +.app-shell { + height: 100%; + min-height: 0; + display: grid; + grid-template-columns: 250px minmax(0, 1fr); +} +.sidebar { + position: fixed; + inset: 0 auto 0 0; + width: 250px; + z-index: 30; + display: flex; + flex-direction: column; + overflow-y: auto; + padding: 22px 16px; + color: var(--text); + background: #fff; + border-right: 1px solid var(--border); +} +.brand { + display: flex; + align-items: center; + gap: 12px; + padding: 0 8px 24px; +} +.brand__mark { + width: 40px; + height: 40px; + display: grid; + place-items: center; + border-radius: 10px; + color: #fff; + background: var(--primary); + font-weight: 800; + font-size: 1.15rem; +} +.brand__name { + font-weight: 800; + letter-spacing: 0; +} +.nav-list { + display: grid; + gap: 5px; +} +.nav-item { + display: flex; + align-items: center; + gap: 11px; + min-height: 44px; + padding: 9px 11px; + border-radius: var(--radius); + color: #475569; + font-weight: 700; + transition: background .14s ease, color .14s ease; +} +.nav-item:hover { + color: var(--primary); + background: var(--primary-soft); +} +.nav-item.active { + color: var(--primary); + background: var(--primary-soft); +} +.nav-icon { + width: 28px; + height: 28px; + display: grid; + place-items: center; + border-radius: 8px; + color: var(--primary); + background: #e0ecff; + font-size: .78rem; + font-weight: 800; +} +.nav-icon .icon { + width: 17px; + height: 17px; +} +.app-main { + grid-column: 2; + min-width: 0; + height: 100%; + overflow-y: auto; + overscroll-behavior: contain; + -webkit-overflow-scrolling: touch; +} +.topbar { + position: sticky; + top: 0; + z-index: 20; + min-height: 64px; + display: flex; + align-items: center; + gap: 12px; + padding: 0 32px; + background: rgba(255,255,255,.94); + border-bottom: 1px solid var(--border); + backdrop-filter: blur(12px); +} +.topbar strong { + display: block; + font-size: .96rem; +} +.api-indicator { + margin-left: auto; + border: 1px solid #bfdbfe; + border-radius: 999px; + padding: 5px 10px; + color: var(--primary); + background: var(--primary-soft); + font-size: .72rem; + font-weight: 800; +} +.content { + width: min(1480px, 100%); + margin: 0 auto; + padding: 30px 32px 42px; +} +.mobile-menu-button, .sidebar-backdrop { + display: none; +} +.page-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 18px; + margin-bottom: 22px; +} +.page-header h1 { + margin: 0; + font-size: clamp(1.55rem, 2vw, 2.05rem); + line-height: 1.15; + letter-spacing: 0; +} +.page-header__actions { + display: flex; + gap: 10px; +} +.card { + background: var(--surface); + border: 1px solid var(--border); + border-radius: var(--radius); + box-shadow: var(--shadow); +} +.panel { + padding: 20px; +} +.panel__header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 14px; + margin-bottom: 16px; +} +.panel__header h2, .process-card__header h2 { + margin: 0; + font-size: 1.08rem; + letter-spacing: 0; +} +.panel__header p { + margin: 0; +} +.btn { + min-height: 39px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 7px; + border: 1px solid transparent; + border-radius: var(--radius); + padding: 8px 13px; + font-weight: 750; + transition: background .12s ease, border-color .12s ease, box-shadow .12s ease; +} +.btn .icon { + width: 16px; + height: 16px; +} +.btn:hover:not(:disabled) { + box-shadow: 0 8px 18px rgba(15, 23, 42, .08); +} +.btn-primary { + color: #fff; + background: var(--primary); +} +.btn-primary:hover:not(:disabled) { + background: var(--primary-dark); +} +.btn-secondary { + color: #334155; + background: #fff; + border-color: #cbd5e1; +} +.btn-secondary:hover:not(:disabled) { + background: #f8fafc; + border-color: #94a3b8; +} +.btn-danger { + color: #fff; + background: var(--danger); +} +.btn-danger-outline { + color: var(--danger); + background: #fff; + border-color: #fecaca; +} +.btn-danger-outline:hover:not(:disabled) { + background: var(--danger-soft); +} +.btn-ghost { + color: var(--text-muted); + background: transparent; +} +.btn-ghost:hover:not(:disabled) { + color: var(--primary); + background: var(--primary-soft); + box-shadow: none; +} +.btn-small { + min-height: 33px; + padding: 6px 10px; + font-size: .82rem; +} +.btn-large { + min-height: 48px; + padding: 10px 18px; +} +.icon-button { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border: 0; + border-radius: var(--radius); + color: #475569; + background: transparent; + font-size: 1.25rem; +} +.icon-button .icon { + width: 20px; + height: 20px; +} +.icon-button:hover { + background: #eef2f7; +} +.text-link { + color: var(--primary); + font-weight: 800; + font-size: .85rem; +} +.text-danger { + color: var(--danger) !important; +} +.stats-grid { + display: grid; + grid-template-columns: repeat(4, minmax(0, 1fr)); + gap: 16px; + margin-bottom: 20px; +} +.stat-card { + display: flex; + align-items: center; + gap: 14px; + min-height: 104px; + padding: 18px; +} +.stat-card__icon { + width: 44px; + height: 44px; + flex: 0 0 44px; + display: grid; + place-items: center; + border-radius: 10px; + font-size: 1rem; + font-weight: 900; +} +.stat-card__icon .icon { + width: 22px; + height: 22px; +} +.stat-card__icon--primary { + color: var(--primary); + background: var(--primary-soft); +} +.stat-card__icon--danger { + color: var(--danger); + background: var(--danger-soft); +} +.stat-card__icon--success { + color: var(--success); + background: var(--success-soft); +} +.stat-card__icon--violet { + color: #334155; + background: #eef2f7; +} +.stat-card__value { + font-size: 1.62rem; + line-height: 1; + font-weight: 850; + letter-spacing: 0; +} +.stat-card__label { + margin-top: 6px; + color: var(--text-muted); + font-size: .84rem; +} +.dashboard-grid { + display: grid; + grid-template-columns: minmax(0, 1.1fr) minmax(340px, .9fr); + gap: 20px; +} +.quick-actions { + display: grid; + grid-template-columns: repeat(2, minmax(0,1fr)); + gap: 12px; +} +.quick-action { + display: flex; + align-items: center; + gap: 12px; + min-height: 72px; + padding: 13px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: #fff; + transition: border .12s ease, background .12s ease; +} +.quick-action:hover { + border-color: #93c5fd; + background: var(--primary-soft); +} +.quick-action__icon { + width: 36px; + height: 36px; + display: grid; + place-items: center; + border-radius: 9px; + color: var(--primary); + background: #dbeafe; + font-size: .95rem; + font-weight: 900; +} +.quick-action__icon .icon { + width: 18px; + height: 18px; +} +.quick-action strong, .quick-action small { + display: block; +} +.quick-action small { + margin-top: 3px; + color: var(--text-muted); +} +.compact-list { + display: grid; + gap: 9px; +} +.compact-list__item { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 11px 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + background: var(--surface-muted); +} +.compact-list__item--danger { + border-color: #fecaca; + background: var(--danger-soft); +} +.compact-list__item strong, .compact-list__item small { + display: block; +} +.compact-list__item small { + margin-top: 3px; + color: var(--text-muted); +} +.empty-inline { + min-height: 140px; + display: grid; + place-items: center; + align-content: center; + gap: 6px; + color: var(--text-muted); + text-align: center; +} +.empty-inline span { + width: 38px; + height: 38px; + display: grid; + place-items: center; + border-radius: 10px; + color: var(--success); + background: var(--success-soft); + font-weight: 900; +} +.empty-inline .icon { + width: 19px; + height: 19px; +} +.empty-inline p { + margin: 0; +} +.toolbar { + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + margin-bottom: 17px; +} +.toolbar--wrap { + flex-wrap: wrap; +} +.search-group { + display: flex; + align-items: center; + gap: 9px; + min-width: min(100%, 620px); +} +.search-group .form-control:not(.form-select) { + min-width: 280px; +} +.search-group--books { + min-width: min(100%, 760px); +} +.search-group .form-select { + flex: 0 0 145px; +} +.result-count { + color: var(--text-muted); + font-size: .83rem; + font-weight: 800; + white-space: nowrap; +} +.form-control { + width: 100%; + min-height: 42px; + border: 1px solid #cbd5e1; + border-radius: var(--radius); + padding: 9px 11px; + color: var(--text); + background: #fff; + outline: none; + transition: border .12s ease, box-shadow .12s ease; +} +.form-control:focus { + border-color: #60a5fa; + box-shadow: 0 0 0 4px rgba(37,99,235,.12); +} +.form-control--large { + min-height: 49px; + padding: 11px 13px; + font-size: 1rem; +} +.form-select { + appearance: auto; +} +.form-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0,1fr)); + gap: 15px; +} +.form-field { + display: grid; + align-content: start; + gap: 6px; +} +.form-field--full { + grid-column: 1 / -1; +} +.form-field label { + font-size: .83rem; + font-weight: 800; +} +.search-field { + min-width: 0; +} +.suggestion-list { + display: grid; + gap: 6px; + max-height: 220px; + overflow-y: auto; + border: 1px solid var(--border); + border-radius: var(--radius); + padding: 6px; + background: var(--surface-muted); +} +.suggestion-item { + display: grid; + gap: 2px; + width: 100%; + border: 1px solid transparent; + border-radius: 7px; + padding: 10px 11px; + color: var(--text); + background: #fff; + text-align: left; +} +.suggestion-item:focus, .suggestion-item:hover { + border-color: #93c5fd; + background: var(--primary-soft); + outline: none; +} +.suggestion-item strong { + font-size: .9rem; +} +.suggestion-item span { + color: var(--text-muted); + font-size: .8rem; + overflow-wrap: anywhere; +} +.validation-message { + color: var(--danger); + font-size: .78rem; +} +.sr-only { + position: absolute; + width: 1px; + height: 1px; + margin: -1px; + overflow: hidden; + clip: rect(0,0,0,0); + white-space: nowrap; + border: 0; +} +.table-responsive { + width: 100%; + overflow-x: auto; +} +.data-table { + width: 100%; + border-collapse: collapse; + font-size: .88rem; +} +.data-table th { + padding: 11px 12px; + color: #64748b; + background: #f8fafc; + border-bottom: 1px solid var(--border); + text-align: left; + font-size: .74rem; + letter-spacing: 0; + text-transform: uppercase; +} +.data-table td { + padding: 13px 12px; + border-bottom: 1px solid #e8eef6; + vertical-align: middle; +} +.data-table tbody tr:hover { + background: #f8fbff; +} +.data-table tbody tr:last-child td { + border-bottom: 0; +} +.table-actions { + text-align: right !important; + white-space: nowrap; +} +.table-actions .btn + .btn { + margin-left: 6px; +} +.cell-subtitle { + display: block; + margin-top: 3px; + color: var(--text-muted); + font-size: .76rem; +} +.table-empty { + padding: 30px !important; + color: var(--text-muted); + text-align: center; +} +.row-overdue { + background: var(--danger-soft); +} +.row-overdue:hover { + background: #fee2e2 !important; +} +.badge { + display: inline-flex; + align-items: center; + min-height: 25px; + border-radius: 999px; + padding: 4px 9px; + font-size: .73rem; + font-weight: 850; + line-height: 1; + white-space: nowrap; +} +.badge-success { + color: #166534; + background: #dcfce7; +} +.badge-danger { + color: #991b1b; + background: #fee2e2; +} +.badge-primary { + color: #1d4ed8; + background: #dbeafe; +} +.badge-neutral { + color: #475569; + background: #eef2f7; +} +.alert { + position: relative; + display: flex; + align-items: flex-start; + gap: 10px; + margin-bottom: 16px; + border: 1px solid; + border-radius: var(--radius); + padding: 12px 42px 12px 13px; + line-height: 1.45; +} +.alert-error { + color: #991b1b; + border-color: #fecaca; + background: var(--danger-soft); +} +.alert-success { + color: #166534; + border-color: #bbf7d0; + background: var(--success-soft); +} +.alert-warning { + color: #92400e; + border-color: #fde68a; + background: var(--warning-soft); +} +.alert__icon { + width: 22px; + height: 22px; + flex: 0 0 22px; + display: grid; + place-items: center; + border-radius: 50%; + border: 1px solid currentColor; + font-weight: 900; +} +.alert__icon .icon { + width: 13px; + height: 13px; +} +.alert__close { + position: absolute; + top: 7px; + right: 7px; + width: 30px; + height: 30px; + border: 0; + border-radius: 7px; + color: currentColor; + background: transparent; + font-size: 1.1rem; +} +.alert__close:hover { + background: rgba(15,23,42,.06); +} +.loading-panel { + min-height: 220px; + display: flex; + align-items: center; + justify-content: center; + gap: 12px; + color: var(--text-muted); +} +.spinner { + width: 26px; + height: 26px; + border: 3px solid #dbeafe; + border-top-color: var(--primary); + border-radius: 50%; + animation: spin .75s linear infinite; +} +@keyframes spin { + to { + transform: rotate(360deg); + } +} +.empty-state { + min-height: 250px; + display: grid; + place-items: center; + align-content: center; + color: var(--text-muted); + text-align: center; +} +.empty-state h1, .empty-state h2 { + margin: 8px 0 2px; + color: var(--text); +} +.empty-state p { + margin: 4px 0 16px; +} +.empty-state__icon { + width: 56px; + height: 56px; + display: grid; + place-items: center; + border-radius: 14px; + color: var(--primary); + background: var(--primary-soft); + font-weight: 900; + font-size: 1.1rem; +} +.empty-state__icon .icon { + width: 26px; + height: 26px; +} +.modal-backdrop { + position: fixed; + inset: 0; + z-index: 100; + display: grid; + place-items: center; + padding: 20px; + background: rgba(15,23,42,.44); + backdrop-filter: blur(4px); +} +.modal { + width: min(720px, 100%); + max-height: calc(100vh - 40px); + overflow-y: auto; + overscroll-behavior: contain; + border-radius: 12px; + background: #fff; + box-shadow: 0 24px 60px rgba(15,23,42,.24); +} +.modal--small { + width: min(460px,100%); +} +.modal__header { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 20px 22px; + border-bottom: 1px solid var(--border); +} +.modal__header h2 { + margin: 2px 0 0; + font-size: 1.2rem; +} +.modal__body { + padding: 22px; +} +.modal__body p { + margin: 0; + line-height: 1.55; +} +.modal__footer { + display: flex; + justify-content: flex-end; + gap: 9px; + padding: 16px 22px; + background: #f8fafc; + border-top: 1px solid var(--border); +} +.process-steps { + display: grid; + grid-template-columns: repeat(5, minmax(0,1fr)); + gap: 8px; + margin-bottom: 18px; +} +.process-steps--two { + grid-template-columns: repeat(2, minmax(0,1fr)); + max-width: 520px; + margin-inline: auto; +} +.process-steps--three { + grid-template-columns: repeat(3, minmax(0,1fr)); +} +.process-step { + min-height: 54px; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: 4px; + color: #94a3b8; + background: #eef2f7; + border-radius: var(--radius); + text-align: center; +} +.process-step span { + width: 27px; + height: 27px; + display: grid; + place-items: center; + border-radius: 50%; + background: #fff; + font-weight: 900; +} +.process-step small { + font-weight: 800; +} +.process-step--active { + color: var(--primary); + background: var(--primary-soft); + box-shadow: inset 0 0 0 1px #bfdbfe; +} +.process-step--active span { + color: #fff; + background: var(--primary); +} +.process-card { + max-width: 940px; + margin: 0 auto; + padding: 26px; +} +.process-card__header { + margin-bottom: 20px; +} +.process-card__header p:last-child { + max-width: 700px; + margin: 8px 0 0; + color: var(--text-muted); + line-height: 1.55; +} +.process-form { + margin-bottom: 22px; +} +.process-actions { + display: flex; + justify-content: flex-end; + gap: 10px; + margin-top: 21px; +} +.process-actions--split { + justify-content: space-between; +} +.confirmation-card { + border: 1px solid #bbf7d0; + border-radius: var(--radius); + padding: 19px; + background: #f7fef9; +} +.confirmation-card--overdue { + border-color: #fecaca; + background: var(--danger-soft); +} +.confirmation-card__status { + display: flex; + align-items: center; + gap: 10px; + margin-bottom: 16px; + color: var(--success); +} +.confirmation-card--overdue .confirmation-card__status { + color: var(--danger); +} +.confirmation-card__status span { + width: 33px; + height: 33px; + display: grid; + place-items: center; + border-radius: 50%; + color: #fff; + background: var(--success); + font-weight: 900; +} +.confirmation-card__status .icon { + width: 17px; + height: 17px; +} +.confirmation-card--overdue .confirmation-card__status span { + background: var(--danger); +} +.detail-list { + display: grid; + grid-template-columns: repeat(2, minmax(0,1fr)); + gap: 0; + margin: 0; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + background: #fff; +} +.detail-list div { + padding: 13px 15px; + border-bottom: 1px solid var(--border); +} +.detail-list div:nth-child(odd) { + border-right: 1px solid var(--border); +} +.detail-list div:nth-last-child(-n+2) { + border-bottom: 0; +} +.detail-list dt { + color: var(--text-muted); + font-size: .73rem; + font-weight: 800; + text-transform: uppercase; + letter-spacing: 0; +} +.detail-list dd { + margin: 5px 0 0; + font-weight: 750; +} +.return-search { + display: grid; + grid-template-columns: minmax(0,1fr) auto; + align-items: end; + gap: 12px; +} +.report-grid { + display: grid; + grid-template-columns: minmax(0, 1.45fr) minmax(300px, .55fr); + gap: 20px; +} +.report-grid .report-panel:last-child { + grid-column: 1 / -1; +} +@media (prefers-reduced-motion: reduce) { + *, *::before, *::after { + animation-duration: .01ms !important; + transition-duration: .01ms !important; + } +} +@media (max-width: 1180px) { + .stats-grid { + grid-template-columns: repeat(2, minmax(0,1fr)); + } + .dashboard-grid, .report-grid { + grid-template-columns: 1fr; + } + .report-grid .report-panel:last-child { + grid-column: auto; + } +} +@media (max-width: 820px) { + .app-shell { + display: block; + } + .app-main { + min-width: 0; + } + .sidebar { + transform: translateX(-105%); + transition: transform .2s ease; + box-shadow: 12px 0 30px rgba(15,23,42,.12); + } + .sidebar--open { + transform: translateX(0); + } + .sidebar-backdrop { + display: block; + position: fixed; + inset: 0; + z-index: 25; + border: 0; + background: rgba(15,23,42,.38); + } + .topbar { + padding: 0 18px; + } + .mobile-menu-button { + display: grid; + } + .content { + padding: 24px 18px 36px; + } + .page-header { + align-items: stretch; + flex-direction: column; + gap: 14px; + } + .page-header__actions .btn { + width: 100%; + } + .toolbar { + align-items: stretch; + flex-direction: column; + } + .search-group, .search-group--books { + min-width: 0; + width: 100%; + flex-wrap: wrap; + } + .search-group .form-control:not(.form-select) { + min-width: 180px; + flex: 1 1 230px; + } + .search-group--books .form-select { + flex: 1 1 130px; + } + .result-count { + align-self: flex-end; + } + .process-steps { + grid-template-columns: repeat(5, minmax(62px,1fr)); + overflow-x: auto; + padding-bottom: 4px; + } + .process-steps--two { + grid-template-columns: repeat(2, minmax(120px,1fr)); + } + .process-steps--three { + grid-template-columns: repeat(3, minmax(95px,1fr)); + } +} +@media (max-width: 640px) { + .stats-grid, .quick-actions, .form-grid, .detail-list { + grid-template-columns: 1fr; + } + .detail-list div:nth-child(odd) { + border-right: 0; + } + .detail-list div:nth-last-child(-n+2) { + border-bottom: 1px solid var(--border); + } + .detail-list div:last-child { + border-bottom: 0; + } + .return-search { + grid-template-columns: 1fr; + } + .process-card { + padding: 18px; + } + .process-actions, .process-actions--split { + flex-direction: column-reverse; + } + .process-actions .btn { + width: 100%; + } + .data-table thead { + display: none; + } + .data-table, .data-table tbody, .data-table tr, .data-table td { + display: block; + width: 100%; + } + .data-table tr { + margin-bottom: 12px; + border: 1px solid var(--border); + border-radius: var(--radius); + overflow: hidden; + background: #fff !important; + } + .data-table td { + display: grid; + grid-template-columns: minmax(92px, .38fr) minmax(0, .62fr); + gap: 10px; + padding: 10px 12px; + text-align: left !important; + } + .data-table td::before { + content: attr(data-label); + color: var(--text-muted); + font-size: .72rem; + font-weight: 850; + text-transform: uppercase; + } + .table-actions .btn { + width: 100%; + margin: 0 0 6px !important; + } + .table-actions .btn:last-child { + margin-bottom: 0 !important; + } + .modal-backdrop { + padding: 0; + align-items: end; + } + .modal { + max-height: 94vh; + border-radius: 14px 14px 0 0; + } +} diff --git a/src/Bsevita.Library.Web/Bsevita.Library.Web.csproj b/src/Bsevita.Library.Web/Bsevita.Library.Web.csproj new file mode 100644 index 0000000..af5fcdc --- /dev/null +++ b/src/Bsevita.Library.Web/Bsevita.Library.Web.csproj @@ -0,0 +1,9 @@ + + + net10.0 + Bsevita.Library.Web + + + + + diff --git a/src/Bsevita.Library.Web/Components/App.razor b/src/Bsevita.Library.Web/Components/App.razor new file mode 100644 index 0000000..4a803f2 --- /dev/null +++ b/src/Bsevita.Library.Web/Components/App.razor @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/Bsevita.Library.Web/Components/_Imports.razor b/src/Bsevita.Library.Web/Components/_Imports.razor new file mode 100644 index 0000000..faaedbb --- /dev/null +++ b/src/Bsevita.Library.Web/Components/_Imports.razor @@ -0,0 +1,6 @@ +@using System.Net.Http +@using Microsoft.AspNetCore.Components.Routing +@using Microsoft.AspNetCore.Components.Web +@using static Microsoft.AspNetCore.Components.Web.RenderMode +@using Bsevita.Library.Ui +@using Bsevita.Library.Web.Components diff --git a/src/Bsevita.Library.Web/Program.cs b/src/Bsevita.Library.Web/Program.cs new file mode 100644 index 0000000..d1a76f0 --- /dev/null +++ b/src/Bsevita.Library.Web/Program.cs @@ -0,0 +1,30 @@ +using Bsevita.Library.Ui; +using Bsevita.Library.Ui.Services; +using Bsevita.Library.Web.Components; + +var builder = WebApplication.CreateBuilder(args); + +builder.Services.AddProblemDetails(); +builder.Services.AddRazorComponents().AddInteractiveServerComponents(); +builder.Services.AddLibraryUi(); +builder.Services.AddScoped(_ => new HttpClient +{ + BaseAddress = new Uri(builder.Configuration["ApiBaseUrl"] ?? "http://localhost:5180/"), + Timeout = TimeSpan.FromSeconds(20) +}); + +var app = builder.Build(); +if (!app.Environment.IsDevelopment()) +{ + app.UseExceptionHandler(); + app.UseHsts(); +} + +app.UseHttpsRedirection(); +app.UseAntiforgery(); +app.MapStaticAssets(); +app.MapRazorComponents() + .AddInteractiveServerRenderMode() + .AddAdditionalAssemblies(typeof(AppRoutes).Assembly); + +await app.RunAsync(); diff --git a/src/Bsevita.Library.Web/appsettings.json b/src/Bsevita.Library.Web/appsettings.json new file mode 100644 index 0000000..fea44a5 --- /dev/null +++ b/src/Bsevita.Library.Web/appsettings.json @@ -0,0 +1,10 @@ +{ + "ApiBaseUrl": "http://localhost:5180/", + "Logging": { + "LogLevel": { + "Default": "Information", + "Microsoft.AspNetCore": "Warning" + } + }, + "AllowedHosts": "*" +} diff --git a/tests/Bsevita.Library.Api.Tests/ApiIntegrationTests.cs b/tests/Bsevita.Library.Api.Tests/ApiIntegrationTests.cs new file mode 100644 index 0000000..c481b63 --- /dev/null +++ b/tests/Bsevita.Library.Api.Tests/ApiIntegrationTests.cs @@ -0,0 +1,198 @@ +using System.Net; +using System.Net.Http.Json; +using System.Text.Json.Nodes; +using Bsevita.Library.Api.Data.Generated; +using Bsevita.Library.Api.Data.Generated.Entities; +using Bsevita.Library.Models.Students; +using Microsoft.AspNetCore.Hosting; +using Microsoft.AspNetCore.Mvc.Testing; +using Microsoft.EntityFrameworkCore; +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.Extensions.DependencyInjection; +using Microsoft.Extensions.DependencyInjection.Extensions; +using Microsoft.Extensions.Logging; + +namespace Bsevita.Library.Api.Tests; + +public sealed class ApiIntegrationTests +{ + [Fact] + public async Task SwaggerUi_AndSwaggerJson_AreAvailableInDevelopment() + { + await using var factory = new ApiFactory(); + var client = factory.CreateClient(); + + var ui = await client.GetAsync("/swagger"); + var document = await client.GetFromJsonAsync("/swagger/v1/swagger.json"); + + Assert.Equal(HttpStatusCode.OK, ui.StatusCode); + Assert.Equal("Bsevita.Library.Api", document?["info"]?["title"]?.GetValue()); + Assert.NotNull(document?["paths"]?["/api/students"]); + Assert.NotNull(document?["paths"]?["/api/books"]); + Assert.NotNull(document?["paths"]?["/api/loans"]); + Assert.NotNull(document?["paths"]?["/api/returns/verify/{bookNumber}"]); + Assert.NotNull(document?["paths"]?["/api/reports/statistics"]); + } + + [Fact] + public async Task PostStudent_ReturnsCreatedStudent() + { + await using var factory = new ApiFactory(); + var client = factory.CreateClient(); + + var response = await client.PostAsJsonAsync("/api/students", new SaveStudentRequest + { + CardNumber = "S-1", + FirstName = "Ada", + LastName = "Lovelace", + ClassName = "4AHIT" + }); + var body = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.Created, response.StatusCode); + Assert.Equal("S-1", body?.CardNumber); + Assert.Equal("Ada Lovelace", body?.FullName); + } + + [Fact] + public async Task InvalidBody_ReturnsValidationProblemDetails() + { + await using var factory = new ApiFactory(); + var client = factory.CreateClient(); + + var response = await client.PostAsJsonAsync("/api/students", new { cardNumber = "" }); + var problem = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.BadRequest, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + Assert.Equal(400, problem?["status"]?.GetValue()); + Assert.NotNull(problem?["errors"]); + } + + [Fact] + public async Task MissingResource_ReturnsProblemDetails() + { + await using var factory = new ApiFactory(); + var client = factory.CreateClient(); + + var response = await client.GetAsync($"/api/students/{Guid.NewGuid()}"); + var problem = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + Assert.Equal(404, problem?["status"]?.GetValue()); + Assert.Equal("Nicht gefunden", problem?["title"]?.GetValue()); + } + + [Fact] + public async Task UnknownRoute_ReturnsNotFound() + { + await using var factory = new ApiFactory(); + var client = factory.CreateClient(); + + var response = await client.GetAsync("/api/does-not-exist"); + + Assert.Equal(HttpStatusCode.NotFound, response.StatusCode); + } + + [Fact] + public async Task Health_ReturnsOk() + { + await using var factory = new ApiFactory(); + var client = factory.CreateClient(); + + var response = await client.GetAsync("/health"); + + Assert.Equal(HttpStatusCode.OK, response.StatusCode); + } + + [Fact] + public async Task LoanConflict_ReturnsConflictProblemDetails() + { + await using var factory = new ApiFactory(); + await factory.SeedBorrowedBookAsync(); + var client = factory.CreateClient(); + + var response = await client.PostAsJsonAsync("/api/loans", new + { + cardNumber = "S-2", + bookNumber = "B-1" + }); + var problem = await response.Content.ReadFromJsonAsync(); + + Assert.Equal(HttpStatusCode.Conflict, response.StatusCode); + Assert.Equal("application/problem+json", response.Content.Headers.ContentType?.MediaType); + Assert.Equal(409, problem?["status"]?.GetValue()); + } + + private sealed class ApiFactory : WebApplicationFactory + { + private readonly string _databaseName = $"Bsevita.Library.Api.Tests.{Guid.NewGuid():N}"; + + protected override void ConfigureWebHost(IWebHostBuilder builder) + { + builder.UseEnvironment("Development"); + builder.UseSetting("Database:ValidateOnStartup", "false"); + builder.ConfigureLogging(logging => logging.ClearProviders()); + builder.ConfigureServices(services => + { + services.RemoveAll(); + services.RemoveAll(); + services.RemoveAll>(); + services.RemoveAll>(); + services.RemoveAll(); + services.AddDbContext(options => options.UseInMemoryDatabase(_databaseName)); + services.AddSingleton(new FixedTimeProvider(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero))); + }); + } + + public async Task SeedBorrowedBookAsync() + { + using var scope = Services.CreateScope(); + var dbContext = scope.ServiceProvider.GetRequiredService(); + var now = new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero); + var firstStudent = new Student + { + StudentId = Guid.NewGuid(), + CardNumber = "S-1", + FirstName = "Ada", + LastName = "Lovelace", + ClassName = "4AHIT", + IsActive = true, + CreatedAt = now, + UpdatedAt = now + }; + var secondStudent = new Student + { + StudentId = Guid.NewGuid(), + CardNumber = "S-2", + FirstName = "Grace", + LastName = "Hopper", + ClassName = "5BHIT", + IsActive = true, + CreatedAt = now, + UpdatedAt = now + }; + var book = new Book + { + BookId = Guid.NewGuid(), + BookNumber = "B-1", + Title = "Borrowed", + Author = "A", + Subject = "S", + IsActive = true, + CreatedAt = now, + UpdatedAt = now + }; + dbContext.AddRange(firstStudent, secondStudent, book, new Loan + { + LoanId = Guid.NewGuid(), + StudentId = firstStudent.StudentId, + BookId = book.BookId, + LoanedAt = now, + DueAt = now.AddDays(14) + }); + await dbContext.SaveChangesAsync(); + } + } +} diff --git a/tests/Bsevita.Library.Api.Tests/Bsevita.Library.Api.Tests.csproj b/tests/Bsevita.Library.Api.Tests/Bsevita.Library.Api.Tests.csproj new file mode 100644 index 0000000..6342902 --- /dev/null +++ b/tests/Bsevita.Library.Api.Tests/Bsevita.Library.Api.Tests.csproj @@ -0,0 +1,25 @@ + + + net10.0 + false + true + + + + + + + + + + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + all + runtime; build; native; contentfiles; analyzers; buildtransitive + + + diff --git a/tests/Bsevita.Library.Api.Tests/FixedTimeProvider.cs b/tests/Bsevita.Library.Api.Tests/FixedTimeProvider.cs new file mode 100644 index 0000000..2006927 --- /dev/null +++ b/tests/Bsevita.Library.Api.Tests/FixedTimeProvider.cs @@ -0,0 +1,6 @@ +namespace Bsevita.Library.Api.Tests; + +internal sealed class FixedTimeProvider(DateTimeOffset utcNow) : TimeProvider +{ + public override DateTimeOffset GetUtcNow() => utcNow; +} diff --git a/tests/Bsevita.Library.Api.Tests/LibraryWorkflowTests.cs b/tests/Bsevita.Library.Api.Tests/LibraryWorkflowTests.cs new file mode 100644 index 0000000..c48f5cd --- /dev/null +++ b/tests/Bsevita.Library.Api.Tests/LibraryWorkflowTests.cs @@ -0,0 +1,645 @@ +using Bsevita.Library.Api.Data.Generated.Entities; +using Bsevita.Library.Api.Infrastructure; +using Bsevita.Library.Api.Services; +using Bsevita.Library.Models.Books; +using Bsevita.Library.Models.Loans; +using Bsevita.Library.Models.Returns; +using Bsevita.Library.Models.Students; + +namespace Bsevita.Library.Api.Tests; + +public sealed class LibraryWorkflowTests +{ + [Fact] + public async Task LoanAndReturn_UpdatesReportsAndAvailability() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = new FixedTimeProvider(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero)); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + var loans = new LoanService(database.Context, clock); + var returns = new ReturnService(database.Context, clock); + var reports = new ReportService(database.Context, clock); + + await students.CreateAsync(new SaveStudentRequest + { + CardNumber = "S-1", + FirstName = "Ada", + LastName = "Lovelace", + ClassName = "4AHIT" + }, CancellationToken.None); + await books.CreateAsync(new SaveBookRequest + { + BookNumber = "B-1", + Title = "Clean Architecture", + Author = "Robert C. Martin", + Subject = "Informatik" + }, CancellationToken.None); + + var verification = await loans.VerifyAsync(new VerifyLoanRequest { CardNumber = "S-1", BookNumber = "B-1" }, CancellationToken.None); + Assert.True(verification.CanBorrow); + + var loan = await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-1" }, CancellationToken.None); + Assert.Equal(clock.GetUtcNow().AddDays(14), loan.DueAt); + Assert.Single(await reports.GetActiveLoansAsync(CancellationToken.None)); + Assert.False((await books.GetByIdAsync(loan.BookId, CancellationToken.None)).IsAvailable); + + var returned = await returns.ReturnAsync(new ReturnBookRequest { BookNumber = "B-1" }, CancellationToken.None); + Assert.Equal(loan.Id, returned.LoanId); + Assert.Empty(await reports.GetActiveLoansAsync(CancellationToken.None)); + Assert.True((await books.GetByIdAsync(loan.BookId, CancellationToken.None)).IsAvailable); + } + + [Fact] + public async Task CreateLoan_UsesCustomDueDate_WhenProvided() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = new FixedTimeProvider(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero)); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + var loans = new LoanService(database.Context, clock); + + await students.CreateAsync(new SaveStudentRequest + { + CardNumber = "S-1", + FirstName = "Ada", + LastName = "Lovelace", + ClassName = "4AHIT" + }, CancellationToken.None); + await books.CreateAsync(new SaveBookRequest + { + BookNumber = "B-1", + Title = "Clean Architecture", + Author = "Robert C. Martin", + Subject = "Informatik" + }, CancellationToken.None); + + var dueAt = new DateTimeOffset(2026, 7, 20, 0, 0, 0, TimeSpan.Zero); + var loan = await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-1", DueAt = dueAt }, CancellationToken.None); + + Assert.Equal(dueAt, loan.DueAt); + } + + [Fact] + public async Task DuplicateCardNumber_IsRejected() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var service = new StudentService(database.Context, new FixedTimeProvider(DateTimeOffset.UtcNow)); + var request = new SaveStudentRequest { CardNumber = "same", FirstName = "A", LastName = "B", ClassName = "1A" }; + await service.CreateAsync(request, CancellationToken.None); + await Assert.ThrowsAsync( + () => service.CreateAsync(new SaveStudentRequest { CardNumber = "SAME", FirstName = "C", LastName = "D", ClassName = "1B" }, CancellationToken.None)); + } + + [Fact] + public async Task Statistics_CountsAvailableActiveBooks() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var now = new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero); + var student = new Student + { + StudentId = Guid.NewGuid(), + CardNumber = "S-1", + FirstName = "Ada", + LastName = "Lovelace", + ClassName = "4AHIT", + IsActive = true, + CreatedAt = now, + UpdatedAt = now + }; + var activeBook = new Book + { + BookId = Guid.NewGuid(), + BookNumber = "B-1", + Title = "Active", + Author = "A", + Subject = "S", + IsActive = true, + CreatedAt = now, + UpdatedAt = now + }; + var inactiveBook = new Book + { + BookId = Guid.NewGuid(), + BookNumber = "B-2", + Title = "Inactive", + Author = "A", + Subject = "S", + IsActive = false, + CreatedAt = now, + UpdatedAt = now + }; + database.Context.AddRange(student, activeBook, inactiveBook, new Loan + { + LoanId = Guid.NewGuid(), + StudentId = student.StudentId, + BookId = inactiveBook.BookId, + LoanedAt = now.AddDays(-1), + DueAt = now.AddDays(13) + }); + await database.Context.SaveChangesAsync(); + + var statistics = await new ReportService(database.Context, new FixedTimeProvider(now)).GetStatisticsAsync(CancellationToken.None); + + Assert.Equal(1, statistics.TotalBooks); + Assert.Equal(1, statistics.AvailableBooks); + Assert.Equal(1, statistics.ActiveLoans); + } + + [Fact] + public async Task BookList_CanFilterAvailabilityAndLimitRows() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = new FixedTimeProvider(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero)); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + var loans = new LoanService(database.Context, clock); + + await students.CreateAsync(new SaveStudentRequest { CardNumber = "S-1", FirstName = "Ada", LastName = "Lovelace", ClassName = "4AHIT" }, CancellationToken.None); + await books.CreateAsync(new SaveBookRequest { BookNumber = "B-1", Title = "Available", Author = "A", Subject = "S" }, CancellationToken.None); + await books.CreateAsync(new SaveBookRequest { BookNumber = "B-2", Title = "Borrowed", Author = "A", Subject = "S" }, CancellationToken.None); + await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-2" }, CancellationToken.None); + + var available = await books.GetAllAsync(availableOnly: true, skip: 0, take: 1, CancellationToken.None); + var borrowed = await books.GetAllAsync(availableOnly: false, skip: 0, take: 10, CancellationToken.None); + + Assert.Single(available); + Assert.Equal("B-1", available[0].BookNumber); + Assert.Collection(borrowed, book => Assert.Equal("B-2", book.BookNumber)); + } + + [Fact] + public async Task Reports_ReturnsBundledReportData() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = new FixedTimeProvider(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero)); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + var loans = new LoanService(database.Context, clock); + + await students.CreateAsync(new SaveStudentRequest { CardNumber = "S-1", FirstName = "Ada", LastName = "Lovelace", ClassName = "4AHIT" }, CancellationToken.None); + await books.CreateAsync(new SaveBookRequest { BookNumber = "B-1", Title = "Overdue", Author = "A", Subject = "S" }, CancellationToken.None); + await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-1", DueAt = clock.GetUtcNow().AddDays(1) }, CancellationToken.None); + + var reports = await new ReportService(database.Context, new FixedTimeProvider(clock.GetUtcNow().AddDays(2))).GetReportsAsync(CancellationToken.None); + + Assert.Single(reports.ActiveLoans); + Assert.Single(reports.OverdueLoans); + Assert.Single(reports.ActiveStudents); + Assert.Equal(1, reports.Statistics.ActiveLoans); + Assert.Equal(0, reports.Statistics.AvailableBooks); + } + + [Fact] + public async Task Reports_LimitsListsButKeepsFullStatistics() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var now = new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero); + var student = new Student + { + StudentId = Guid.NewGuid(), + CardNumber = "S-1", + FirstName = "Ada", + LastName = "Lovelace", + ClassName = "4AHIT", + IsActive = true, + CreatedAt = now, + UpdatedAt = now + }; + database.Context.Students.Add(student); + for (var i = 0; i < 101; i++) + { + var book = new Book + { + BookId = Guid.NewGuid(), + BookNumber = $"B-{i}", + Title = $"Book {i}", + Author = "A", + Subject = "S", + IsActive = true, + CreatedAt = now, + UpdatedAt = now + }; + database.Context.Add(book); + database.Context.Loans.Add(new Loan + { + LoanId = Guid.NewGuid(), + StudentId = student.StudentId, + BookId = book.BookId, + LoanedAt = now.AddDays(-1), + DueAt = now.AddDays(13) + }); + } + await database.Context.SaveChangesAsync(); + + var reports = await new ReportService(database.Context, new FixedTimeProvider(now)).GetReportsAsync(CancellationToken.None); + + Assert.Equal(100, reports.ActiveLoans.Count); + Assert.Equal(101, reports.Statistics.ActiveLoans); + } + + [Fact] + public async Task Search_FindsPartialMatches() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = new FixedTimeProvider(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero)); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + + await students.CreateAsync(new SaveStudentRequest { CardNumber = "S-1", FirstName = "Ada", LastName = "Lovelace", ClassName = "4AHIT" }, CancellationToken.None); + await books.CreateAsync(new SaveBookRequest { BookNumber = "B-1", Title = "Clean Architecture", Author = "Robert Martin", Subject = "Informatik" }, CancellationToken.None); + + Assert.Single(await students.SearchAsync("love", CancellationToken.None)); + Assert.Single(await books.SearchAsync(title: "arch", author: null, subject: null, CancellationToken.None)); + } + + [Fact] + public async Task Loan_ThrowsNotFound_WhenStudentOrBookIsMissing() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + var loans = new LoanService(database.Context, clock); + + await Assert.ThrowsAsync( + () => loans.VerifyAsync(new VerifyLoanRequest { CardNumber = "missing", BookNumber = "missing" }, CancellationToken.None)); + + await students.CreateAsync(Student("S-1"), CancellationToken.None); + await Assert.ThrowsAsync( + () => loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "missing" }, CancellationToken.None)); + + await books.CreateAsync(Book("B-1"), CancellationToken.None); + await Assert.ThrowsAsync( + () => loans.CreateAsync(new CreateLoanRequest { CardNumber = "missing", BookNumber = "B-1" }, CancellationToken.None)); + } + + [Fact] + public async Task Loan_ThrowsConflict_WhenBookIsAlreadyBorrowed() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + var loans = new LoanService(database.Context, clock); + + await students.CreateAsync(Student("S-1"), CancellationToken.None); + await students.CreateAsync(Student("S-2"), CancellationToken.None); + await books.CreateAsync(Book("B-1"), CancellationToken.None); + await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-1" }, CancellationToken.None); + + await Assert.ThrowsAsync( + () => loans.VerifyAsync(new VerifyLoanRequest { CardNumber = "S-2", BookNumber = "B-1" }, CancellationToken.None)); + await Assert.ThrowsAsync( + () => loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-2", BookNumber = "B-1" }, CancellationToken.None)); + } + + [Fact] + public async Task Loan_ThrowsValidation_WhenDueDateIsInPast() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + var loans = new LoanService(database.Context, clock); + + await students.CreateAsync(Student("S-1"), CancellationToken.None); + await books.CreateAsync(Book("B-1"), CancellationToken.None); + + await Assert.ThrowsAsync( + () => loans.CreateAsync(new CreateLoanRequest + { + CardNumber = "S-1", + BookNumber = "B-1", + DueAt = clock.GetUtcNow().AddSeconds(-1) + }, CancellationToken.None)); + } + + [Fact] + public async Task Return_ThrowsNotFound_WhenNoActiveLoanExists() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var returns = new ReturnService(database.Context, NewClock()); + + await Assert.ThrowsAsync( + () => returns.VerifyAsync("missing", CancellationToken.None)); + await Assert.ThrowsAsync( + () => returns.ReturnAsync(new ReturnBookRequest { BookNumber = "missing" }, CancellationToken.None)); + } + + [Fact] + public async Task Return_ReportsOverdueDays() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var start = new FixedTimeProvider(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero)); + var overdueClock = new FixedTimeProvider(start.GetUtcNow().AddDays(3)); + var students = new StudentService(database.Context, start); + var books = new BookService(database.Context, start); + var loans = new LoanService(database.Context, start); + + await students.CreateAsync(Student("S-1"), CancellationToken.None); + await books.CreateAsync(Book("B-1"), CancellationToken.None); + await loans.CreateAsync(new CreateLoanRequest + { + CardNumber = "S-1", + BookNumber = "B-1", + DueAt = start.GetUtcNow().AddDays(1) + }, CancellationToken.None); + + var returns = new ReturnService(database.Context, overdueClock); + var verification = await returns.VerifyAsync("B-1", CancellationToken.None); + var result = await returns.ReturnAsync(new ReturnBookRequest { BookNumber = "B-1" }, CancellationToken.None); + + Assert.True(verification.IsOverdue); + Assert.Equal(2, verification.DaysOverdue); + Assert.True(result.WasOverdue); + Assert.Equal(2, result.DaysOverdue); + } + + [Fact] + public async Task Delete_ThrowsConflict_WhenActiveLoanExists() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + var loans = new LoanService(database.Context, clock); + + var student = await students.CreateAsync(Student("S-1"), CancellationToken.None); + var book = await books.CreateAsync(Book("B-1"), CancellationToken.None); + await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-1" }, CancellationToken.None); + + await Assert.ThrowsAsync(() => students.DeleteAsync(student.Id, CancellationToken.None)); + await Assert.ThrowsAsync(() => books.DeleteAsync(book.Id, CancellationToken.None)); + } + + [Fact] + public async Task Delete_DeactivatesStudentAndBook() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + + var student = await students.CreateAsync(Student("S-1"), CancellationToken.None); + var book = await books.CreateAsync(Book("B-1"), CancellationToken.None); + + await students.DeleteAsync(student.Id, CancellationToken.None); + await books.DeleteAsync(book.Id, CancellationToken.None); + + Assert.Empty(await students.GetAllAsync(0, 10, CancellationToken.None)); + Assert.Empty(await books.GetAllAsync(null, 0, 10, CancellationToken.None)); + await Assert.ThrowsAsync(() => students.GetByIdAsync(student.Id, CancellationToken.None)); + await Assert.ThrowsAsync(() => books.GetByIdAsync(book.Id, CancellationToken.None)); + } + + [Fact] + public async Task BookIdentifiers_AreUnique() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var books = new BookService(database.Context, NewClock()); + + await books.CreateAsync(Book("B-1", isbn: "978-1"), CancellationToken.None); + + await Assert.ThrowsAsync( + () => books.CreateAsync(Book("b-1"), CancellationToken.None)); + await Assert.ThrowsAsync( + () => books.CreateAsync(Book("B-2", isbn: "978-1"), CancellationToken.None)); + } + + [Fact] + public async Task Update_ThrowsConflict_WhenIdentifiersAlreadyExist() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + + await students.CreateAsync(Student("S-1"), CancellationToken.None); + var secondStudent = await students.CreateAsync(Student("S-2"), CancellationToken.None); + await books.CreateAsync(Book("B-1", isbn: "978-1"), CancellationToken.None); + var secondBook = await books.CreateAsync(Book("B-2", isbn: "978-2"), CancellationToken.None); + + await Assert.ThrowsAsync( + () => students.UpdateAsync(secondStudent.Id, Student("S-1"), CancellationToken.None)); + await Assert.ThrowsAsync( + () => books.UpdateAsync(secondBook.Id, Book("B-1", isbn: "978-2"), CancellationToken.None)); + await Assert.ThrowsAsync( + () => books.UpdateAsync(secondBook.Id, Book("B-2", isbn: "978-1"), CancellationToken.None)); + } + + [Fact] + public async Task Search_ThrowsValidation_WhenSearchInputIsMissing() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + + await Assert.ThrowsAsync( + () => students.SearchAsync(" ", CancellationToken.None)); + await Assert.ThrowsAsync( + () => books.SearchAsync(null, null, null, CancellationToken.None)); + } + + [Fact] + public async Task Reads_ThrowNotFound_WhenResourcesDoNotExist() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + + await Assert.ThrowsAsync( + () => students.GetByIdAsync(Guid.NewGuid(), CancellationToken.None)); + await Assert.ThrowsAsync( + () => students.GetByCardAsync("missing", CancellationToken.None)); + await Assert.ThrowsAsync( + () => books.GetByIdAsync(Guid.NewGuid(), CancellationToken.None)); + await Assert.ThrowsAsync( + () => books.GetByNumberAsync("missing", CancellationToken.None)); + } + + [Fact] + public async Task Paging_ClampsInvalidSkipAndTake() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + + await students.CreateAsync(Student("S-1"), CancellationToken.None); + await books.CreateAsync(Book("B-1"), CancellationToken.None); + + Assert.Single(await students.GetAllAsync(-10, 0, CancellationToken.None)); + Assert.Single(await books.GetAllAsync(null, -10, 0, CancellationToken.None)); + } + + [Fact] + public async Task Update_ChangesStudentAndBookValues() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + + var student = await students.CreateAsync(Student("S-1"), CancellationToken.None); + var book = await books.CreateAsync(Book("B-1"), CancellationToken.None); + + var updatedStudent = await students.UpdateAsync(student.Id, new SaveStudentRequest + { + CardNumber = "S-2", + FirstName = "Grace", + LastName = "Hopper", + ClassName = "5BHIT", + Email = "grace@example.test" + }, CancellationToken.None); + var updatedBook = await books.UpdateAsync(book.Id, new SaveBookRequest + { + BookNumber = "B-2", + Isbn = "978-2", + Title = "Compiler Design", + Author = "Grace Hopper", + Subject = "Informatik", + Publisher = "ACM", + PublicationYear = 1952 + }, CancellationToken.None); + + Assert.Equal("Grace Hopper", updatedStudent.FullName); + Assert.Equal("S-2", updatedStudent.CardNumber); + Assert.Equal("grace@example.test", updatedStudent.Email); + Assert.Equal("Compiler Design", updatedBook.Title); + Assert.Equal("978-2", updatedBook.Isbn); + Assert.Equal(1952, updatedBook.PublicationYear); + } + + [Fact] + public async Task Search_CoversStudentFullNameAndBookAuthorAndSubject() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + + await students.CreateAsync(new SaveStudentRequest { CardNumber = "S-1", FirstName = "Ada", LastName = "Lovelace", ClassName = "4AHIT" }, CancellationToken.None); + await books.CreateAsync(new SaveBookRequest { BookNumber = "B-1", Title = "Clean Architecture", Author = "Robert Martin", Subject = "Software Engineering" }, CancellationToken.None); + + Assert.Single(await students.SearchAsync("ada love", CancellationToken.None)); + Assert.Single(await books.SearchAsync(title: null, author: "martin", subject: null, CancellationToken.None)); + Assert.Single(await books.SearchAsync(title: null, author: null, subject: "engineering", CancellationToken.None)); + Assert.Empty(await books.SearchAsync(title: "missing", author: null, subject: null, CancellationToken.None)); + } + + [Fact] + public async Task Lists_AreSortedAndPaged() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + var loans = new LoanService(database.Context, clock); + var reports = new ReportService(database.Context, clock); + + await students.CreateAsync(new SaveStudentRequest { CardNumber = "S-1", FirstName = "B", LastName = "Beta", ClassName = "1A" }, CancellationToken.None); + await students.CreateAsync(new SaveStudentRequest { CardNumber = "S-2", FirstName = "A", LastName = "Alpha", ClassName = "1A" }, CancellationToken.None); + await books.CreateAsync(new SaveBookRequest { BookNumber = "B-1", Title = "Zulu", Author = "A", Subject = "S" }, CancellationToken.None); + await books.CreateAsync(new SaveBookRequest { BookNumber = "B-2", Title = "Alpha", Author = "A", Subject = "S" }, CancellationToken.None); + await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-1", DueAt = clock.GetUtcNow().AddDays(5) }, CancellationToken.None); + await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-2", BookNumber = "B-2", DueAt = clock.GetUtcNow().AddDays(1) }, CancellationToken.None); + + var orderedStudents = await students.GetAllAsync(0, 10, CancellationToken.None); + var pagedBooks = await books.GetAllAsync(null, 1, 1, CancellationToken.None); + var orderedLoans = await reports.GetActiveLoansAsync(0, 10, CancellationToken.None); + + Assert.Equal(["Alpha", "Beta"], orderedStudents.Select(student => student.LastName).ToArray()); + Assert.Equal("Zulu", pagedBooks.Single().Title); + Assert.Equal(["Alpha", "Zulu"], orderedLoans.Select(loan => loan.BookTitle).ToArray()); + } + + [Fact] + public async Task Return_ThrowsNotFound_WhenBookIsReturnedTwice() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + var loans = new LoanService(database.Context, clock); + var returns = new ReturnService(database.Context, clock); + + await students.CreateAsync(Student("S-1"), CancellationToken.None); + await books.CreateAsync(Book("B-1"), CancellationToken.None); + await loans.CreateAsync(new CreateLoanRequest { CardNumber = "S-1", BookNumber = "B-1" }, CancellationToken.None); + + await returns.ReturnAsync(new ReturnBookRequest { BookNumber = "B-1" }, CancellationToken.None); + + await Assert.ThrowsAsync( + () => returns.ReturnAsync(new ReturnBookRequest { BookNumber = "B-1" }, CancellationToken.None)); + } + + [Fact] + public async Task SoftDeletedResources_AreHiddenFromSearchAndLookup() + { + await using var database = new TestDatabase(); + await database.InitializeAsync(); + var clock = NewClock(); + var students = new StudentService(database.Context, clock); + var books = new BookService(database.Context, clock); + + var student = await students.CreateAsync(Student("S-1"), CancellationToken.None); + var book = await books.CreateAsync(Book("B-1"), CancellationToken.None); + + await students.DeleteAsync(student.Id, CancellationToken.None); + await books.DeleteAsync(book.Id, CancellationToken.None); + + Assert.Empty(await students.SearchAsync("Ada", CancellationToken.None)); + Assert.Empty(await books.SearchAsync(title: "Book", author: null, subject: null, CancellationToken.None)); + await Assert.ThrowsAsync(() => students.GetByCardAsync("S-1", CancellationToken.None)); + await Assert.ThrowsAsync(() => books.GetByNumberAsync("B-1", CancellationToken.None)); + } + + private static FixedTimeProvider NewClock() => + new(new DateTimeOffset(2026, 6, 22, 8, 0, 0, TimeSpan.Zero)); + + private static SaveStudentRequest Student(string cardNumber) => + new() + { + CardNumber = cardNumber, + FirstName = "Ada", + LastName = "Lovelace", + ClassName = "4AHIT" + }; + + private static SaveBookRequest Book(string bookNumber, string? isbn = null) => + new() + { + BookNumber = bookNumber, + Isbn = isbn, + Title = $"Book {bookNumber}", + Author = "Author", + Subject = "Subject" + }; +} diff --git a/tests/Bsevita.Library.Api.Tests/TestDatabase.cs b/tests/Bsevita.Library.Api.Tests/TestDatabase.cs new file mode 100644 index 0000000..380342f --- /dev/null +++ b/tests/Bsevita.Library.Api.Tests/TestDatabase.cs @@ -0,0 +1,23 @@ +using Bsevita.Library.Api.Data.Generated; +using Microsoft.EntityFrameworkCore; + +namespace Bsevita.Library.Api.Tests; + +internal sealed class TestDatabase : IAsyncDisposable +{ + public LibraryDbContext Context { get; private set; } = null!; + + public Task InitializeAsync() + { + var options = new DbContextOptionsBuilder() + .UseInMemoryDatabase($"Bsevita.Library.Tests.{Guid.NewGuid():N}") + .Options; + Context = new LibraryDbContext(options); + return Task.CompletedTask; + } + + public async ValueTask DisposeAsync() + { + await Context.DisposeAsync(); + } +} diff --git a/tests/Bsevita.Library.Api.Tests/Usings.cs b/tests/Bsevita.Library.Api.Tests/Usings.cs new file mode 100644 index 0000000..c802f44 --- /dev/null +++ b/tests/Bsevita.Library.Api.Tests/Usings.cs @@ -0,0 +1 @@ +global using Xunit;