From 1a660c8f137c83f154ea705457452af8ffef2ae4 Mon Sep 17 00:00:00 2001 From: Rhywden Date: Sun, 23 Aug 2026 18:48:39 +0200 Subject: [PATCH] added FS questionnaire --- .../Components/Pages/Admin/AdminHome.razor | 74 ++++++++++ Wollmilchsau/Components/Pages/Home.razor | 118 +++++++-------- .../Pages/Students/DeadlineCountdown.razor | 49 +++++++ .../Pages/Students/Questionaire.razor | 109 ++++++++++++++ .../Pages/Teachers/QuestionaireResults.razor | 136 ++++++++++++++++++ Wollmilchsau/Components/Routes.razor | 6 +- .../Models/QuestionnaireSubmission.cs | 10 ++ .../QuestionnaireActions.cs | 11 ++ .../QuestionnaireEffects.cs | 91 ++++++++++++ .../QuestionnaireReducers.cs | 87 +++++++++++ .../QuestionnaireStore/QuestionnaireState.cs | 91 ++++++++++++ Wollmilchsau/appsettings.Development.json | 2 +- .../wwwroot/images/administration.svg | 18 +++ .../wwwroot/images/questionnaire-results.svg | 24 ++++ Wollmilchsau/wwwroot/images/questionnaire.svg | 22 +++ 15 files changed, 779 insertions(+), 69 deletions(-) create mode 100644 Wollmilchsau/Components/Pages/Admin/AdminHome.razor create mode 100644 Wollmilchsau/Components/Pages/Students/DeadlineCountdown.razor create mode 100644 Wollmilchsau/Components/Pages/Students/Questionaire.razor create mode 100644 Wollmilchsau/Components/Pages/Teachers/QuestionaireResults.razor create mode 100644 Wollmilchsau/Models/QuestionnaireSubmission.cs create mode 100644 Wollmilchsau/Store/QuestionnaireStore/QuestionnaireActions.cs create mode 100644 Wollmilchsau/Store/QuestionnaireStore/QuestionnaireEffects.cs create mode 100644 Wollmilchsau/Store/QuestionnaireStore/QuestionnaireReducers.cs create mode 100644 Wollmilchsau/Store/QuestionnaireStore/QuestionnaireState.cs create mode 100644 Wollmilchsau/wwwroot/images/administration.svg create mode 100644 Wollmilchsau/wwwroot/images/questionnaire-results.svg create mode 100644 Wollmilchsau/wwwroot/images/questionnaire.svg diff --git a/Wollmilchsau/Components/Pages/Admin/AdminHome.razor b/Wollmilchsau/Components/Pages/Admin/AdminHome.razor new file mode 100644 index 0000000..ffe6d78 --- /dev/null +++ b/Wollmilchsau/Components/Pages/Admin/AdminHome.razor @@ -0,0 +1,74 @@ +@page "/admin" +@using Microsoft.AspNetCore.Authorization +@attribute [Authorize(Roles = "ROLE_ADMIN")] +@inherits Fluxor.Blazor.Web.Components.FluxorComponent +@inject Fluxor.IDispatcher Dispatcher +@inject Fluxor.IState AdminState +@inject IJSRuntime JsRuntime + +LDAP Users + + + +

LDAP Users

+ +@if (!AdminState.Value.LdapUsersLoaded) +{ +

Loading users...

+} +else if (!Users.Any()) +{ +

No LDAP users found.

+} +else +{ + + + + + + + + + + + + + @foreach (var user in Users) + { + + + + + + + + + } + +
UsernameSurnameClassroomGiven nameIncompleteRole
@user.Username@user.Surname@user.Classroom@user.GivenName@user.Incomplete@user.Role
+} + +@code { + private IEnumerable Users => AdminState.Value.LdapUsers.Values; + + protected override void OnInitialized() + { + base.OnInitialized(); + Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllLdapUsers()); + Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllClassrooms()); + } + + private Task DownloadCsvAsync() + { + var csv = string.Join("\r\n", Users + .Where(user => user.Role == Wollmilchsau.Models.LdapUserRole.Student) + .Select(user => string.Join(';', + user.GivenName ?? string.Empty, + user.Surname ?? string.Empty, + user.Classroom ?? string.Empty, + user.Username))); + + return JsRuntime.InvokeVoidAsync("downloadTextFile", "ldap-users.csv", csv).AsTask(); + } +} \ No newline at end of file diff --git a/Wollmilchsau/Components/Pages/Home.razor b/Wollmilchsau/Components/Pages/Home.razor index e954f46..f95c101 100644 --- a/Wollmilchsau/Components/Pages/Home.razor +++ b/Wollmilchsau/Components/Pages/Home.razor @@ -1,72 +1,56 @@ @page "/" -@inherits Fluxor.Blazor.Web.Components.FluxorComponent -@inject Fluxor.IDispatcher Dispatcher -@inject Fluxor.IState AdminState -@inject IJSRuntime JsRuntime -LDAP Users +Home - + + + + + + + + Umfragen + Hier kannst du an Umfragen teilnehmen. + + + Umfragen + + + + + -

LDAP Users

+ + + + + + + Umfrageergebnisse + Hier kannst du die Ergebnisse von Umfragen auswerten. + + + Ergebnisse + + + + + -@if (!AdminState.Value.LdapUsersLoaded) -{ -

Loading users...

-} -else if (!Users.Any()) -{ -

No LDAP users found.

-} -else -{ - - - - - - - - - - - - - @foreach (var user in Users) - { - - - - - - - - - } - -
UsernameSurnameClassroomGiven nameIncompleteRole
@user.Username@user.Surname@user.Classroom@user.GivenName@user.Incomplete@user.Role
-} - -@code { - private IEnumerable Users => AdminState.Value.LdapUsers.Values; - - protected override void OnInitialized() - { - base.OnInitialized(); - Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllLdapUsers()); - Dispatcher.Dispatch(new Wollmilchsau.Store.AdminStore.GetAllClassrooms()); - } - - private Task DownloadCsvAsync() - { - var csv = string.Join("\r\n", Users - .Where(user => user.Role == Wollmilchsau.Models.LdapUserRole.Student) - .Select(user => string.Join(';', - user.GivenName ?? string.Empty, - user.Surname ?? string.Empty, - user.Classroom ?? string.Empty, - user.Username))); - - return JsRuntime.InvokeVoidAsync("downloadTextFile", "ldap-users.csv", csv).AsTask(); - } -} + + + + + + + Administration + Hier kannst du Benutzer und Klassen verwalten. + + + Administration + + + + + +
diff --git a/Wollmilchsau/Components/Pages/Students/DeadlineCountdown.razor b/Wollmilchsau/Components/Pages/Students/DeadlineCountdown.razor new file mode 100644 index 0000000..4d303ce --- /dev/null +++ b/Wollmilchsau/Components/Pages/Students/DeadlineCountdown.razor @@ -0,0 +1,49 @@ +@implements IDisposable + +@if (!HasExpired) +{ + + Verbleibende Zeit bis zur Abgabe: @FormatRemainingTime() + +} + +@code { + [Parameter, EditorRequired] + public DateTimeOffset Deadline { get; set; } + + private System.Threading.Timer? countdownTimer; + + private bool HasExpired => Deadline <= DateTimeOffset.UtcNow; + + protected override void OnInitialized() + { + countdownTimer = new System.Threading.Timer( + _ => _ = InvokeAsync(StateHasChanged), + null, + TimeSpan.Zero, + TimeSpan.FromMinutes(1)); + } + + public void Dispose() => countdownTimer?.Dispose(); + + private string FormatRemainingTime() + { + var remaining = Deadline - DateTimeOffset.UtcNow; + if (remaining <= TimeSpan.Zero) + { + return "expired"; + } + + var units = new[] + { + FormatUnit(remaining.Days, "Tag"), + FormatUnit(remaining.Hours, "Stunde"), + FormatUnit(remaining.Minutes, "Minute") + }.Where(unit => unit is not null).ToArray(); + + return units.Length == 0 ? "weniger als 1 Minute" : string.Join(", ", units); + } + + private static string? FormatUnit(int value, string unit) => + value == 0 ? null : $"{value} {unit}{(value == 1 ? string.Empty : "n")}"; +} diff --git a/Wollmilchsau/Components/Pages/Students/Questionaire.razor b/Wollmilchsau/Components/Pages/Students/Questionaire.razor new file mode 100644 index 0000000..57eb3b4 --- /dev/null +++ b/Wollmilchsau/Components/Pages/Students/Questionaire.razor @@ -0,0 +1,109 @@ +@page "/students/questionaire" +@using System.Security.Claims +@inherits Fluxor.Blazor.Web.Components.FluxorComponent +@inject AuthenticationStateProvider AuthenticationStateProvider +@inject Fluxor.IDispatcher Dispatcher +@inject Fluxor.IState QuestionnaireState + +Kurswahl + + + Kurswahl + Wählen Sie vier verschiedene Optionen für die vier aufeinanderfolgenden Halbjahre. Vervollständigen Sie drei unterschiedliche Alternativen, damit wir diese berücksichtigen können, falls Ihre erste Wahl nicht verfügbar ist. + + + + @if (!QuestionnaireState.Value.IsLoading && !QuestionnaireState.Value.ParticipationAllowed) + { + @Wollmilchsau.Store.QuestionnaireStore.QuestionnaireSubmissionPolicy.ClassroomNotAllowedMessage + } + else if (QuestionnaireState.Value.SubmissionClosed) + { + @Wollmilchsau.Store.QuestionnaireStore.QuestionnaireSubmissionPolicy.ClosedMessage Ihre gespeicherten Alternativen werden unten angezeigt. + } + else if (QuestionnaireState.Value.Saved) + { + Ihre drei Alternativen wurden gespeichert. Sie können sie bis Dienstag, 25. August 2026 um 10:00 MEST aktualisieren. + } + + @if (QuestionnaireState.Value.ShowValidationErrors && QuestionnaireState.Value.ValidationError is not null) + { + @QuestionnaireState.Value.ValidationError + } + + + @for (var distributionIndex = 0; distributionIndex < QuestionnaireState.Value.Distributions.Count; distributionIndex++) + { + var currentDistributionIndex = distributionIndex; + var alternativeNumber = currentDistributionIndex + 1; + + Alternative @alternativeNumber + Weisen Sie jedem Halbjahr eine andere Option zu. + + + @for (var semesterIndex = 0; semesterIndex < 4; semesterIndex++) + { + var currentSemesterIndex = semesterIndex; + var semesterLabel = $"Semester {currentSemesterIndex + 1}"; + var selectedOption = QuestionnaireState.Value.Distributions[currentDistributionIndex][currentSemesterIndex]; + + + @foreach (var option in Wollmilchsau.Store.QuestionnaireStore.QuestionnaireValidation.Options) + { + @option + } + + + } + + + } + + Auswahl speichern + + + +@code { + protected override async Task OnInitializedAsync() + { + await base.OnInitializedAsync(); + + var authenticationState = await AuthenticationStateProvider.GetAuthenticationStateAsync(); + var user = authenticationState.User; + var studentId = user.FindFirst(ClaimTypes.NameIdentifier)?.Value ?? user.FindFirst("sub")?.Value ?? user.Identity?.Name; + + if (!string.IsNullOrWhiteSpace(studentId)) + { + Dispatcher.Dispatch(new Wollmilchsau.Store.QuestionnaireStore.LoadQuestionnaire(studentId, user.FindFirst("classroom")?.Value)); + } + } + + private void SetSemesterOption(int distributionIndex, int semesterIndex, string? option) => + Dispatcher.Dispatch(new Wollmilchsau.Store.QuestionnaireStore.SetSemesterOption(distributionIndex, semesterIndex, option)); + + private Task SaveAsync() + { + Dispatcher.Dispatch(new Wollmilchsau.Store.QuestionnaireStore.SaveQuestionnaire()); + return Task.CompletedTask; + } + + private bool HasDistributionError(int distributionIndex) => + QuestionnaireState.Value.ShowValidationErrors && IsInvalidDistribution(distributionIndex); + + private bool IsInvalidDistribution(int distributionIndex) + { + var distribution = QuestionnaireState.Value.Distributions[distributionIndex]; + return distribution.Any(string.IsNullOrWhiteSpace) + || distribution.Distinct(StringComparer.Ordinal).Count() != distribution.Count + || QuestionnaireState.Value.Distributions.Where((_, index) => index != distributionIndex) + .Any(other => other.SequenceEqual(distribution, StringComparer.Ordinal)); + } +} diff --git a/Wollmilchsau/Components/Pages/Teachers/QuestionaireResults.razor b/Wollmilchsau/Components/Pages/Teachers/QuestionaireResults.razor new file mode 100644 index 0000000..b08700d --- /dev/null +++ b/Wollmilchsau/Components/Pages/Teachers/QuestionaireResults.razor @@ -0,0 +1,136 @@ +@page "/teachers/questionaire-results" +@using Marten +@using Microsoft.AspNetCore.Authorization +@using Wollmilchsau.Models +@using Wollmilchsau.Services.Ldap +@attribute [Authorize(Roles = "ROLE_TEACHER,ROLE_ADMIN")] +@inject ILdapService LdapService +@inject IDocumentSession DocumentSession + +Questionnaire results + + + Questionnaire results + Eligible students and their submitted semester preferences. + + @if (isLoading) + { + + + Loading questionnaire results... + + } + else if (results.Count == 0) + { + No eligible students were found. + } + else + { + + @CompletedCount of @results.Count eligible students have submitted their choices. + + + + @foreach (var classroom in results.GroupBy(result => result.Classroom).OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase)) + { + + @classroom.Key + + + @foreach (var result in classroom) + { + + +
+ @result.DisplayName + @result.Username +
+ + @(result.Submission is null ? "Not submitted" : "Submitted") + +
+ + @if (result.Submission is null) + { + This student has not made a choice yet. + } + else + { + Last updated: @result.Submission.SubmittedAt.LocalDateTime.ToString("g") + + @for (var alternativeIndex = 0; alternativeIndex < result.Submission.Alternatives.Count; alternativeIndex++) + { + var currentAlternativeIndex = alternativeIndex; + var alternative = result.Submission.Alternatives[currentAlternativeIndex]; + + + Alternative @(currentAlternativeIndex + 1) + @for (var semesterIndex = 0; semesterIndex < alternative.Count; semesterIndex++) + { + Semester @(semesterIndex + 1): @alternative[semesterIndex] + } + + + } + + } +
+ } +
+
+ } +
+ } +
+ +@code { + private IReadOnlyList results = []; + private bool isLoading = true; + + private int CompletedCount => results.Count(result => result.Submission is not null); + + protected override async Task OnInitializedAsync() + { + var submissions = await DocumentSession.Query().ToListAsync(); + var submissionsByStudent = submissions + .GroupBy(submission => submission.StudentId, StringComparer.OrdinalIgnoreCase) + .ToDictionary( + group => group.Key, + group => group.OrderByDescending(submission => submission.SubmittedAt).First(), + StringComparer.OrdinalIgnoreCase); + var students = new List(); + + await foreach (var user in LdapService.GetAllUsers()) + { + if (user.Role == LdapUserRole.Student && + Wollmilchsau.Store.QuestionnaireStore.QuestionnaireSubmissionPolicy.IsClassroomAllowed(user.Classroom)) + { + students.Add(user); + } + } + + results = students + .OrderBy(student => student.Classroom, StringComparer.OrdinalIgnoreCase) + .ThenBy(student => student.Surname, StringComparer.OrdinalIgnoreCase) + .ThenBy(student => student.GivenName, StringComparer.OrdinalIgnoreCase) + .Select(student => new StudentQuestionnaireResult( + student.Username, + student.Classroom!, + GetDisplayName(student), + submissionsByStudent.GetValueOrDefault(student.Username))) + .ToList(); + isLoading = false; + } + + private static string GetDisplayName(LdapUser student) + { + var displayName = string.Join(" ", new[] { student.GivenName, student.Surname }.Where(value => !string.IsNullOrWhiteSpace(value))); + return string.IsNullOrWhiteSpace(displayName) ? student.Username : displayName; + } + + private sealed record StudentQuestionnaireResult( + string Username, + string Classroom, + string DisplayName, + QuestionnaireSubmission? Submission); +} diff --git a/Wollmilchsau/Components/Routes.razor b/Wollmilchsau/Components/Routes.razor index d3da355..ddf5c84 100644 --- a/Wollmilchsau/Components/Routes.razor +++ b/Wollmilchsau/Components/Routes.razor @@ -2,6 +2,10 @@ - + + +

You are not authorized to access this page.

+
+
diff --git a/Wollmilchsau/Models/QuestionnaireSubmission.cs b/Wollmilchsau/Models/QuestionnaireSubmission.cs new file mode 100644 index 0000000..bb553be --- /dev/null +++ b/Wollmilchsau/Models/QuestionnaireSubmission.cs @@ -0,0 +1,10 @@ +namespace Wollmilchsau.Models; + +public sealed class QuestionnaireSubmission +{ + public Guid Id { get; init; } = Guid.NewGuid(); + public required string StudentId { get; init; } + public string? Classroom { get; init; } + public DateTimeOffset SubmittedAt { get; init; } = DateTimeOffset.UtcNow; + public required IReadOnlyList> Alternatives { get; init; } +} diff --git a/Wollmilchsau/Store/QuestionnaireStore/QuestionnaireActions.cs b/Wollmilchsau/Store/QuestionnaireStore/QuestionnaireActions.cs new file mode 100644 index 0000000..d99726c --- /dev/null +++ b/Wollmilchsau/Store/QuestionnaireStore/QuestionnaireActions.cs @@ -0,0 +1,11 @@ +using System.Collections.Immutable; + +namespace Wollmilchsau.Store.QuestionnaireStore; + +public sealed record LoadQuestionnaire(string StudentId, string? Classroom); +public sealed record QuestionnaireLoaded(ImmutableList> Distributions, bool SubmissionClosed); +public sealed record QuestionnaireSubmissionClosed(); +public sealed record SetSemesterOption(int DistributionIndex, int SemesterIndex, string? Option); +public sealed record SaveQuestionnaire(); +public sealed record QuestionnaireSaveSucceeded(); +public sealed record QuestionnaireSaveRejected(string Error); diff --git a/Wollmilchsau/Store/QuestionnaireStore/QuestionnaireEffects.cs b/Wollmilchsau/Store/QuestionnaireStore/QuestionnaireEffects.cs new file mode 100644 index 0000000..c03369b --- /dev/null +++ b/Wollmilchsau/Store/QuestionnaireStore/QuestionnaireEffects.cs @@ -0,0 +1,91 @@ +using System.Collections.Immutable; +using Fluxor; +using Marten; +using Wollmilchsau.Models; + +namespace Wollmilchsau.Store.QuestionnaireStore; + +public sealed class QuestionnaireEffects(IState questionnaireState, IDocumentSession session) +{ + [EffectMethod] + public async Task HandleLoadQuestionnaire(LoadQuestionnaire action, IDispatcher dispatcher) + { + if (!QuestionnaireSubmissionPolicy.IsClassroomAllowed(action.Classroom)) + { + dispatcher.Dispatch(new QuestionnaireLoaded( + QuestionnaireState.CreateEmptyDistributions(), + QuestionnaireSubmissionPolicy.IsSubmissionClosed(DateTimeOffset.UtcNow))); + return; + } + + var existingSubmission = await session.Query() + .FirstOrDefaultAsync(submission => submission.StudentId == action.StudentId); + var distributions = existingSubmission is null + ? QuestionnaireState.CreateEmptyDistributions() + : ToDistributions(existingSubmission); + + dispatcher.Dispatch(new QuestionnaireLoaded( + distributions, + QuestionnaireSubmissionPolicy.IsSubmissionClosed(DateTimeOffset.UtcNow))); + } + + [EffectMethod] + public async Task HandleSaveQuestionnaire(SaveQuestionnaire action, IDispatcher dispatcher) + { + var state = questionnaireState.Value; + if (!QuestionnaireSubmissionPolicy.IsClassroomAllowed(state.Classroom)) + { + dispatcher.Dispatch(new QuestionnaireSaveRejected(QuestionnaireSubmissionPolicy.ClassroomNotAllowedMessage)); + return; + } + + if (QuestionnaireSubmissionPolicy.IsSubmissionClosed(DateTimeOffset.UtcNow)) + { + dispatcher.Dispatch(new QuestionnaireSubmissionClosed()); + dispatcher.Dispatch(new QuestionnaireSaveRejected(QuestionnaireSubmissionPolicy.ClosedMessage)); + return; + } + + var validationError = QuestionnaireValidation.Validate(state.Distributions); + if (validationError is not null) + { + dispatcher.Dispatch(new QuestionnaireSaveRejected(validationError)); + return; + } + + if (string.IsNullOrWhiteSpace(state.StudentId)) + { + dispatcher.Dispatch(new QuestionnaireSaveRejected("Your student identity could not be determined.")); + return; + } + + var existingSubmission = await session.Query() + .FirstOrDefaultAsync(submission => submission.StudentId == state.StudentId); + + session.Store(new QuestionnaireSubmission + { + Id = existingSubmission?.Id ?? Guid.NewGuid(), + StudentId = state.StudentId, + Classroom = state.Classroom, + SubmittedAt = DateTimeOffset.UtcNow, + Alternatives = state.Distributions + .Select(distribution => (IReadOnlyList)distribution.Select(option => option!).ToArray()) + .ToArray() + }); + await session.SaveChangesAsync(); + + dispatcher.Dispatch(new QuestionnaireSaveSucceeded()); + } + + private static ImmutableList> ToDistributions(QuestionnaireSubmission submission) + { + if (submission.Alternatives.Count != 3 || submission.Alternatives.Any(distribution => distribution.Count != 4)) + { + return QuestionnaireState.CreateEmptyDistributions(); + } + + return submission.Alternatives + .Select(distribution => distribution.Select(option => (string?)option).ToImmutableList()) + .ToImmutableList(); + } +} diff --git a/Wollmilchsau/Store/QuestionnaireStore/QuestionnaireReducers.cs b/Wollmilchsau/Store/QuestionnaireStore/QuestionnaireReducers.cs new file mode 100644 index 0000000..67c47aa --- /dev/null +++ b/Wollmilchsau/Store/QuestionnaireStore/QuestionnaireReducers.cs @@ -0,0 +1,87 @@ +using System.Collections.Immutable; +using Fluxor; + +namespace Wollmilchsau.Store.QuestionnaireStore; + +public static class QuestionnaireReducers +{ + [ReducerMethod] + public static QuestionnaireState ReduceLoadQuestionnaire(QuestionnaireState state, LoadQuestionnaire action) => state with + { + StudentId = action.StudentId, + Classroom = action.Classroom, + Distributions = QuestionnaireState.CreateEmptyDistributions(), + IsLoading = true, + ParticipationAllowed = QuestionnaireSubmissionPolicy.IsClassroomAllowed(action.Classroom), + SubmissionClosed = false, + ShowValidationErrors = false, + ValidationError = null, + Saved = false + }; + + [ReducerMethod] + public static QuestionnaireState ReduceQuestionnaireLoaded(QuestionnaireState state, QuestionnaireLoaded action) => state with + { + Distributions = action.Distributions, + IsLoading = false, + SubmissionClosed = action.SubmissionClosed + }; + + [ReducerMethod] + public static QuestionnaireState ReduceQuestionnaireSubmissionClosed(QuestionnaireState state, QuestionnaireSubmissionClosed action) => state with + { + SubmissionClosed = true, + Saved = false + }; + + [ReducerMethod] + public static QuestionnaireState ReduceSetSemesterOption(QuestionnaireState state, SetSemesterOption action) + { + if (state.IsLoading || !state.ParticipationAllowed || state.SubmissionClosed) + { + return state; + } + + var distributions = state.Distributions.ToArray(); + distributions[action.DistributionIndex] = distributions[action.DistributionIndex].SetItem(action.SemesterIndex, action.Option); + var updatedState = state with + { + Distributions = distributions.ToImmutableList(), + Saved = false + }; + + return updatedState with + { + ValidationError = updatedState.ShowValidationErrors + ? QuestionnaireValidation.Validate(updatedState.Distributions) + : null + }; + } + + [ReducerMethod] + public static QuestionnaireState ReduceSaveQuestionnaire(QuestionnaireState state, SaveQuestionnaire action) => state with + { + ShowValidationErrors = true, + ValidationError = !state.ParticipationAllowed + ? QuestionnaireSubmissionPolicy.ClassroomNotAllowedMessage + : state.SubmissionClosed + ? QuestionnaireSubmissionPolicy.ClosedMessage + : QuestionnaireValidation.Validate(state.Distributions), + Saved = false + }; + + [ReducerMethod] + public static QuestionnaireState ReduceQuestionnaireSaveSucceeded(QuestionnaireState state, QuestionnaireSaveSucceeded action) => state with + { + Saved = true, + ValidationError = null + }; + + [ReducerMethod] + public static QuestionnaireState ReduceQuestionnaireSaveRejected(QuestionnaireState state, QuestionnaireSaveRejected action) => state with + { + ShowValidationErrors = true, + ValidationError = action.Error, + Saved = false + }; +} diff --git a/Wollmilchsau/Store/QuestionnaireStore/QuestionnaireState.cs b/Wollmilchsau/Store/QuestionnaireStore/QuestionnaireState.cs new file mode 100644 index 0000000..ffe8217 --- /dev/null +++ b/Wollmilchsau/Store/QuestionnaireStore/QuestionnaireState.cs @@ -0,0 +1,91 @@ +using System.Collections.Immutable; +using Fluxor; + +namespace Wollmilchsau.Store.QuestionnaireStore; + +[FeatureState] +public sealed record QuestionnaireState +{ + public QuestionnaireState() { } + + public string StudentId { get; init; } = string.Empty; + public string? Classroom { get; init; } + public ImmutableList> Distributions { get; init; } = CreateEmptyDistributions(); + public bool IsLoading { get; init; } = true; + public bool ParticipationAllowed { get; init; } + public bool SubmissionClosed { get; init; } + public bool ShowValidationErrors { get; init; } + public string? ValidationError { get; init; } + public bool Saved { get; init; } + + public static ImmutableList> CreateEmptyDistributions() => + Enumerable.Range(0, 3) + .Select(_ => ImmutableList.CreateRange([null, null, null, null])) + .ToImmutableList(); +} + +public static class QuestionnaireSubmissionPolicy +{ + private static readonly HashSet AllowedClassrooms = new(StringComparer.OrdinalIgnoreCase) + { + "FS25-8B", + "FS26-8X", + "FS26-8Y", + "FSU26-8A", + "absagen.spa" + }; + + public static readonly DateTimeOffset Deadline = new(2026, 8, 25, 10, 0, 0, TimeSpan.FromHours(2)); + + public static bool IsClassroomAllowed(string? classroom) => + !string.IsNullOrWhiteSpace(classroom) && AllowedClassrooms.Contains(classroom); + + public static bool IsSubmissionClosed(DateTimeOffset now) => now >= Deadline; + + public static string ClosedMessage => "Die Umfrage wurde am Dienstag, 25. August 2026 um 10:00 MEST geschlossen."; + public static string ClassroomNotAllowedMessage => "Ihre Klasse ist nicht berechtigt, an diesem Fragebogen teilzunehmen."; +} + +public static class QuestionnaireValidation +{ + public static readonly IReadOnlyList Options = ["Heilpädagogik/Inklusion", "Erlebnispädagogik", "\"Debattieren\"", "Bewegung in der Kita", "Musikpädagogik", "BBNE/Gartenpädagogik"]; + + public static string? Validate(IReadOnlyList> distributions) + { + if (distributions.Any(distribution => distribution.Any(string.IsNullOrWhiteSpace))) + { + return "Wählen Sie für jedes Halbjahr in allen drei Alternativen eine Option aus."; + } + + if (distributions.Any(distribution => distribution.Distinct(StringComparer.Ordinal).Count() != distribution.Count)) + { + return "Eine Option kann innerhalb jeder Vier-Halbjahre-Alternative nur einmal verwendet werden."; + } + + if (distributions.Distinct(DistributionComparer.Instance).Count() != distributions.Count) + { + return "Jede Alternative muss eine unterschiedliche Halbjahresverteilung haben."; + } + + return null; + } + + private sealed class DistributionComparer : IEqualityComparer> + { + public static DistributionComparer Instance { get; } = new(); + + public bool Equals(ImmutableList? x, ImmutableList? y) => + ReferenceEquals(x, y) || (x is not null && y is not null && x.SequenceEqual(y, StringComparer.Ordinal)); + + public int GetHashCode(ImmutableList distribution) + { + var hash = new HashCode(); + foreach (var option in distribution) + { + hash.Add(option, StringComparer.Ordinal); + } + + return hash.ToHashCode(); + } + } +} diff --git a/Wollmilchsau/appsettings.Development.json b/Wollmilchsau/appsettings.Development.json index 52f985b..2c611b8 100644 --- a/Wollmilchsau/appsettings.Development.json +++ b/Wollmilchsau/appsettings.Development.json @@ -26,7 +26,7 @@ }, "ConnectionStrings": { - "Marten": "Host=lxc-db3;Port=5432;Database=oneforall_dev;Username=OneForAll;Password=ia9vgM6F9kUt", + "Marten": "Host=lxc-db3;Port=5432;Database=wollmilchsau_dev;Username=wollmilchsau;Password=rVx0EoLDOnld", "RedisCache": "lxc-matrix:6379" }, "OIDC": { diff --git a/Wollmilchsau/wwwroot/images/administration.svg b/Wollmilchsau/wwwroot/images/administration.svg new file mode 100644 index 0000000..13b1dca --- /dev/null +++ b/Wollmilchsau/wwwroot/images/administration.svg @@ -0,0 +1,18 @@ + + Administration + Abstrakte Illustration eines Verwaltungsbereichs mit Schild und Einstellungen. + + + + + + + + + + + + + + + diff --git a/Wollmilchsau/wwwroot/images/questionnaire-results.svg b/Wollmilchsau/wwwroot/images/questionnaire-results.svg new file mode 100644 index 0000000..54997d5 --- /dev/null +++ b/Wollmilchsau/wwwroot/images/questionnaire-results.svg @@ -0,0 +1,24 @@ + + Umfrageergebnisse + Abstrakte Illustration eines Diagramms mit aufsteigenden Balken. + + + + + + + + + + + + + + + + + + + + + diff --git a/Wollmilchsau/wwwroot/images/questionnaire.svg b/Wollmilchsau/wwwroot/images/questionnaire.svg new file mode 100644 index 0000000..7bf36cd --- /dev/null +++ b/Wollmilchsau/wwwroot/images/questionnaire.svg @@ -0,0 +1,22 @@ + + Umfragen + Abstrakte Illustration eines Fragebogens mit Auswahlfeldern. + + + + + + + + + + + + + + + + + + +