This commit is contained in:
@@ -0,0 +1,229 @@
|
|||||||
|
using System.Collections.ObjectModel;
|
||||||
|
using System.Text.RegularExpressions;
|
||||||
|
using CommunityToolkit.Mvvm.ComponentModel;
|
||||||
|
using CommunityToolkit.Mvvm.Input;
|
||||||
|
using LehrerApp.Core.Interfaces;
|
||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Core.Services;
|
||||||
|
using LehrerApp.Desktop.Services;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
|
||||||
|
/// <summary>Eine Zeile bleibt auch ohne automatische Zuordnung sichtbar - <see cref="AssignedStudent"/>
|
||||||
|
/// kann manuell per Auswahlliste gesetzt werden, gleiches Muster wie
|
||||||
|
/// <see cref="WebUntisLessonAbsenceComparisonViewModel"/> und
|
||||||
|
/// <see cref="LehrerApp.Desktop.ViewModels.Students.WebUntisDocumentationComparisonViewModel"/>.</summary>
|
||||||
|
public partial class WebUntisHomeworkRow : ObservableObject
|
||||||
|
{
|
||||||
|
public required string ClassName { get; init; }
|
||||||
|
public required DateOnly Date { get; init; }
|
||||||
|
public required string UntisStudentName { get; init; }
|
||||||
|
public string? SubjectLabel { get; init; }
|
||||||
|
public string? Text { get; init; }
|
||||||
|
public required IReadOnlyList<Student> Candidates { get; init; }
|
||||||
|
internal Guid? MatchedSubjectId { get; init; }
|
||||||
|
internal Action<WebUntisHomeworkRow>? OnAssignmentChanged { get; init; }
|
||||||
|
public string DateLabel => Date.ToString("dd.MM.yyyy");
|
||||||
|
public bool CanApply => SessionId is not null;
|
||||||
|
|
||||||
|
[ObservableProperty] private Student? _assignedStudent;
|
||||||
|
[ObservableProperty] private string _localStatus = "ohne Zuordnung";
|
||||||
|
[ObservableProperty] private Guid? _sessionId;
|
||||||
|
[ObservableProperty] private bool _selected;
|
||||||
|
|
||||||
|
partial void OnAssignedStudentChanged(Student? value) => OnAssignmentChanged?.Invoke(this);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Abgleich negativer Klassenbucheinträge zum Stichwort "Hausaufgabe" (eigene, siehe
|
||||||
|
/// <see cref="WebUntisIntegrationService.GetOwnClassRegisterEventsAsync"/>) gegen
|
||||||
|
/// <see cref="ParticipationEntry.Homework"/> - Dashboard-weit statt pro Lerngruppe, aus demselben
|
||||||
|
/// Grund wie beim Dokumentations-Abgleich (der WebUntis-"-alle-"-Bericht ist klassenübergreifend,
|
||||||
|
/// siehe TODO.md). Nutzer-Feedback: WebUntis liefert für Klassenbucheinträge weder eine externe
|
||||||
|
/// Schülerkennung noch eine feste Fach-/Lerngruppenzuordnung, aber der Lehrkraft-eigene Text enthält
|
||||||
|
/// praktisch immer das Untis-Fachkürzel - darüber wird die passende Lerngruppe (SubjectId + aktive
|
||||||
|
/// Mitgliedschaft am Eintragsdatum) aufgelöst, mehrdeutige Treffer bleiben unaufgelöst statt zu raten.
|
||||||
|
/// Ein Eintrag setzt lokal ausschließlich <see cref="HomeworkStatus.MissingOpen"/> vor, und auch nur,
|
||||||
|
/// wenn dort noch gar kein Status hinterlegt ist - bereits vorhandene, feinere Erfassungen ("Teilweise
|
||||||
|
/// angefertigt", "nachgereicht" usw.) werden nie automatisch überschrieben, sondern nur zum Vergleich
|
||||||
|
/// danebengestellt (Nutzerwunsch).</summary>
|
||||||
|
public partial class WebUntisHomeworkComparisonViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
private readonly WebUntisIntegrationService _untis;
|
||||||
|
private readonly IStudentRepository _students;
|
||||||
|
private readonly IGroupRepository _groups;
|
||||||
|
private readonly ISubjectRepository _subjects;
|
||||||
|
private readonly IGroupMembershipRepository _memberships;
|
||||||
|
private readonly IParticipationSessionRepository _sessions;
|
||||||
|
private readonly IParticipationRepository _participation;
|
||||||
|
private readonly SchoolYearService _schoolYears;
|
||||||
|
|
||||||
|
public ObservableCollection<WebUntisHomeworkRow> Rows { get; } = [];
|
||||||
|
[ObservableProperty] private DateTimeOffset? _startDate = DateTimeOffset.Now.AddDays(-7);
|
||||||
|
[ObservableProperty] private DateTimeOffset? _endDate = DateTimeOffset.Now;
|
||||||
|
[ObservableProperty] private string _status = "Zeitraum wählen und Klassenbucheinträge laden.";
|
||||||
|
[ObservableProperty] private bool _busy;
|
||||||
|
|
||||||
|
public WebUntisHomeworkComparisonViewModel(WebUntisIntegrationService untis, IStudentRepository students,
|
||||||
|
IGroupRepository groups, ISubjectRepository subjects, IGroupMembershipRepository memberships,
|
||||||
|
IParticipationSessionRepository sessions, IParticipationRepository participation,
|
||||||
|
SchoolYearService schoolYears)
|
||||||
|
{
|
||||||
|
_untis = untis; _students = students; _groups = groups; _subjects = subjects;
|
||||||
|
_memberships = memberships; _sessions = sessions; _participation = participation;
|
||||||
|
_schoolYears = schoolYears;
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private async Task Load()
|
||||||
|
{
|
||||||
|
var start = DateOnly.FromDateTime((StartDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||||
|
var end = DateOnly.FromDateTime((EndDate ?? DateTimeOffset.Now).LocalDateTime);
|
||||||
|
if (end < start) { Status = "Das Enddatum darf nicht vor dem Startdatum liegen."; return; }
|
||||||
|
Busy = true; Rows.Clear();
|
||||||
|
try
|
||||||
|
{
|
||||||
|
var ownStudents = _students.GetAll();
|
||||||
|
var globalIndex = BuildNameIndex(ownStudents);
|
||||||
|
// Wie beim Dokumentations-Abgleich zusätzlich pro Klasse indiziert, um gleiche Namen in
|
||||||
|
// verschiedenen Klassen unterscheiden zu können.
|
||||||
|
var classIndexes = _groups.GetAll()
|
||||||
|
.Where(g => g.Type == GroupType.Class)
|
||||||
|
.GroupBy(g => g.Name, StringComparer.OrdinalIgnoreCase)
|
||||||
|
.ToDictionary(
|
||||||
|
g => g.Key,
|
||||||
|
g => BuildNameIndex(g.SelectMany(x => _students.GetByGroup(x.Id)).Distinct().ToList()),
|
||||||
|
StringComparer.OrdinalIgnoreCase);
|
||||||
|
var shortNameIndex = _subjects.GetAll()
|
||||||
|
.Where(s => !string.IsNullOrWhiteSpace(s.ShortName))
|
||||||
|
.GroupBy(s => s.ShortName.Trim().ToUpperInvariant())
|
||||||
|
.Where(g => g.Count() == 1) // mehrdeutiges Kürzel lieber nicht zuordnen als raten
|
||||||
|
.ToDictionary(g => g.Key, g => g.First());
|
||||||
|
|
||||||
|
var entries = await _untis.GetOwnClassRegisterEventsAsync(start, end);
|
||||||
|
var ordered = entries
|
||||||
|
.Where(IsMissingHomeworkEntry)
|
||||||
|
.Select(e => (Entry: e, Date: TryDate(e.Date, out var d) ? d : (DateOnly?)null))
|
||||||
|
.Where(x => x.Date is not null)
|
||||||
|
.OrderBy(x => x.Date).ThenBy(x => x.Entry.StudentName);
|
||||||
|
|
||||||
|
void ResolveLocalMatch(WebUntisHomeworkRow row)
|
||||||
|
{
|
||||||
|
if (row.AssignedStudent is not { } student)
|
||||||
|
{
|
||||||
|
row.SessionId = null; row.LocalStatus = "ohne Zuordnung"; row.Selected = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
var group = ResolveGroup(student, row.MatchedSubjectId, row.Date);
|
||||||
|
var session = group is null ? null
|
||||||
|
: _sessions.GetByGroup(group.Id).FirstOrDefault(s => s.Date == row.Date);
|
||||||
|
var entry = session is null ? null : _participation.GetBySessionAndStudent(session.Id, student.Id);
|
||||||
|
var current = entry is null ? null : HomeworkDisplay.Effective(entry);
|
||||||
|
row.SessionId = session?.Id;
|
||||||
|
row.LocalStatus = session is null
|
||||||
|
? row.MatchedSubjectId is null ? "Fachkürzel nicht erkannt"
|
||||||
|
: group is null ? "kein passender Kurs gefunden" : "keine lokale Stunde"
|
||||||
|
: HomeworkDisplay.Label(current);
|
||||||
|
// Nur vorbelegen, wenn lokal noch überhaupt nichts erfasst ist - jeder vorhandene
|
||||||
|
// Status (auch ein bereits gesetztes "fehlt") bleibt unangetastet, siehe Klassenkommentar.
|
||||||
|
row.Selected = session is not null && current is null;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach (var (entry, date) in ordered)
|
||||||
|
{
|
||||||
|
var nameKey = NameKey(entry.StudentName);
|
||||||
|
var match = (classIndexes.TryGetValue(entry.ClassName, out var classIndex)
|
||||||
|
? classIndex.GetValueOrDefault(nameKey)
|
||||||
|
: null)
|
||||||
|
?? globalIndex.GetValueOrDefault(nameKey);
|
||||||
|
var subject = MatchSubject(entry.Text, shortNameIndex) ?? MatchSubject(entry.CategoryName, shortNameIndex);
|
||||||
|
|
||||||
|
var row = new WebUntisHomeworkRow
|
||||||
|
{
|
||||||
|
ClassName = entry.ClassName, Date = date!.Value, UntisStudentName = entry.StudentName,
|
||||||
|
SubjectLabel = subject?.ShortName, Text = entry.Text, Candidates = ownStudents,
|
||||||
|
MatchedSubjectId = subject?.Id, OnAssignmentChanged = ResolveLocalMatch,
|
||||||
|
};
|
||||||
|
Rows.Add(row);
|
||||||
|
row.AssignedStudent = match; // löst OnAssignedStudentChanged aus und setzt SessionId/LocalStatus/Selected
|
||||||
|
}
|
||||||
|
|
||||||
|
var unresolvedStudent = Rows.Count(x => x.AssignedStudent is null);
|
||||||
|
var unresolvedSubject = Rows.Count(x => x.MatchedSubjectId is null);
|
||||||
|
Status = $"{Rows.Count} Einträge \"fehlende Hausaufgabe\" erhalten" +
|
||||||
|
(unresolvedStudent > 0 ? $", {unresolvedStudent} bitte manuell zuordnen" : "") +
|
||||||
|
(unresolvedSubject > 0 ? $", bei {unresolvedSubject} kein Fachkürzel im Text erkannt" : "") +
|
||||||
|
$". {Rows.Count(x => x.CanApply)} einer lokalen Stunde zuordenbar.";
|
||||||
|
}
|
||||||
|
catch (WebUntisIntegrationException ex) { Status = ex.Message; }
|
||||||
|
finally { Busy = false; }
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Apply()
|
||||||
|
{
|
||||||
|
var selected = Rows.Where(x => x.Selected && x.SessionId is not null && x.AssignedStudent is not null).ToList();
|
||||||
|
foreach (var row in selected)
|
||||||
|
{
|
||||||
|
var studentId = row.AssignedStudent!.Id;
|
||||||
|
var entry = _participation.GetBySessionAndStudent(row.SessionId!.Value, studentId)
|
||||||
|
?? new ParticipationEntry { SessionId = row.SessionId.Value, StudentId = studentId };
|
||||||
|
entry.Homework = HomeworkStatus.MissingOpen;
|
||||||
|
entry.HomeworkMissing = true;
|
||||||
|
entry.UpdatedAt = DateTime.UtcNow;
|
||||||
|
_participation.Save(entry);
|
||||||
|
row.LocalStatus = HomeworkDisplay.Label(HomeworkStatus.MissingOpen);
|
||||||
|
row.Selected = false;
|
||||||
|
}
|
||||||
|
Status = $"{selected.Count} Hausaufgaben-Status übernommen.";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Nur Lerngruppen (nicht die Klasse selbst), bei denen der/die Schüler*in am Eintragsdatum aktiv
|
||||||
|
/// Mitglied ist und deren Fach zum erkannten Kürzel passt - mehr als ein Treffer bleibt bewusst
|
||||||
|
/// unaufgelöst statt irgendeinen davon zu wählen.
|
||||||
|
private LearningGroup? ResolveGroup(Student student, Guid? subjectId, DateOnly date)
|
||||||
|
{
|
||||||
|
if (subjectId is null) return null;
|
||||||
|
var schoolYear = _schoolYears.CurrentSchoolYear(date);
|
||||||
|
var candidates = _memberships.GetByStudent(student.Id)
|
||||||
|
.Where(m => GroupMembershipService.IsActiveOn(m, date))
|
||||||
|
.Select(m => _groups.GetById(m.GroupId))
|
||||||
|
.Where(g => g is not null && g.SubjectId == subjectId && g.SchoolYear == schoolYear)
|
||||||
|
.Cast<LearningGroup>()
|
||||||
|
.ToList();
|
||||||
|
return candidates.Count == 1 ? candidates[0] : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Gleiche Heuristik wie ClassTeacherDetailsViewModel.ContainsHomework, zusätzlich auf negative
|
||||||
|
// Einträge eingeschränkt (eine positive "Hausaufgabe"-Kategorie wäre kein Fehlen-Signal).
|
||||||
|
private static bool IsMissingHomeworkEntry(UntisClassRegisterEventDto entry) =>
|
||||||
|
string.Equals(entry.CategoryGroup, "Negativ", StringComparison.OrdinalIgnoreCase) &&
|
||||||
|
(entry.CategoryName?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true ||
|
||||||
|
entry.Text?.Contains("Hausauf", StringComparison.OrdinalIgnoreCase) == true);
|
||||||
|
|
||||||
|
private static Subject? MatchSubject(string? text, IReadOnlyDictionary<string, Subject> shortNameIndex)
|
||||||
|
{
|
||||||
|
if (string.IsNullOrWhiteSpace(text)) return null;
|
||||||
|
foreach (var token in Regex.Split(text, @"[^\p{L}\p{Nd}]+"))
|
||||||
|
if (token.Length > 0 && shortNameIndex.TryGetValue(token.ToUpperInvariant(), out var subject))
|
||||||
|
return subject;
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static string NameKey(string value) => value.Trim().ToLowerInvariant();
|
||||||
|
|
||||||
|
// Wie bei den übrigen WebUntis-Abgleichen: beide Namensreihenfolgen registriert, aber nur falls
|
||||||
|
// innerhalb der Kandidaten eindeutig.
|
||||||
|
private static Dictionary<string, Student> BuildNameIndex(IReadOnlyList<Student> candidates) =>
|
||||||
|
candidates
|
||||||
|
.SelectMany(student => new[]
|
||||||
|
{
|
||||||
|
NameKey($"{student.LastName} {student.FirstName}"),
|
||||||
|
NameKey($"{student.FirstName} {student.LastName}"),
|
||||||
|
}.Select(key => (Key: key, Student: student)))
|
||||||
|
.GroupBy(x => x.Key)
|
||||||
|
.Where(group => group.Select(x => x.Student).Distinct().Count() == 1)
|
||||||
|
.ToDictionary(group => group.Key, group => group.First().Student);
|
||||||
|
|
||||||
|
private static bool TryDate(int value, out DateOnly date) =>
|
||||||
|
DateOnly.TryParseExact(value.ToString(), "yyyyMMdd", out date);
|
||||||
|
}
|
||||||
@@ -150,8 +150,11 @@ public partial class MainWindowViewModel : ObservableObject
|
|||||||
|
|
||||||
private TimetableViewModel GetTimetable()
|
private TimetableViewModel GetTimetable()
|
||||||
{
|
{
|
||||||
|
// WeekOffset bewusst nicht zurücksetzen: TimetableViewModel ist ein DI-Singleton, die
|
||||||
|
// zuletzt angezeigte Woche bleibt also automatisch über Tab-Wechsel hinweg erhalten
|
||||||
|
// (Nutzer-Feedback: beim Planen der nächsten Woche nervt es, nach jedem kurzen Abstecher
|
||||||
|
// in einen anderen Bereich wieder manuell dorthin zurückblättern zu müssen).
|
||||||
var timetable = _services.GetRequiredService<TimetableViewModel>();
|
var timetable = _services.GetRequiredService<TimetableViewModel>();
|
||||||
timetable.WeekOffset = 0;
|
|
||||||
timetable.Load();
|
timetable.Load();
|
||||||
timetable.ActiveTabIndex = 0;
|
timetable.ActiveTabIndex = 0;
|
||||||
return timetable;
|
return timetable;
|
||||||
|
|||||||
@@ -28,6 +28,8 @@
|
|||||||
<WrapPanel Grid.Row="1" Orientation="Horizontal" ItemSpacing="8" LineSpacing="8" Margin="0,10,0,0">
|
<WrapPanel Grid.Row="1" Orientation="Horizontal" ItemSpacing="8" LineSpacing="8" Margin="0,10,0,0">
|
||||||
<Button Content="Klassenbuch abgleichen…" Click="OnCompareWebUntisDocumentationClick"
|
<Button Content="Klassenbuch abgleichen…" Click="OnCompareWebUntisDocumentationClick"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
|
<Button Content="Fehlende Hausaufgaben abgleichen…" Click="OnCompareWebUntisHomeworkClick"
|
||||||
|
VerticalAlignment="Center"/>
|
||||||
<Button Content="Bereiche anpassen" Command="{Binding ToggleDashboardSettingsCommand}"
|
<Button Content="Bereiche anpassen" Command="{Binding ToggleDashboardSettingsCommand}"
|
||||||
VerticalAlignment="Center"/>
|
VerticalAlignment="Center"/>
|
||||||
</WrapPanel>
|
</WrapPanel>
|
||||||
|
|||||||
@@ -5,7 +5,9 @@ using LehrerApp.Core.Models;
|
|||||||
using LehrerApp.Core.Services;
|
using LehrerApp.Core.Services;
|
||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels;
|
using LehrerApp.Desktop.ViewModels;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
using LehrerApp.Desktop.ViewModels.Students;
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
using LehrerApp.Desktop.Views.Groups;
|
||||||
using LehrerApp.Desktop.Views.Students;
|
using LehrerApp.Desktop.Views.Students;
|
||||||
using LehrerApp.Desktop.Views.Workload;
|
using LehrerApp.Desktop.Views.Workload;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
@@ -42,4 +44,20 @@ public partial class DashboardView : UserControl
|
|||||||
App.Services.GetRequiredService<SchoolYearService>());
|
App.Services.GetRequiredService<SchoolYearService>());
|
||||||
await new WebUntisDocumentationComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
|
await new WebUntisDocumentationComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private async void OnCompareWebUntisHomeworkClick(object? sender, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
var owner = TopLevel.GetTopLevel(this) as Window;
|
||||||
|
if (owner is null) return;
|
||||||
|
var dialogVm = new WebUntisHomeworkComparisonViewModel(
|
||||||
|
App.Services.GetRequiredService<WebUntisIntegrationService>(),
|
||||||
|
App.Services.GetRequiredService<IStudentRepository>(),
|
||||||
|
App.Services.GetRequiredService<IGroupRepository>(),
|
||||||
|
App.Services.GetRequiredService<ISubjectRepository>(),
|
||||||
|
App.Services.GetRequiredService<IGroupMembershipRepository>(),
|
||||||
|
App.Services.GetRequiredService<IParticipationSessionRepository>(),
|
||||||
|
App.Services.GetRequiredService<IParticipationRepository>(),
|
||||||
|
App.Services.GetRequiredService<SchoolYearService>());
|
||||||
|
await new WebUntisHomeworkComparisonDialog { DataContext = dialogVm }.ShowDialog(owner);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,58 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||||
|
xmlns:conv="clr-namespace:LehrerApp.Desktop.Converters"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.Groups.WebUntisHomeworkComparisonDialog"
|
||||||
|
x:DataType="vm:WebUntisHomeworkComparisonViewModel"
|
||||||
|
Title="Fehlende Hausaufgaben mit WebUntis abgleichen" Width="1100" Height="680"
|
||||||
|
MinWidth="900" MinHeight="480" WindowStartupLocation="CenterOwner">
|
||||||
|
<Grid RowDefinitions="Auto,Auto,Auto,*,Auto" Margin="24" RowSpacing="8">
|
||||||
|
<StackPanel Grid.Row="0" Spacing="4">
|
||||||
|
<TextBlock Text="Fehlende Hausaufgaben mit WebUntis abgleichen" Classes="dialogtitle"/>
|
||||||
|
<TextBlock TextWrapping="Wrap" FontSize="12" Opacity="0.65"
|
||||||
|
Text="Nur eigene, negative Klassenbucheinträge zum Stichwort „Hausaufgabe“. Fach wird über das Untis-Fachkürzel im Text erkannt. Zeilen mit bereits vorhandenem lokalem Hausaufgaben-Status (z.B. „Teilweise“, „nachgereicht“) werden nie überschrieben, nur zum Vergleich angezeigt."/>
|
||||||
|
</StackPanel>
|
||||||
|
<StackPanel Grid.Row="1" Orientation="Horizontal" Spacing="8">
|
||||||
|
<CalendarDatePicker SelectedDate="{Binding StartDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
|
||||||
|
<TextBlock Text="bis" VerticalAlignment="Center"/>
|
||||||
|
<CalendarDatePicker SelectedDate="{Binding EndDate, Converter={x:Static conv:DateTimeOffsetToDateTimeConverter.Instance}}"/>
|
||||||
|
<Button Content="Klassenbucheinträge laden" Command="{Binding LoadCommand}" IsEnabled="{Binding !Busy}"/>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<Grid Grid.Row="2" ColumnDefinitions="Auto,70,60,55,1.1*,1.1*,1.4*,1.2*" ColumnSpacing="8" Margin="4,0">
|
||||||
|
<TextBlock Grid.Column="1" Text="Datum" FontSize="11" Opacity="0.6"/>
|
||||||
|
<TextBlock Grid.Column="2" Text="Klasse" FontSize="11" Opacity="0.6"/>
|
||||||
|
<TextBlock Grid.Column="3" Text="Fach" FontSize="11" Opacity="0.6"/>
|
||||||
|
<TextBlock Grid.Column="4" Text="Name (WebUntis)" FontSize="11" Opacity="0.6"/>
|
||||||
|
<TextBlock Grid.Column="5" Text="Zuordnung" FontSize="11" Opacity="0.6"/>
|
||||||
|
<TextBlock Grid.Column="6" Text="Text" FontSize="11" Opacity="0.6"/>
|
||||||
|
<TextBlock Grid.Column="7" Text="Lokaler Hausaufgaben-Status" FontSize="11" Opacity="0.6"/>
|
||||||
|
</Grid>
|
||||||
|
<ScrollViewer Grid.Row="3">
|
||||||
|
<ItemsControl ItemsSource="{Binding Rows}">
|
||||||
|
<ItemsControl.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:WebUntisHomeworkRow">
|
||||||
|
<Grid ColumnDefinitions="Auto,70,60,55,1.1*,1.1*,1.4*,1.2*" ColumnSpacing="8" Margin="0,3">
|
||||||
|
<CheckBox Grid.Column="0" IsChecked="{Binding Selected}" IsEnabled="{Binding CanApply}"/>
|
||||||
|
<TextBlock Grid.Column="1" Text="{Binding DateLabel}" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Column="2" Text="{Binding ClassName}" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Column="3" Text="{Binding SubjectLabel}" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Column="4" Text="{Binding UntisStudentName}" VerticalAlignment="Center" TextWrapping="Wrap"/>
|
||||||
|
<ComboBox Grid.Column="5" ItemsSource="{Binding Candidates}" SelectedItem="{Binding AssignedStudent}"
|
||||||
|
DisplayMemberBinding="{Binding FullName}" PlaceholderText="Schüler*in wählen…"
|
||||||
|
HorizontalAlignment="Stretch"/>
|
||||||
|
<TextBlock Grid.Column="6" Text="{Binding Text}" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||||
|
<TextBlock Grid.Column="7" Text="{Binding LocalStatus}" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||||
|
</Grid>
|
||||||
|
</DataTemplate>
|
||||||
|
</ItemsControl.ItemTemplate>
|
||||||
|
</ItemsControl>
|
||||||
|
</ScrollViewer>
|
||||||
|
|
||||||
|
<Grid Grid.Row="4" ColumnDefinitions="*,Auto,Auto" ColumnSpacing="8">
|
||||||
|
<TextBlock Grid.Column="0" Text="{Binding Status}" FontSize="12" TextWrapping="Wrap" VerticalAlignment="Center"/>
|
||||||
|
<Button Grid.Column="1" Content="Schließen" Click="OnClose"/>
|
||||||
|
<Button Grid.Column="2" Content="Markierte übernehmen" Command="{Binding ApplyCommand}" IsEnabled="{Binding !Busy}"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Views.Groups;
|
||||||
|
|
||||||
|
public partial class WebUntisHomeworkComparisonDialog : Window
|
||||||
|
{
|
||||||
|
public WebUntisHomeworkComparisonDialog() => InitializeComponent();
|
||||||
|
|
||||||
|
private void OnClose(object? sender, RoutedEventArgs e) => Close();
|
||||||
|
}
|
||||||
@@ -982,7 +982,9 @@ angezeigten Woche geklebt, nicht nur in der einen Woche, zu der es gehört). Fü
|
|||||||
berechnen `HolidayBadgeFor`/`IsLastBeforeExamFor` die Badges deshalb jetzt je Zelle am dort
|
berechnen `HolidayBadgeFor`/`IsLastBeforeExamFor` die Badges deshalb jetzt je Zelle am dort
|
||||||
gezeigten Datum statt an "heute" — das Bearbeiten-Raster (zeigt ohnehin nur das wiederkehrende
|
gezeigten Datum statt an "heute" — das Bearbeiten-Raster (zeigt ohnehin nur das wiederkehrende
|
||||||
Muster ohne Datum) behält die alte, "heute"-verankerte Berechnung. Beim erneuten Navigieren in den
|
Muster ohne Datum) behält die alte, "heute"-verankerte Berechnung. Beim erneuten Navigieren in den
|
||||||
Stundenplan (Sidebar-Klick) springt die Ansicht wieder auf die laufende Woche zurück.
|
Stundenplan (Sidebar-Klick) sprang die Ansicht ursprünglich wieder auf die laufende Woche zurück —
|
||||||
|
siehe "Nachtrag zu 4.3, Bugfix Wochennavigation (September 2026)" am Ende dieses Abschnitts, wo
|
||||||
|
dieses Verhalten auf Nutzer-Feedback hin wieder entfernt wurde.
|
||||||
|
|
||||||
**Nachtrag zu 4.3, fünfte Iteration (Aufsichten + Vertretung):** Nutzer-Feedback: zwischen manchen
|
**Nachtrag zu 4.3, fünfte Iteration (Aufsichten + Vertretung):** Nutzer-Feedback: zwischen manchen
|
||||||
Stunden ist auch Pausenaufsicht zu erledigen, und gelegentlich kommen Sonderfälle vor — eine
|
Stunden ist auch Pausenaufsicht zu erledigen, und gelegentlich kommen Sonderfälle vor — eine
|
||||||
@@ -1930,6 +1932,69 @@ eigenen Unterricht abfragt und deshalb mit den regulären Lehrkraft-Rechten funk
|
|||||||
Vorgänge je Klassen-Roster-Namen über denselben Namensabgleich wie die übrigen `Build*`-Methoden
|
Vorgänge je Klassen-Roster-Namen über denselben Namensabgleich wie die übrigen `Build*`-Methoden
|
||||||
dieser Klasse auf (`IVorgangRepository.GetByStudent`).
|
dieser Klasse auf (`IVorgangRepository.GetByStudent`).
|
||||||
|
|
||||||
|
**Nachtrag zu 4.3, Bugfix Wochennavigation (September 2026):** Nutzer-Feedback: beim Planen der
|
||||||
|
nächsten Woche musste nach jedem kurzen Abstecher in einen anderen Bereich der Sidebar wieder
|
||||||
|
manuell zur zuvor betrachteten Woche zurückgeblättert werden ("man muss nicht nur einmal
|
||||||
|
hinspringen, sondern jedes Mal wieder neu"). Ursache war das in der vierten Iteration dokumentierte
|
||||||
|
Verhalten, dass `MainWindowViewModel.GetTimetable()` bei jedem erneuten Aufruf der Seitenleisten-
|
||||||
|
Navigation `TimetableViewModel.WeekOffset` explizit auf 0 zurücksetzte — obwohl `TimetableViewModel`
|
||||||
|
als DI-Singleton ohnehin über die gesamte Sitzung hinweg bestehen bleibt und `WeekOffset` daher ohne
|
||||||
|
diese Zeile ganz von selbst erhalten bliebe. Der Reset wurde entfernt; ein separater Zustands-
|
||||||
|
Singleton/eine statische Variable dafür war nicht nötig. Das erklärt vermutlich auch den zweiten
|
||||||
|
gemeldeten Effekt ("der Zurück-Button springt manchmal zwei Wochen zurück"): wer z.B. in Woche +1
|
||||||
|
plante, kurz wegnavigierte und danach einmal "‹" klickte, landete wegen des Resets in Woche -1 statt
|
||||||
|
der erwarteten Woche 0 — gefühlt ein Sprung um zwei Wochen, obwohl `PreviousWeekCommand`/
|
||||||
|
`NextWeekCommand` selbst korrekt je Klick nur um eine Woche verschieben (siehe
|
||||||
|
`NextWeek_BadgeGehoertZurAngezeigtenWoche_NichtZuHeute`, das zwei aufeinanderfolgende Klicks bewusst
|
||||||
|
als zwei Wochen erwartet). Der "Diese Woche"-Button existierte bereits seit der vierten Iteration
|
||||||
|
(`CurrentWeekCommand`, nur sichtbar wenn `!IsCurrentWeek`).
|
||||||
|
|
||||||
|
**Nachtrag zu 4.3, Abgleich fehlender Hausaufgaben (September 2026):** Nutzer-Feedback: negative
|
||||||
|
Klassenbucheinträge zur Kategorie "fehlende HA" sind ein brauchbares Signal, ob ein/e Schüler*in die
|
||||||
|
Hausaufgabe nicht gemacht hat, und sollen als Vorschlag mit der eigenen Erfassung
|
||||||
|
(`ParticipationEntry.Homework`) abgeglichen werden können — ausdrücklich nicht die erteilte
|
||||||
|
Hausaufgabe selbst (`Lesson.Homework`), das bleibt ein offener, separater Punkt.
|
||||||
|
- Neuer Dashboard-weiter Dialog `WebUntisHomeworkComparisonViewModel`/`WebUntisHomeworkComparisonDialog`
|
||||||
|
([LehrerApp.Desktop/ViewModels/Groups/WebUntisHomeworkComparisonViewModel.cs](LehrerApp.Desktop/ViewModels/Groups/WebUntisHomeworkComparisonViewModel.cs)),
|
||||||
|
Button "Fehlende Hausaufgaben abgleichen…" neben dem bereits vorhandenen "Klassenbuch
|
||||||
|
abgleichen…" auf dem Dashboard. Baut auf demselben Muster wie
|
||||||
|
`WebUntisDocumentationComparisonViewModel` (Dashboard-weit, da der WebUntis-"-alle-"-Bericht
|
||||||
|
klassenübergreifend ist) und `WebUntisLessonAbsenceComparisonViewModel` (schreibt wie dort über
|
||||||
|
`IParticipationRepository.GetBySessionAndStudent`/`Save`).
|
||||||
|
- **Filter:** `CategoryGroup == "Negativ"` und `CategoryName`/`Text` enthält "Hausauf" — dieselbe
|
||||||
|
Substring-Heuristik wie `ClassTeacherDetailsViewModel.ContainsHomework`, nur zusätzlich auf
|
||||||
|
negative Einträge eingeschränkt.
|
||||||
|
- **Fach-Erkennung:** Der Klassenbuch-Bericht liefert kein zuverlässiges Fach-Feld, aber laut Nutzer
|
||||||
|
enthält der mitgeschickte Text praktisch immer das Untis-Fachkürzel. Deshalb wird `Text` (Fallback
|
||||||
|
`CategoryName`) gegen `Subject.ShortName` alle Lerngruppen-Fächer abgeglichen (Wortgrenzen-Split,
|
||||||
|
nicht `Contains`, um kurze Kürzel nicht versehentlich in anderen Wörtern zu treffen); mehrdeutige
|
||||||
|
Kürzel (zwei Fächer mit demselben `ShortName`) werden bewusst nicht zugeordnet statt zu raten.
|
||||||
|
- **Lerngruppe/Stunde auflösen:** aus dem per Namensabgleich gefundenen `Student` werden über
|
||||||
|
`IGroupMembershipRepository.GetByStudent` alle am Eintragsdatum aktiven Mitgliedschaften
|
||||||
|
(`GroupMembershipService.IsActiveOn`) genommen und auf das erkannte Fach + Schuljahr des
|
||||||
|
Eintragsdatums eingeschränkt; bleibt mehr als eine Lerngruppe übrig, bleibt die Zeile unaufgelöst
|
||||||
|
statt irgendeine zu wählen (gleiches Vorsichtsprinzip wie beim Namensabgleich). Die
|
||||||
|
`ParticipationSession` der Lerngruppe am Eintragsdatum liefert dann die Stunde für
|
||||||
|
`IParticipationRepository`.
|
||||||
|
- **Kein Überschreiben (Kernanforderung):** eine Zeile wird nur vorausgewählt, wenn
|
||||||
|
`HomeworkDisplay.Effective(entry)` noch `null` ist, also lokal überhaupt noch nichts erfasst wurde
|
||||||
|
— jeder bereits vorhandene Status (auch ein bereits gesetztes "fehlt", nicht nur "Teilweise"/
|
||||||
|
"nachgereicht") bleibt unangetastet und wird nur zum Vergleich daneben angezeigt; die Checkbox
|
||||||
|
bleibt trotzdem bedienbar, falls doch bewusst überschrieben werden soll. Übernommen wird immer nur
|
||||||
|
`HomeworkStatus.MissingOpen` ("Nachreichen offen") — nie automatisch `MissingOverdue`, die weitere
|
||||||
|
Nachverfolgung bleibt laut Nutzer App-intern.
|
||||||
|
- **Bewusst kein Caching:** `GetOwnClassRegisterEventsAsync` wird wie beim bereits bestehenden
|
||||||
|
`WebUntisDocumentationComparisonViewModel` direkt (ungecacht) aufgerufen statt über
|
||||||
|
`UntisReportCacheService`, dessen Cache-Tabellen aktuell klassen-/`GetForeignClassRegisterEventsAsync`-
|
||||||
|
spezifisch aufgebaut sind (ein Schema-Umbau für die eigenen, klassenübergreifenden Einträge wäre ein
|
||||||
|
eigenständiger, deutlich größerer Umbau gewesen, siehe Nutzer-Rückfrage). Beide "eigene Einträge"-
|
||||||
|
Dialoge lösen pro Öffnen einen eigenen Live-Abruf aus; falls das in der Praxis stört, wäre eine
|
||||||
|
gemeinsame Cache-Erweiterung ein sinnvoller separater Nachtrag.
|
||||||
|
- Aus Konsistenz mit den beiden bestehenden, strukturell identischen Abgleichs-ViewModels bewusst
|
||||||
|
ungetestet gelassen: `WebUntisIntegrationService` ist `sealed` und nicht auf ein Interface
|
||||||
|
gemockt, `WebUntisLessonAbsenceComparisonViewModel`/`WebUntisDocumentationComparisonViewModel`
|
||||||
|
haben aus demselben Grund ebenfalls keine Tests.
|
||||||
|
|
||||||
### 4.4 Wochen-/Tagesansicht
|
### 4.4 Wochen-/Tagesansicht
|
||||||
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
|
- [x] **4.4.1** Kalenderansicht über alle Gruppen: Woche und Tag — siehe Nachtrag zu 4.3
|
||||||
("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.
|
("Heute"-Tab: Tagesliste unten angedockt, gruppenübergreifendes Wochenraster darüber, inkl.
|
||||||
|
|||||||
Reference in New Issue
Block a user