Project to GitHub

This commit is contained in:
2026-06-24 08:33:23 +02:00
commit ecd89a68f8
121 changed files with 6481 additions and 0 deletions
+11
View File
@@ -0,0 +1,11 @@
{
"version": 1,
"isRoot": true,
"tools": {
"dotnet-ef": {
"version": "10.0.0",
"commands": ["dotnet-ef"],
"rollForward": false
}
}
}
+22
View File
@@ -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
+1
View File
@@ -0,0 +1 @@
* text eol=crlf
+298
View File
@@ -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
+56
View File
@@ -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
+10
View File
@@ -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>
+20
View File
@@ -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>
+39
View File
@@ -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)
+126
View File
@@ -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
+16
View File
@@ -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
View File
@@ -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`.
+50
View File
@@ -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.
+6
View File
@@ -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();
}
+86
View File
@@ -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"
}
}
}
+20
View File
@@ -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": "*"
}
+10
View File
@@ -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>
+24
View File
@@ -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>
+15
View File
@@ -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();
}
}
+31
View File
@@ -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; }
}
+6
View File
@@ -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";
}
+9
View File
@@ -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();
}
+86
View File
@@ -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" />
+199
View File
@@ -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.";
}
+81
View File
@@ -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>
+127
View File
@@ -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 &Uuml;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