added FS questionnaire

This commit is contained in:
Rhywden
2026-08-23 18:48:39 +02:00
parent c06e6c0e84
commit 1a660c8f13
15 changed files with 779 additions and 69 deletions
@@ -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<Wollmilchsau.Store.AdminStore.AdminState> AdminState
@inject IJSRuntime JsRuntime
<PageTitle>LDAP Users</PageTitle>
<button class="btn btn-primary" @onclick="DownloadCsvAsync" disabled="@(!Users.Any())">Export CSV</button>
<h1>LDAP Users</h1>
@if (!AdminState.Value.LdapUsersLoaded)
{
<p><em>Loading users...</em></p>
}
else if (!Users.Any())
{
<p>No LDAP users found.</p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Username</th>
<th>Surname</th>
<th>Classroom</th>
<th>Given name</th>
<th>Incomplete</th>
<th>Role</th>
</tr>
</thead>
<tbody>
@foreach (var user in Users)
{
<tr>
<td>@user.Username</td>
<td>@user.Surname</td>
<td>@user.Classroom</td>
<td>@user.GivenName</td>
<td>@user.Incomplete</td>
<td>@user.Role</td>
</tr>
}
</tbody>
</table>
}
@code {
private IEnumerable<Wollmilchsau.Models.LdapUser> 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();
}
}
+51 -67
View File
@@ -1,72 +1,56 @@
@page "/"
@inherits Fluxor.Blazor.Web.Components.FluxorComponent
@inject Fluxor.IDispatcher Dispatcher
@inject Fluxor.IState<Wollmilchsau.Store.AdminStore.AdminState> AdminState
@inject IJSRuntime JsRuntime
<PageTitle>LDAP Users</PageTitle>
<PageTitle>Home</PageTitle>
<button class="btn btn-primary" @onclick="DownloadCsvAsync" disabled="@(!Users.Any())">Export CSV</button>
<MudStack Spacing="10">
<AuthorizeView Roles="ROLE_STUDENT,ROLE_TEACHER,ROLE_ADMIN">
<Authorized>
<MudContainer>
<MudCard Style="width: 300px;">
<MudCardMedia Image="images/questionnaire.svg" Height="200" Style="width: 300px;" />
<MudCardContent>
<MudText Typo="Typo.h5">Umfragen</MudText>
<MudText Typo="Typo.body2">Hier kannst du an Umfragen teilnehmen.</MudText>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Href="/students/questionaire">Umfragen</MudButton>
</MudCardActions>
</MudCard>
</MudContainer>
</Authorized>
</AuthorizeView>
<h1>LDAP Users</h1>
<AuthorizeView Roles="ROLE_TEACHER,ROLE_ADMIN">
<Authorized>
<MudContainer>
<MudCard Style="width: 300px;">
<MudCardMedia Image="images/questionnaire-results.svg" Height="200" Style="width: 300px;" />
<MudCardContent>
<MudText Typo="Typo.h5">Umfrageergebnisse</MudText>
<MudText Typo="Typo.body2">Hier kannst du die Ergebnisse von Umfragen auswerten.</MudText>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Href="/teachers/questionaire-results">Ergebnisse</MudButton>
</MudCardActions>
</MudCard>
</MudContainer>
</Authorized>
</AuthorizeView>
@if (!AdminState.Value.LdapUsersLoaded)
{
<p><em>Loading users...</em></p>
}
else if (!Users.Any())
{
<p>No LDAP users found.</p>
}
else
{
<table class="table">
<thead>
<tr>
<th>Username</th>
<th>Surname</th>
<th>Classroom</th>
<th>Given name</th>
<th>Incomplete</th>
<th>Role</th>
</tr>
</thead>
<tbody>
@foreach (var user in Users)
{
<tr>
<td>@user.Username</td>
<td>@user.Surname</td>
<td>@user.Classroom</td>
<td>@user.GivenName</td>
<td>@user.Incomplete</td>
<td>@user.Role</td>
</tr>
}
</tbody>
</table>
}
@code {
private IEnumerable<Wollmilchsau.Models.LdapUser> 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();
}
}
<AuthorizeView Roles="ROLE_ADMIN">
<Authorized>
<MudContainer>
<MudCard Style="width: 300px;">
<MudCardMedia Image="images/administration.svg" Height="200" Style="width: 300px;" />
<MudCardContent>
<MudText Typo="Typo.h5">Administration</MudText>
<MudText Typo="Typo.body2">Hier kannst du Benutzer und Klassen verwalten.</MudText>
</MudCardContent>
<MudCardActions>
<MudButton Variant="Variant.Filled" Color="Color.Primary" Href="/admin">Administration</MudButton>
</MudCardActions>
</MudCard>
</MudContainer>
</Authorized>
</AuthorizeView>
</MudStack>
@@ -0,0 +1,49 @@
@implements IDisposable
@if (!HasExpired)
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-4">
Verbleibende Zeit bis zur Abgabe: @FormatRemainingTime()
</MudAlert>
}
@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")}";
}
@@ -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<Wollmilchsau.Store.QuestionnaireStore.QuestionnaireState> QuestionnaireState
<PageTitle>Kurswahl</PageTitle>
<MudContainer MaxWidth="MaxWidth.Medium" Class="mt-6 mb-6">
<MudText Typo="Typo.h3" GutterBottom="true">Kurswahl</MudText>
<MudText Typo="Typo.body1" Class="mb-6">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.</MudText>
<DeadlineCountdown Deadline="Wollmilchsau.Store.QuestionnaireStore.QuestionnaireSubmissionPolicy.Deadline" />
@if (!QuestionnaireState.Value.IsLoading && !QuestionnaireState.Value.ParticipationAllowed)
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Filled" Class="mb-4">@Wollmilchsau.Store.QuestionnaireStore.QuestionnaireSubmissionPolicy.ClassroomNotAllowedMessage</MudAlert>
}
else if (QuestionnaireState.Value.SubmissionClosed)
{
<MudAlert Severity="Severity.Warning" Variant="Variant.Filled" Class="mb-4">@Wollmilchsau.Store.QuestionnaireStore.QuestionnaireSubmissionPolicy.ClosedMessage Ihre gespeicherten Alternativen werden unten angezeigt.</MudAlert>
}
else if (QuestionnaireState.Value.Saved)
{
<MudAlert Severity="Severity.Success" Variant="Variant.Filled" Class="mb-4">Ihre drei Alternativen wurden gespeichert. Sie können sie bis Dienstag, 25. August 2026 um 10:00 MEST aktualisieren.</MudAlert>
}
@if (QuestionnaireState.Value.ShowValidationErrors && QuestionnaireState.Value.ValidationError is not null)
{
<MudAlert Severity="Severity.Error" Variant="Variant.Filled" Class="mb-4">@QuestionnaireState.Value.ValidationError</MudAlert>
}
<MudStack Spacing="4">
@for (var distributionIndex = 0; distributionIndex < QuestionnaireState.Value.Distributions.Count; distributionIndex++)
{
var currentDistributionIndex = distributionIndex;
var alternativeNumber = currentDistributionIndex + 1;
<MudPaper Outlined="true" Class="pa-4">
<MudText Typo="Typo.h5">Alternative @alternativeNumber</MudText>
<MudText Typo="Typo.body2" Color="Color.Secondary" Class="mb-4">Weisen Sie jedem Halbjahr eine andere Option zu.</MudText>
<MudGrid Spacing="3">
@for (var semesterIndex = 0; semesterIndex < 4; semesterIndex++)
{
var currentSemesterIndex = semesterIndex;
var semesterLabel = $"Semester {currentSemesterIndex + 1}";
var selectedOption = QuestionnaireState.Value.Distributions[currentDistributionIndex][currentSemesterIndex];
<MudItem xs="12" sm="6">
<MudSelect T="string"
Label="@semesterLabel"
Value="@selectedOption"
ValueChanged="option => SetSemesterOption(currentDistributionIndex, currentSemesterIndex, option)"
Error="@HasDistributionError(currentDistributionIndex)"
ErrorText="Wählen Sie eine eindeutige Option für diese Alternative."
Disabled="@(QuestionnaireState.Value.IsLoading || !QuestionnaireState.Value.ParticipationAllowed || QuestionnaireState.Value.SubmissionClosed)"
Variant="Variant.Outlined"
FullWidth="true">
@foreach (var option in Wollmilchsau.Store.QuestionnaireStore.QuestionnaireValidation.Options)
{
<MudSelectItem Value="@option">@option</MudSelectItem>
}
</MudSelect>
</MudItem>
}
</MudGrid>
</MudPaper>
}
<MudButton Variant="Variant.Filled" Color="Color.Primary" Size="Size.Large" OnClick="SaveAsync" Disabled="@(QuestionnaireState.Value.IsLoading || !QuestionnaireState.Value.ParticipationAllowed || QuestionnaireState.Value.SubmissionClosed)" FullWidth="true">Auswahl speichern</MudButton>
</MudStack>
</MudContainer>
@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));
}
}
@@ -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
<PageTitle>Questionnaire results</PageTitle>
<MudContainer MaxWidth="MaxWidth.Large" Class="mt-6 mb-6">
<MudText Typo="Typo.h3" GutterBottom="true">Questionnaire results</MudText>
<MudText Typo="Typo.body1" Class="mb-6">Eligible students and their submitted semester preferences.</MudText>
@if (isLoading)
{
<MudStack AlignItems="AlignItems.Center" Class="py-8">
<MudProgressCircular Color="Color.Primary" Indeterminate="true" />
<MudText>Loading questionnaire results...</MudText>
</MudStack>
}
else if (results.Count == 0)
{
<MudAlert Severity="Severity.Info">No eligible students were found.</MudAlert>
}
else
{
<MudAlert Severity="Severity.Info" Variant="Variant.Outlined" Class="mb-4">
@CompletedCount of @results.Count eligible students have submitted their choices.
</MudAlert>
<MudStack Spacing="4">
@foreach (var classroom in results.GroupBy(result => result.Classroom).OrderBy(group => group.Key, StringComparer.OrdinalIgnoreCase))
{
<MudPaper Outlined="true" Class="pa-4">
<MudText Typo="Typo.h5" GutterBottom="true">@classroom.Key</MudText>
<MudStack Spacing="3">
@foreach (var result in classroom)
{
<MudPaper Elevation="0" Outlined="true" Class="pa-3">
<MudStack Row="true" Justify="Justify.SpaceBetween" AlignItems="AlignItems.Center" Class="mb-3">
<div>
<MudText Typo="Typo.h6">@result.DisplayName</MudText>
<MudText Typo="Typo.caption" Color="Color.Secondary">@result.Username</MudText>
</div>
<MudChip T="string" Color="@(result.Submission is null ? Color.Warning : Color.Success)" Variant="Variant.Filled" Size="Size.Small">
@(result.Submission is null ? "Not submitted" : "Submitted")
</MudChip>
</MudStack>
@if (result.Submission is null)
{
<MudText Color="Color.Secondary">This student has not made a choice yet.</MudText>
}
else
{
<MudText Typo="Typo.caption" Color="Color.Secondary" Class="mb-2">Last updated: @result.Submission.SubmittedAt.LocalDateTime.ToString("g")</MudText>
<MudGrid Spacing="2">
@for (var alternativeIndex = 0; alternativeIndex < result.Submission.Alternatives.Count; alternativeIndex++)
{
var currentAlternativeIndex = alternativeIndex;
var alternative = result.Submission.Alternatives[currentAlternativeIndex];
<MudItem xs="12" md="4">
<MudPaper Outlined="true" Class="pa-3 h-100">
<MudText Typo="Typo.subtitle2" GutterBottom="true">Alternative @(currentAlternativeIndex + 1)</MudText>
@for (var semesterIndex = 0; semesterIndex < alternative.Count; semesterIndex++)
{
<MudText Typo="Typo.body2">Semester @(semesterIndex + 1): @alternative[semesterIndex]</MudText>
}
</MudPaper>
</MudItem>
}
</MudGrid>
}
</MudPaper>
}
</MudStack>
</MudPaper>
}
</MudStack>
}
</MudContainer>
@code {
private IReadOnlyList<StudentQuestionnaireResult> 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<QuestionnaireSubmission>().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<LdapUser>();
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);
}
+5 -1
View File
@@ -2,6 +2,10 @@
<Fluxor.Blazor.Web.StoreInitializer />
<Router AppAssembly="typeof(Program).Assembly" NotFoundPage="typeof(Pages.NotFound)">
<Found Context="routeData">
<RouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)" />
<AuthorizeRouteView RouteData="routeData" DefaultLayout="typeof(Layout.MainLayout)">
<NotAuthorized>
<p>You are not authorized to access this page.</p>
</NotAuthorized>
</AuthorizeRouteView>
</Found>
</Router>
@@ -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<IReadOnlyList<string>> Alternatives { get; init; }
}
@@ -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<ImmutableList<string?>> 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);
@@ -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> 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<QuestionnaireSubmission>()
.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<QuestionnaireSubmission>()
.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<string>)distribution.Select(option => option!).ToArray())
.ToArray()
});
await session.SaveChangesAsync();
dispatcher.Dispatch(new QuestionnaireSaveSucceeded());
}
private static ImmutableList<ImmutableList<string?>> 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();
}
}
@@ -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
};
}
@@ -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<ImmutableList<string?>> 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<ImmutableList<string?>> CreateEmptyDistributions() =>
Enumerable.Range(0, 3)
.Select(_ => ImmutableList.CreateRange<string?>([null, null, null, null]))
.ToImmutableList();
}
public static class QuestionnaireSubmissionPolicy
{
private static readonly HashSet<string> 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<string> Options = ["Heilpädagogik/Inklusion", "Erlebnispädagogik", "\"Debattieren\"", "Bewegung in der Kita", "Musikpädagogik", "BBNE/Gartenpädagogik"];
public static string? Validate(IReadOnlyList<ImmutableList<string?>> 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<ImmutableList<string?>>
{
public static DistributionComparer Instance { get; } = new();
public bool Equals(ImmutableList<string?>? x, ImmutableList<string?>? y) =>
ReferenceEquals(x, y) || (x is not null && y is not null && x.SequenceEqual(y, StringComparer.Ordinal));
public int GetHashCode(ImmutableList<string?> distribution)
{
var hash = new HashCode();
foreach (var option in distribution)
{
hash.Add(option, StringComparer.Ordinal);
}
return hash.ToHashCode();
}
}
}
+1 -1
View File
@@ -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": {
@@ -0,0 +1,18 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 400" role="img" aria-labelledby="title description">
<title id="title">Administration</title>
<desc id="description">Abstrakte Illustration eines Verwaltungsbereichs mit Schild und Einstellungen.</desc>
<defs>
<linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#7a3e16" />
<stop offset="100%" stop-color="#b65b1c" />
</linearGradient>
</defs>
<rect width="1200" height="400" fill="url(#background)" />
<circle cx="1050" cy="55" r="145" fill="#ffffff" fill-opacity="0.08" />
<circle cx="1150" cy="350" r="200" fill="#ffffff" fill-opacity="0.06" />
<rect x="398" y="54" width="405" height="292" rx="24" fill="#ffffff" fill-opacity="0.96" />
<path d="M575 107l88 31v70c0 56-37 92-88 116-51-24-88-60-88-116v-70l88-31z" fill="#b65b1c" />
<path d="M537 208l25 25 53-61" fill="none" stroke="#ffffff" stroke-width="14" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="707" cy="169" r="31" fill="none" stroke="#4b6f91" stroke-width="12" />
<path d="M707 116v16M707 206v16M654 169h16M744 169h16M669 131l11 11M734 196l11 11M745 131l-11 11M680 196l-11 11" stroke="#4b6f91" stroke-width="10" stroke-linecap="round" />
</svg>

After

Width:  |  Height:  |  Size: 1.2 KiB

@@ -0,0 +1,24 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 400" role="img" aria-labelledby="title description">
<title id="title">Umfrageergebnisse</title>
<desc id="description">Abstrakte Illustration eines Diagramms mit aufsteigenden Balken.</desc>
<defs>
<linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#4c3b8f" />
<stop offset="100%" stop-color="#246aa0" />
</linearGradient>
</defs>
<rect width="1200" height="400" fill="url(#background)" />
<circle cx="1050" cy="55" r="145" fill="#ffffff" fill-opacity="0.08" />
<circle cx="1150" cy="350" r="200" fill="#ffffff" fill-opacity="0.06" />
<rect x="398" y="54" width="405" height="292" rx="24" fill="#ffffff" fill-opacity="0.96" />
<rect x="452" y="105" width="180" height="20" rx="10" fill="#4c3b8f" />
<path d="M455 294V160M455 294h286" fill="none" stroke="#c8d8e6" stroke-width="8" stroke-linecap="round" />
<rect x="495" y="225" width="44" height="69" rx="8" fill="#74c0e3" />
<rect x="568" y="182" width="44" height="112" rx="8" fill="#5d78cb" />
<rect x="641" y="137" width="44" height="157" rx="8" fill="#38a169" />
<path d="M495 214l95-73 73 19 68-71" fill="none" stroke="#f0a34d" stroke-width="10" stroke-linecap="round" stroke-linejoin="round" />
<circle cx="495" cy="214" r="9" fill="#f0a34d" />
<circle cx="590" cy="141" r="9" fill="#f0a34d" />
<circle cx="663" cy="160" r="9" fill="#f0a34d" />
<circle cx="731" cy="89" r="9" fill="#f0a34d" />
</svg>

After

Width:  |  Height:  |  Size: 1.5 KiB

@@ -0,0 +1,22 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1200 400" role="img" aria-labelledby="title description">
<title id="title">Umfragen</title>
<desc id="description">Abstrakte Illustration eines Fragebogens mit Auswahlfeldern.</desc>
<defs>
<linearGradient id="background" x1="0" y1="0" x2="1" y2="1">
<stop offset="0%" stop-color="#1b5e8c" />
<stop offset="100%" stop-color="#254c8f" />
</linearGradient>
</defs>
<rect width="1200" height="400" fill="url(#background)" />
<circle cx="1060" cy="68" r="150" fill="#ffffff" fill-opacity="0.08" />
<circle cx="1150" cy="350" r="190" fill="#ffffff" fill-opacity="0.06" />
<rect x="390" y="54" width="420" height="292" rx="24" fill="#ffffff" fill-opacity="0.96" />
<rect x="440" y="106" width="182" height="20" rx="10" fill="#1b5e8c" />
<rect x="440" y="155" width="30" height="30" rx="8" fill="#38a169" />
<path d="M448 170l9 9 17-19" fill="none" stroke="#ffffff" stroke-width="6" stroke-linecap="round" stroke-linejoin="round" />
<rect x="492" y="160" width="244" height="18" rx="9" fill="#dce8f3" />
<rect x="440" y="213" width="30" height="30" rx="15" fill="none" stroke="#7fa1ba" stroke-width="6" />
<rect x="492" y="219" width="192" height="18" rx="9" fill="#dce8f3" />
<rect x="440" y="271" width="30" height="30" rx="15" fill="none" stroke="#7fa1ba" stroke-width="6" />
<rect x="492" y="277" width="222" height="18" rx="9" fill="#dce8f3" />
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB