Stundenplan: verwaiste Stunden per "FixIt"-Button verknüpfen
CI / build-and-test (push) Canceled after 0s
CI / build-and-test (push) Canceled after 0s
Der Stundenplan erkennt eine Stunde nur über Datum+Stundennummer (kein gespeichertes Verknüpfungsfeld). Per JSON-Import oder KI ohne Stundenplan-Bezug angelegte Stunden ohne passende Stundennummer tauchten dort nie auf - ein Klick auf den Termin bot nur "neu anlegen" an und hätte eine Dublette erzeugt. Neuer Button im Anlegen-Dialog sucht stattdessen nach einer passenden vorhandenen Stunde und hängt sie auf den geklickten Termin um. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,94 @@
|
|||||||
|
using LehrerApp.Core.Models;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Tests;
|
||||||
|
|
||||||
|
/// Deckt die Kandidatensuche für den "Vorhandene Stunde verknüpfen"-Button im Stunden-anlegen-
|
||||||
|
/// Dialog ab (Nutzer-Feedback: per JSON-Import/KI ohne Stundennummer angelegte Stunden tauchen im
|
||||||
|
/// Stundenplan nicht auf, da der dort verwendete FindLessonForSlot per Datum+Stundennummer sucht).
|
||||||
|
public class LessonFixItSearchTests
|
||||||
|
{
|
||||||
|
[Fact]
|
||||||
|
public void FindCandidates_FindetVerwaisteStundeOhneStundennummerAmSelbenTag()
|
||||||
|
{
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var unitId = Guid.NewGuid();
|
||||||
|
var date = new DateOnly(2026, 9, 8);
|
||||||
|
var orphan = new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = null, Topic = "Reflexionsgesetz" };
|
||||||
|
lessons.Add(orphan);
|
||||||
|
|
||||||
|
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
|
||||||
|
|
||||||
|
var found = Assert.Single(candidates);
|
||||||
|
Assert.Equal(orphan.Id, found.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FindCandidates_FindetStundeAmSelbenTagMitAbweichenderStundennummer()
|
||||||
|
{
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var unitId = Guid.NewGuid();
|
||||||
|
var date = new DateOnly(2026, 9, 8);
|
||||||
|
var wrongPeriod = new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = 7, Topic = "Reflexionsgesetz" };
|
||||||
|
lessons.Add(wrongPeriod);
|
||||||
|
|
||||||
|
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
|
||||||
|
|
||||||
|
var found = Assert.Single(candidates);
|
||||||
|
Assert.Equal(wrongPeriod.Id, found.Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FindCandidates_SortiertExaktesDatumVorNahenTerminenOhneStundennummer()
|
||||||
|
{
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var unitId = Guid.NewGuid();
|
||||||
|
var date = new DateOnly(2026, 9, 8);
|
||||||
|
var near = new Lesson { UnitId = unitId, GroupId = groupId, Date = date.AddDays(-2), LessonNumber = null, Topic = "Nah" };
|
||||||
|
var exact = new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = null, Topic = "Exakt" };
|
||||||
|
lessons.Add(near); lessons.Add(exact);
|
||||||
|
|
||||||
|
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
|
||||||
|
|
||||||
|
Assert.Equal(2, candidates.Count);
|
||||||
|
Assert.Equal(exact.Id, candidates[0].Id);
|
||||||
|
Assert.Equal(near.Id, candidates[1].Id);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FindCandidates_IgnoriertStundenAusserhalbDesZeitfenstersUndAnderenGruppen()
|
||||||
|
{
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var otherGroupId = Guid.NewGuid();
|
||||||
|
var unitId = Guid.NewGuid();
|
||||||
|
var date = new DateOnly(2026, 9, 8);
|
||||||
|
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = date.AddDays(-30), LessonNumber = null, Topic = "Zu weit weg" });
|
||||||
|
lessons.Add(new Lesson { UnitId = unitId, GroupId = otherGroupId, Date = date, LessonNumber = null, Topic = "Andere Gruppe" });
|
||||||
|
// Gesetzte Stundennummer, aber exakt am gesuchten Datum — bewusst als Kandidat enthalten
|
||||||
|
// (z.B. falsch nummerierter Import); der Nutzer entscheidet im Bestätigungs-/
|
||||||
|
// Auswahldialog, ob sie passt.
|
||||||
|
lessons.Add(new Lesson { UnitId = unitId, GroupId = groupId, Date = date, LessonNumber = 3, Topic = "Falsch nummeriert, aber am gesuchten Tag" });
|
||||||
|
|
||||||
|
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
|
||||||
|
|
||||||
|
var found = Assert.Single(candidates);
|
||||||
|
Assert.Equal("Falsch nummeriert, aber am gesuchten Tag", found.Topic);
|
||||||
|
}
|
||||||
|
|
||||||
|
[Fact]
|
||||||
|
public void FindCandidates_LeerOhnePassendeStunde()
|
||||||
|
{
|
||||||
|
var lessons = new FakeLessons();
|
||||||
|
var groupId = Guid.NewGuid();
|
||||||
|
var date = new DateOnly(2026, 9, 8);
|
||||||
|
|
||||||
|
var candidates = LessonFixItSearch.FindCandidates(lessons, groupId, date);
|
||||||
|
|
||||||
|
Assert.Empty(candidates);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -745,8 +745,10 @@ public partial class LessonDialogViewModel : ObservableObject
|
|||||||
// gespeicherte Stunden sinnvoll, eine gerade erst angelegte, noch ungespeicherte Stunde hat
|
// gespeicherte Stunden sinnvoll, eine gerade erst angelegte, noch ungespeicherte Stunde hat
|
||||||
// keine echte Id, auf die sich die KI beziehen könnte.
|
// keine echte Id, auf die sich die KI beziehen könnte.
|
||||||
public Guid UnitId => _unitId;
|
public Guid UnitId => _unitId;
|
||||||
|
public Guid GroupId => _groupId;
|
||||||
public Lesson? EditingLesson => _editingLesson;
|
public Lesson? EditingLesson => _editingLesson;
|
||||||
public bool CanAiAssist => _editingLesson is not null;
|
public bool CanAiAssist => _editingLesson is not null;
|
||||||
|
public bool IsNewLesson => _editingLesson is null;
|
||||||
|
|
||||||
/// Vom Code-Behind nach einer über die KI angewendeten Änderung aufgerufen: der Dialog schließt
|
/// Vom Code-Behind nach einer über die KI angewendeten Änderung aufgerufen: der Dialog schließt
|
||||||
/// sich danach mit Result != null, damit die aufrufende Liste neu lädt — die eigenen, jetzt
|
/// sich danach mit Result != null, damit die aufrufende Liste neu lädt — die eigenen, jetzt
|
||||||
@@ -1258,6 +1260,56 @@ public partial class ChangeLessonUnitDialogViewModel : ObservableObject
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Dialog: Vorhandene Stunde zum Verknüpfen auswählen (Stundenplan-FixIt) ────
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Sucht Stunden, die zu einem Stundenplan-Termin gehören könnten, aber (z.B. durch JSON-Import
|
||||||
|
/// oder KI-Übernahme ohne Stundenplan-Bezug) keine passende <see cref="Lesson.LessonNumber"/>
|
||||||
|
/// haben und deshalb von <c>TimetableViewModel.FindLessonForSlot</c> nicht gefunden werden.
|
||||||
|
/// Als eigene, von Avalonia unabhängige Methode extrahiert, damit die Zuordnungslogik ohne Fenster
|
||||||
|
/// testbar ist — Aufrufer ist <c>LessonDialog.axaml.cs</c> (Button "Vorhandene Stunde verknüpfen").
|
||||||
|
/// </summary>
|
||||||
|
public static class LessonFixItSearch
|
||||||
|
{
|
||||||
|
public static List<Lesson> FindCandidates(ILessonRepository lessons, Guid groupId, DateOnly date) =>
|
||||||
|
[.. lessons.GetByGroupAndRange(groupId, date.AddDays(-14), date.AddDays(14))
|
||||||
|
.Where(l => l.LessonNumber is null || l.Date == date)
|
||||||
|
.OrderBy(l => l.Date == date ? 0 : 1)
|
||||||
|
.ThenBy(l => Math.Abs(l.Date.DayNumber - date.DayNumber))];
|
||||||
|
}
|
||||||
|
|
||||||
|
public sealed class LessonLinkOption(Lesson lesson, string unitTitle)
|
||||||
|
{
|
||||||
|
public Lesson Model { get; } = lesson;
|
||||||
|
public string Label { get; } = string.IsNullOrWhiteSpace(lesson.Topic) ? "(ohne Thema)" : lesson.Topic;
|
||||||
|
public string Detail { get; } =
|
||||||
|
$"{lesson.Date:dd.MM.yyyy} · {(lesson.LessonNumber is int n ? $"{n}. Stunde" : "keine Stundennummer")} · Einheit „{unitTitle}“";
|
||||||
|
}
|
||||||
|
|
||||||
|
public partial class LinkExistingLessonDialogViewModel : ObservableObject
|
||||||
|
{
|
||||||
|
[ObservableProperty] private LessonLinkOption? _selectedOption;
|
||||||
|
[ObservableProperty] private string _error = "";
|
||||||
|
|
||||||
|
public ObservableCollection<LessonLinkOption> Options { get; } = [];
|
||||||
|
public Lesson? Result { get; private set; }
|
||||||
|
|
||||||
|
public LinkExistingLessonDialogViewModel(List<Lesson> candidates, IUnitRepository units)
|
||||||
|
{
|
||||||
|
foreach (var lesson in candidates)
|
||||||
|
Options.Add(new LessonLinkOption(lesson, units.GetById(lesson.UnitId)?.Title ?? "?"));
|
||||||
|
SelectedOption = Options.FirstOrDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
[RelayCommand]
|
||||||
|
private void Save()
|
||||||
|
{
|
||||||
|
Error = "";
|
||||||
|
if (SelectedOption is null) { Error = "Bitte eine Stunde auswählen."; return; }
|
||||||
|
Result = SelectedOption.Model;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Dialog: Stunden serienweise aus dem Stundenplan erzeugen (4.2.5) ────────
|
// ── Dialog: Stunden serienweise aus dem Stundenplan erzeugen (4.2.5) ────────
|
||||||
|
|
||||||
public partial class GenerateLessonSeriesDialogViewModel : ObservableObject
|
public partial class GenerateLessonSeriesDialogViewModel : ObservableObject
|
||||||
|
|||||||
@@ -185,6 +185,9 @@
|
|||||||
<Button Grid.Column="2" Content="🤖 KI-Unterstützung für diese Stunde" HorizontalAlignment="Stretch"
|
<Button Grid.Column="2" Content="🤖 KI-Unterstützung für diese Stunde" HorizontalAlignment="Stretch"
|
||||||
Click="OnAiAssist" IsVisible="{Binding CanAiAssist}"
|
Click="OnAiAssist" IsVisible="{Binding CanAiAssist}"
|
||||||
ToolTip.Tip="Fragt die KI gezielt zu genau dieser Stunde — Speicherstand geht dabei direkt in die Datenbank, nicht über die Felder hier."/>
|
ToolTip.Tip="Fragt die KI gezielt zu genau dieser Stunde — Speicherstand geht dabei direkt in die Datenbank, nicht über die Felder hier."/>
|
||||||
|
<Button Grid.Column="2" Content="🔧 Vorhandene Stunde verknüpfen" HorizontalAlignment="Stretch"
|
||||||
|
Click="OnFixIt" IsVisible="{Binding IsNewLesson}"
|
||||||
|
ToolTip.Tip="Sucht nach einer bereits vorhandenen Stunde (z.B. durch Import oder KI ohne Stundenplan-Bezug angelegt) und verknüpft sie mit diesem Termin, statt eine neue Stunde anzulegen."/>
|
||||||
<Button Grid.Column="4" Content="{Binding SaveButtonText}"
|
<Button Grid.Column="4" Content="{Binding SaveButtonText}"
|
||||||
HorizontalAlignment="Stretch" Click="OnSave"/>
|
HorizontalAlignment="Stretch" Click="OnSave"/>
|
||||||
</Grid>
|
</Grid>
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ using LehrerApp.Core.Models;
|
|||||||
using LehrerApp.Desktop.Services;
|
using LehrerApp.Desktop.Services;
|
||||||
using LehrerApp.Desktop.ViewModels.Groups;
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
using LehrerApp.Desktop.ViewModels.Students;
|
using LehrerApp.Desktop.ViewModels.Students;
|
||||||
|
using LehrerApp.Desktop.Views.Shared;
|
||||||
using Microsoft.Extensions.DependencyInjection;
|
using Microsoft.Extensions.DependencyInjection;
|
||||||
|
using System.Globalization;
|
||||||
|
|
||||||
namespace LehrerApp.Desktop.Views.Groups;
|
namespace LehrerApp.Desktop.Views.Groups;
|
||||||
|
|
||||||
@@ -200,4 +202,59 @@ public partial class LessonDialog : Window
|
|||||||
if (updated is not null) vm.MarkAppliedExternally(updated);
|
if (updated is not null) vm.MarkAppliedExternally(updated);
|
||||||
Close(true);
|
Close(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Deckt die Lücke ab, dass der Stundenplan eine Stunde nur über Datum+Stundennummer findet
|
||||||
|
/// (kein gespeichertes Verknüpfungsfeld) — eine per JSON-Import oder KI ohne Stundennummer
|
||||||
|
/// angelegte Stunde taucht dort nie auf und würde sonst beim Klick auf den Termin dupliziert.
|
||||||
|
/// Statt neu anzulegen, wird die gefundene vorhandene Stunde auf diesen Termin umgehängt.
|
||||||
|
private async void OnFixIt(object? s, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is not LessonDialogViewModel vm) return;
|
||||||
|
if (!DateOnly.TryParseExact(vm.DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
|
||||||
|
{
|
||||||
|
App.Services.GetRequiredService<NotificationService>()
|
||||||
|
.ShowError("Bitte zuerst ein gültiges Datum eintragen.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
var lessonRepo = App.Services.GetRequiredService<ILessonRepository>();
|
||||||
|
var candidates = LessonFixItSearch.FindCandidates(lessonRepo, vm.GroupId, date);
|
||||||
|
|
||||||
|
if (candidates.Count == 0)
|
||||||
|
{
|
||||||
|
App.Services.GetRequiredService<NotificationService>()
|
||||||
|
.ShowError("Keine passende bestehende Stunde gefunden.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Lesson chosen;
|
||||||
|
if (candidates.Count == 1)
|
||||||
|
{
|
||||||
|
var info = new ConfirmDialogInfo
|
||||||
|
{
|
||||||
|
Title = "Vorhandene Stunde verknüpfen?",
|
||||||
|
Message = $"Vorhandene Stunde „{candidates[0].Topic}“ vom {candidates[0].Date:dd.MM.yyyy} gefunden. " +
|
||||||
|
"Mit diesem Termin verknüpfen, statt eine neue Stunde anzulegen?",
|
||||||
|
ConfirmText = "Verknüpfen",
|
||||||
|
};
|
||||||
|
var confirm = new ConfirmDialog { DataContext = info };
|
||||||
|
if (!await confirm.ShowDialog<bool>(this)) return;
|
||||||
|
chosen = candidates[0];
|
||||||
|
}
|
||||||
|
else
|
||||||
|
{
|
||||||
|
var pickerVm = new LinkExistingLessonDialogViewModel(candidates,
|
||||||
|
App.Services.GetRequiredService<IUnitRepository>());
|
||||||
|
var picker = new LinkExistingLessonDialog { DataContext = pickerVm };
|
||||||
|
if (!await picker.ShowDialog<bool>(this) || pickerVm.Result is not { } picked) return;
|
||||||
|
chosen = picked;
|
||||||
|
}
|
||||||
|
|
||||||
|
chosen.UnitId = vm.UnitId;
|
||||||
|
chosen.Date = date;
|
||||||
|
chosen.LessonNumber = vm.LessonNumber;
|
||||||
|
lessonRepo.Save(chosen);
|
||||||
|
vm.MarkAppliedExternally(chosen);
|
||||||
|
Close(true);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,38 @@
|
|||||||
|
<Window xmlns="https://github.com/avaloniaui"
|
||||||
|
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
|
||||||
|
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Groups"
|
||||||
|
x:Class="LehrerApp.Desktop.Views.Groups.LinkExistingLessonDialog"
|
||||||
|
x:DataType="vm:LinkExistingLessonDialogViewModel"
|
||||||
|
Title="Vorhandene Stunde verknüpfen"
|
||||||
|
Width="470" Height="360" MinWidth="420" MinHeight="320"
|
||||||
|
CanResize="False" WindowStartupLocation="CenterOwner">
|
||||||
|
<Grid RowDefinitions="*,Auto" Margin="24">
|
||||||
|
<StackPanel Grid.Row="0" Spacing="16">
|
||||||
|
<TextBlock Text="Vorhandene Stunde verknüpfen" Classes="dialogtitle"/>
|
||||||
|
<TextBlock Text="Mehrere Stunden kommen für diesen Termin infrage — bitte die passende auswählen."
|
||||||
|
FontSize="12" TextWrapping="Wrap" Opacity="0.75"/>
|
||||||
|
|
||||||
|
<StackPanel Spacing="5">
|
||||||
|
<TextBlock Text="Stunde" FontSize="12" Opacity="0.7"/>
|
||||||
|
<ComboBox ItemsSource="{Binding Options}" SelectedItem="{Binding SelectedOption}"
|
||||||
|
HorizontalAlignment="Stretch">
|
||||||
|
<ComboBox.ItemTemplate>
|
||||||
|
<DataTemplate x:DataType="vm:LessonLinkOption">
|
||||||
|
<StackPanel>
|
||||||
|
<TextBlock Text="{Binding Label}" FontWeight="SemiBold"/>
|
||||||
|
<TextBlock Text="{Binding Detail}" FontSize="11" Opacity="0.6"/>
|
||||||
|
</StackPanel>
|
||||||
|
</DataTemplate>
|
||||||
|
</ComboBox.ItemTemplate>
|
||||||
|
</ComboBox>
|
||||||
|
</StackPanel>
|
||||||
|
|
||||||
|
<TextBlock Text="{Binding Error}" Foreground="Red" FontSize="11"
|
||||||
|
IsVisible="{Binding Error, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
|
||||||
|
</StackPanel>
|
||||||
|
<Grid Grid.Row="1" ColumnDefinitions="*,8,*" Margin="0,20,0,0">
|
||||||
|
<Button Grid.Column="0" Content="Abbrechen" HorizontalAlignment="Stretch" Click="OnCancel"/>
|
||||||
|
<Button Grid.Column="2" Content="Verknüpfen" HorizontalAlignment="Stretch" Click="OnSave"/>
|
||||||
|
</Grid>
|
||||||
|
</Grid>
|
||||||
|
</Window>
|
||||||
@@ -0,0 +1,21 @@
|
|||||||
|
using Avalonia.Controls;
|
||||||
|
using Avalonia.Interactivity;
|
||||||
|
using LehrerApp.Desktop.ViewModels.Groups;
|
||||||
|
|
||||||
|
namespace LehrerApp.Desktop.Views.Groups;
|
||||||
|
|
||||||
|
public partial class LinkExistingLessonDialog : Window
|
||||||
|
{
|
||||||
|
public LinkExistingLessonDialog() => InitializeComponent();
|
||||||
|
|
||||||
|
private void OnSave(object? s, RoutedEventArgs e)
|
||||||
|
{
|
||||||
|
if (DataContext is LinkExistingLessonDialogViewModel vm && vm.SaveCommand.CanExecute(null))
|
||||||
|
{
|
||||||
|
vm.SaveCommand.Execute(null);
|
||||||
|
if (vm.Result is not null) Close(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void OnCancel(object? s, RoutedEventArgs e) => Close(false);
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user