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