Archived
Project to GitHub
This commit is contained in:
@@ -0,0 +1,11 @@
|
|||||||
|
{
|
||||||
|
"version": 1,
|
||||||
|
"isRoot": true,
|
||||||
|
"tools": {
|
||||||
|
"dotnet-ef": {
|
||||||
|
"version": "10.0.0",
|
||||||
|
"commands": ["dotnet-ef"],
|
||||||
|
"rollForward": false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
* text eol=crlf
|
||||||
+298
@@ -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
|
||||||
@@ -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
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<LangVersion>14.0</LangVersion>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<AnalysisLevel>latest</AnalysisLevel>
|
||||||
|
<TreatWarningsAsErrors>true</TreatWarningsAsErrors>
|
||||||
|
<Deterministic>true</Deterministic>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project>
|
||||||
|
<PropertyGroup>
|
||||||
|
<ManagePackageVersionsCentrally>true</ManagePackageVersionsCentrally>
|
||||||
|
<MauiVersion>10.0.71</MauiVersion>
|
||||||
|
<EfCoreVersion>10.0.9</EfCoreVersion>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageVersion Include="Microsoft.AspNetCore.Components.WebView.Maui" Version="$(MauiVersion)" />
|
||||||
|
<PackageVersion Include="Microsoft.AspNetCore.Mvc.Testing" Version="10.0.9" />
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.Design" Version="$(EfCoreVersion)" />
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.SqlServer" Version="$(EfCoreVersion)" />
|
||||||
|
<PackageVersion Include="Microsoft.EntityFrameworkCore.InMemory" Version="$(EfCoreVersion)" />
|
||||||
|
<PackageVersion Include="Microsoft.Maui.Controls" Version="$(MauiVersion)" />
|
||||||
|
<PackageVersion Include="Swashbuckle.AspNetCore" Version="10.2.3" />
|
||||||
|
<PackageVersion Include="Microsoft.NET.Test.Sdk" Version="18.7.0" />
|
||||||
|
<PackageVersion Include="coverlet.collector" Version="10.0.1" />
|
||||||
|
<PackageVersion Include="xunit" Version="2.9.3" />
|
||||||
|
<PackageVersion Include="xunit.runner.visualstudio" Version="3.1.5" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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)
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
+51
@@ -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`.
|
||||||
@@ -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.
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"sdk": {
|
||||||
|
"version": "10.0.100",
|
||||||
|
"rollForward": "latestFeature"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Web">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<RootNamespace>Bsevita.Library.Api</RootNamespace>
|
||||||
|
<UserSecretsId>bsevita-library-api</UserSecretsId>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>$(NoWarn);1573;1591</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../Bsevita.Library.Models/Bsevita.Library.Models.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.Design">
|
||||||
|
<PrivateAssets>all</PrivateAssets>
|
||||||
|
<IncludeAssets>runtime; build; native; contentfiles; analyzers; buildtransitive</IncludeAssets>
|
||||||
|
</PackageReference>
|
||||||
|
<PackageReference Include="Microsoft.EntityFrameworkCore.SqlServer" />
|
||||||
|
<PackageReference Include="Swashbuckle.AspNetCore" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>Verwaltet Bücher im Bibliotheksbestand.</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/books")]
|
||||||
|
public sealed class BooksController(BookService service) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>Liefert Bücher seitenweise.</summary>
|
||||||
|
/// <param name="availableOnly">Filtert optional nach verfügbaren oder ausgeliehenen Büchern.</param>
|
||||||
|
/// <param name="skip">Anzahl zu überspringender Einträge.</param>
|
||||||
|
/// <param name="take">Maximale Anzahl Einträge.</param>
|
||||||
|
/// <returns>Bücherliste.</returns>
|
||||||
|
/// <response code="200">Bücher wurden geliefert.</response>
|
||||||
|
/// <response code="400">Query-Parameter sind ungültig.</response>
|
||||||
|
/// <response code="500">Interner Serverfehler.</response>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType<IReadOnlyList<BookDto>>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<BookDto>>> GetAll(
|
||||||
|
[FromQuery] bool? availableOnly,
|
||||||
|
[FromQuery] int skip = 0,
|
||||||
|
[FromQuery] int take = LibraryRules.DefaultPageSize,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Ok(await service.GetAllAsync(availableOnly, skip, take, cancellationToken));
|
||||||
|
|
||||||
|
/// <summary>Liefert ein Buch anhand der ID.</summary>
|
||||||
|
/// <param name="id">Buch-ID.</param>
|
||||||
|
/// <returns>Gefundenes Buch.</returns>
|
||||||
|
/// <response code="200">Buch wurde gefunden.</response>
|
||||||
|
/// <response code="400">ID ist ungültig.</response>
|
||||||
|
/// <response code="404">Buch wurde nicht gefunden.</response>
|
||||||
|
/// <response code="500">Interner Serverfehler.</response>
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
[ProducesResponseType<BookDto>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<ActionResult<BookDto>> GetById(Guid id, CancellationToken cancellationToken) =>
|
||||||
|
Ok(await service.GetByIdAsync(id, cancellationToken));
|
||||||
|
|
||||||
|
/// <summary>Liefert ein Buch anhand der Buchnummer.</summary>
|
||||||
|
/// <param name="bookNumber">Buchnummer.</param>
|
||||||
|
/// <returns>Gefundenes Buch.</returns>
|
||||||
|
/// <response code="200">Buch wurde gefunden.</response>
|
||||||
|
/// <response code="400">Buchnummer ist ungültig.</response>
|
||||||
|
/// <response code="404">Buch wurde nicht gefunden.</response>
|
||||||
|
/// <response code="500">Interner Serverfehler.</response>
|
||||||
|
[HttpGet("by-number/{bookNumber}")]
|
||||||
|
[ProducesResponseType<BookDto>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<ActionResult<BookDto>> GetByNumber(string bookNumber, CancellationToken cancellationToken) =>
|
||||||
|
Ok(await service.GetByNumberAsync(bookNumber, cancellationToken));
|
||||||
|
|
||||||
|
/// <summary>Sucht aktive Bücher nach Titel, Autor oder Fach.</summary>
|
||||||
|
/// <param name="title">Optionaler Titelfilter.</param>
|
||||||
|
/// <param name="author">Optionaler Autorfilter.</param>
|
||||||
|
/// <param name="subject">Optionaler Fachfilter.</param>
|
||||||
|
/// <returns>Passende Bücher.</returns>
|
||||||
|
/// <response code="200">Suche wurde ausgeführt.</response>
|
||||||
|
/// <response code="400">Mindestens ein Suchfilter fehlt oder ist ungültig.</response>
|
||||||
|
/// <response code="500">Interner Serverfehler.</response>
|
||||||
|
[HttpGet("search")]
|
||||||
|
[ProducesResponseType<IReadOnlyList<BookDto>>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<BookDto>>> Search(
|
||||||
|
[FromQuery] string? title,
|
||||||
|
[FromQuery] string? author,
|
||||||
|
[FromQuery] string? subject,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
Ok(await service.SearchAsync(title, author, subject, cancellationToken));
|
||||||
|
|
||||||
|
/// <summary>Legt ein Buch an.</summary>
|
||||||
|
/// <param name="request">Buchdaten im Body.</param>
|
||||||
|
/// <returns>Angelegtes Buch.</returns>
|
||||||
|
/// <response code="201">Buch wurde angelegt.</response>
|
||||||
|
/// <response code="400">Body ist ungültig.</response>
|
||||||
|
/// <response code="409">Buchnummer oder ISBN ist bereits vergeben.</response>
|
||||||
|
/// <response code="500">Interner Serverfehler.</response>
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType<BookDto>(StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<ActionResult<BookDto>> Create(SaveBookRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var created = await service.CreateAsync(request, cancellationToken);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Aktualisiert ein Buch.</summary>
|
||||||
|
/// <param name="id">Buch-ID.</param>
|
||||||
|
/// <param name="request">Neue Buchdaten im Body.</param>
|
||||||
|
/// <returns>Aktualisiertes Buch.</returns>
|
||||||
|
/// <response code="200">Buch wurde aktualisiert.</response>
|
||||||
|
/// <response code="400">ID oder Body ist ungültig.</response>
|
||||||
|
/// <response code="404">Buch wurde nicht gefunden.</response>
|
||||||
|
/// <response code="409">Buchnummer oder ISBN ist bereits vergeben.</response>
|
||||||
|
/// <response code="500">Interner Serverfehler.</response>
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
[ProducesResponseType<BookDto>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<ActionResult<BookDto>> Update(Guid id, SaveBookRequest request, CancellationToken cancellationToken) =>
|
||||||
|
Ok(await service.UpdateAsync(id, request, cancellationToken));
|
||||||
|
|
||||||
|
/// <summary>Deaktiviert ein Buch.</summary>
|
||||||
|
/// <param name="id">Buch-ID.</param>
|
||||||
|
/// <returns>Kein Inhalt.</returns>
|
||||||
|
/// <response code="204">Buch wurde deaktiviert.</response>
|
||||||
|
/// <response code="400">ID ist ungültig.</response>
|
||||||
|
/// <response code="404">Buch wurde nicht gefunden.</response>
|
||||||
|
/// <response code="409">Buch ist noch ausgeliehen.</response>
|
||||||
|
/// <response code="500">Interner Serverfehler.</response>
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await service.DeleteAsync(id, cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
using Bsevita.Library.Api.Services;
|
||||||
|
using Bsevita.Library.Models.Loans;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Bsevita.Library.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Prüft und erstellt Ausleihen.</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/loans")]
|
||||||
|
public sealed class LoansController(LoanService service) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>Prüft, ob ein Buch ausgeliehen werden kann.</summary>
|
||||||
|
/// <param name="request">Ausweis- und Buchnummer im Body.</param>
|
||||||
|
/// <returns>Ergebnis der Ausleihpruefung.</returns>
|
||||||
|
/// <response code="200">Ausleihe ist möglich.</response>
|
||||||
|
/// <response code="400">Body ist ungültig.</response>
|
||||||
|
/// <response code="404">Schüler oder Buch wurde nicht gefunden.</response>
|
||||||
|
/// <response code="409">Buch ist bereits ausgeliehen.</response>
|
||||||
|
/// <response code="500">Interner Serverfehler.</response>
|
||||||
|
[HttpPost("verify")]
|
||||||
|
[ProducesResponseType<LoanVerificationDto>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<ActionResult<LoanVerificationDto>> Verify(VerifyLoanRequest request, CancellationToken cancellationToken) =>
|
||||||
|
Ok(await service.VerifyAsync(request, cancellationToken));
|
||||||
|
|
||||||
|
/// <summary>Erstellt eine Ausleihe.</summary>
|
||||||
|
/// <param name="request">Ausleihdaten im Body.</param>
|
||||||
|
/// <returns>Angelegte Ausleihe.</returns>
|
||||||
|
/// <response code="201">Ausleihe wurde angelegt.</response>
|
||||||
|
/// <response code="400">Body ist ungültig.</response>
|
||||||
|
/// <response code="404">Schüler oder Buch wurde nicht gefunden.</response>
|
||||||
|
/// <response code="409">Buch ist bereits ausgeliehen.</response>
|
||||||
|
/// <response code="500">Interner Serverfehler.</response>
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType<LoanDto>(StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<ActionResult<LoanDto>> Create(CreateLoanRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var created = await service.CreateAsync(request, cancellationToken);
|
||||||
|
return StatusCode(StatusCodes.Status201Created, created);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>Liefert Auswertungen zum Bibliotheksbetrieb.</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/reports")]
|
||||||
|
public sealed class ReportsController(ReportService service) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>Liefert die wichtigsten Reports gebündelt.</summary>
|
||||||
|
/// <returns>Dashboard-Reports.</returns>
|
||||||
|
/// <response code="200">Reports wurden geliefert.</response>
|
||||||
|
/// <response code="500">Interner Serverfehler.</response>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType<ReportsDto>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<ActionResult<ReportsDto>> Reports(CancellationToken cancellationToken) =>
|
||||||
|
Ok(await service.GetReportsAsync(cancellationToken));
|
||||||
|
|
||||||
|
/// <summary>Liefert aktive Ausleihen seitenweise.</summary>
|
||||||
|
/// <param name="skip">Anzahl zu überspringender Einträge.</param>
|
||||||
|
/// <param name="take">Maximale Anzahl Einträge.</param>
|
||||||
|
/// <returns>Aktive Ausleihen.</returns>
|
||||||
|
/// <response code="200">Aktive Ausleihen wurden geliefert.</response>
|
||||||
|
/// <response code="400">Query-Parameter sind ungültig.</response>
|
||||||
|
/// <response code="500">Interner Serverfehler.</response>
|
||||||
|
[HttpGet("active-loans")]
|
||||||
|
[ProducesResponseType<IReadOnlyList<LoanDto>>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<LoanDto>>> ActiveLoans(
|
||||||
|
[FromQuery] int skip = 0,
|
||||||
|
[FromQuery] int take = LibraryRules.DefaultPageSize,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Ok(await service.GetActiveLoansAsync(skip, take, cancellationToken));
|
||||||
|
|
||||||
|
/// <summary>Liefert Schüler mit aktiven Ausleihen seitenweise.</summary>
|
||||||
|
/// <param name="skip">Anzahl zu überspringender Einträge.</param>
|
||||||
|
/// <param name="take">Maximale Anzahl Einträge.</param>
|
||||||
|
/// <returns>Schüler mit aktiven Ausleihen.</returns>
|
||||||
|
/// <response code="200">Schüler wurden geliefert.</response>
|
||||||
|
/// <response code="400">Query-Parameter sind ungültig.</response>
|
||||||
|
/// <response code="500">Interner Serverfehler.</response>
|
||||||
|
[HttpGet("active-students")]
|
||||||
|
[ProducesResponseType<IReadOnlyList<ActiveStudentDto>>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<ActiveStudentDto>>> ActiveStudents(
|
||||||
|
[FromQuery] int skip = 0,
|
||||||
|
[FromQuery] int take = LibraryRules.DefaultPageSize,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Ok(await service.GetActiveStudentsAsync(skip, take, cancellationToken));
|
||||||
|
|
||||||
|
/// <summary>Liefert überfällige Ausleihen seitenweise.</summary>
|
||||||
|
/// <param name="skip">Anzahl zu überspringender Einträge.</param>
|
||||||
|
/// <param name="take">Maximale Anzahl Einträge.</param>
|
||||||
|
/// <returns>Ueberfällige Ausleihen.</returns>
|
||||||
|
/// <response code="200">Ueberfällige Ausleihen wurden geliefert.</response>
|
||||||
|
/// <response code="400">Query-Parameter sind ungültig.</response>
|
||||||
|
/// <response code="500">Interner Serverfehler.</response>
|
||||||
|
[HttpGet("overdue")]
|
||||||
|
[ProducesResponseType<IReadOnlyList<LoanDto>>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<LoanDto>>> Overdue(
|
||||||
|
[FromQuery] int skip = 0,
|
||||||
|
[FromQuery] int take = LibraryRules.DefaultPageSize,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Ok(await service.GetOverdueAsync(skip, take, cancellationToken));
|
||||||
|
|
||||||
|
/// <summary>Liefert Bestands- und Ausleihstatistiken.</summary>
|
||||||
|
/// <returns>Bibliotheksstatistiken.</returns>
|
||||||
|
/// <response code="200">Statistiken wurden geliefert.</response>
|
||||||
|
/// <response code="500">Interner Serverfehler.</response>
|
||||||
|
[HttpGet("statistics")]
|
||||||
|
[ProducesResponseType<LibraryStatisticsDto>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<ActionResult<LibraryStatisticsDto>> Statistics(CancellationToken cancellationToken) =>
|
||||||
|
Ok(await service.GetStatisticsAsync(cancellationToken));
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
using Bsevita.Library.Api.Services;
|
||||||
|
using Bsevita.Library.Models.Returns;
|
||||||
|
using Microsoft.AspNetCore.Mvc;
|
||||||
|
|
||||||
|
namespace Bsevita.Library.Api.Controllers;
|
||||||
|
|
||||||
|
/// <summary>Prüft und verarbeitet Rückgaben.</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/returns")]
|
||||||
|
public sealed class ReturnsController(ReturnService service) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>Prüft die aktive Ausleihe eines Buchs.</summary>
|
||||||
|
/// <param name="bookNumber">Buchnummer.</param>
|
||||||
|
/// <returns>Aktive Ausleihe mit Rückgabestatus.</returns>
|
||||||
|
/// <response code="200">Aktive Ausleihe wurde gefunden.</response>
|
||||||
|
/// <response code="400">Buchnummer ist ungültig.</response>
|
||||||
|
/// <response code="404">Keine aktive Ausleihe gefunden.</response>
|
||||||
|
/// <response code="500">Interner Serverfehler.</response>
|
||||||
|
[HttpGet("verify/{bookNumber}")]
|
||||||
|
[ProducesResponseType<ReturnVerificationDto>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<ActionResult<ReturnVerificationDto>> Verify(string bookNumber, CancellationToken cancellationToken) =>
|
||||||
|
Ok(await service.VerifyAsync(bookNumber, cancellationToken));
|
||||||
|
|
||||||
|
/// <summary>Schließt die aktive Ausleihe eines Buchs ab.</summary>
|
||||||
|
/// <param name="request">Buchnummer im Body.</param>
|
||||||
|
/// <returns>Ergebnis der Rückgabe.</returns>
|
||||||
|
/// <response code="200">Rückgabe wurde abgeschlossen.</response>
|
||||||
|
/// <response code="400">Body ist ungültig.</response>
|
||||||
|
/// <response code="404">Keine aktive Ausleihe gefunden.</response>
|
||||||
|
/// <response code="409">Datenbankkonflikt bei paralleler Änderung.</response>
|
||||||
|
/// <response code="500">Interner Serverfehler.</response>
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType<ReturnResultDto>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<ActionResult<ReturnResultDto>> Return(ReturnBookRequest request, CancellationToken cancellationToken) =>
|
||||||
|
Ok(await service.ReturnAsync(request, cancellationToken));
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>Verwaltet Schüler und ihre Bibliotheksausweise.</summary>
|
||||||
|
[ApiController]
|
||||||
|
[Route("api/students")]
|
||||||
|
public sealed class StudentsController(StudentService service) : ControllerBase
|
||||||
|
{
|
||||||
|
/// <summary>Liefert Schüler seitenweise.</summary>
|
||||||
|
/// <param name="skip">Anzahl zu überspringender Einträge.</param>
|
||||||
|
/// <param name="take">Maximale Anzahl Einträge.</param>
|
||||||
|
/// <returns>Schülerliste.</returns>
|
||||||
|
/// <response code="200">Schüler wurden geliefert.</response>
|
||||||
|
/// <response code="400">Query-Parameter sind ungültig.</response>
|
||||||
|
/// <response code="500">Interner Serverfehler.</response>
|
||||||
|
[HttpGet]
|
||||||
|
[ProducesResponseType<IReadOnlyList<StudentDto>>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<StudentDto>>> GetAll(
|
||||||
|
[FromQuery] int skip = 0,
|
||||||
|
[FromQuery] int take = LibraryRules.DefaultPageSize,
|
||||||
|
CancellationToken cancellationToken = default) =>
|
||||||
|
Ok(await service.GetAllAsync(skip, take, cancellationToken));
|
||||||
|
|
||||||
|
/// <summary>Liefert einen Schüler anhand der ID.</summary>
|
||||||
|
/// <param name="id">Schüler-ID.</param>
|
||||||
|
/// <returns>Gefundener Schüler.</returns>
|
||||||
|
/// <response code="200">Schüler wurde gefunden.</response>
|
||||||
|
/// <response code="400">ID ist ungültig.</response>
|
||||||
|
/// <response code="404">Schüler wurde nicht gefunden.</response>
|
||||||
|
/// <response code="500">Interner Serverfehler.</response>
|
||||||
|
[HttpGet("{id:guid}")]
|
||||||
|
[ProducesResponseType<StudentDto>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<ActionResult<StudentDto>> GetById(Guid id, CancellationToken cancellationToken) =>
|
||||||
|
Ok(await service.GetByIdAsync(id, cancellationToken));
|
||||||
|
|
||||||
|
/// <summary>Liefert einen Schüler anhand der Ausweisnummer.</summary>
|
||||||
|
/// <param name="cardNumber">Ausweisnummer.</param>
|
||||||
|
/// <returns>Gefundener Schüler.</returns>
|
||||||
|
/// <response code="200">Schüler wurde gefunden.</response>
|
||||||
|
/// <response code="400">Ausweisnummer ist ungültig.</response>
|
||||||
|
/// <response code="404">Schüler wurde nicht gefunden.</response>
|
||||||
|
/// <response code="500">Interner Serverfehler.</response>
|
||||||
|
[HttpGet("by-card/{cardNumber}")]
|
||||||
|
[ProducesResponseType<StudentDto>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<ActionResult<StudentDto>> GetByCard(string cardNumber, CancellationToken cancellationToken) =>
|
||||||
|
Ok(await service.GetByCardAsync(cardNumber, cancellationToken));
|
||||||
|
|
||||||
|
/// <summary>Sucht aktive Schüler nach Name.</summary>
|
||||||
|
/// <param name="name">Suchbegriff.</param>
|
||||||
|
/// <returns>Passende Schüler.</returns>
|
||||||
|
/// <response code="200">Suche wurde ausgeführt.</response>
|
||||||
|
/// <response code="400">Suchbegriff fehlt oder ist ungültig.</response>
|
||||||
|
/// <response code="500">Interner Serverfehler.</response>
|
||||||
|
[HttpGet("search")]
|
||||||
|
[ProducesResponseType<IReadOnlyList<StudentDto>>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<ActionResult<IReadOnlyList<StudentDto>>> Search([FromQuery] string name, CancellationToken cancellationToken) =>
|
||||||
|
Ok(await service.SearchAsync(name, cancellationToken));
|
||||||
|
|
||||||
|
/// <summary>Legt einen Schüler an.</summary>
|
||||||
|
/// <param name="request">Schülerdaten im Body.</param>
|
||||||
|
/// <returns>Angelegter Schüler.</returns>
|
||||||
|
/// <response code="201">Schüler wurde angelegt.</response>
|
||||||
|
/// <response code="400">Body ist ungültig.</response>
|
||||||
|
/// <response code="409">Ausweisnummer ist bereits vergeben.</response>
|
||||||
|
/// <response code="500">Interner Serverfehler.</response>
|
||||||
|
[HttpPost]
|
||||||
|
[ProducesResponseType<StudentDto>(StatusCodes.Status201Created)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<ActionResult<StudentDto>> Create(SaveStudentRequest request, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var created = await service.CreateAsync(request, cancellationToken);
|
||||||
|
return CreatedAtAction(nameof(GetById), new { id = created.Id }, created);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Aktualisiert einen Schüler.</summary>
|
||||||
|
/// <param name="id">Schüler-ID.</param>
|
||||||
|
/// <param name="request">Neue Schülerdaten im Body.</param>
|
||||||
|
/// <returns>Aktualisierter Schüler.</returns>
|
||||||
|
/// <response code="200">Schüler wurde aktualisiert.</response>
|
||||||
|
/// <response code="400">ID oder Body ist ungültig.</response>
|
||||||
|
/// <response code="404">Schüler wurde nicht gefunden.</response>
|
||||||
|
/// <response code="409">Ausweisnummer ist bereits vergeben.</response>
|
||||||
|
/// <response code="500">Interner Serverfehler.</response>
|
||||||
|
[HttpPut("{id:guid}")]
|
||||||
|
[ProducesResponseType<StudentDto>(StatusCodes.Status200OK)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<ActionResult<StudentDto>> Update(Guid id, SaveStudentRequest request, CancellationToken cancellationToken) =>
|
||||||
|
Ok(await service.UpdateAsync(id, request, cancellationToken));
|
||||||
|
|
||||||
|
/// <summary>Deaktiviert einen Schüler.</summary>
|
||||||
|
/// <param name="id">Schüler-ID.</param>
|
||||||
|
/// <returns>Kein Inhalt.</returns>
|
||||||
|
/// <response code="204">Schüler wurde deaktiviert.</response>
|
||||||
|
/// <response code="400">ID ist ungültig.</response>
|
||||||
|
/// <response code="404">Schüler wurde nicht gefunden.</response>
|
||||||
|
/// <response code="409">Schüler hat aktive Ausleihen.</response>
|
||||||
|
/// <response code="500">Interner Serverfehler.</response>
|
||||||
|
[HttpDelete("{id:guid}")]
|
||||||
|
[ProducesResponseType(StatusCodes.Status204NoContent)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status400BadRequest)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status404NotFound)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status409Conflict)]
|
||||||
|
[ProducesResponseType<ProblemDetails>(StatusCodes.Status500InternalServerError)]
|
||||||
|
public async Task<IActionResult> Delete(Guid id, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
await service.DeleteAsync(id, cancellationToken);
|
||||||
|
return NoContent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// <auto-generated>Generated from database/001_create_database.sql.</auto-generated>
|
||||||
|
#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<Loan> Loans { get; set; } = new List<Loan>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
// <auto-generated>Generated from database/001_create_database.sql.</auto-generated>
|
||||||
|
#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!;
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
// <auto-generated>Generated from database/001_create_database.sql.</auto-generated>
|
||||||
|
#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<Loan> Loans { get; set; } = new List<Loan>();
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
// <auto-generated>Generated from database/001_create_database.sql.</auto-generated>
|
||||||
|
using Bsevita.Library.Api.Data.Generated.Entities;
|
||||||
|
using Microsoft.EntityFrameworkCore;
|
||||||
|
|
||||||
|
namespace Bsevita.Library.Api.Data.Generated;
|
||||||
|
|
||||||
|
public partial class LibraryDbContext(DbContextOptions<LibraryDbContext> options) : DbContext(options)
|
||||||
|
{
|
||||||
|
public virtual DbSet<Book> Books { get; set; } = null!;
|
||||||
|
public virtual DbSet<Loan> Loans { get; set; } = null!;
|
||||||
|
public virtual DbSet<Student> Students { get; set; } = null!;
|
||||||
|
|
||||||
|
protected override void OnModelCreating(ModelBuilder modelBuilder)
|
||||||
|
{
|
||||||
|
modelBuilder.Entity<Book>(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<Student>(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<Loan>(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);
|
||||||
|
}
|
||||||
@@ -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<LibraryDbContext>
|
||||||
|
{
|
||||||
|
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<LibraryDbContext>()
|
||||||
|
.UseSqlServer(connectionString)
|
||||||
|
.Options;
|
||||||
|
|
||||||
|
return new LibraryDbContext(options);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ApiExceptionHandler> logger) : IExceptionHandler
|
||||||
|
{
|
||||||
|
public async ValueTask<bool> 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
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
{ }
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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<ApiExceptionHandler>();
|
||||||
|
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<string[]>() ?? [];
|
||||||
|
if (origins.Length > 0)
|
||||||
|
{
|
||||||
|
policy.WithOrigins(origins).AllowAnyHeader().AllowAnyMethod();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
builder.Services.AddDbContext<LibraryDbContext>(options =>
|
||||||
|
options.UseSqlServer(connectionString, sql => sql.EnableRetryOnFailure(5, TimeSpan.FromSeconds(5), null)));
|
||||||
|
builder.Services.AddSingleton(TimeProvider.System);
|
||||||
|
builder.Services.AddScoped<StudentService>();
|
||||||
|
builder.Services.AddScoped<BookService>();
|
||||||
|
builder.Services.AddScoped<LoanService>();
|
||||||
|
builder.Services.AddScoped<ReturnService>();
|
||||||
|
builder.Services.AddScoped<ReportService>();
|
||||||
|
|
||||||
|
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<LibraryDbContext>();
|
||||||
|
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 { }
|
||||||
@@ -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<IReadOnlyList<BookDto>> GetAllAsync(
|
||||||
|
bool? availableOnly,
|
||||||
|
int skip,
|
||||||
|
int take,
|
||||||
|
CancellationToken cancellationToken) =>
|
||||||
|
QueryAsync(null, null, null, availableOnly, skip, take, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<BookDto> 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<BookDto> 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<IReadOnlyList<BookDto>> 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<BookDto> 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<BookDto> 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<IReadOnlyList<BookDto>> 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<Book> ApplyTextSearch(
|
||||||
|
IQueryable<Book> 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<Book> 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<LoanVerificationDto> 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<LoanDto> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ReportsDto> 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<IReadOnlyList<LoanDto>> 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<IReadOnlyList<LoanDto>> GetActiveLoansAsync(CancellationToken cancellationToken) =>
|
||||||
|
GetActiveLoansAsync(0, LibraryRules.DefaultPageSize, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<LoanDto>> 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<IReadOnlyList<LoanDto>> GetOverdueAsync(CancellationToken cancellationToken) =>
|
||||||
|
GetOverdueAsync(0, LibraryRules.DefaultPageSize, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<IReadOnlyList<ActiveStudentDto>> 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<IReadOnlyList<ActiveStudentDto>> GetActiveStudentsAsync(CancellationToken cancellationToken) =>
|
||||||
|
GetActiveStudentsAsync(0, LibraryRules.DefaultPageSize, cancellationToken);
|
||||||
|
|
||||||
|
public async Task<LibraryStatisticsDto> 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<ReturnVerificationDto> 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<ReturnResultDto> 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<Loan> FindActiveLoanAsync(string bookNumber, bool asTracking, CancellationToken cancellationToken)
|
||||||
|
{
|
||||||
|
var key = StringNormalizer.Key(bookNumber, "Buchnummer");
|
||||||
|
IQueryable<Loan> 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<IReadOnlyList<StudentDto>> 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<Student> ActiveStudents() =>
|
||||||
|
dbContext.Students
|
||||||
|
.AsNoTracking()
|
||||||
|
.Where(student => student.IsActive);
|
||||||
|
|
||||||
|
private static System.Linq.Expressions.Expression<Func<Student, StudentDto>> 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<StudentDto> 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<StudentDto> 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<IReadOnlyList<StudentDto>> 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<StudentDto> 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<StudentDto> 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<Student> 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.");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"Logging": {
|
||||||
|
"LogLevel": {
|
||||||
|
"Default": "Information",
|
||||||
|
"Microsoft.AspNetCore": "Information",
|
||||||
|
"Microsoft.EntityFrameworkCore.Database.Command": "Information"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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": "*"
|
||||||
|
}
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
|
<Application xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||||
|
x:Class="Bsevita.Library.Maui.App">
|
||||||
|
<Application.Resources>
|
||||||
|
<ResourceDictionary>
|
||||||
|
<Color x:Key="PageBackgroundColor">#F6F4EF</Color>
|
||||||
|
</ResourceDictionary>
|
||||||
|
</Application.Resources>
|
||||||
|
</Application>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,43 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0-android</TargetFramework>
|
||||||
|
<TargetFramework Condition="$([MSBuild]::IsOSPlatform('windows'))">net10.0-windows10.0.19041.0</TargetFramework>
|
||||||
|
<TargetFrameworks Condition="$([MSBuild]::IsOSPlatform('osx'))">net10.0-ios;net10.0-maccatalyst</TargetFrameworks>
|
||||||
|
<OutputType>Exe</OutputType>
|
||||||
|
<RootNamespace>Bsevita.Library.Maui</RootNamespace>
|
||||||
|
<TreatWarningsAsErrors>false</TreatWarningsAsErrors>
|
||||||
|
<UseMaui>true</UseMaui>
|
||||||
|
<SingleProject>true</SingleProject>
|
||||||
|
<ImplicitUsings>enable</ImplicitUsings>
|
||||||
|
<Nullable>enable</Nullable>
|
||||||
|
<ApplicationTitle>BSEVITA Library</ApplicationTitle>
|
||||||
|
<ApplicationId>top.lechner.bsevita.library</ApplicationId>
|
||||||
|
<ApplicationDisplayVersion>1.0</ApplicationDisplayVersion>
|
||||||
|
<ApplicationVersion>1</ApplicationVersion>
|
||||||
|
<WindowsPackageType>None</WindowsPackageType>
|
||||||
|
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'ios'">15.0</SupportedOSPlatformVersion>
|
||||||
|
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">15.0</SupportedOSPlatformVersion>
|
||||||
|
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'android'">24.0</SupportedOSPlatformVersion>
|
||||||
|
<SupportedOSPlatformVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</SupportedOSPlatformVersion>
|
||||||
|
<TargetPlatformMinVersion Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'windows'">10.0.17763.0</TargetPlatformMinVersion>
|
||||||
|
<CodesignEntitlements Condition="$([MSBuild]::GetTargetPlatformIdentifier('$(TargetFramework)')) == 'maccatalyst'">Platforms/MacCatalyst/Entitlements.plist</CodesignEntitlements>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<MauiIcon Include="Resources/AppIcon/appicon.svg" ForegroundFile="Resources/AppIcon/appiconfg.svg" Color="#172554" />
|
||||||
|
<MauiSplashScreen Include="Resources/Splash/splash.svg" Color="#172554" BaseSize="128,128" />
|
||||||
|
<MauiImage Include="Resources/Images/*" />
|
||||||
|
<MauiAsset Include="Resources/Raw/**" LogicalName="%(RecursiveDir)%(Filename)%(Extension)" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<PackageReference Include="Microsoft.Maui.Controls" />
|
||||||
|
<PackageReference Include="Microsoft.AspNetCore.Components.WebView.Maui" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../Bsevita.Library.Ui/Bsevita.Library.Ui.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<Reference Include="Mono.Android">
|
||||||
|
<HintPath>..\..\..\..\..\..\..\Program Files\dotnet\packs\Microsoft.Android.Ref.36\36.1.30\ref\net10.0\Mono.Android.dll</HintPath>
|
||||||
|
</Reference>
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,15 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8" ?>
|
||||||
|
<ContentPage xmlns="http://schemas.microsoft.com/dotnet/2021/maui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2009/xaml"
|
||||||
|
xmlns:blazor="clr-namespace:Microsoft.AspNetCore.Components.WebView.Maui;assembly=Microsoft.AspNetCore.Components.WebView.Maui"
|
||||||
|
xmlns:ui="clr-namespace:Bsevita.Library.Ui;assembly=Bsevita.Library.Ui"
|
||||||
|
x:Class="Bsevita.Library.Maui.MainPage"
|
||||||
|
BackgroundColor="{StaticResource PageBackgroundColor}">
|
||||||
|
<blazor:BlazorWebView HostPage="wwwroot/index.html"
|
||||||
|
HorizontalOptions="Fill"
|
||||||
|
VerticalOptions="Fill">
|
||||||
|
<blazor:BlazorWebView.RootComponents>
|
||||||
|
<blazor:RootComponent Selector="#app" ComponentType="{x:Type ui:AppRoutes}" />
|
||||||
|
</blazor:BlazorWebView.RootComponents>
|
||||||
|
</blazor:BlazorWebView>
|
||||||
|
</ContentPage>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
namespace Bsevita.Library.Maui;
|
||||||
|
|
||||||
|
public partial class MainPage : ContentPage
|
||||||
|
{
|
||||||
|
public MainPage()
|
||||||
|
{
|
||||||
|
InitializeComponent();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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<App>();
|
||||||
|
builder.Services.AddMauiBlazorWebView();
|
||||||
|
builder.Services.AddLibraryUi();
|
||||||
|
builder.Services.AddSingleton<ApiEndpointResolver>();
|
||||||
|
builder.Services.AddScoped(serviceProvider =>
|
||||||
|
{
|
||||||
|
var resolver = serviceProvider.GetRequiredService<ApiEndpointResolver>();
|
||||||
|
return new HttpClient
|
||||||
|
{
|
||||||
|
BaseAddress = resolver.GetBaseAddress(),
|
||||||
|
Timeout = TimeSpan.FromSeconds(20)
|
||||||
|
};
|
||||||
|
});
|
||||||
|
#if DEBUG
|
||||||
|
builder.Services.AddBlazorWebViewDeveloperTools();
|
||||||
|
builder.Logging.AddDebug();
|
||||||
|
#endif
|
||||||
|
return builder.Build();
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<application android:allowBackup="true" android:supportsRtl="true" android:usesCleartextTraffic="true"></application>
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||||
|
</manifest>
|
||||||
@@ -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 { }
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources><color name="colorPrimary">#3157D5</color><color name="colorPrimaryDark">#172554</color><color name="colorAccent">#6941C6</color></resources>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Foundation;
|
||||||
|
|
||||||
|
namespace Bsevita.Library.Maui;
|
||||||
|
|
||||||
|
[Register("AppDelegate")]
|
||||||
|
public class AppDelegate : MauiUIApplicationDelegate
|
||||||
|
{
|
||||||
|
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||||
|
}
|
||||||
@@ -0,0 +1,3 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0"><dict><key>com.apple.security.app-sandbox</key><true/><key>com.apple.security.network.client</key><true/></dict></plist>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0"><dict>
|
||||||
|
<key>UIDeviceFamily</key><array><integer>2</integer></array>
|
||||||
|
<key>UISupportedInterfaceOrientations</key><array><string>UIInterfaceOrientationPortrait</string><string>UIInterfaceOrientationLandscapeLeft</string><string>UIInterfaceOrientationLandscapeRight</string></array>
|
||||||
|
<key>NSAppTransportSecurity</key><dict><key>NSAllowsLocalNetworking</key><true/></dict>
|
||||||
|
</dict></plist>
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
<maui:MauiWinUIApplication x:Class="Bsevita.Library.Maui.WinUI.App"
|
||||||
|
xmlns="http://schemas.microsoft.com/winfx/2006/xaml/presentation"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:maui="using:Microsoft.Maui"></maui:MauiWinUIApplication>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Bsevita.Library.Maui.WinUI;
|
||||||
|
|
||||||
|
public partial class App : MauiWinUIApplication
|
||||||
|
{
|
||||||
|
public App() => InitializeComponent();
|
||||||
|
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<Package xmlns="http://schemas.microsoft.com/appx/manifest/foundation/windows10" xmlns:uap="http://schemas.microsoft.com/appx/manifest/uap/windows10" xmlns:mp="http://schemas.microsoft.com/appx/2014/phone/manifest" IgnorableNamespaces="uap mp">
|
||||||
|
<Identity Name="top.lechner.bsevita.library" Publisher="CN=Julian Lechner" Version="1.0.0.0" />
|
||||||
|
<mp:PhoneIdentity PhoneProductId="9dce7ba3-2783-4edc-a805-62b7ca38b3e6" PhonePublisherId="00000000-0000-0000-0000-000000000000"/>
|
||||||
|
<Properties><DisplayName>BSEVITA Library</DisplayName><PublisherDisplayName>Julian Lechner</PublisherDisplayName><Logo>$placeholder$.png</Logo></Properties>
|
||||||
|
<Dependencies><TargetDeviceFamily Name="Windows.Universal" MinVersion="10.0.17763.0" MaxVersionTested="10.0.19041.0" /></Dependencies>
|
||||||
|
<Resources><Resource Language="x-generate" /></Resources>
|
||||||
|
<Applications><Application Id="App" Executable="$targetnametoken$.exe" EntryPoint="$targetentrypoint$"><uap:VisualElements DisplayName="BSEVITA Library" Description="Schulbibliotheksverwaltung" Square150x150Logo="$placeholder$.png" Square44x44Logo="$placeholder$.png" BackgroundColor="transparent"><uap:DefaultTile Square71x71Logo="$placeholder$.png" Wide310x150Logo="$placeholder$.png" Square310x310Logo="$placeholder$.png" /></uap:VisualElements></Application></Applications>
|
||||||
|
</Package>
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
using Foundation;
|
||||||
|
|
||||||
|
namespace Bsevita.Library.Maui;
|
||||||
|
|
||||||
|
[Register("AppDelegate")]
|
||||||
|
public class AppDelegate : MauiUIApplicationDelegate
|
||||||
|
{
|
||||||
|
protected override MauiApp CreateMauiApp() => MauiProgram.CreateMauiApp();
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
|
||||||
|
<plist version="1.0"><dict>
|
||||||
|
<key>LSRequiresIPhoneOS</key><true/>
|
||||||
|
<key>UIDeviceFamily</key><array><integer>1</integer><integer>2</integer></array>
|
||||||
|
<key>UIRequiredDeviceCapabilities</key><array><string>arm64</string></array>
|
||||||
|
<key>UISupportedInterfaceOrientations</key><array><string>UIInterfaceOrientationPortrait</string><string>UIInterfaceOrientationLandscapeLeft</string><string>UIInterfaceOrientationLandscapeRight</string></array>
|
||||||
|
<key>NSAppTransportSecurity</key><dict><key>NSAllowsLocalNetworking</key><true/></dict>
|
||||||
|
</dict></plist>
|
||||||
@@ -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));
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><rect width="128" height="128" rx="26" fill="#172554"/></svg>
|
||||||
|
After Width: | Height: | Size: 124 B |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><path fill="#fff" d="M33 27h39c14 0 23 8 23 20 0 8-4 14-11 17 9 3 14 10 14 20 0 14-10 22-26 22H33V27zm34 31c7 0 11-3 11-9s-4-9-11-9H50v18h17zm3 35c8 0 12-4 12-11s-4-11-12-11H50v22h20z"/></svg>
|
||||||
|
After Width: | Height: | Size: 255 B |
@@ -0,0 +1 @@
|
|||||||
|
This folder is reserved for MAUI raw assets.
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 128 128"><rect x="20" y="18" width="88" height="92" rx="18" fill="#fff"/><path fill="#3157d5" d="M39 37h33c12 0 20 7 20 17 0 7-4 12-10 15 8 3 12 9 12 17 0 12-8 19-22 19H39V37zm29 27c6 0 9-3 9-8s-3-8-9-8H53v16h15zm2 29c7 0 10-3 10-9s-3-9-10-9H53v18h17z"/></svg>
|
||||||
|
After Width: | Height: | Size: 314 B |
@@ -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 + "/");
|
||||||
|
}
|
||||||
@@ -0,0 +1,17 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="de">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>BSEVITA Library</title>
|
||||||
|
<base href="/" />
|
||||||
|
<link rel="stylesheet" href="_content/Bsevita.Library.Ui/css/app.css" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app">
|
||||||
|
<div class="loading-panel"><span class="spinner"></span><span>Anwendung wird gestartet …</span></div>
|
||||||
|
</div>
|
||||||
|
<div id="blazor-error-ui" style="display:none">Ein unerwarteter Fehler ist aufgetreten. <a href="" class="reload">Neu laden</a></div>
|
||||||
|
<script src="_framework/blazor.webview.js" autostart="false"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Bsevita.Library.Models.Common;
|
||||||
|
|
||||||
|
namespace Bsevita.Library.Models.Books;
|
||||||
|
|
||||||
|
/// <summary>Buch mit Bestands- und Verfügbarkeitsdaten.</summary>
|
||||||
|
/// <param name="Id">Eindeutige Buch-ID.</param>
|
||||||
|
/// <param name="BookNumber">Interne Buchnummer.</param>
|
||||||
|
/// <param name="Isbn">Optionale ISBN.</param>
|
||||||
|
/// <param name="Title">Titel.</param>
|
||||||
|
/// <param name="Author">Autor.</param>
|
||||||
|
/// <param name="Subject">Fach oder Kategorie.</param>
|
||||||
|
/// <param name="Publisher">Optionaler Verlag.</param>
|
||||||
|
/// <param name="PublicationYear">Optionales Erscheinungsjahr.</param>
|
||||||
|
/// <param name="IsAvailable">Gibt an, ob das Buch ausleihbar ist.</param>
|
||||||
|
/// <param name="IsActive">Gibt an, ob das Buch aktiv im Bestand ist.</param>
|
||||||
|
/// <param name="CreatedAt">Erstellzeitpunkt.</param>
|
||||||
|
/// <param name="UpdatedAt">Letzter Änderungszeitpunkt.</param>
|
||||||
|
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);
|
||||||
|
|
||||||
|
/// <summary>Daten zum Anlegen oder Aktualisieren eines Buchs.</summary>
|
||||||
|
public sealed class SaveBookRequest
|
||||||
|
{
|
||||||
|
/// <summary>Interne Buchnummer.</summary>
|
||||||
|
[Required, StringLength(LibraryRules.MaxBookNumberLength, MinimumLength = 1)]
|
||||||
|
public string BookNumber { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Optionale ISBN.</summary>
|
||||||
|
[StringLength(20)]
|
||||||
|
public string? Isbn { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Titel.</summary>
|
||||||
|
[Required, StringLength(LibraryRules.MaxTitleLength, MinimumLength = 1)]
|
||||||
|
public string Title { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Autor.</summary>
|
||||||
|
[Required, StringLength(LibraryRules.MaxAuthorLength, MinimumLength = 1)]
|
||||||
|
public string Author { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Fach oder Kategorie.</summary>
|
||||||
|
[Required, StringLength(LibraryRules.MaxSubjectLength, MinimumLength = 1)]
|
||||||
|
public string Subject { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Optionaler Verlag.</summary>
|
||||||
|
[StringLength(160)]
|
||||||
|
public string? Publisher { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Optionales Erscheinungsjahr.</summary>
|
||||||
|
[Range(1000, 2200)]
|
||||||
|
public int? PublicationYear { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,8 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<RootNamespace>Bsevita.Library.Models</RootNamespace>
|
||||||
|
<GenerateDocumentationFile>true</GenerateDocumentationFile>
|
||||||
|
<NoWarn>$(NoWarn);1591</NoWarn>
|
||||||
|
</PropertyGroup>
|
||||||
|
</Project>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
|
||||||
|
/// <summary>Daten zur Prüfung einer Ausleihe.</summary>
|
||||||
|
public sealed class VerifyLoanRequest
|
||||||
|
{
|
||||||
|
/// <summary>Bibliotheksausweisnummer des Schülers.</summary>
|
||||||
|
[Required, StringLength(LibraryRules.MaxCardNumberLength)]
|
||||||
|
public string CardNumber { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Buchnummer des auszuleihenden Buchs.</summary>
|
||||||
|
[Required, StringLength(LibraryRules.MaxBookNumberLength)]
|
||||||
|
public string BookNumber { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Daten zum Erstellen einer Ausleihe.</summary>
|
||||||
|
public sealed class CreateLoanRequest
|
||||||
|
{
|
||||||
|
/// <summary>Bibliotheksausweisnummer des Schülers.</summary>
|
||||||
|
[Required, StringLength(LibraryRules.MaxCardNumberLength)]
|
||||||
|
public string CardNumber { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Buchnummer des auszuleihenden Buchs.</summary>
|
||||||
|
[Required, StringLength(LibraryRules.MaxBookNumberLength)]
|
||||||
|
public string BookNumber { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Optionale Fälligkeit; ohne Wert wird die Standardfrist verwendet.</summary>
|
||||||
|
public DateTimeOffset? DueAt { get; set; }
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ergebnis einer Ausleihpruefung.</summary>
|
||||||
|
/// <param name="Student">Gefundener Schüler.</param>
|
||||||
|
/// <param name="Book">Gefundenes Buch.</param>
|
||||||
|
/// <param name="DueAt">Berechnetes Rückgabedatum.</param>
|
||||||
|
/// <param name="CanBorrow">Gibt an, ob die Ausleihe erlaubt ist.</param>
|
||||||
|
/// <param name="StatusMessage">Lesbare Statusmeldung.</param>
|
||||||
|
public sealed record LoanVerificationDto(
|
||||||
|
StudentDto Student,
|
||||||
|
BookDto Book,
|
||||||
|
DateTimeOffset DueAt,
|
||||||
|
bool CanBorrow,
|
||||||
|
string StatusMessage);
|
||||||
|
|
||||||
|
/// <summary>Ausleihe mit Schüler-, Buch- und Fristdaten.</summary>
|
||||||
|
/// <param name="Id">Eindeutige Ausleih-ID.</param>
|
||||||
|
/// <param name="StudentId">ID des Schülers.</param>
|
||||||
|
/// <param name="StudentName">Name des Schülers.</param>
|
||||||
|
/// <param name="CardNumber">Bibliotheksausweisnummer.</param>
|
||||||
|
/// <param name="BookId">ID des Buchs.</param>
|
||||||
|
/// <param name="BookTitle">Titel des Buchs.</param>
|
||||||
|
/// <param name="BookNumber">Buchnummer.</param>
|
||||||
|
/// <param name="LoanedAt">Ausleihzeitpunkt.</param>
|
||||||
|
/// <param name="DueAt">Fälligkeit der Rückgabe.</param>
|
||||||
|
/// <param name="ReturnedAt">Rückgabezeitpunkt, falls abgeschlossen.</param>
|
||||||
|
/// <param name="IsOverdue">Gibt an, ob die Ausleihe überfällig ist.</param>
|
||||||
|
/// <param name="DaysOverdue">Anzahl überfälliger Tage.</param>
|
||||||
|
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);
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
using Bsevita.Library.Models.Loans;
|
||||||
|
|
||||||
|
namespace Bsevita.Library.Models.Reports;
|
||||||
|
|
||||||
|
/// <summary>Schüler mit aktiven Ausleihen.</summary>
|
||||||
|
/// <param name="StudentId">ID des Schülers.</param>
|
||||||
|
/// <param name="StudentName">Name des Schülers.</param>
|
||||||
|
/// <param name="CardNumber">Bibliotheksausweisnummer.</param>
|
||||||
|
/// <param name="ClassName">Klasse oder Kurs.</param>
|
||||||
|
/// <param name="ActiveLoanCount">Anzahl aktiver Ausleihen.</param>
|
||||||
|
public sealed record ActiveStudentDto(
|
||||||
|
Guid StudentId,
|
||||||
|
string StudentName,
|
||||||
|
string CardNumber,
|
||||||
|
string ClassName,
|
||||||
|
int ActiveLoanCount);
|
||||||
|
|
||||||
|
/// <summary>Aggregierte Bibliotheksstatistiken.</summary>
|
||||||
|
/// <param name="TotalBooks">Anzahl aktiver Bücher.</param>
|
||||||
|
/// <param name="AvailableBooks">Anzahl verfügbarer Bücher.</param>
|
||||||
|
/// <param name="TotalStudents">Anzahl aktiver Schüler.</param>
|
||||||
|
/// <param name="ActiveLoans">Anzahl aktiver Ausleihen.</param>
|
||||||
|
/// <param name="OverdueLoans">Anzahl überfälliger Ausleihen.</param>
|
||||||
|
public sealed record LibraryStatisticsDto(
|
||||||
|
int TotalBooks,
|
||||||
|
int AvailableBooks,
|
||||||
|
int TotalStudents,
|
||||||
|
int ActiveLoans,
|
||||||
|
int OverdueLoans);
|
||||||
|
|
||||||
|
/// <summary>Gebundelte Reports für das Dashboard.</summary>
|
||||||
|
/// <param name="ActiveLoans">Aktive Ausleihen.</param>
|
||||||
|
/// <param name="OverdueLoans">Ueberfällige Ausleihen.</param>
|
||||||
|
/// <param name="ActiveStudents">Schüler mit aktiven Ausleihen.</param>
|
||||||
|
/// <param name="Statistics">Aggregierte Statistiken.</param>
|
||||||
|
public sealed record ReportsDto(
|
||||||
|
IReadOnlyList<LoanDto> ActiveLoans,
|
||||||
|
IReadOnlyList<LoanDto> OverdueLoans,
|
||||||
|
IReadOnlyList<ActiveStudentDto> ActiveStudents,
|
||||||
|
LibraryStatisticsDto Statistics);
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Bsevita.Library.Models.Common;
|
||||||
|
|
||||||
|
namespace Bsevita.Library.Models.Returns;
|
||||||
|
|
||||||
|
/// <summary>Daten zum Abschließen einer Rückgabe.</summary>
|
||||||
|
public sealed class ReturnBookRequest
|
||||||
|
{
|
||||||
|
/// <summary>Buchnummer des zurückgegebenen Buchs.</summary>
|
||||||
|
[Required, StringLength(LibraryRules.MaxBookNumberLength)]
|
||||||
|
public string BookNumber { get; set; } = string.Empty;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ergebnis einer Rückgabeprüfung.</summary>
|
||||||
|
/// <param name="LoanId">ID der aktiven Ausleihe.</param>
|
||||||
|
/// <param name="StudentName">Name des Schülers.</param>
|
||||||
|
/// <param name="CardNumber">Bibliotheksausweisnummer.</param>
|
||||||
|
/// <param name="BookTitle">Titel des Buchs.</param>
|
||||||
|
/// <param name="BookNumber">Buchnummer.</param>
|
||||||
|
/// <param name="LoanedAt">Ausleihzeitpunkt.</param>
|
||||||
|
/// <param name="DueAt">Fälligkeit der Rückgabe.</param>
|
||||||
|
/// <param name="IsOverdue">Gibt an, ob die Ausleihe überfällig ist.</param>
|
||||||
|
/// <param name="DaysOverdue">Anzahl überfälliger Tage.</param>
|
||||||
|
public sealed record ReturnVerificationDto(
|
||||||
|
Guid LoanId,
|
||||||
|
string StudentName,
|
||||||
|
string CardNumber,
|
||||||
|
string BookTitle,
|
||||||
|
string BookNumber,
|
||||||
|
DateTimeOffset LoanedAt,
|
||||||
|
DateTimeOffset DueAt,
|
||||||
|
bool IsOverdue,
|
||||||
|
int DaysOverdue);
|
||||||
|
|
||||||
|
/// <summary>Ergebnis einer abgeschlossenen Rückgabe.</summary>
|
||||||
|
/// <param name="LoanId">ID der abgeschlossenen Ausleihe.</param>
|
||||||
|
/// <param name="StudentName">Name des Schülers.</param>
|
||||||
|
/// <param name="BookTitle">Titel des Buchs.</param>
|
||||||
|
/// <param name="BookNumber">Buchnummer.</param>
|
||||||
|
/// <param name="ReturnedAt">Rückgabezeitpunkt.</param>
|
||||||
|
/// <param name="WasOverdue">Gibt an, ob die Ausleihe überfällig war.</param>
|
||||||
|
/// <param name="DaysOverdue">Anzahl überfälliger Tage.</param>
|
||||||
|
public sealed record ReturnResultDto(
|
||||||
|
Guid LoanId,
|
||||||
|
string StudentName,
|
||||||
|
string BookTitle,
|
||||||
|
string BookNumber,
|
||||||
|
DateTimeOffset ReturnedAt,
|
||||||
|
bool WasOverdue,
|
||||||
|
int DaysOverdue);
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
using System.ComponentModel.DataAnnotations;
|
||||||
|
using Bsevita.Library.Models.Common;
|
||||||
|
|
||||||
|
namespace Bsevita.Library.Models.Students;
|
||||||
|
|
||||||
|
/// <summary>Schüler mit Ausweis- und Kontaktdaten.</summary>
|
||||||
|
/// <param name="Id">Eindeutige Schüler-ID.</param>
|
||||||
|
/// <param name="CardNumber">Bibliotheksausweisnummer.</param>
|
||||||
|
/// <param name="FirstName">Vorname.</param>
|
||||||
|
/// <param name="LastName">Nachname.</param>
|
||||||
|
/// <param name="FullName">Anzeigename aus Vor- und Nachname.</param>
|
||||||
|
/// <param name="ClassName">Klasse oder Kurs.</param>
|
||||||
|
/// <param name="Email">Optionale E-Mail-Adresse.</param>
|
||||||
|
/// <param name="IsActive">Gibt an, ob der Schüler aktiv ist.</param>
|
||||||
|
/// <param name="CreatedAt">Erstellzeitpunkt.</param>
|
||||||
|
/// <param name="UpdatedAt">Letzter Änderungszeitpunkt.</param>
|
||||||
|
public sealed record StudentDto(
|
||||||
|
Guid Id,
|
||||||
|
string CardNumber,
|
||||||
|
string FirstName,
|
||||||
|
string LastName,
|
||||||
|
string FullName,
|
||||||
|
string ClassName,
|
||||||
|
string? Email,
|
||||||
|
bool IsActive,
|
||||||
|
DateTimeOffset CreatedAt,
|
||||||
|
DateTimeOffset UpdatedAt);
|
||||||
|
|
||||||
|
/// <summary>Daten zum Anlegen oder Aktualisieren eines Schülers.</summary>
|
||||||
|
public sealed class SaveStudentRequest
|
||||||
|
{
|
||||||
|
/// <summary>Bibliotheksausweisnummer.</summary>
|
||||||
|
[Required, StringLength(LibraryRules.MaxCardNumberLength, MinimumLength = 2)]
|
||||||
|
public string CardNumber { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Vorname.</summary>
|
||||||
|
[Required, StringLength(LibraryRules.MaxNameLength, MinimumLength = 1)]
|
||||||
|
public string FirstName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Nachname.</summary>
|
||||||
|
[Required, StringLength(LibraryRules.MaxNameLength, MinimumLength = 1)]
|
||||||
|
public string LastName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Klasse oder Kurs.</summary>
|
||||||
|
[Required, StringLength(LibraryRules.MaxClassNameLength, MinimumLength = 1)]
|
||||||
|
public string ClassName { get; set; } = string.Empty;
|
||||||
|
|
||||||
|
/// <summary>Optionale E-Mail-Adresse.</summary>
|
||||||
|
[EmailAddress, StringLength(254)]
|
||||||
|
public string? Email { get; set; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
<Router AppAssembly="typeof(AppRoutes).Assembly" NotFoundPage="typeof(Bsevita.Library.Ui.Pages.NotFound)">
|
||||||
|
<Found Context="routeData">
|
||||||
|
<RouteView RouteData="routeData" DefaultLayout="typeof(MainLayout)" />
|
||||||
|
<FocusOnNavigate RouteData="routeData" Selector="h1" />
|
||||||
|
</Found>
|
||||||
|
</Router>
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<Project Sdk="Microsoft.NET.Sdk.Razor">
|
||||||
|
<PropertyGroup>
|
||||||
|
<TargetFramework>net10.0</TargetFramework>
|
||||||
|
<RootNamespace>Bsevita.Library.Ui</RootNamespace>
|
||||||
|
<AddRazorSupportForMvc>true</AddRazorSupportForMvc>
|
||||||
|
</PropertyGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<ProjectReference Include="../Bsevita.Library.Models/Bsevita.Library.Models.csproj" />
|
||||||
|
</ItemGroup>
|
||||||
|
<ItemGroup>
|
||||||
|
<FrameworkReference Include="Microsoft.AspNetCore.App" />
|
||||||
|
</ItemGroup>
|
||||||
|
</Project>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
@if (!string.IsNullOrWhiteSpace(Message))
|
||||||
|
{
|
||||||
|
<div class="alert alert-@Kind" role="alert">
|
||||||
|
<span class="alert__icon"><Icon Name="@(Kind == "success" ? "check" : Kind == "warning" ? "alert-triangle" : "x-circle")" /></span>
|
||||||
|
<span>@Message</span>
|
||||||
|
@if (Dismissible)
|
||||||
|
{
|
||||||
|
<button class="alert__close" type="button" aria-label="Meldung schließen" @onclick="Dismiss"><Icon Name="x" /></button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
@if (Visible)
|
||||||
|
{
|
||||||
|
<div class="modal-backdrop" @onclick="Cancel">
|
||||||
|
<section class="modal modal--small" role="dialog" aria-modal="true" aria-labelledby="confirm-title" @onclick:stopPropagation="true">
|
||||||
|
<div class="modal__header">
|
||||||
|
<h2 id="confirm-title">@Title</h2>
|
||||||
|
<button class="icon-button" type="button" aria-label="Dialog schließen" @onclick="Cancel"><Icon Name="x" /></button>
|
||||||
|
</div>
|
||||||
|
<div class="modal__body">
|
||||||
|
<p>@Message</p>
|
||||||
|
</div>
|
||||||
|
<div class="modal__footer">
|
||||||
|
<button class="btn btn-secondary" type="button" @onclick="Cancel">Abbrechen</button>
|
||||||
|
<button class="btn btn-danger" type="button" @onclick="Confirm">@ConfirmText</button>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<svg class="@CssClass" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true" focusable="false">
|
||||||
|
@((MarkupString)Path)
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
@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" => """<path d="m21.73 18-8-14a2 2 0 0 0-3.48 0l-8 14A2 2 0 0 0 4 21h16a2 2 0 0 0 1.73-3"/><path d="M12 9v4"/><path d="M12 17h.01"/>""",
|
||||||
|
"arrow-left-right" => """<path d="M8 3 4 7l4 4"/><path d="M4 7h16"/><path d="m16 21 4-4-4-4"/><path d="M20 17H4"/>""",
|
||||||
|
"book-open" => """<path d="M12 7v14"/><path d="M3 18a1 1 0 0 1-1-1V4a1 1 0 0 1 1-1h5a4 4 0 0 1 4 4 4 4 0 0 1 4-4h5a1 1 0 0 1 1 1v13a1 1 0 0 1-1 1h-6a3 3 0 0 0-3 3 3 3 0 0 0-3-3z"/>""",
|
||||||
|
"check" => """<path d="M20 6 9 17l-5-5"/>""",
|
||||||
|
"clipboard-list" => """<rect width="8" height="4" x="8" y="2" rx="1" ry="1"/><path d="M16 4h2a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2H6a2 2 0 0 1-2-2V6a2 2 0 0 1 2-2h2"/><path d="M12 11h4"/><path d="M12 16h4"/><path d="M8 11h.01"/><path d="M8 16h.01"/>""",
|
||||||
|
"graduation-cap" => """<path d="M21.42 10.922a1 1 0 0 0-.019-1.838L12.83 5.18a2 2 0 0 0-1.66 0L2.6 9.084a1 1 0 0 0 0 1.832l8.57 3.908a2 2 0 0 0 1.66 0z"/><path d="M22 10v6"/><path d="M6 12.5V16a6 3 0 0 0 12 0v-3.5"/>""",
|
||||||
|
"house" => """<path d="M15 21v-8a1 1 0 0 0-1-1h-4a1 1 0 0 0-1 1v8"/><path d="M3 10a2 2 0 0 1 .709-1.528l7-5.999a2 2 0 0 1 2.582 0l7 5.999A2 2 0 0 1 21 10v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2z"/>""",
|
||||||
|
"plus" => """<path d="M5 12h14"/><path d="M12 5v14"/>""",
|
||||||
|
"rotate-ccw" => """<path d="M3 12a9 9 0 1 0 9-9 9.75 9.75 0 0 0-6.74 2.74L3 8"/><path d="M3 3v5h5"/>""",
|
||||||
|
"search" => """<path d="m21 21-4.34-4.34"/><circle cx="11" cy="11" r="8"/>""",
|
||||||
|
"x" => """<path d="M18 6 6 18"/><path d="m6 6 12 12"/>""",
|
||||||
|
"x-circle" => """<circle cx="12" cy="12" r="10"/><path d="m15 9-6 6"/><path d="m9 9 6 6"/>""",
|
||||||
|
_ => """<circle cx="12" cy="12" r="10"/>"""
|
||||||
|
};
|
||||||
|
}
|
||||||
|
After Width: | Height: | Size: 2.2 KiB |
@@ -0,0 +1,4 @@
|
|||||||
|
<div class="loading-panel" role="status" aria-live="polite">
|
||||||
|
<span class="spinner"></span>
|
||||||
|
<span>@Text</span>
|
||||||
|
</div>
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
namespace Bsevita.Library.Ui.Components;
|
||||||
|
|
||||||
|
public partial class LoadingIndicator
|
||||||
|
{
|
||||||
|
[Parameter] public string Text { get; set; } = "Daten werden geladen …";
|
||||||
|
}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<div class="page-header">
|
||||||
|
<div>
|
||||||
|
<h1>@Title</h1>
|
||||||
|
</div>
|
||||||
|
@if (Actions is not null)
|
||||||
|
{
|
||||||
|
<div class="page-header__actions">@Actions</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
@@ -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; }
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
<div class="stat-card card">
|
||||||
|
<div class="stat-card__icon stat-card__icon--@Tone"><Icon Name="@Icon" /></div>
|
||||||
|
<div>
|
||||||
|
<div class="stat-card__value">@Value</div>
|
||||||
|
<div class="stat-card__label">@Label</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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";
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -0,0 +1,22 @@
|
|||||||
|
@inherits LayoutComponentBase
|
||||||
|
|
||||||
|
<div class="app-shell">
|
||||||
|
<aside class="sidebar @(_mobileMenuOpen ? "sidebar--open" : string.Empty)">
|
||||||
|
<NavMenu OnNavigate="CloseMenu" />
|
||||||
|
</aside>
|
||||||
|
@if (_mobileMenuOpen)
|
||||||
|
{
|
||||||
|
<button class="sidebar-backdrop" aria-label="Navigation schließen" @onclick="CloseMenu"></button>
|
||||||
|
}
|
||||||
|
<div class="app-main">
|
||||||
|
<header class="topbar">
|
||||||
|
<div>
|
||||||
|
<strong>Library System</strong>
|
||||||
|
</div>
|
||||||
|
<span class="api-indicator" title="Bibliothekssystem ist erreichbar">Bereit</span>
|
||||||
|
</header>
|
||||||
|
<main class="content">
|
||||||
|
@Body
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,27 @@
|
|||||||
|
<div class="brand">
|
||||||
|
<div class="brand__mark">B</div>
|
||||||
|
<div>
|
||||||
|
<div class="brand__name">BSEVITA</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<nav class="nav-list" aria-label="Hauptnavigation">
|
||||||
|
<NavLink class="nav-item" href="/" Match="NavLinkMatch.All" @onclick="Navigate">
|
||||||
|
<span class="nav-icon"><Icon Name="house" /></span><span>Übersicht</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink class="nav-item" href="students" @onclick="Navigate">
|
||||||
|
<span class="nav-icon"><Icon Name="graduation-cap" /></span><span>Schüler</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink class="nav-item" href="books" @onclick="Navigate">
|
||||||
|
<span class="nav-icon"><Icon Name="book-open" /></span><span>Bücher</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink class="nav-item" href="loans" @onclick="Navigate">
|
||||||
|
<span class="nav-icon"><Icon Name="arrow-left-right" /></span><span>Ausleihe</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink class="nav-item" href="returns" @onclick="Navigate">
|
||||||
|
<span class="nav-icon"><Icon Name="rotate-ccw" /></span><span>Rückgabe</span>
|
||||||
|
</NavLink>
|
||||||
|
<NavLink class="nav-item" href="reports" @onclick="Navigate">
|
||||||
|
<span class="nav-icon"><Icon Name="clipboard-list" /></span><span>Listen</span>
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
namespace Bsevita.Library.Ui.Layout;
|
||||||
|
|
||||||
|
public partial class NavMenu
|
||||||
|
{
|
||||||
|
[Parameter] public EventCallback OnNavigate { get; set; }
|
||||||
|
private Task Navigate() => OnNavigate.InvokeAsync();
|
||||||
|
}
|
||||||
@@ -0,0 +1,86 @@
|
|||||||
|
@page "/books"
|
||||||
|
@inject BookApiClient BooksApi
|
||||||
|
|
||||||
|
<PageTitle>Bücher - Library</PageTitle>
|
||||||
|
<PageHeader Title="Bücher">
|
||||||
|
<Actions><button class="btn btn-primary" type="button" @onclick="OpenCreate"><Icon Name="plus" /> Neues Buch</button></Actions>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<AlertMessage Message="@_errorMessage" Kind="error" OnDismiss="ClearMessages" />
|
||||||
|
<AlertMessage Message="@_successMessage" Kind="success" OnDismiss="ClearMessages" />
|
||||||
|
|
||||||
|
<section class="card panel">
|
||||||
|
<div class="toolbar toolbar--wrap">
|
||||||
|
<div class="search-group search-group--books">
|
||||||
|
<label class="sr-only" for="book-filter">Suchfeld</label>
|
||||||
|
<select id="book-filter" class="form-control form-select" @bind="_searchFilter">
|
||||||
|
<option value="title">Titel</option><option value="author">Autor</option><option value="subject">Sachgebiet</option><option value="number">Buchnummer</option>
|
||||||
|
</select>
|
||||||
|
<label class="sr-only" for="book-search">Buch suchen</label>
|
||||||
|
<input id="book-search" class="form-control" placeholder="Titel, Autor oder Buchnummer eingeben …" @bind="_searchText" @bind:event="oninput" @onkeydown="HandleSearchKey" autofocus="@ShouldFocusSearch" />
|
||||||
|
<button class="btn btn-secondary" type="button" disabled="@_isLoading" @onclick="SearchAsync">Suchen</button>
|
||||||
|
@if (HasSearchText) { <button class="btn btn-ghost" type="button" @onclick="ResetSearchAsync">Zurücksetzen</button> }
|
||||||
|
</div>
|
||||||
|
<span class="result-count">@_books.Count Bücher</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
<LoadingIndicator Text="Bücher werden geladen …" />
|
||||||
|
}
|
||||||
|
else if (_books.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state__icon"><Icon Name="book-open" /></div>
|
||||||
|
<h2>Keine Bücher gefunden</h2>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th>Titel</th><th>Autor</th><th>Sachgebiet</th><th>Buchnummer</th><th>Status</th><th class="table-actions">Aktionen</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var book in _books)
|
||||||
|
{
|
||||||
|
<tr @key="book.Id">
|
||||||
|
<td data-label="Titel"><strong>@book.Title</strong>@if (HasIsbn(book)) { <small class="cell-subtitle">ISBN @book.Isbn</small> }</td>
|
||||||
|
<td data-label="Autor">@book.Author</td>
|
||||||
|
<td data-label="Sachgebiet">@book.Subject</td>
|
||||||
|
<td data-label="Buchnummer"><code>@book.BookNumber</code></td>
|
||||||
|
<td data-label="Status"><span class="badge @(book.IsAvailable ? "badge-success" : "badge-danger")">@(book.IsAvailable ? "Verfügbar" : "Ausgeliehen")</span></td>
|
||||||
|
<td data-label="Aktionen" class="table-actions">
|
||||||
|
<button class="btn btn-small btn-secondary" type="button" @onclick="() => OpenEditAsync(book.Id)">Bearbeiten</button>
|
||||||
|
<button class="btn btn-small btn-danger-outline" type="button" @onclick="() => AskDelete(book)">Löschen</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (_showEditor)
|
||||||
|
{
|
||||||
|
<div class="modal-backdrop" @onclick="CloseEditor">
|
||||||
|
<section class="modal" role="dialog" aria-modal="true" aria-labelledby="book-editor-title" @onclick:stopPropagation="true">
|
||||||
|
<div class="modal__header"><div><h2 id="book-editor-title">@EditorTitle</h2></div><button class="icon-button" type="button" aria-label="Dialog schließen" @onclick="CloseEditor"><Icon Name="x" /></button></div>
|
||||||
|
<EditForm Model="@_editModel" OnValidSubmit="SaveAsync">
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<div class="modal__body form-grid">
|
||||||
|
<div class="form-field"><label for="book-number">Buchnummer</label><InputText id="book-number" class="form-control" @bind-Value="_editModel.BookNumber" /><ValidationMessage For="@(() => _editModel.BookNumber)" /></div>
|
||||||
|
<div class="form-field"><label for="book-isbn">ISBN (optional)</label><InputText id="book-isbn" class="form-control" @bind-Value="_editModel.Isbn" /><ValidationMessage For="@(() => _editModel.Isbn)" /></div>
|
||||||
|
<div class="form-field form-field--full"><label for="book-title">Titel</label><InputText id="book-title" class="form-control" @bind-Value="_editModel.Title" /><ValidationMessage For="@(() => _editModel.Title)" /></div>
|
||||||
|
<div class="form-field"><label for="book-author">Autor</label><InputText id="book-author" class="form-control" @bind-Value="_editModel.Author" /><ValidationMessage For="@(() => _editModel.Author)" /></div>
|
||||||
|
<div class="form-field"><label for="book-subject">Sachgebiet</label><InputText id="book-subject" class="form-control" @bind-Value="_editModel.Subject" /><ValidationMessage For="@(() => _editModel.Subject)" /></div>
|
||||||
|
<div class="form-field"><label for="book-publisher">Verlag (optional)</label><InputText id="book-publisher" class="form-control" @bind-Value="_editModel.Publisher" /><ValidationMessage For="@(() => _editModel.Publisher)" /></div>
|
||||||
|
<div class="form-field"><label for="book-year">Erscheinungsjahr</label><InputNumber id="book-year" class="form-control" @bind-Value="_editModel.PublicationYear" /><ValidationMessage For="@(() => _editModel.PublicationYear)" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal__footer"><button class="btn btn-secondary" type="button" @onclick="CloseEditor">Abbrechen</button><button class="btn btn-primary" type="submit" disabled="@_isSaving">@SaveButtonText</button></div>
|
||||||
|
</EditForm>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<ConfirmDialog Visible="@(_bookToDelete is not null)" Title="Buch löschen" Message="@DeleteMessage" ConfirmText="Endgültig löschen" OnConfirm="DeleteAsync" OnCancel="CancelDelete" />
|
||||||
@@ -0,0 +1,199 @@
|
|||||||
|
namespace Bsevita.Library.Ui.Pages;
|
||||||
|
|
||||||
|
public partial class Books
|
||||||
|
{
|
||||||
|
[SupplyParameterFromQuery(Name = "focus")]
|
||||||
|
public string? Focus { get; set; }
|
||||||
|
|
||||||
|
private List<BookDto> _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.";
|
||||||
|
}
|
||||||
@@ -0,0 +1,82 @@
|
|||||||
|
@page "/"
|
||||||
|
@inject ReportApiClient Reports
|
||||||
|
|
||||||
|
<PageTitle>Übersicht - Library</PageTitle>
|
||||||
|
<PageHeader Title="Übersicht" />
|
||||||
|
|
||||||
|
<AlertMessage Message="@_errorMessage" Kind="error" OnDismiss="ClearError" />
|
||||||
|
|
||||||
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
<LoadingIndicator Text="Übersicht wird geladen …" />
|
||||||
|
}
|
||||||
|
else if (_statistics is not null)
|
||||||
|
{
|
||||||
|
<section class="stats-grid" aria-label="Bibliotheksstatistik">
|
||||||
|
<StatCard Label="Aktive Ausleihen" Value="@_statistics.ActiveLoans.ToString()" Icon="arrow-left-right" Tone="primary" />
|
||||||
|
<StatCard Label="Überfällige Bücher" Value="@_statistics.OverdueLoans.ToString()" Icon="alert-triangle" Tone="danger" />
|
||||||
|
<StatCard Label="Schüler" Value="@_statistics.TotalStudents.ToString()" Icon="graduation-cap" Tone="success" />
|
||||||
|
<StatCard Label="Bücher" Value="@_statistics.TotalBooks.ToString()" Icon="book-open" Tone="violet" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="dashboard-grid">
|
||||||
|
<div class="card panel">
|
||||||
|
<div class="panel__header">
|
||||||
|
<div>
|
||||||
|
<h2>Was möchten Sie tun?</h2>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="quick-actions">
|
||||||
|
<a class="quick-action" href="loans">
|
||||||
|
<span class="quick-action__icon"><Icon Name="plus" /></span>
|
||||||
|
<span><strong>Buch ausleihen</strong></span>
|
||||||
|
</a>
|
||||||
|
<a class="quick-action" href="returns">
|
||||||
|
<span class="quick-action__icon"><Icon Name="rotate-ccw" /></span>
|
||||||
|
<span><strong>Buch zurücknehmen</strong></span>
|
||||||
|
</a>
|
||||||
|
<a class="quick-action" href="students?focus=search">
|
||||||
|
<span class="quick-action__icon"><Icon Name="search" /></span>
|
||||||
|
<span><strong>Schüler finden</strong></span>
|
||||||
|
</a>
|
||||||
|
<a class="quick-action" href="books?focus=search">
|
||||||
|
<span class="quick-action__icon"><Icon Name="search" /></span>
|
||||||
|
<span><strong>Buch finden</strong></span>
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card panel">
|
||||||
|
<div class="panel__header">
|
||||||
|
<div>
|
||||||
|
<h2>Überfällige Bücher</h2>
|
||||||
|
</div>
|
||||||
|
<a class="text-link" href="reports">Alle Listen</a>
|
||||||
|
</div>
|
||||||
|
@if (_overdueLoans.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="empty-inline"><span><Icon Name="check" /></span><p>Alles im grünen Bereich.</p></div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="compact-list">
|
||||||
|
@foreach (var loan in VisibleOverdueLoans)
|
||||||
|
{
|
||||||
|
<div class="compact-list__item compact-list__item--danger">
|
||||||
|
<div><strong>@loan.BookTitle</strong><small>@loan.StudentName · @loan.BookNumber</small></div>
|
||||||
|
<span class="badge badge-danger">@loan.DaysOverdue Tag@(loan.DaysOverdue == 1 ? "" : "e")</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state__icon"><Icon Name="alert-triangle" /></div>
|
||||||
|
<h2>Übersicht konnte nicht geladen werden</h2>
|
||||||
|
<button class="btn btn-secondary" type="button" @onclick="LoadAsync">Erneut versuchen</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
namespace Bsevita.Library.Ui.Pages;
|
||||||
|
|
||||||
|
public partial class Dashboard
|
||||||
|
{
|
||||||
|
private LibraryStatisticsDto? _statistics;
|
||||||
|
private IReadOnlyList<LoanDto> _overdueLoans = [];
|
||||||
|
private bool _isLoading = true;
|
||||||
|
private string? _errorMessage;
|
||||||
|
private IEnumerable<LoanDto> 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.";
|
||||||
|
}
|
||||||
@@ -0,0 +1,81 @@
|
|||||||
|
@page "/loans"
|
||||||
|
@inject LoanApiClient LoansApi
|
||||||
|
@inject StudentApiClient StudentsApi
|
||||||
|
@inject BookApiClient BooksApi
|
||||||
|
|
||||||
|
<PageTitle>Ausleihe - Library</PageTitle>
|
||||||
|
<PageHeader Title="Buch ausleihen" />
|
||||||
|
|
||||||
|
<AlertMessage Message="@_errorMessage" Kind="error" OnDismiss="ClearMessages" />
|
||||||
|
<AlertMessage Message="@_warningMessage" Kind="warning" OnDismiss="ClearMessages" />
|
||||||
|
<AlertMessage Message="@_successMessage" Kind="success" OnDismiss="ClearMessages" />
|
||||||
|
|
||||||
|
<div class="process-steps process-steps--two" aria-label="Ausleihprozess">
|
||||||
|
@foreach (var step in _steps)
|
||||||
|
{
|
||||||
|
<div class="process-step @(CurrentStep >= step.Number ? "process-step--active" : string.Empty)"><span>@step.Number</span><small>@step.Label</small></div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card process-card">
|
||||||
|
@if (_verification is null)
|
||||||
|
{
|
||||||
|
<div class="process-card__header"><h2>Wer leiht welches Buch aus?</h2></div>
|
||||||
|
<EditForm Model="@_verifyModel" OnValidSubmit="VerifyAsync">
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<div class="form-grid process-form">
|
||||||
|
<div class="form-field search-field">
|
||||||
|
<label for="loan-card">Ausweisnummer</label>
|
||||||
|
<input id="loan-card" class="form-control form-control--large" value="@_verifyModel.CardNumber" @oninput="OnCardInput" autocomplete="off" />
|
||||||
|
<ValidationMessage For="@(() => _verifyModel.CardNumber)" />
|
||||||
|
@if (CardSuggestions.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="suggestion-list">
|
||||||
|
@foreach (var student in CardSuggestions)
|
||||||
|
{
|
||||||
|
<button class="suggestion-item" type="button" @onclick="() => SelectStudent(student)">
|
||||||
|
<strong>@student.CardNumber</strong>
|
||||||
|
<span>@student.FullName (@student.ClassName)</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<div class="form-field search-field">
|
||||||
|
<label for="loan-book">Buchnummer</label>
|
||||||
|
<input id="loan-book" class="form-control form-control--large" value="@_verifyModel.BookNumber" @oninput="OnBookInput" autocomplete="off" />
|
||||||
|
<ValidationMessage For="@(() => _verifyModel.BookNumber)" />
|
||||||
|
@if (BookSuggestions.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="suggestion-list">
|
||||||
|
@foreach (var book in BookSuggestions)
|
||||||
|
{
|
||||||
|
<button class="suggestion-item" type="button" @onclick="() => SelectBook(book)">
|
||||||
|
<strong>@book.BookNumber</strong>
|
||||||
|
<span>@book.Title - @book.Author</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="process-actions"><button class="btn btn-primary btn-large" type="submit" disabled="@_isWorking">@(_isWorking ? "Prüft …" : "Weiter")</button></div>
|
||||||
|
</EditForm>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="process-card__header"><h2>Stimmt alles?</h2></div>
|
||||||
|
<div class="confirmation-card">
|
||||||
|
<div class="confirmation-card__status"><span><Icon Name="check" /></span><strong>Bereit zum Ausleihen</strong></div>
|
||||||
|
<dl class="detail-list">
|
||||||
|
<div><dt>Schüler</dt><dd>@_verification.Student.FullName</dd></div>
|
||||||
|
<div><dt>Klasse / Ausweis</dt><dd>@_verification.Student.ClassName · @_verification.Student.CardNumber</dd></div>
|
||||||
|
<div><dt>Buch</dt><dd>@_verification.Book.Title</dd></div>
|
||||||
|
<div><dt>Autor / Nummer</dt><dd>@_verification.Book.Author · @_verification.Book.BookNumber</dd></div>
|
||||||
|
<div><dt>Fällig am</dt><dd><input class="form-control" type="date" value="@_dueDate" @onchange="OnDueDateChanged" /></dd></div>
|
||||||
|
<div><dt>Status</dt><dd><span class="badge badge-success">Verfügbar</span></dd></div>
|
||||||
|
</dl>
|
||||||
|
</div>
|
||||||
|
<div class="process-actions process-actions--split"><button class="btn btn-secondary" type="button" @onclick="ResetVerification">Angaben ändern</button><button class="btn btn-primary btn-large" type="button" disabled="@_isWorking" @onclick="ConfirmAsync">@(_isWorking ? "Bucht …" : "Ausleihe bestätigen")</button></div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -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<StudentDto> _students = [];
|
||||||
|
private List<BookDto> _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<StudentDto> 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<BookDto> 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.";
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
@page "/not-found"
|
||||||
|
@layout MainLayout
|
||||||
|
|
||||||
|
<PageTitle>Seite nicht gefunden</PageTitle>
|
||||||
|
|
||||||
|
<section class="empty-state card">
|
||||||
|
<div class="empty-state__icon">404</div>
|
||||||
|
<h1>Seite nicht gefunden</h1>
|
||||||
|
<p>Die angeforderte Seite existiert nicht.</p>
|
||||||
|
<a class="btn btn-primary" href="/">Zur Übersicht</a>
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
@page "/reports"
|
||||||
|
@inject ReportApiClient ReportsApi
|
||||||
|
|
||||||
|
<PageTitle>Listen - Library</PageTitle>
|
||||||
|
<PageHeader Title="Listen und Überblick" />
|
||||||
|
|
||||||
|
<AlertMessage Message="@_errorMessage" Kind="error" OnDismiss="ClearError" />
|
||||||
|
|
||||||
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
<LoadingIndicator Text="Listen werden geladen …" />
|
||||||
|
}
|
||||||
|
else if (_statistics is not null)
|
||||||
|
{
|
||||||
|
<section class="stats-grid stats-grid--reports">
|
||||||
|
<StatCard Label="Gesamtbestand" Value="@_statistics.TotalBooks.ToString()" Icon="book-open" Tone="violet" />
|
||||||
|
<StatCard Label="Verfügbar" Value="@_statistics.AvailableBooks.ToString()" Icon="check" Tone="success" />
|
||||||
|
<StatCard Label="Aktive Ausleihen" Value="@_statistics.ActiveLoans.ToString()" Icon="arrow-left-right" Tone="primary" />
|
||||||
|
<StatCard Label="Überfällig" Value="@_statistics.OverdueLoans.ToString()" Icon="alert-triangle" Tone="danger" />
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section class="report-grid">
|
||||||
|
<div class="card panel report-panel report-panel--wide">
|
||||||
|
<div class="panel__header"><div><h2>Offene Ausleihen</h2></div><span class="result-count">@_activeLoans.Count</span></div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="data-table"><thead><tr><th>Schüler</th><th>Buch</th><th>Ausgeliehen</th><th>Fällig</th><th>Status</th></tr></thead><tbody>
|
||||||
|
@if (_activeLoans.Count == 0)
|
||||||
|
{
|
||||||
|
<tr><td colspan="5" class="table-empty">Keine aktiven Ausleihen.</td></tr>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var loan in _activeLoans)
|
||||||
|
{
|
||||||
|
<tr class="@(loan.IsOverdue ? "row-overdue" : string.Empty)"><td data-label="Schüler"><strong>@loan.StudentName</strong><small class="cell-subtitle">@loan.CardNumber</small></td><td data-label="Buch">@loan.BookTitle<small class="cell-subtitle">@loan.BookNumber</small></td><td data-label="Ausgeliehen">@loan.LoanedAt.ToLocalTime().ToString("dd.MM.yyyy")</td><td data-label="Fällig">@loan.DueAt.ToLocalTime().ToString("dd.MM.yyyy")</td><td data-label="Status"><span class="badge @(loan.IsOverdue ? "badge-danger" : "badge-success")">@(loan.IsOverdue ? $"{loan.DaysOverdue} Tage überfällig" : "Aktiv")</span></td></tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card panel">
|
||||||
|
<div class="panel__header"><div><h2>Schüler mit Ausleihen</h2></div></div>
|
||||||
|
@if (_activeStudents.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="empty-inline"><span><Icon Name="graduation-cap" /></span><p>Keine aktiven Schüler.</p></div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="compact-list">
|
||||||
|
@foreach (var student in _activeStudents)
|
||||||
|
{
|
||||||
|
<div class="compact-list__item" @key="student.StudentId">
|
||||||
|
<div>
|
||||||
|
<strong>@student.StudentName</strong>
|
||||||
|
<small>@student.ClassName · @student.CardNumber</small>
|
||||||
|
</div>
|
||||||
|
<span class="badge badge-primary">@student.ActiveLoanCount</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card panel report-panel report-panel--wide">
|
||||||
|
<div class="panel__header"><div><h2>Überfällige Ausleihen</h2></div><span class="badge badge-danger">@_overdueLoans.Count offen</span></div>
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="data-table"><thead><tr><th>Verzug</th><th>Schüler</th><th>Buch</th><th>Fällig seit</th></tr></thead><tbody>
|
||||||
|
@if (_overdueLoans.Count == 0)
|
||||||
|
{
|
||||||
|
<tr><td colspan="4" class="table-empty">Keine überfälligen Ausleihen.</td></tr>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
@foreach (var loan in _overdueLoans)
|
||||||
|
{
|
||||||
|
<tr class="row-overdue"><td data-label="Verzug"><span class="badge badge-danger">@loan.DaysOverdue Tage</span></td><td data-label="Schüler"><strong>@loan.StudentName</strong><small class="cell-subtitle">@loan.CardNumber</small></td><td data-label="Buch">@loan.BookTitle<small class="cell-subtitle">@loan.BookNumber</small></td><td data-label="Fällig seit" class="text-danger"><strong>@loan.DueAt.ToLocalTime().ToString("dd.MM.yyyy")</strong></td></tr>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</tbody></table>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="empty-state">
|
||||||
|
<div class="empty-state__icon"><Icon Name="alert-triangle" /></div>
|
||||||
|
<h2>Listen konnten nicht geladen werden</h2>
|
||||||
|
<button class="btn btn-secondary" type="button" @onclick="LoadAsync">Erneut versuchen</button>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
namespace Bsevita.Library.Ui.Pages;
|
||||||
|
|
||||||
|
public partial class Reports
|
||||||
|
{
|
||||||
|
private IReadOnlyList<LoanDto> _activeLoans = [];
|
||||||
|
private IReadOnlyList<LoanDto> _overdueLoans = [];
|
||||||
|
private IReadOnlyList<ActiveStudentDto> _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;
|
||||||
|
}
|
||||||
@@ -0,0 +1,65 @@
|
|||||||
|
@page "/returns"
|
||||||
|
@inject ReturnApiClient ReturnsApi
|
||||||
|
@inject BookApiClient BooksApi
|
||||||
|
|
||||||
|
<PageTitle>Rückgabe - Library</PageTitle>
|
||||||
|
<PageHeader Title="Buch zurückgeben" />
|
||||||
|
|
||||||
|
<AlertMessage Message="@_errorMessage" Kind="error" OnDismiss="ClearMessages" />
|
||||||
|
<AlertMessage Message="@_successMessage" Kind="success" OnDismiss="ClearMessages" />
|
||||||
|
|
||||||
|
<div class="process-steps process-steps--three" aria-label="Rückgabeprozess">
|
||||||
|
<div class="process-step process-step--active"><span>1</span><small>Buchnummer</small></div>
|
||||||
|
<div class="process-step @(_verification is not null ? "process-step--active" : string.Empty)"><span>2</span><small>Details</small></div>
|
||||||
|
<div class="process-step @(_verification is not null ? "process-step--active" : string.Empty)"><span>3</span><small>Speichern</small></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<section class="card process-card">
|
||||||
|
<div class="return-search">
|
||||||
|
<div class="form-field search-field">
|
||||||
|
<label for="return-book">Buchnummer</label>
|
||||||
|
<input id="return-book" class="form-control form-control--large" placeholder="z. B. B-1001" value="@_bookNumber" @oninput="OnBookInput" @onkeydown="HandleKey" autocomplete="off" />
|
||||||
|
@if (BookSuggestions.Count > 0)
|
||||||
|
{
|
||||||
|
<div class="suggestion-list">
|
||||||
|
@foreach (var book in BookSuggestions)
|
||||||
|
{
|
||||||
|
<button class="suggestion-item" type="button" @onclick="() => SelectBook(book)">
|
||||||
|
<strong>@book.BookNumber</strong>
|
||||||
|
<span>@book.Title - @book.Author</span>
|
||||||
|
</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<button class="btn btn-secondary btn-large" type="button" disabled="@_isWorking" @onclick="VerifyAsync">@(_isWorking ? "Sucht …" : "Ausleihe finden")</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_verification is not null)
|
||||||
|
{
|
||||||
|
<div class="confirmation-card @(_verification.IsOverdue ? "confirmation-card--overdue" : string.Empty)">
|
||||||
|
<div class="confirmation-card__status"><span><Icon Name="@(_verification.IsOverdue ? "alert-triangle" : "check")" /></span><strong>@(_verification.IsOverdue ? "Überfällige Ausleihe" : "Aktive Ausleihe")</strong></div>
|
||||||
|
<dl class="detail-list">
|
||||||
|
<div><dt>Schüler</dt><dd>@_verification.StudentName</dd></div>
|
||||||
|
<div><dt>Ausweisnummer</dt><dd>@_verification.CardNumber</dd></div>
|
||||||
|
<div><dt>Buch</dt><dd>@_verification.BookTitle</dd></div>
|
||||||
|
<div><dt>Ausgeliehen am</dt><dd>@_verification.LoanedAt.ToLocalTime().ToString("dd.MM.yyyy")</dd></div>
|
||||||
|
<div><dt>Fällig am</dt><dd class="@(_verification.IsOverdue ? "text-danger" : string.Empty)">@_verification.DueAt.ToLocalTime().ToString("dd.MM.yyyy")</dd></div>
|
||||||
|
<div>
|
||||||
|
<dt>Status</dt>
|
||||||
|
<dd>
|
||||||
|
@if (_verification.IsOverdue)
|
||||||
|
{
|
||||||
|
<span class="badge badge-danger">@_verification.DaysOverdue Tage überfällig</span>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<span class="badge badge-success">Fristgerecht</span>
|
||||||
|
}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
</dl>
|
||||||
|
<div class="process-actions"><button class="btn btn-primary btn-large" type="button" disabled="@_isWorking" @onclick="ConfirmAsync">@(_isWorking ? "Bucht …" : "Rückgabe bestätigen")</button></div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
@@ -0,0 +1,61 @@
|
|||||||
|
namespace Bsevita.Library.Ui.Pages;
|
||||||
|
|
||||||
|
public partial class Returns
|
||||||
|
{
|
||||||
|
private string _bookNumber = string.Empty;
|
||||||
|
private ReturnVerificationDto? _verification;
|
||||||
|
private List<BookDto> _borrowedBooks = [];
|
||||||
|
private bool _isWorking;
|
||||||
|
private string? _errorMessage;
|
||||||
|
private string? _successMessage;
|
||||||
|
private List<BookDto> 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.";
|
||||||
|
}
|
||||||
@@ -0,0 +1,92 @@
|
|||||||
|
@page "/students"
|
||||||
|
@inject StudentApiClient StudentsApi
|
||||||
|
|
||||||
|
<PageTitle>Schüler - Library</PageTitle>
|
||||||
|
<PageHeader Title="Schüler">
|
||||||
|
<Actions>
|
||||||
|
<button class="btn btn-primary" type="button" @onclick="OpenCreate"><Icon Name="plus" /> Neuer Schüler</button>
|
||||||
|
</Actions>
|
||||||
|
</PageHeader>
|
||||||
|
|
||||||
|
<AlertMessage Message="@_errorMessage" Kind="error" OnDismiss="ClearMessages" />
|
||||||
|
<AlertMessage Message="@_successMessage" Kind="success" OnDismiss="ClearMessages" />
|
||||||
|
|
||||||
|
<section class="card panel">
|
||||||
|
<div class="toolbar">
|
||||||
|
<div class="search-group">
|
||||||
|
<label class="sr-only" for="student-filter">Suchart</label>
|
||||||
|
<select id="student-filter" class="form-control form-select" @bind="_searchMode">
|
||||||
|
<option value="name">Name</option>
|
||||||
|
<option value="card">Ausweisnummer</option>
|
||||||
|
</select>
|
||||||
|
<label class="sr-only" for="student-search">Schüler suchen</label>
|
||||||
|
<input id="student-search" class="form-control" placeholder="Name oder Ausweisnummer eingeben …" @bind="_searchText" @bind:event="oninput" @onkeydown="HandleSearchKey" autofocus="@ShouldFocusSearch" />
|
||||||
|
<button class="btn btn-secondary" type="button" disabled="@_isLoading" @onclick="SearchAsync">Suchen</button>
|
||||||
|
@if (HasSearchText)
|
||||||
|
{
|
||||||
|
<button class="btn btn-ghost" type="button" @onclick="ResetSearchAsync">Zurücksetzen</button>
|
||||||
|
}
|
||||||
|
</div>
|
||||||
|
<span class="result-count">@_students.Count gefunden</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
@if (_isLoading)
|
||||||
|
{
|
||||||
|
<LoadingIndicator Text="Schüler werden geladen …" />
|
||||||
|
}
|
||||||
|
else if (_students.Count == 0)
|
||||||
|
{
|
||||||
|
<div class="empty-state"><div class="empty-state__icon"><Icon Name="graduation-cap" /></div><h2>Keine Schüler gefunden</h2></div>
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
<div class="table-responsive">
|
||||||
|
<table class="data-table">
|
||||||
|
<thead><tr><th>Name</th><th>Klasse</th><th>Ausweisnummer</th><th>E-Mail</th><th class="table-actions">Aktionen</th></tr></thead>
|
||||||
|
<tbody>
|
||||||
|
@foreach (var student in _students)
|
||||||
|
{
|
||||||
|
<tr @key="student.Id">
|
||||||
|
<td data-label="Name"><strong>@student.FullName</strong></td>
|
||||||
|
<td data-label="Klasse"><span class="badge badge-neutral">@student.ClassName</span></td>
|
||||||
|
<td data-label="Ausweisnummer"><code>@student.CardNumber</code></td>
|
||||||
|
<td data-label="E-Mail">@(student.Email ?? "–")</td>
|
||||||
|
<td data-label="Aktionen" class="table-actions">
|
||||||
|
<button class="btn btn-small btn-secondary" type="button" @onclick="() => OpenEditAsync(student.Id)">Bearbeiten</button>
|
||||||
|
<button class="btn btn-small btn-danger-outline" type="button" @onclick="() => AskDelete(student)">Löschen</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
@if (_showEditor)
|
||||||
|
{
|
||||||
|
<div class="modal-backdrop" @onclick="CloseEditor">
|
||||||
|
<section class="modal" role="dialog" aria-modal="true" aria-labelledby="student-editor-title" @onclick:stopPropagation="true">
|
||||||
|
<div class="modal__header">
|
||||||
|
<div><h2 id="student-editor-title">@EditorTitle</h2></div>
|
||||||
|
<button class="icon-button" type="button" aria-label="Dialog schließen" @onclick="CloseEditor"><Icon Name="x" /></button>
|
||||||
|
</div>
|
||||||
|
<EditForm Model="@_editModel" OnValidSubmit="SaveAsync">
|
||||||
|
<DataAnnotationsValidator />
|
||||||
|
<div class="modal__body form-grid">
|
||||||
|
<div class="form-field"><label for="student-card">Ausweisnummer</label><InputText id="student-card" class="form-control" @bind-Value="_editModel.CardNumber" /><ValidationMessage For="@(() => _editModel.CardNumber)" /></div>
|
||||||
|
<div class="form-field"><label for="student-class">Klasse</label><InputText id="student-class" class="form-control" @bind-Value="_editModel.ClassName" /><ValidationMessage For="@(() => _editModel.ClassName)" /></div>
|
||||||
|
<div class="form-field"><label for="student-firstname">Vorname</label><InputText id="student-firstname" class="form-control" @bind-Value="_editModel.FirstName" /><ValidationMessage For="@(() => _editModel.FirstName)" /></div>
|
||||||
|
<div class="form-field"><label for="student-lastname">Nachname</label><InputText id="student-lastname" class="form-control" @bind-Value="_editModel.LastName" /><ValidationMessage For="@(() => _editModel.LastName)" /></div>
|
||||||
|
<div class="form-field form-field--full"><label for="student-email">E-Mail (optional)</label><InputText id="student-email" class="form-control" type="email" @bind-Value="_editModel.Email" /><ValidationMessage For="@(() => _editModel.Email)" /></div>
|
||||||
|
</div>
|
||||||
|
<div class="modal__footer">
|
||||||
|
<button class="btn btn-secondary" type="button" @onclick="CloseEditor">Abbrechen</button>
|
||||||
|
<button class="btn btn-primary" type="submit" disabled="@_isSaving">@SaveButtonText</button>
|
||||||
|
</div>
|
||||||
|
</EditForm>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
|
||||||
|
<ConfirmDialog Visible="@(_studentToDelete is not null)" Title="Schüler löschen" Message="@DeleteMessage" ConfirmText="Endgültig löschen" OnConfirm="DeleteAsync" OnCancel="CancelDelete" />
|
||||||
@@ -0,0 +1,117 @@
|
|||||||
|
namespace Bsevita.Library.Ui.Pages;
|
||||||
|
|
||||||
|
public partial class Students
|
||||||
|
{
|
||||||
|
[SupplyParameterFromQuery(Name = "focus")] public string? Focus { get; set; }
|
||||||
|
private List<StudentDto> _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.";
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user