Dashboard und Schnellnavigation fix

This commit is contained in:
2026-08-29 19:35:05 +02:00
parent 7d3d021d09
commit b7a105af73
18 changed files with 368 additions and 31 deletions
+23
View File
@@ -170,6 +170,7 @@ public class App : Application
};
search.OnQuickAddTask = startAsReminder => ShowQuickTaskDialog(startAsReminder, dash);
search.OnQuickAddStudent = ShowAddStudentDialog;
search.OnQuickAddGroupDocumentation = () => ShowQuickGroupDocumentationDialog(dash);
// StudentList → StudentDetail + Anlegen
var sl = Services.GetRequiredService<StudentListViewModel>();
@@ -203,4 +204,26 @@ public class App : Application
dashboard.RefreshCommand.Execute(null);
Services.GetRequiredService<WorkTaskListViewModel>().Load();
}
private static async Task ShowQuickGroupDocumentationDialog(DashboardViewModel dashboard)
{
if (Application.Current?.ApplicationLifetime is not IClassicDesktopStyleApplicationLifetime
{ MainWindow: { } owner }) return;
var subjects = Services.GetRequiredService<ISubjectRepository>().GetAll()
.ToDictionary(s => s.Id);
var options = Services.GetRequiredService<IGroupRepository>().GetAll()
.Where(g => g.IsActive)
.OrderBy(g => g.Name)
.Select(g => new GroupDocumentationOption(g.Id,
g.SubjectId is { } subjectId && subjects.TryGetValue(subjectId, out var subject)
? $"{g.Name} · {(string.IsNullOrWhiteSpace(subject.ShortName) ? subject.Name : subject.ShortName)}"
: g.Name));
var vm = new GroupDocumentationQuickViewModel(options);
var dialog = new Views.Students.GroupDocumentationQuickDialog { DataContext = vm };
if (!await dialog.ShowDialog<bool>(owner) || vm.Result is null) return;
Services.GetRequiredService<IDocumentationRepository>().Save(vm.Result);
dashboard.RefreshCommand.Execute(null);
}
}
@@ -56,6 +56,7 @@ public partial class DashboardViewModel : ObservableObject
[ObservableProperty] private string _currentSchoolYear = "";
[ObservableProperty] private DateOnly _calendarMonth = FirstOfMonth(DateTime.Today);
[ObservableProperty] private string _selectedDayLabel = "";
[ObservableProperty] private DateOnly _selectedCalendarDate = DateOnly.FromDateTime(DateTime.Today);
[ObservableProperty] private bool _isDashboardSettingsOpen;
[ObservableProperty] private bool _isWeatherPanelVisible;
[ObservableProperty] private string _weatherSummary = "";
@@ -196,7 +197,7 @@ public partial class DashboardViewModel : ObservableObject
IsHighPriority = t.Priority == TaskPriority.High });
CurrentGroups.Clear();
foreach (var g in groups.Values.OrderBy(g => g.Name))
foreach (var g in groups.Values)
CurrentGroups.Add(new()
{
GroupId = g.Id,
@@ -749,9 +750,35 @@ public partial class DashboardViewModel : ObservableObject
{
if (day is null) return;
foreach (var cell in CalendarDays) cell.IsSelected = cell == day;
SelectedCalendarDate = day.Date;
SelectedDayLabel = day.Date.ToString("dddd, d. MMMM", De);
SelectedDayEvents.Clear();
foreach (var item in day.Events) SelectedDayEvents.Add(item);
SortCurrentGroups(day.Date);
}
private void SortCurrentGroups(DateOnly date)
{
var schoolHolidays = _schoolHolidays.GetAll();
var publicHolidays = _publicHolidays.GetHolidays(date.Year, _calendarSettings.State)
.Select(h => h.Date).ToHashSet();
var isFreeDay = IsFreeDay(date, schoolHolidays, publicHolidays);
var cancelledPeriods = _substitutions.GetByDate(date)
.Where(s => s.Kind == SubstitutionKind.Cancelled)
.Select(s => s.PeriodNumber).ToHashSet();
foreach (var chip in CurrentGroups)
{
var hasLesson = _lessons.GetByGroupAndDate(chip.GroupId, date).Count > 0;
var hasActiveSlot = !isFreeDay && _timetableSlots.GetByGroup(chip.GroupId)
.Any(s => s.Weekday == date.DayOfWeek && !cancelledPeriods.Contains(s.PeriodNumber));
chip.IsOnSelectedDay = hasLesson || hasActiveSlot;
}
var sorted = CurrentGroups.OrderByDescending(g => g.IsOnSelectedDay)
.ThenBy(g => g.Name, StringComparer.CurrentCultureIgnoreCase).ToList();
CurrentGroups.Clear();
foreach (var chip in sorted) CurrentGroups.Add(chip);
}
[RelayCommand]
@@ -891,7 +918,13 @@ public class LessonItem
public bool HasRoom => !string.IsNullOrWhiteSpace(Room);
}
public class TaskItem { public string Title { get; set; } = ""; public string DueDate { get; set; } = ""; public bool IsOverdue { get; set; } public bool IsReminder { get; set; } public bool IsHighPriority { get; set; } }
public class GroupChip { public Guid GroupId { get; set; } public string Name { get; set; } = ""; public string Subject { get; set; } = ""; }
public class GroupChip
{
public Guid GroupId { get; set; }
public string Name { get; set; } = "";
public string Subject { get; set; } = "";
public bool IsOnSelectedDay { get; set; }
}
// ── Offene Entschuldigungen (aus Mitarbeit-Fehltagen) ────────────────────────
@@ -17,6 +17,7 @@ public partial class GlobalSearchViewModel : ObservableObject
private readonly IStudentRepository _students;
private readonly IGroupRepository _groups;
private readonly ISubjectRepository _subjects;
private readonly IExamRepository _exams;
private readonly IWorkTaskRepository _tasks;
@@ -30,13 +31,15 @@ public partial class GlobalSearchViewModel : ObservableObject
public Action<GlobalSearchResult>? OnNavigate { get; set; }
public Func<bool, Task>? OnQuickAddTask { get; set; }
public Func<Task>? OnQuickAddStudent { get; set; }
public Func<Task>? OnQuickAddGroupDocumentation { get; set; }
public Action? OnClose { get; set; }
public GlobalSearchViewModel(IStudentRepository students, IGroupRepository groups,
IExamRepository exams, IWorkTaskRepository tasks)
ISubjectRepository subjects, IExamRepository exams, IWorkTaskRepository tasks)
{
_students = students;
_groups = groups;
_subjects = subjects;
_exams = exams;
_tasks = tasks;
RefreshResults();
@@ -59,23 +62,34 @@ public partial class GlobalSearchViewModel : ObservableObject
if (query.Length > 0)
{
var groups = _groups.GetAll(includeInactive: true);
var groups = _groups.GetAll().Where(g => g.IsActive).ToList();
var groupNames = groups.ToDictionary(g => g.Id, g => g.Name);
var subjects = _subjects.GetAll().ToDictionary(s => s.Id);
Subject? GroupSubject(LearningGroup group) => group.SubjectId is { } subjectId
? subjects.GetValueOrDefault(subjectId)
: null;
static string SubjectLabel(Subject? subject) => subject is null ? ""
: string.IsNullOrWhiteSpace(subject.ShortName)
|| subject.Name.Equals(subject.ShortName, StringComparison.CurrentCultureIgnoreCase)
? subject.Name
: $"{subject.ShortName} {subject.Name}";
var candidates = new List<GlobalSearchResult>();
candidates.AddRange(_students.GetAll(includeInactive: true)
candidates.AddRange(_students.GetAll().Where(s => s.IsActive)
.Where(s => Matches(s.FullName, query))
.Select(s => GlobalSearchResult.ForStudent(s)));
candidates.AddRange(groups
.Where(g => Matches($"{g.Name} {g.SchoolYear} {g.GradeLevel}", query))
.Select(GlobalSearchResult.ForGroup));
.Where(g => Matches($"{g.Name} {GroupSubject(g)?.Name} {GroupSubject(g)?.ShortName} {g.SchoolYear} {g.GradeLevel}", query))
.Select(g => GlobalSearchResult.ForGroup(g, SubjectLabel(GroupSubject(g)))));
candidates.AddRange(_exams.GetAll()
.Where(e => groupNames.ContainsKey(e.GroupId))
.Where(e => Matches($"{e.Title} {groupNames.GetValueOrDefault(e.GroupId)}", query))
.Select(e => GlobalSearchResult.ForExam(e, groupNames.GetValueOrDefault(e.GroupId) ?? "")));
candidates.AddRange(_tasks.GetAll()
.Where(t => t.GroupId is null || groupNames.ContainsKey(t.GroupId.Value))
.Where(t => Matches($"{t.Title} {t.Notes} {groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty)}", query))
.Select(t => GlobalSearchResult.ForTask(t, groupNames.GetValueOrDefault(t.GroupId ?? Guid.Empty) ?? "")));
@@ -99,6 +113,7 @@ public partial class GlobalSearchViewModel : ObservableObject
GlobalSearchResult.ForAction(GlobalSearchAction.NewTask, "Aufgabe anlegen", "Mit Fälligkeit, Gruppe und Priorität", ""),
GlobalSearchResult.ForAction(GlobalSearchAction.NewReminder, "Erinnerung anlegen", "Kurze Notiz ohne Zeiterfassung", "◷"),
GlobalSearchResult.ForAction(GlobalSearchAction.NewStudent, "Schüler anlegen", "Neue Stammdaten erfassen", ""),
GlobalSearchResult.ForAction(GlobalSearchAction.NewGroupDocumentation, "Lerngruppen-Eintrag", "Planung, Klausur oder Erinnerung dokumentieren", "N"),
};
foreach (var action in actions.Where(a => query.Length == 0 || Matches(a.Title, query)))
@@ -124,6 +139,9 @@ public partial class GlobalSearchViewModel : ObservableObject
case GlobalSearchAction.NewStudent:
if (OnQuickAddStudent is not null) await OnQuickAddStudent();
break;
case GlobalSearchAction.NewGroupDocumentation:
if (OnQuickAddGroupDocumentation is not null) await OnQuickAddGroupDocumentation();
break;
default:
OnNavigate?.Invoke(result);
break;
@@ -134,7 +152,7 @@ public partial class GlobalSearchViewModel : ObservableObject
}
public enum GlobalSearchResultKind { Action, Student, Group, Exam, Task }
public enum GlobalSearchAction { None, NewTask, NewReminder, NewStudent }
public enum GlobalSearchAction { None, NewTask, NewReminder, NewStudent, NewGroupDocumentation }
public sealed class GlobalSearchResult
{
@@ -171,10 +189,13 @@ public sealed class GlobalSearchResult
Title = student.FullName, Subtitle = student.IsActive ? "Aktiv" : "Inaktiv", Icon = "P",
};
public static GlobalSearchResult ForGroup(LearningGroup group) => new()
public static GlobalSearchResult ForGroup(LearningGroup group, string subjectName) => new()
{
Kind = GlobalSearchResultKind.Group, EntityId = group.Id, GroupId = group.Id,
Title = group.Name, Subtitle = $"{group.SchoolYear} · Stufe {group.GradeLevel}", Icon = "G",
Title = group.Name,
Subtitle = string.Join(" · ", new[] { subjectName, group.SchoolYear, $"Stufe {group.GradeLevel}" }
.Where(x => !string.IsNullOrWhiteSpace(x))),
Icon = "G",
};
public static GlobalSearchResult ForExam(Exam exam, string groupName) => new()
@@ -25,6 +25,7 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
private List<StudentOption> _groupStudents = [];
public static readonly StudentOption AllStudentsOption = new(Guid.Empty, "Alle Schüler");
public static readonly StudentOption WholeGroupOption = new(Guid.Empty, "Gesamte Lerngruppe");
public ObservableCollection<DocumentationItem> Entries { get; } = [];
public ObservableCollection<StudentOption> StudentFilterOptions { get; } = [AllStudentsOption];
@@ -62,8 +63,6 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
private void Load()
{
Entries.Clear();
if (_groupStudents.Count == 0) return;
var studentNameById = _groupStudents.ToDictionary(s => s.Id, s => s.Name);
var groupNameCache = new Dictionary<Guid, string>();
string GroupLabel(Guid id)
@@ -80,6 +79,10 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
var all = relevantStudentIds
.SelectMany(id => _docs.GetByStudent(id))
.Concat(SelectedStudentFilter.Id == Guid.Empty
? _docs.GetAll().Where(d => d.StudentId == Guid.Empty && d.GroupId == _groupId)
: [])
.DistinctBy(d => d.Id)
.Where(d => !OnlyThisGroup || d.GroupId == _groupId)
.OrderByDescending(d => d.IsDraft)
.ThenByDescending(d => d.Date)
@@ -90,7 +93,10 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
{
var isOwnGroup = d.GroupId is null || d.GroupId == _groupId;
var otherGroupLabel = isOwnGroup ? "" : GroupLabel(d.GroupId!.Value);
Entries.Add(new DocumentationItem(d, studentNameById.GetValueOrDefault(d.StudentId, ""),
var studentName = d.StudentId == Guid.Empty
? WholeGroupOption.Name
: studentNameById.GetValueOrDefault(d.StudentId, "");
Entries.Add(new DocumentationItem(d, studentName,
isOwnGroup, otherGroupLabel));
}
}
@@ -100,7 +106,7 @@ public partial class GroupDocumentationTabViewModel : ObservableObject
[RelayCommand]
private async Task AddDocumentation()
{
if (OnEditDocumentation is null || _groupStudents.Count == 0) return;
if (OnEditDocumentation is null) return;
var result = await OnEditDocumentation(_groupId, _groupStudents, null);
if (result is null) return;
_docs.Save(result);
@@ -17,7 +17,7 @@ public record StudentOption(Guid Id, string Name);
public static class DocumentationTypeDisplay
{
public static string[] Options { get; } =
["Gespräch", "Vorkommnis", "Förderplan", "Fehlzeit", "Elternanruf", "Elternbrief"];
["Gespräch", "Vorkommnis", "Förderplan", "Fehlzeit", "Elternanruf", "Elternbrief", "Planung / Erinnerung"];
public static string Label(DocumentationType t) => t switch
{
@@ -27,6 +27,7 @@ public static class DocumentationTypeDisplay
DocumentationType.Absence => "Fehlzeit",
DocumentationType.ParentCall => "Elternanruf",
DocumentationType.ParentLetter => "Elternbrief",
DocumentationType.Planning => "Planung / Erinnerung",
_ => "",
};
@@ -37,6 +38,7 @@ public static class DocumentationTypeDisplay
"Fehlzeit" => DocumentationType.Absence,
"Elternanruf" => DocumentationType.ParentCall,
"Elternbrief" => DocumentationType.ParentLetter,
"Planung / Erinnerung" => DocumentationType.Planning,
_ => DocumentationType.Conversation,
};
}
@@ -301,7 +303,7 @@ public partial class DocumentationDialogViewModel : ObservableObject
LetterSentDateError = ""; LetterResponseDateError = "";
var valid = true;
if (CanPickStudent && SelectedStudent is null) { StudentError = "Schüler auswählen."; valid = false; }
if (CanPickStudent && SelectedStudent is null) { StudentError = "Bezug auswählen."; valid = false; }
if (string.IsNullOrWhiteSpace(Title)) { TitleError = "Titel erforderlich."; valid = false; }
@@ -0,0 +1,46 @@
using CommunityToolkit.Mvvm.ComponentModel;
using CommunityToolkit.Mvvm.Input;
using LehrerApp.Core.Models;
using System.Globalization;
namespace LehrerApp.Desktop.ViewModels.Students;
public sealed record GroupDocumentationOption(Guid Id, string DisplayName);
/// Kompakte Erfassung einer gruppenweiten Planungsnotiz aus der globalen Befehlspalette.
public partial class GroupDocumentationQuickViewModel(IEnumerable<GroupDocumentationOption> groups)
: ObservableObject
{
public List<GroupDocumentationOption> Groups { get; } = groups.ToList();
[ObservableProperty] private GroupDocumentationOption? _selectedGroup;
[ObservableProperty] private string _title = "";
[ObservableProperty] private string _content = "";
[ObservableProperty] private string _dateText = DateOnly.FromDateTime(DateTime.Today).ToString("dd.MM.yyyy");
[ObservableProperty] private string _groupError = "";
[ObservableProperty] private string _titleError = "";
[ObservableProperty] private string _dateError = "";
public Documentation? Result { get; private set; }
[RelayCommand]
private void Save()
{
GroupError = TitleError = DateError = "";
if (SelectedGroup is null) GroupError = "Lerngruppe auswählen.";
if (string.IsNullOrWhiteSpace(Title)) TitleError = "Titel erforderlich.";
if (!DateOnly.TryParseExact(DateText, "dd.MM.yyyy", null, DateTimeStyles.None, out var date))
DateError = "Format TT.MM.JJJJ.";
if (GroupError.Length > 0 || TitleError.Length > 0 || DateError.Length > 0) return;
Result = new Documentation
{
StudentId = Guid.Empty,
GroupId = SelectedGroup!.Id,
Type = DocumentationType.Planning,
Date = date,
Title = Title.Trim(),
Content = Content.Trim(),
ExcludeFromWebUntisSync = true,
};
}
}
@@ -586,10 +586,10 @@
<StackPanel>
<TextBlock Text="MEINE LERNGRUPPEN" FontSize="11" FontWeight="Bold"
Opacity="0.5" Margin="0,0,0,10"/>
<ItemsControl ItemsSource="{Binding CurrentGroups}">
<ItemsControl ItemsSource="{Binding CurrentGroups}" HorizontalAlignment="Stretch">
<ItemsControl.ItemsPanel>
<ItemsPanelTemplate>
<WrapPanel Orientation="Horizontal"/>
<WrapPanel Orientation="Horizontal" ItemSpacing="8" LineSpacing="8"/>
</ItemsPanelTemplate>
</ItemsControl.ItemsPanel>
<ItemsControl.ItemTemplate>
@@ -597,9 +597,15 @@
<Button Command="{Binding $parent[ItemsControl].((vm:DashboardViewModel)DataContext).OpenGroupCommand}"
CommandParameter="{Binding}"
Background="{DynamicResource SystemAccentColorLight2}"
CornerRadius="6" Padding="12,6" Margin="0,0,8,8">
CornerRadius="6" Padding="12,6" MinWidth="150"
ToolTip.Tip="Kurs öffnen">
<StackPanel>
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13"/>
<StackPanel Orientation="Horizontal" Spacing="6">
<Border Width="6" Height="6" CornerRadius="3"
Background="{DynamicResource SystemAccentColor}"
IsVisible="{Binding IsOnSelectedDay}" VerticalAlignment="Center"/>
<TextBlock Text="{Binding Name}" FontWeight="SemiBold" FontSize="13"/>
</StackPanel>
<TextBlock Text="{Binding Subject}" FontSize="11" Opacity="0.7"
IsVisible="{Binding Subject, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
@@ -28,8 +28,15 @@ public partial class GroupDocumentationTabView : UserControl
var owner = TopLevel.GetTopLevel(this) as Window;
if (owner is null) return null;
var options = new List<StudentOption> { GroupDocumentationTabViewModel.WholeGroupOption };
options.AddRange(studentOptions);
var vm = new DocumentationDialogViewModel(editing?.StudentId ?? Guid.Empty, editing,
App.Services.GetRequiredService<IAttachmentStorage>(), studentOptions, groupId);
App.Services.GetRequiredService<IAttachmentStorage>(), options, groupId);
if (editing is null)
{
vm.SelectedStudent = GroupDocumentationTabViewModel.WholeGroupOption;
vm.TypeName = DocumentationTypeDisplay.Label(DocumentationType.Planning);
}
var dialog = new DocumentationDialog { DataContext = vm };
var saved = await dialog.ShowDialog<bool>(owner);
if (!saved) vm.DiscardUnsavedAttachments();
@@ -13,10 +13,10 @@
<TextBlock Text="{Binding DialogTitle}" Classes="dialogtitle"/>
<StackPanel Spacing="4" IsVisible="{Binding CanPickStudent}">
<TextBlock Text="Schüler *" FontSize="12" Opacity="0.7"/>
<TextBlock Text="Bezug *" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding StudentOptions}" SelectedItem="{Binding SelectedStudent}"
DisplayMemberBinding="{Binding Name}" HorizontalAlignment="Stretch"
PlaceholderText="Schüler wählen"/>
PlaceholderText="Schüler oder gesamte Lerngruppe wählen"/>
<TextBlock Text="{Binding StudentError}" Foreground="Red" FontSize="11"
IsVisible="{Binding StudentError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
@@ -0,0 +1,44 @@
<Window xmlns="https://github.com/avaloniaui"
xmlns:x="http://schemas.microsoft.com/winfx/2006/xaml"
xmlns:vm="clr-namespace:LehrerApp.Desktop.ViewModels.Students"
x:Class="LehrerApp.Desktop.Views.Students.GroupDocumentationQuickDialog"
x:DataType="vm:GroupDocumentationQuickViewModel"
Title="Lerngruppen-Eintrag" Width="480" SizeToContent="Height"
CanResize="False" WindowStartupLocation="CenterOwner">
<Grid RowDefinitions="*,Auto" Margin="24">
<StackPanel Spacing="12">
<TextBlock Text="Lerngruppen-Eintrag" Classes="dialogtitle"/>
<TextBlock Text="Planung, Klausur oder Erinnerung für eine ganze Lerngruppe festhalten."
TextWrapping="Wrap" Opacity="0.7"/>
<StackPanel Spacing="4">
<TextBlock Text="Lerngruppe *" FontSize="12" Opacity="0.7"/>
<ComboBox ItemsSource="{Binding Groups}" SelectedItem="{Binding SelectedGroup}"
DisplayMemberBinding="{Binding DisplayName}" PlaceholderText="Lerngruppe wählen"/>
<TextBlock Text="{Binding GroupError}" Foreground="Red" FontSize="11"
IsVisible="{Binding GroupError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<Grid ColumnDefinitions="*,12,140">
<StackPanel Spacing="4">
<TextBlock Text="Titel *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Title}" PlaceholderText="z. B. Klausur ankündigen"/>
<TextBlock Text="{Binding TitleError}" Foreground="Red" FontSize="11"
IsVisible="{Binding TitleError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
<StackPanel Grid.Column="2" Spacing="4">
<TextBlock Text="Datum *" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding DateText}"/>
<TextBlock Text="{Binding DateError}" Foreground="Red" FontSize="11"
IsVisible="{Binding DateError, Converter={x:Static StringConverters.IsNotNullOrEmpty}}"/>
</StackPanel>
</Grid>
<StackPanel Spacing="4">
<TextBlock Text="Notiz" FontSize="12" Opacity="0.7"/>
<TextBox Text="{Binding Content}" AcceptsReturn="True" Height="90" TextWrapping="Wrap"/>
</StackPanel>
</StackPanel>
<StackPanel Grid.Row="1" Orientation="Horizontal" HorizontalAlignment="Right" Spacing="8" Margin="0,20,0,0">
<Button Content="Abbrechen" Click="OnCancel"/>
<Button Content="Speichern" Classes="accent" Click="OnSave"/>
</StackPanel>
</Grid>
</Window>
@@ -0,0 +1,19 @@
using Avalonia.Controls;
using Avalonia.Interactivity;
using LehrerApp.Desktop.ViewModels.Students;
namespace LehrerApp.Desktop.Views.Students;
public partial class GroupDocumentationQuickDialog : Window
{
public GroupDocumentationQuickDialog() => InitializeComponent();
private void OnSave(object? sender, RoutedEventArgs e)
{
if (DataContext is not GroupDocumentationQuickViewModel vm) return;
vm.SaveCommand.Execute(null);
if (vm.Result is not null) Close(true);
}
private void OnCancel(object? sender, RoutedEventArgs e) => Close(false);
}